Send small refs as a fetch call payload

To improve performance of fetch operation if total size of
the ref is smaller than 'payloadMaxRefSize' parameter send
all objects for that ref as a REST Api call payload.
Otherwise use git fetch operation.

Bug: Issue 13915
Change-Id: I3b39bd57120016617128f841a62c66341b968fb0
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
index 8c7dca6..6c8ada0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.Queues;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -36,6 +35,7 @@
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -183,18 +183,18 @@
   }
 
   private CallFunction getCallFunction(NameKey project, String refName, ReplicationState state) {
-    if (RefNames.isNoteDbMetaRef(refName)) {
-      try {
-        RevisionData revision = revisionReader.read(project, refName);
-        return (source) -> callSendObject(source, project, refName, revision, state);
-      } catch (IOException | RefUpdateException e) {
-        stateLog.error(
-            String.format(
-                "Exception during reading ref: %s, project:%s, message: %s",
-                refName, project.get(), e.getMessage()),
-            e,
-            state);
+    try {
+      Optional<RevisionData> revisionData = revisionReader.read(project, refName);
+      if (revisionData.isPresent()) {
+        return ((source) -> callSendObject(source, project, refName, revisionData.get(), state));
       }
+    } catch (IOException | RefUpdateException e) {
+      stateLog.error(
+          String.format(
+              "Exception during reading ref: %s, project:%s, message: %s",
+              refName, project.get(), e.getMessage()),
+          e,
+          state);
     }
 
     return (source) -> callFetch(source, project, refName, state);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
index a10f1da..d88c5f5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReader.java
@@ -14,6 +14,8 @@
 
 package com.googlesource.gerrit.plugins.replication.pull;
 
+import static com.googlesource.gerrit.plugins.replication.pull.PullReplicationLogger.repLog;
+
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -23,11 +25,15 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.RefUpdateException;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.lib.Ref;
@@ -35,16 +41,22 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
 
 public class RevisionReader {
+  private static final String CONFIG_MAX_API_PAYLOAD_SIZE = "maxApiPayloadSize";
+  private static final Long DEFAULT_MAX_PAYLOAD_SIZE_IN_BYTES = 10000L;
   private GitRepositoryManager gitRepositoryManager;
+  private Long maxRefSize;
 
   @Inject
-  public RevisionReader(GitRepositoryManager gitRepositoryManager) {
+  public RevisionReader(GitRepositoryManager gitRepositoryManager, Config cfg) {
     this.gitRepositoryManager = gitRepositoryManager;
+    this.maxRefSize =
+        cfg.getLong("replication", CONFIG_MAX_API_PAYLOAD_SIZE, DEFAULT_MAX_PAYLOAD_SIZE_IN_BYTES);
   }
 
-  public RevisionData read(Project.NameKey project, String refName)
+  public Optional<RevisionData> read(Project.NameKey project, String refName)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           RepositoryNotFoundException, RefUpdateException, IOException {
     try (Repository git = gitRepositoryManager.openRepository(project)) {
@@ -54,30 +66,97 @@
             String.format("Cannot find ref %s in project %s", refName, project.get()));
       }
 
+      Long totalRefSize = 0l;
+
       ObjectLoader commitLoader = git.open(head.getObjectId());
+      totalRefSize += commitLoader.getSize();
+      verifySize(totalRefSize, commitLoader);
+
       RevCommit commit = RevCommit.parse(commitLoader.getCachedBytes());
       RevisionObjectData commitRev =
           new RevisionObjectData(commit.getType(), commitLoader.getCachedBytes());
 
       RevTree tree = commit.getTree();
       ObjectLoader treeLoader = git.open(commit.getTree().toObjectId());
+      totalRefSize += treeLoader.getSize();
+      verifySize(totalRefSize, treeLoader);
+
       RevisionObjectData treeRev =
           new RevisionObjectData(tree.getType(), treeLoader.getCachedBytes());
 
       List<RevisionObjectData> blobs = Lists.newLinkedList();
       try (TreeWalk walk = new TreeWalk(git)) {
-        walk.addTree(tree);
-        while (walk.next()) {
-          ObjectId blobObjectId = walk.getObjectId(0);
-          ObjectLoader blobLoader = git.open(blobObjectId);
-          if (blobLoader.getType() == Constants.OBJ_BLOB) {
-            RevisionObjectData rev =
-                new RevisionObjectData(blobLoader.getType(), blobLoader.getCachedBytes());
-            blobs.add(rev);
-          }
+        if (commit.getParentCount() > 0) {
+          List<DiffEntry> diffEntries = readDiffs(git, commit, tree, walk);
+          blobs = readBlobs(git, totalRefSize, diffEntries);
+        } else {
+          walk.setRecursive(true);
+          walk.addTree(tree);
+          blobs = readBlobs(git, totalRefSize, walk);
         }
       }
-      return new RevisionData(commitRev, treeRev, blobs);
+      return Optional.of(new RevisionData(commitRev, treeRev, blobs));
+    } catch (LargeObjectException e) {
+      repLog.trace(
+          "Ref %s size for project %s is greater than configured '%s'",
+          refName, project, CONFIG_MAX_API_PAYLOAD_SIZE);
+      return Optional.empty();
+    }
+  }
+
+  private List<DiffEntry> readDiffs(Repository git, RevCommit commit, RevTree tree, TreeWalk walk)
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
+    walk.setFilter(TreeFilter.ANY_DIFF);
+    walk.reset(getParentTree(git, commit), tree);
+    return DiffEntry.scan(walk, true);
+  }
+
+  private List<RevisionObjectData> readBlobs(Repository git, Long totalRefSize, TreeWalk walk)
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
+    List<RevisionObjectData> blobs = Lists.newLinkedList();
+    while (walk.next()) {
+      ObjectId objectId = walk.getObjectId(0);
+      ObjectLoader objectLoader = git.open(objectId);
+      totalRefSize += objectLoader.getSize();
+      verifySize(totalRefSize, objectLoader);
+
+      RevisionObjectData rev =
+          new RevisionObjectData(objectLoader.getType(), objectLoader.getCachedBytes());
+      blobs.add(rev);
+    }
+    return blobs;
+  }
+
+  private List<RevisionObjectData> readBlobs(
+      Repository git, Long totalRefSize, List<DiffEntry> diffEntries)
+      throws MissingObjectException, IOException {
+    List<RevisionObjectData> blobs = Lists.newLinkedList();
+    for (DiffEntry diffEntry : diffEntries) {
+      if (!ChangeType.DELETE.equals(diffEntry.getChangeType())) {
+        ObjectLoader objectLoader = git.open(diffEntry.getNewId().toObjectId());
+        totalRefSize += objectLoader.getSize();
+        verifySize(totalRefSize, objectLoader);
+        RevisionObjectData rev =
+            new RevisionObjectData(objectLoader.getType(), objectLoader.getCachedBytes());
+        blobs.add(rev);
+      }
+    }
+    return blobs;
+  }
+
+  private RevTree getParentTree(Repository git, RevCommit commit)
+      throws MissingObjectException, IOException {
+    RevCommit parent = commit.getParent(0);
+    ObjectLoader parentLoader = git.open(parent.getId());
+    RevCommit parentCommit = RevCommit.parse(parentLoader.getCachedBytes());
+    return parentCommit.getTree();
+  }
+
+  private void verifySize(Long totalRefSize, ObjectLoader loader) throws LargeObjectException {
+    if (loader.isLarge() || totalRefSize > maxRefSize) {
+      throw new LargeObjectException();
     }
   }
 }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index ae2c2fd..c3e5ad4 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -181,6 +181,13 @@
 
     By default, all other refs are included.
 
+replication.maxApiPayloadSize
+:	Maximum size in bytes of the ref to be sent as a REST Api call
+	payload. For refs larger than threshold git fetch operation
+	will be used.
+
+	Default: 10000
+
 remote.NAME.url
 :	Address of the remote server to fetch from. Single URL can be
 	specified within a single remote block. A remote node can request
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
index e582b4f..44b69db 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
@@ -40,7 +40,9 @@
 import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.Optional;
 import org.apache.http.client.ClientProtocolException;
+import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -73,7 +75,7 @@
   private Path pluginDataPath;
 
   @Before
-  public void setup() throws IOException, RefUpdateException {
+  public void setup() throws IOException, LargeObjectException, RefUpdateException {
     Path sitePath = createTempPath("site");
     sitePaths = new SitePaths(sitePath);
     Path pluginDataPath = createTempPath("data");
@@ -86,7 +88,7 @@
     when(source.getApis()).thenReturn(apis);
     when(sourceCollection.getAll()).thenReturn(Lists.newArrayList(source));
     when(rd.get()).thenReturn(sourceCollection);
-    when(revReader.read(any(), anyString())).thenReturn(revisionData);
+    when(revReader.read(any(), anyString())).thenReturn(Optional.of(revisionData));
     when(fetchClientFactory.create(any())).thenReturn(fetchRestApiClient);
     when(fetchRestApiClient.callSendObject(any(), anyString(), any(), any()))
         .thenReturn(httpResult);
@@ -107,21 +109,34 @@
   }
 
   @Test
-  public void shouldCallFetchWhenPatchSetRef() throws ClientProtocolException, IOException {
+  public void shouldCallSendObjectWhenPatchSetRef() throws ClientProtocolException, IOException {
     Event event = new TestEvent("refs/changes/01/1/1");
     objectUnderTest.start();
     objectUnderTest.onGitReferenceUpdated(event);
 
+    verify(fetchRestApiClient).callSendObject(any(), anyString(), any(), any());
+  }
+
+  @Test
+  public void shouldFallbackToCallFetchWhenIOException()
+      throws ClientProtocolException, IOException, LargeObjectException, RefUpdateException {
+    Event event = new TestEvent("refs/changes/01/1/meta");
+    objectUnderTest.start();
+
+    when(revReader.read(any(), anyString())).thenThrow(IOException.class);
+
+    objectUnderTest.onGitReferenceUpdated(event);
+
     verify(fetchRestApiClient).callFetch(any(), anyString(), any());
   }
 
   @Test
-  public void shouldFallbackToCallFetchWhenIOException()
-      throws ClientProtocolException, IOException, RefUpdateException {
-    Event event = new TestEvent("refs/changes/01/1/meta");
+  public void shouldFallbackToCallFetchWhenLargeRef()
+      throws ClientProtocolException, IOException, LargeObjectException, RefUpdateException {
+    Event event = new TestEvent("refs/changes/01/1/1");
     objectUnderTest.start();
 
-    when(revReader.read(any(), anyString())).thenThrow(IOException.class);
+    when(revReader.read(any(), anyString())).thenReturn(Optional.empty());
 
     objectUnderTest.onGitReferenceUpdated(event);
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
index dc36021..ca410d5 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/RevisionReaderIT.java
@@ -34,6 +34,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionData;
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.fetch.ApplyObject;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
 import org.junit.Test;
 
@@ -49,8 +50,10 @@
     Result pushResult = createChange();
     String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
 
-    RevisionData revisionData = objectUnderTest.read(project, refName);
+    Optional<RevisionData> revisionDataOption = objectUnderTest.read(project, refName);
 
+    assertThat(revisionDataOption.isPresent()).isTrue();
+    RevisionData revisionData = revisionDataOption.get();
     assertThat(revisionData).isNotNull();
     assertThat(revisionData.getCommitObject()).isNotNull();
     assertThat(revisionData.getCommitObject().getType()).isEqualTo(Constants.OBJ_COMMIT);
@@ -75,8 +78,10 @@
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     gApi.changes().id(changeId.get()).current().review(reviewInput);
 
-    RevisionData revisionData = objectUnderTest.read(project, refName);
+    Optional<RevisionData> revisionDataOption = objectUnderTest.read(project, refName);
 
+    assertThat(revisionDataOption.isPresent()).isTrue();
+    RevisionData revisionData = revisionDataOption.get();
     assertThat(revisionData).isNotNull();
     assertThat(revisionData.getCommitObject()).isNotNull();
     assertThat(revisionData.getCommitObject().getType()).isEqualTo(Constants.OBJ_COMMIT);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
index 05aa596..5d80d94 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/fetch/ApplyObjectIT.java
@@ -41,6 +41,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData;
 import com.googlesource.gerrit.plugins.replication.pull.api.exception.MissingParentObjectException;
 import java.util.List;
+import java.util.Optional;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -67,13 +68,14 @@
     Result pushResult = createChange();
     String refName = RefNames.changeMetaRef(pushResult.getChange().getId());
 
-    RevisionData revisionData = reader.read(Project.nameKey(testRepoProjectName), refName);
+    Optional<RevisionData> revisionData =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
 
     RefSpec refSpec = new RefSpec(refName);
-    objectUnderTest.apply(project, refSpec, revisionData);
-    RevisionData newRevisionData = reader.read(project, refName);
+    objectUnderTest.apply(project, refSpec, revisionData.get());
+    Optional<RevisionData> newRevisionData = reader.read(project, refName);
 
-    compareObjects(revisionData, newRevisionData);
+    compareObjects(revisionData.get(), newRevisionData);
 
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo); ) {
@@ -91,22 +93,23 @@
     String refName = RefNames.changeMetaRef(changeId);
     RefSpec refSpec = new RefSpec(refName);
 
-    RevisionData revisionData = reader.read(Project.nameKey(testRepoProjectName), refName);
-    objectUnderTest.apply(project, refSpec, revisionData);
+    Optional<RevisionData> revisionData =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
+    objectUnderTest.apply(project, refSpec, revisionData.get());
 
     ReviewInput reviewInput = new ReviewInput();
     CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment");
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     gApi.changes().id(changeId.get()).current().review(reviewInput);
 
-    RevisionData revisionDataWithComment =
+    Optional<RevisionData> revisionDataWithComment =
         reader.read(Project.nameKey(testRepoProjectName), refName);
 
-    objectUnderTest.apply(project, refSpec, revisionDataWithComment);
+    objectUnderTest.apply(project, refSpec, revisionDataWithComment.get());
 
-    RevisionData newRevisionData = reader.read(project, refName);
+    Optional<RevisionData> newRevisionData = reader.read(project, refName);
 
-    compareObjects(revisionDataWithComment, newRevisionData);
+    compareObjects(revisionDataWithComment.get(), newRevisionData);
 
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
@@ -124,20 +127,22 @@
     String refName = RefNames.changeMetaRef(changeId);
 
     CommentInput comment = createCommentInput(1, 0, 1, 1, "Test comment");
-
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     gApi.changes().id(changeId.get()).current().review(reviewInput);
 
-    RevisionData revisionData = reader.read(Project.nameKey(testRepoProjectName), refName);
+    Optional<RevisionData> revisionData =
+        reader.read(Project.nameKey(testRepoProjectName), refName);
 
     RefSpec refSpec = new RefSpec(refName);
     assertThrows(
         MissingParentObjectException.class,
-        () -> objectUnderTest.apply(project, refSpec, revisionData));
+        () -> objectUnderTest.apply(project, refSpec, revisionData.get()));
   }
 
-  private void compareObjects(RevisionData expected, RevisionData actual) {
+  private void compareObjects(RevisionData expected, Optional<RevisionData> actualOption) {
+    assertThat(actualOption.isPresent()).isTrue();
+    RevisionData actual = actualOption.get();
     compareContent(expected.getCommitObject(), actual.getCommitObject());
     compareContent(expected.getTreeObject(), expected.getTreeObject());
     List<List<Byte>> actualBlobs =