From 658ccdf006e2c629f3b61c4b481259ce12068239 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Mon, 11 Mar 2024 11:13:42 +0100 Subject: [PATCH] Improve import of LFS files for pull and mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushed-by: Rene Pfeuffer Co-authored-by: René Pfeuffer Committed-by: René Pfeuffer --- gradle/changelog/lfs_performance.yaml | 2 + .../scm/repository/spi/GitPullCommand.java | 6 +- .../sonia/scm/repository/spi/LfsLoader.java | 100 ++++++++-- .../scm/repository/spi/LfsLoaderTest.java | 186 ++++++++++++++++++ .../spi/scm-git-spi-lfs-loader-test.zip | Bin 0 -> 21042 bytes 5 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 gradle/changelog/lfs_performance.yaml create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/LfsLoaderTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-loader-test.zip diff --git a/gradle/changelog/lfs_performance.yaml b/gradle/changelog/lfs_performance.yaml new file mode 100644 index 0000000000..88556161b6 --- /dev/null +++ b/gradle/changelog/lfs_performance.yaml @@ -0,0 +1,2 @@ +- type: changed + description: Improved performance of LFS imports for imported repositories and mirrors diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java index 290ad629c6..0e9b4f6fab 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java @@ -51,6 +51,8 @@ import sonia.scm.repository.api.PullResponse; import java.io.File; import java.io.IOException; +import java.util.HashSet; +import java.util.Set; public class GitPullCommand extends AbstractGitPushOrPullCommand @@ -225,6 +227,7 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand } private void fetchLfs(PullCommandRequest request, Git git, LfsLoader.LfsLoaderLogger lfsLoaderLogger) throws IOException { + Set alreadyVisited = new HashSet<>(1000); open().getRefDatabase().getRefs().forEach( ref -> lfsLoader.inspectTree( ref.getObjectId(), @@ -233,7 +236,8 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand new MirrorCommandResult.LfsUpdateResult(), repository, pullHttpConnectionProvider.createHttpConnectionFactory(request), - request.getRemoteUrl().toString() + request.getRemoteUrl().toString(), + alreadyVisited ) ); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java index b7a077aacc..f41696593f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java @@ -24,6 +24,7 @@ package sonia.scm.repository.spi; +import com.google.common.annotations.VisibleForTesting; import jakarta.inject.Inject; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lfs.Lfs; @@ -51,7 +52,8 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collection; +import java.util.HashSet; +import java.util.Set; class LfsLoader { @@ -71,8 +73,24 @@ class LfsLoader { sonia.scm.repository.Repository repository, HttpConnectionFactory httpConnectionFactory, String url) { - EntryHandler entryHandler = new EntryHandler(repository, gitRepository, mirrorLog, lfsUpdateResult, httpConnectionFactory); - inspectTree(newObjectId, entryHandler, gitRepository, mirrorLog, lfsUpdateResult, url); + inspectTree(newObjectId, gitRepository, mirrorLog, lfsUpdateResult, repository, httpConnectionFactory, url, new HashSet<>(1000)); + } + + void inspectTree(ObjectId newObjectId, + Repository gitRepository, + LfsLoaderLogger mirrorLog, + LfsUpdateResult lfsUpdateResult, + sonia.scm.repository.Repository repository, + HttpConnectionFactory httpConnectionFactory, + String url, + Set alreadyVisited) { + EntryHandler entryHandler = createEntryHandler(gitRepository, mirrorLog, lfsUpdateResult, repository, httpConnectionFactory); + inspectTree(newObjectId, entryHandler, gitRepository, mirrorLog, lfsUpdateResult, url, alreadyVisited); + } + + @VisibleForTesting + EntryHandler createEntryHandler(Repository gitRepository, LfsLoaderLogger mirrorLog, LfsUpdateResult lfsUpdateResult, sonia.scm.repository.Repository repository, HttpConnectionFactory httpConnectionFactory) { + return new EntryHandler(repository, gitRepository, mirrorLog, lfsUpdateResult, httpConnectionFactory); } private void inspectTree(ObjectId newObjectId, @@ -80,20 +98,25 @@ class LfsLoader { Repository gitRepository, LfsLoaderLogger mirrorLog, LfsUpdateResult lfsUpdateResult, - String sourceUrl) { + String sourceUrl, + Set alreadyVisited) { try { gitRepository .getConfig() .setString(ConfigConstants.CONFIG_SECTION_LFS, null, ConfigConstants.CONFIG_KEY_URL, computeLfsUrl(sourceUrl)); TreeWalk treeWalk = new TreeWalk(gitRepository); - treeWalk.setFilter(new ScmLfsPointerFilter()); + treeWalk.setFilter(new FilteringScmLfsPointerFilter(alreadyVisited)); treeWalk.setRecursive(true); RevWalk revWalk = new RevWalk(gitRepository); revWalk.markStart(revWalk.parseCommit(newObjectId)); for (RevCommit commit : revWalk) { + if (!alreadyVisited.add(commit.toObjectId())) { + LOG.trace("skipping commit {}", commit); + break; + } treeWalk.reset(); treeWalk.addTree(commit.getTree()); while (treeWalk.next()) { @@ -115,7 +138,31 @@ class LfsLoader { } } - private class EntryHandler { + @VisibleForTesting + Path downloadLfsResource(Lfs lfs, Repository gitRepository, HttpConnectionFactory connectionFactory, LfsPointer lfsPointer) throws IOException { + return SmudgeFilter.downloadLfsResource( + lfs, + gitRepository, + connectionFactory, + lfsPointer + ) + .iterator() + .next(); + } + + @VisibleForTesting + void storeLfsBlob(AnyLongObjectId oid, Path tempFilePath, BlobStore lfsBlobStore) throws IOException { + LOG.trace("temporary lfs file: {}", tempFilePath); + Files.copy( + tempFilePath, + lfsBlobStore + .create(oid.name()) + .getOutputStream() + ); + } + + @VisibleForTesting + class EntryHandler { private final BlobStore lfsBlobStore; private final Repository gitRepository; @@ -137,15 +184,19 @@ class LfsLoader { this.httpConnectionFactory = httpConnectionFactory; } - private void handleTreeEntry(TreeWalk treeWalk) { + @VisibleForTesting + void handleTreeEntry(TreeWalk treeWalk) { try (InputStream is = gitRepository.open(treeWalk.getObjectId(0), Constants.OBJ_BLOB).openStream()) { LfsPointer lfsPointer = LfsPointer.parseLfsPointer(is); AnyLongObjectId oid = lfsPointer.getOid(); if (lfsBlobStore.get(oid.name()) == null) { Path tempFilePath = loadLfsFile(lfsPointer); - storeLfsBlob(oid, tempFilePath); - Files.delete(tempFilePath); + try { + storeLfsBlob(oid, tempFilePath, lfsBlobStore); + } finally { + Files.delete(tempFilePath); + } } } catch (Exception e) { LOG.warn("failed to load lfs file", e); @@ -161,23 +212,12 @@ class LfsLoader { Lfs lfs = new Lfs(gitRepository); lfs.getMediaFile(lfsPointer.getOid()); - Collection paths = SmudgeFilter.downloadLfsResource( + return downloadLfsResource( lfs, gitRepository, httpConnectionFactory, lfsPointer ); - return paths.iterator().next(); - } - - private void storeLfsBlob(AnyLongObjectId oid, Path tempFilePath) throws IOException { - LOG.trace("temporary lfs file: {}", tempFilePath); - Files.copy( - tempFilePath, - lfsBlobStore - .create(oid.name()) - .getOutputStream() - ); } } @@ -207,4 +247,22 @@ class LfsLoader { return super.include(walk); } } + + private static class FilteringScmLfsPointerFilter extends ScmLfsPointerFilter { + + private final Set alreadyVisited; + + private FilteringScmLfsPointerFilter(Set alreadyVisited) { + this.alreadyVisited = alreadyVisited; + } + + @Override + public boolean include(TreeWalk walk) throws IOException { + if (!alreadyVisited.add(walk.getObjectId(0))) { + LOG.trace("skipping object {}", walk.getObjectId(0)); + return false; + } + return super.include(walk); + } + } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/LfsLoaderTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/LfsLoaderTest.java new file mode 100644 index 0000000000..61784491d5 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/LfsLoaderTest.java @@ -0,0 +1,186 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.eclipse.jgit.lfs.Lfs; +import org.eclipse.jgit.lfs.LfsPointer; +import org.eclipse.jgit.lfs.lib.AnyLongObjectId; +import org.eclipse.jgit.lfs.lib.LongObjectId; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.http.HttpConnectionFactory; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.store.BlobStore; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/* + * This test uses a repository with the following layout: + * 7100a0f (branch/b) Add fourth png + * d89ee93 (branch/a) Add third png + | * f3134d6 (master) Add second png + |/ + * 62c8598 Add first png + * cccd744 init lfs + * + * Each commit with the text "Add ..." adds exactly one lfs file to the repository. + */ +public class LfsLoaderTest extends ZippedRepositoryTestBase { + + @Rule + public MockitoRule rule = MockitoJUnit.rule(); + + @Mock + private LfsBlobStoreFactory lfsBlobStoreFactory; + @Mock + private BlobStore lfsBlobStore; + @Mock + private LfsLoader.LfsLoaderLogger lfsLoaderLogger; + @Mock + private MirrorCommandResult.LfsUpdateResult lfsUpdateResult; + @Mock + private HttpConnectionFactory httpConnectionFactory; + + private LfsLoader lfsLoader; + + private LfsLoader.EntryHandler entryHandler; + private final Map storedBlobs = new HashMap<>(); + private Path lfsTemp; + + @Before + public void initLfsBlobStore() { + when(lfsBlobStoreFactory.getLfsBlobStore(repository)).thenReturn(lfsBlobStore); + } + + @Before + public void initLfsLoader() { + lfsLoader = new LfsLoader(lfsBlobStoreFactory) { + @Override + Path downloadLfsResource(Lfs lfs, Repository gitRepository, HttpConnectionFactory connectionFactory, LfsPointer lfsPointer) throws IOException { + Path file = lfsTemp.resolve(lfsPointer.getOid().getName()); + Files.createFile(file); + return file; + } + + @Override + void storeLfsBlob(AnyLongObjectId oid, Path tempFilePath, BlobStore lfsBlobStore) { + storedBlobs.put(oid, tempFilePath); + } + + @Override + EntryHandler createEntryHandler(Repository gitRepository, LfsLoaderLogger mirrorLog, MirrorCommandResult.LfsUpdateResult lfsUpdateResult, sonia.scm.repository.Repository repository, HttpConnectionFactory httpConnectionFactory) { + entryHandler = spy(super.createEntryHandler(gitRepository, mirrorLog, lfsUpdateResult, repository, httpConnectionFactory)); + return entryHandler; + } + }; + } + + @Before + public void initLfsTemp() throws IOException { + lfsTemp = tempFolder.newFolder("lfs").toPath(); + } + + @Test + public void shouldLoadAllLfsFiles() throws IOException { + lfsLoader.inspectTree( + ObjectId.fromString("f3134d622484981329034ef63c6c1c9b0e5c5232"), + GitUtil.open(repositoryDirectory), + lfsLoaderLogger, + lfsUpdateResult, + repository, + httpConnectionFactory, + "http://localhost:8081/scm/repo.git" + ); + + assertThat(storedBlobs) + .hasSize(2) + .containsAllEntriesOf( + Map.of( + LongObjectId.fromString("53c234e5e8472b6ac51c1ae1cab3fe06fad053beb8ebfd8977b010655bfdd3c3"), lfsTemp.resolve("53c234e5e8472b6ac51c1ae1cab3fe06fad053beb8ebfd8977b010655bfdd3c3"), + LongObjectId.fromString("4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865"), lfsTemp.resolve("4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865") + )); + assertThat(lfsTemp).isEmptyDirectory(); + } + + @Test + public void shouldCheckLfsFilesOnlyOnce() throws IOException { + Set alreadyVisited = new HashSet<>(); + + try (Repository repository = GitUtil.open(repositoryDirectory)) { + asList( + "d89ee931a676bec618177fb4ae6e056f8907a82b", // branch/a + "7100a0f377b142a9a746ee0b18cb4124309c0900" // branch/b + ) + .forEach( + objId -> lfsLoader.inspectTree( + ObjectId.fromString(objId), + repository, + lfsLoaderLogger, + lfsUpdateResult, + this.repository, + httpConnectionFactory, + "http://localhost:8081/scm/repo.git", + alreadyVisited + ) + ); + } + + assertThat(storedBlobs).hasSize(3); + // The second entry handler created for the last revision (branch/b) should be used only once, because all other + // object ids for this revision have been handled by the other revision (branch/a) before: + verify(entryHandler).handleTreeEntry(any()); + } + + @Override + protected String getType() { + return "git"; + } + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-git-spi-lfs-loader-test.zip"; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-loader-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-loader-test.zip new file mode 100644 index 0000000000000000000000000000000000000000..b2c4ed57e4d23a6fb668b1a207e5215258a6e6b2 GIT binary patch literal 21042 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