From ee8ccc747d535cfd30e7d37345ed6fa319c9d266 Mon Sep 17 00:00:00 2001 From: Ikhun Um Date: Wed, 2 Oct 2024 22:50:42 +0900 Subject: [PATCH] Handle concurrent requests to prevent multiple mirroring executions --- package-lock.json | 21 +++ package.json | 5 + .../internal/mirror/AbstractGitMirror.java | 40 +++-- ....java => MirrorSchedulingServiceTest.java} | 4 +- .../centraldogma/server/CentralDogma.java | 25 ++- .../internal/api/MirroringServiceV1.java | 25 +-- .../internal/mirror/AbstractMirror.java | 5 +- .../mirror/DefaultMirroringServicePlugin.java | 10 +- .../server/internal/mirror/MirrorRunner.java | 142 ++++++++++++++++++ ...vice.java => MirrorSchedulingService.java} | 6 +- ...=> MirrorSchedulingServicePluginTest.java} | 2 +- webapp/package-lock.json | 10 ++ webapp/package.json | 1 + .../{RunMirror.tsx => RunMirrorButton.tsx} | 63 ++++++-- .../project/settings/mirrors/MirrorForm.tsx | 31 ++-- .../project/settings/mirrors/MirrorList.tsx | 37 +++-- .../project/settings/mirrors/MirrorView.tsx | 22 ++- 17 files changed, 364 insertions(+), 85 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json rename server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/{DefaultMirroringServiceTest.java => MirrorSchedulingServiceTest.java} (97%) create mode 100644 server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java rename server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/{DefaultMirroringService.java => MirrorSchedulingService.java} (98%) rename server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/{DefaultMirroringServicePluginTest.java => MirrorSchedulingServicePluginTest.java} (98%) rename webapp/src/dogma/features/mirror/{RunMirror.tsx => RunMirrorButton.tsx} (55%) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..1f6f452cab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "centraldogma", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "cronstrue": "^2.50.0" + } + }, + "node_modules/cronstrue": { + "version": "2.50.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.50.0.tgz", + "integrity": "sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..4f058b1278 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "cronstrue": "^2.50.0" + } +} diff --git a/server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java b/server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java index 5013ea0696..0c8dbd14b5 100644 --- a/server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java +++ b/server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletionException; import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -81,6 +82,7 @@ import com.linecorp.centraldogma.common.Entry; import com.linecorp.centraldogma.common.EntryType; import com.linecorp.centraldogma.common.Markup; +import com.linecorp.centraldogma.common.RedundantChangeException; import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.internal.Util; @@ -255,13 +257,7 @@ MirrorResult mirrorRemoteToLocal( final String mirrorStatePath = localPath() + MIRROR_STATE_FILE_NAME; final Revision localRev = localRepo().normalizeNow(Revision.HEAD); if (!needsFetch(headBranchRef, mirrorStatePath, localRev)) { - final String abbrId = headBranchRef.getObjectId().abbreviate(OBJECT_ID_ABBREV_STRING_LENGTH).name(); - final String message = String.format("Repository '%s/%s' already at %s, %s#%s", - localRepo().parent().name(), localRepo().name(), abbrId, - remoteRepoUri(), remoteBranch()); - // The local repository is up-to date. - logger.debug(message); - return newMirrorResult(MirrorStatus.UP_TO_DATE, message); + return newMirrorResultForUpToDate(headBranchRef); } // Update the head commit ID again because there's a chance a commit is pushed between the @@ -357,11 +353,28 @@ MirrorResult mirrorRemoteToLocal( } }); - final CommitResult commitResult = executor.execute(Command.push( - MIRROR_AUTHOR, localRepo().parent().name(), localRepo().name(), - Revision.HEAD, summary, detail, Markup.PLAINTEXT, changes.values())).join(); - final String description = summary + ", Revision: " + commitResult.revision(); - return newMirrorResult(MirrorStatus.SUCCESS, description); + try { + final CommitResult commitResult = executor.execute(Command.push( + MIRROR_AUTHOR, localRepo().parent().name(), localRepo().name(), + Revision.HEAD, summary, detail, Markup.PLAINTEXT, changes.values())).join(); + final String description = summary + ", Revision: " + commitResult.revision(); + return newMirrorResult(MirrorStatus.SUCCESS, description); + } catch (CompletionException e) { + if (e.getCause() instanceof RedundantChangeException) { + return newMirrorResultForUpToDate(headBranchRef); + } + throw e; + } + } + + private MirrorResult newMirrorResultForUpToDate(Ref headBranchRef) { + final String abbrId = headBranchRef.getObjectId().abbreviate(OBJECT_ID_ABBREV_STRING_LENGTH).name(); + final String message = String.format("Repository '%s/%s' already at %s, %s#%s", + localRepo().parent().name(), localRepo().name(), abbrId, + remoteRepoUri(), remoteBranch()); + // The local repository is up-to date. + logger.debug(message); + return newMirrorResult(MirrorStatus.UP_TO_DATE, message); } private boolean needsFetch(Ref headBranchRef, String mirrorStatePath, Revision localRev) @@ -377,7 +390,8 @@ private boolean needsFetch(Ref headBranchRef, String mirrorStatePath, Revision l final ObjectId headCommitId = headBranchRef.getObjectId(); if (headCommitId.name().equals(localSourceRevision)) { - return false; + // TODO(ikhoon): Revert + return true; } return true; } diff --git a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServiceTest.java b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java similarity index 97% rename from server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServiceTest.java rename to server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java index 34de24e2ef..4cbfcadbab 100644 --- a/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServiceTest.java +++ b/server-mirror-git/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServiceTest.java @@ -49,7 +49,7 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -class DefaultMirroringServiceTest { +class MirrorSchedulingServiceTest { @TempDir static File temporaryFolder; @@ -92,7 +92,7 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo when(mr.mirrors()).thenReturn(CompletableFuture.completedFuture(ImmutableList.of(mirror))); - final DefaultMirroringService service = new DefaultMirroringService( + final MirrorSchedulingService service = new MirrorSchedulingService( temporaryFolder, pm, new SimpleMeterRegistry(), 1, 1, 1); final CommandExecutor executor = mock(CommandExecutor.class); service.start(executor); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java index c11ff9f437..4f8d86fb96 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -148,6 +148,7 @@ import com.linecorp.centraldogma.server.internal.api.auth.RequiresRoleDecorator.RequiresRoleDecoratorFactory; import com.linecorp.centraldogma.server.internal.api.converter.HttpApiRequestConverter; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin; +import com.linecorp.centraldogma.server.internal.mirror.MirrorRunner; import com.linecorp.centraldogma.server.internal.replication.ZooKeeperCommandExecutor; import com.linecorp.centraldogma.server.internal.storage.project.DefaultProjectManager; import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; @@ -250,6 +251,8 @@ public static CentralDogma forConfig(File configFile) throws IOException { private ServerStatusManager statusManager; @Nullable private InternalProjectInitializer projectInitializer; + @Nullable + private volatile MirrorRunner mirrorRunner; CentralDogma(CentralDogmaConfig cfg, MeterRegistry meterRegistry) { this.cfg = requireNonNull(cfg, "cfg"); @@ -438,7 +441,7 @@ private void doStart() throws Exception { this.server = server; this.sessionManager = sessionManager; } else { - doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager); + doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager, mirrorRunner); } } } @@ -813,8 +816,9 @@ private void configureHttpApi(ServerBuilder sb, .annotatedService(new RepositoryServiceV1(executor, mds)); if (GIT_MIRROR_ENABLED) { + mirrorRunner = new MirrorRunner(projectApiManager, executor, cfg, meterRegistry); apiV1ServiceBuilder.annotatedService( - new MirroringServiceV1(projectApiManager, executor, cfg.dataDir())) + new MirroringServiceV1(projectApiManager, executor, mirrorRunner)) .annotatedService(new CredentialServiceV1(projectApiManager, executor)); } @@ -1022,12 +1026,14 @@ private void doStop() { final ExecutorService repositoryWorker = this.repositoryWorker; final ExecutorService purgeWorker = this.purgeWorker; final SessionManager sessionManager = this.sessionManager; + final MirrorRunner mirrorRunner = this.mirrorRunner; this.server = null; this.executor = null; this.pm = null; this.repositoryWorker = null; this.sessionManager = null; + this.mirrorRunner = null; projectInitializer = null; if (meterRegistryToBeClosed != null) { assert meterRegistry instanceof CompositeMeterRegistry; @@ -1037,7 +1043,7 @@ private void doStop() { } logger.info("Stopping the Central Dogma .."); - if (!doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager)) { + if (!doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager, mirrorRunner)) { logger.warn("Stopped the Central Dogma with failure."); } else { logger.info("Stopped the Central Dogma successfully."); @@ -1048,7 +1054,7 @@ private static boolean doStop( @Nullable Server server, @Nullable CommandExecutor executor, @Nullable ProjectManager pm, @Nullable ExecutorService repositoryWorker, @Nullable ExecutorService purgeWorker, - @Nullable SessionManager sessionManager) { + @Nullable SessionManager sessionManager, @Nullable MirrorRunner mirrorRunner) { boolean success = true; try { @@ -1117,6 +1123,17 @@ private static boolean doStop( success = false; } + try { + if (mirrorRunner != null) { + logger.info("Stopping the mirror runner.."); + mirrorRunner.close(); + logger.info("Stopped the mirror runner."); + } + } catch (Throwable t) { + success = false; + logger.warn("Failed to stop the mirror runner:", t); + } + try { if (server != null) { logger.info("Stopping the RPC server .."); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java index e2da49ca6c..33452acb1b 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MirroringServiceV1.java @@ -19,15 +19,12 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.ImmutableList.toImmutableList; -import java.io.File; import java.net.URI; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; import com.cronutils.model.Cron; -import com.linecorp.armeria.server.annotation.Blocking; import com.linecorp.armeria.server.annotation.ConsumesJson; import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.annotation.Param; @@ -41,10 +38,10 @@ import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.internal.api.auth.RequiresReadPermission; import com.linecorp.centraldogma.server.internal.api.auth.RequiresWritePermission; +import com.linecorp.centraldogma.server.internal.mirror.MirrorRunner; import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; import com.linecorp.centraldogma.server.mirror.Mirror; import com.linecorp.centraldogma.server.mirror.MirrorResult; -import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.repository.MetaRepository; @@ -59,12 +56,13 @@ public class MirroringServiceV1 extends AbstractService { // - Add Java APIs to the CentralDogma client private final ProjectApiManager projectApiManager; - private final File workDir; + private final MirrorRunner mirrorRunner; - public MirroringServiceV1(ProjectApiManager projectApiManager, CommandExecutor executor, File dataDir) { + public MirroringServiceV1(ProjectApiManager projectApiManager, CommandExecutor executor, + MirrorRunner mirrorRunner) { super(executor); - workDir = new File(dataDir, "_mirrors_manual"); this.projectApiManager = projectApiManager; + this.mirrorRunner = mirrorRunner; } /** @@ -134,17 +132,8 @@ private CompletableFuture createOrUpdate(String projectName, } @Post("/projects/{projectName}/mirrors/{mirrorId}/run") - @Blocking - public MirrorResult runMirror(@Param String projectName, @Param String mirrorId) throws Exception { - final Mirror mirror = metaRepo(projectName).mirror(mirrorId).get(10, TimeUnit.SECONDS); - if (mirror.schedule() != null) { - throw new UnsupportedOperationException("The mirror is scheduled to run automatically."); - } - - return mirror.mirror(workDir, executor(), - // TODO(ikhoon): Use cfg.pluginConfigMap().get(configType()) - MirroringServicePluginConfig.INSTANCE.maxNumFilesPerMirror(), - MirroringServicePluginConfig.INSTANCE.maxNumBytesPerMirror()); + public CompletableFuture runMirror(@Param String projectName, @Param String mirrorId) throws Exception { + return mirrorRunner.run(projectName, mirrorId); } private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror) { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractMirror.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractMirror.java index 6bdcdd428a..c58e08a226 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractMirror.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractMirror.java @@ -210,7 +210,6 @@ protected final MirrorResult newMirrorResult(MirrorStatus mirrorStatus, @Nullabl public String toString() { final ToStringHelper helper = MoreObjects.toStringHelper("") .omitNullValues() - .add("schedule", CronDescriptor.instance().describe(schedule)) .add("direction", direction) .add("localProj", localRepo.parent().name()) .add("localRepo", localRepo.name()) @@ -220,7 +219,9 @@ public String toString() { .add("remoteBranch", remoteBranch) .add("gitignore", gitignore) .add("credential", credential); - + if (schedule != null) { + helper.add("schedule", CronDescriptor.instance().describe(schedule)); + } return helper.toString(); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java index 168f81301c..fc71360940 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePlugin.java @@ -34,7 +34,7 @@ public final class DefaultMirroringServicePlugin implements Plugin { @Nullable - private volatile DefaultMirroringService mirroringService; + private volatile MirrorSchedulingService mirroringService; @Override public PluginTarget target() { @@ -45,7 +45,7 @@ public PluginTarget target() { public synchronized CompletionStage start(PluginContext context) { requireNonNull(context, "context"); - DefaultMirroringService mirroringService = this.mirroringService; + MirrorSchedulingService mirroringService = this.mirroringService; if (mirroringService == null) { final CentralDogmaConfig cfg = context.config(); final MirroringServicePluginConfig mirroringServicePluginConfig = @@ -63,7 +63,7 @@ public synchronized CompletionStage start(PluginContext context) { maxNumFilesPerMirror = MirroringServicePluginConfig.INSTANCE.maxNumFilesPerMirror(); maxNumBytesPerMirror = MirroringServicePluginConfig.INSTANCE.maxNumBytesPerMirror(); } - mirroringService = new DefaultMirroringService(new File(cfg.dataDir(), "_mirrors"), + mirroringService = new MirrorSchedulingService(new File(cfg.dataDir(), "_mirrors"), context.projectManager(), context.meterRegistry(), numThreads, @@ -77,7 +77,7 @@ public synchronized CompletionStage start(PluginContext context) { @Override public synchronized CompletionStage stop(PluginContext context) { - final DefaultMirroringService mirroringService = this.mirroringService; + final MirrorSchedulingService mirroringService = this.mirroringService; if (mirroringService != null && mirroringService.isStarted()) { mirroringService.stop(); } @@ -90,7 +90,7 @@ public Class configType() { } @Nullable - public DefaultMirroringService mirroringService() { + public MirrorSchedulingService mirroringService() { return mirroringService; } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java new file mode 100644 index 0000000000..9b84827a34 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorRunner.java @@ -0,0 +1,142 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.mirror; + +import static com.linecorp.centraldogma.server.internal.ExecutorServiceUtil.terminate; + +import java.io.File; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.centraldogma.server.CentralDogmaConfig; +import com.linecorp.centraldogma.server.command.CommandExecutor; +import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; +import com.linecorp.centraldogma.server.mirror.MirrorResult; +import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig; +import com.linecorp.centraldogma.server.storage.repository.MetaRepository; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics; +import io.netty.util.concurrent.DefaultThreadFactory; + +public final class MirrorRunner implements SafeCloseable { + + private final ProjectApiManager projectApiManager; + private final CommandExecutor commandExecutor; + private final File workDir; + private final MirroringServicePluginConfig mirrorConfig; + private final ExecutorService worker; + + private final Map> inflightRequests = new ConcurrentHashMap<>(); + + public MirrorRunner(ProjectApiManager projectApiManager, CommandExecutor commandExecutor, + CentralDogmaConfig cfg, MeterRegistry meterRegistry) { + this.projectApiManager = projectApiManager; + this.commandExecutor = commandExecutor; + workDir = new File(cfg.dataDir(), "_mirrors_manual"); + MirroringServicePluginConfig mirrorConfig = + (MirroringServicePluginConfig) cfg.pluginConfigMap() + .get(MirroringServicePluginConfig.class); + if (mirrorConfig == null) { + mirrorConfig = MirroringServicePluginConfig.INSTANCE; + } + this.mirrorConfig = mirrorConfig; + + final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( + 0, mirrorConfig.numMirroringThreads(), + // TODO(minwoox): Use LinkedTransferQueue when we upgrade to JDK 21. + 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), + new DefaultThreadFactory("mirror-api-worker", true)); + threadPoolExecutor.allowCoreThreadTimeOut(true); + worker = ExecutorServiceMetrics.monitor(meterRegistry, threadPoolExecutor, + "mirrorApiWorker"); + } + + public CompletableFuture run(String projectName, String mirrorId) { + // If there is an inflight request, return it to avoid running the same mirror task multiple times. + return inflightRequests.computeIfAbsent(new MirrorKey(projectName, mirrorId), this::run); + } + + private CompletableFuture run(MirrorKey mirrorKey) { + final CompletableFuture future = + metaRepo(mirrorKey.projectName).mirror(mirrorKey.mirrorId).thenApplyAsync(mirror -> { + return mirror.mirror(workDir, commandExecutor, + mirrorConfig.maxNumFilesPerMirror(), + mirrorConfig.maxNumBytesPerMirror()); + }, worker); + // Remove the inflight request when the mirror task is done. + future.handleAsync((unused0, unused1) -> inflightRequests.remove(mirrorKey)); + return future; + } + + private MetaRepository metaRepo(String projectName) { + return projectApiManager.getProject(projectName).metaRepo(); + } + + @Override + public void close() { + final boolean interrupted = terminate(worker); + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private static final class MirrorKey { + private final String projectName; + private final String mirrorId; + + private MirrorKey(String projectName, String mirrorId) { + this.projectName = projectName; + this.mirrorId = mirrorId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MirrorKey)) { + return false; + } + final MirrorKey mirrorKey = (MirrorKey) o; + return projectName.equals(mirrorKey.projectName) && + mirrorId.equals(mirrorKey.mirrorId); + } + + @Override + public int hashCode() { + return Objects.hash(projectName, mirrorId); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("projectName", projectName) + .add("mirrorId", mirrorId) + .toString(); + } + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java similarity index 98% rename from server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringService.java rename to server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java index 0190fe7669..44dd6fccfc 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingService.java @@ -58,9 +58,9 @@ import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics; import io.netty.util.concurrent.DefaultThreadFactory; -public final class DefaultMirroringService implements MirroringService { +public final class MirrorSchedulingService implements MirroringService { - private static final Logger logger = LoggerFactory.getLogger(DefaultMirroringService.class); + private static final Logger logger = LoggerFactory.getLogger(MirrorSchedulingService.class); /** * How often to check the mirroring schedules. i.e. every second. @@ -80,7 +80,7 @@ public final class DefaultMirroringService implements MirroringService { private ZonedDateTime lastExecutionTime; private final MeterRegistry meterRegistry; - DefaultMirroringService(File workDir, ProjectManager projectManager, MeterRegistry meterRegistry, + MirrorSchedulingService(File workDir, ProjectManager projectManager, MeterRegistry meterRegistry, int numThreads, int maxNumFilesPerMirror, long maxNumBytesPerMirror) { this.workDir = requireNonNull(workDir, "workDir"); diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePluginTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServicePluginTest.java similarity index 98% rename from server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePluginTest.java rename to server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServicePluginTest.java index d2c717cd1f..59ea58c38a 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/DefaultMirroringServicePluginTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/mirror/MirrorSchedulingServicePluginTest.java @@ -22,7 +22,7 @@ import com.linecorp.centraldogma.server.CentralDogmaConfig; import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig; -class DefaultMirroringServicePluginTest { +class MirrorSchedulingServicePluginTest { @Test void pluginConfig() throws Exception { diff --git a/webapp/package-lock.json b/webapp/package-lock.json index f198216af4..9d28d4466a 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-table": "^8.19.2", "axios": "1.7.2", "chakra-react-select": "^4.9.1", + "cronstrue": "^2.50.0", "date-fns": "^3.6.0", "framer-motion": "^11.2.13", "jsonpath": "^1.1.1", @@ -6137,6 +6138,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cronstrue": { + "version": "2.50.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.50.0.tgz", + "integrity": "sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", diff --git a/webapp/package.json b/webapp/package.json index decb1a2206..f5d02cbcac 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -29,6 +29,7 @@ "@tanstack/react-table": "^8.19.2", "axios": "1.7.2", "chakra-react-select": "^4.9.1", + "cronstrue": "^2.50.0", "date-fns": "^3.6.0", "framer-motion": "^11.2.13", "jsonpath": "^1.1.1", diff --git a/webapp/src/dogma/features/mirror/RunMirror.tsx b/webapp/src/dogma/features/mirror/RunMirrorButton.tsx similarity index 55% rename from webapp/src/dogma/features/mirror/RunMirror.tsx rename to webapp/src/dogma/features/mirror/RunMirrorButton.tsx index 2072e94ceb..d03e6dab01 100644 --- a/webapp/src/dogma/features/mirror/RunMirror.tsx +++ b/webapp/src/dogma/features/mirror/RunMirrorButton.tsx @@ -22,11 +22,30 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; import { MirrorResult } from './MirrorResult'; import { MirrorDto } from '../project/settings/mirrors/MirrorDto'; -import { FaPlay } from 'react-icons/fa'; -import { IconButton } from '@chakra-ui/react'; +import { + Button, + ButtonGroup, + Mark, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + useDisclosure, +} from '@chakra-ui/react'; +import { WithProjectRole } from '../auth/ProjectRole'; +import { ReactNode } from 'react'; -export const RunMirror = ({ mirror }: { mirror: MirrorDto }) => { +type RunMirrorProps = { + mirror: MirrorDto; + children: ({ isLoading }: { isLoading: boolean }) => ReactNode; +}; +export const RunMirror = ({ mirror, children }: RunMirrorProps) => { const [runMirror, { isLoading }] = useRunMirrorMutation(); + const { isOpen, onOpen, onClose } = useDisclosure(); const dispatch = useAppDispatch(); const onClick = async () => { try { @@ -42,19 +61,41 @@ export const RunMirror = ({ mirror }: { mirror: MirrorDto }) => { } else if (result.mirrorStatus === 'UP_TO_DATE') { dispatch(newNotification(`No changes`, result.description, 'info')); } + onClose(); } catch (error) { dispatch(newNotification(`Failed to run mirror ${mirror.id}`, ErrorMessageParser.parse(error), 'error')); } }; return ( - } - /> + + {() => ( + + {children({ isLoading })} + + Confirmation + + + + Do you want to run{' '} + + {mirror.id} + {' '} + mirror? + + + + + + + + + + )} + ); }; diff --git a/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx b/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx index e1557d1887..f54fdc4889 100644 --- a/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx +++ b/webapp/src/dogma/features/project/settings/mirrors/MirrorForm.tsx @@ -51,6 +51,7 @@ import { RepoDto } from 'dogma/features/repo/RepoDto'; import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; import { FiBox } from 'react-icons/fi'; +import cronstrue from 'cronstrue'; interface MirrorFormProps { projectName: string; @@ -79,11 +80,11 @@ const MirrorForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: const { register, handleSubmit, - reset, formState: { errors, isDirty }, setError, setValue, control, + watch, } = useForm(); const isNew = defaultValue.id === ''; @@ -91,6 +92,7 @@ const MirrorForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: const { data: credentials } = useGetCredentialsQuery(projectName); const [isScheduleEnabled, setScheduleEnabled] = useState(defaultValue.schedule != null); + const schedule = watch('schedule'); const repoOptions: OptionType[] = (repos || []) .filter((repo: RepoDto) => !INTERNAL_REPOS.has(repo.name)) @@ -134,7 +136,7 @@ const MirrorForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: return (
{ - return onSubmit(mirror, reset, setError); + return onSubmit(mirror, () => {}, setError); })} >
@@ -194,18 +196,25 @@ const MirrorForm = ({ projectName, defaultValue, onSubmit, isWaitingResponse }: /> {isScheduleEnabled ? ( - + <> + + {schedule && ( + + {cronstrue.toString(schedule, { verbose: true })} + + )} + ) : ( - Scheduling is disabled. Mirroring can be triggered manually. + Scheduling is disabled. )} diff --git a/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx b/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx index e16df4f2a5..1fade6fd2c 100644 --- a/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx +++ b/webapp/src/dogma/features/project/settings/mirrors/MirrorList.tsx @@ -2,11 +2,12 @@ import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; import React, { useMemo } from 'react'; import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination'; import { useGetMirrorsQuery } from 'dogma/features/api/apiSlice'; -import { Badge, Code, Link } from '@chakra-ui/react'; +import { Badge, Code, IconButton, Link, Tooltip } from '@chakra-ui/react'; import { GoRepo } from 'react-icons/go'; import { LabelledIcon } from 'dogma/common/components/LabelledIcon'; import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; -import { RunMirror } from '../../../mirror/RunMirror'; +import { RunMirror } from '../../../mirror/RunMirrorButton'; +import { FaPlay } from 'react-icons/fa'; // eslint-disable-next-line @typescript-eslint/no-unused-vars export type MirrorListProps = { @@ -57,19 +58,33 @@ const MirrorList = ({ projectName }: MirrorListProps) columnHelper.accessor((row: MirrorDto) => row.schedule, { cell: (info) => { return ( - <> - {info.getValue() ? ( - - {info.getValue()} - - ) : ( - - )} - + + {info.getValue() || 'disabled'} + ); }, header: 'Schedule', }), + columnHelper.accessor((row: MirrorDto) => row.schedule, { + cell: (info) => { + return ( + + {({ isLoading }) => ( + + } + /> + + )} + + ); + }, + header: 'Actions', + }), columnHelper.accessor((row: MirrorDto) => row.enabled, { cell: (info) => { if (info.getValue()) { diff --git a/webapp/src/dogma/features/project/settings/mirrors/MirrorView.tsx b/webapp/src/dogma/features/project/settings/mirrors/MirrorView.tsx index 2da0ddf374..e08f69fbdc 100644 --- a/webapp/src/dogma/features/project/settings/mirrors/MirrorView.tsx +++ b/webapp/src/dogma/features/project/settings/mirrors/MirrorView.tsx @@ -27,6 +27,9 @@ import { VscMirror, VscRepoClone } from 'react-icons/vsc'; import { MirrorDto } from 'dogma/features/project/settings/mirrors/MirrorDto'; import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto'; import { FiBox } from 'react-icons/fi'; +import cronstrue from 'cronstrue'; +import { RunMirror } from '../../../mirror/RunMirrorButton'; +import { FaPlay } from 'react-icons/fa'; const HeadRow = ({ children }: { children: ReactNode }) => ( @@ -76,8 +79,13 @@ const MirrorView = ({ projectName, mirror, credential }: MirrorViewProps) => { - {mirror.schedule} + {mirror.schedule || 'disabled'} + {mirror.schedule && ( + + {cronstrue.toString(mirror.schedule, { verbose: true })} + + )} @@ -152,12 +160,18 @@ const MirrorView = ({ projectName, mirror, credential }: MirrorViewProps) => {
- - + + {({ isLoading }) => ( + + )} +