Fix a bug where _moreChanges is set to the wrong value
The query changes REST endpoint supports sending multiple queries in a
single request. The backend implementation uses a cache (a simple map)
to spare computing a ChangeInfo object more than once in case it shows
up in more than a single query result.
The implementation contained a bug that would set _moreChanges on the
last entity of one query that has more results than get sent. However,
through the reuse of the entity, this boolean would also end up in other
entities, where it's simply wrong.
There are multiple, alternative ways how this can be fixed:
1) Remove the cache: We could simply remove the map. This has an unknown
performance impact.
2) Deep-copy objects instead of plainly reusing them: This would add
boiler-plate to a lot of *Info objects that ChangeInfo depends on.
Hopefully we can consider moving to immutable API objects (AutoValue
or Protobuf) in the future, which would obsolete this code. It still
seems undesirable to add this now. Notably, ActionJson already
contains what looks like brittle copy logic.
3) Rework the JSON response to be a List<QueryResponse> where
QueryResponse is an object that has List<ChangeInfo> and _more: Would
be the right path design-wise, but is a non-starter because it's a
major change to a heavily used API.
4) Never reuse the last entity in a result set: In this way, the last
entity is always unique. Setting _moreChanges on it has no side
effects.
This commit implements (4), which seems like the least evil.
Change-Id: I6b007d442249d0445ea49f125c45d8f9022e104e
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index d50e740..b0477cd 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -298,7 +298,6 @@
Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
for (QueryResult<ChangeData> r : in) {
List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
- infos.forEach(c -> cache.put(Change.id(c._number), c));
if (!infos.isEmpty() && r.more()) {
infos.get(infos.size() - 1)._moreChanges = true;
}
@@ -415,15 +414,29 @@
List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
- for (ChangeData cd : changes) {
- ChangeInfo i = cache.get(cd.getId());
- if (i != null) {
- changeInfos.add(i);
+ for (int i = 0; i < changes.size(); i++) {
+ // We can only cache and re-use an entity if it's not the last in the list. The last entity
+ // may later get _moreChanges set. If it was cached or re-used, that setting would propagate
+ // to the original entity yielding wrong results.
+ // This problem has two sides where 'last in the list' has to be respected:
+ // (1) Caching
+ // (2) Reusing
+ boolean isCacheable = i != changes.size() - 1;
+ ChangeData cd = changes.get(i);
+ ChangeInfo info = cache.get(cd.getId());
+ if (info != null && isCacheable) {
+ changeInfos.add(info);
continue;
}
+
+ // Compute and cache if possible
try {
ensureLoaded(Collections.singleton(cd));
- changeInfos.add(format(cd, Optional.empty(), false, ChangeInfo::new));
+ info = format(cd, Optional.empty(), false, ChangeInfo::new);
+ changeInfos.add(info);
+ if (isCacheable) {
+ cache.put(Change.id(info._number), info);
+ }
} catch (RuntimeException e) {
logger.atWarning().withCause(e).log(
"Omitting corrupt change %s from results", cd.getId());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
index 76166e1..92f914b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
@@ -44,8 +44,8 @@
QueryChanges queryChanges = queryChangesProvider.get();
- queryChanges.addQuery("is:open");
- queryChanges.addQuery("is:wip");
+ queryChanges.addQuery("is:open repo:" + project.get());
+ queryChanges.addQuery("is:wip repo:" + project.get());
List<List<ChangeInfo>> result =
(List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
@@ -58,4 +58,48 @@
assertThat(firstResultIds).containsExactly(numericId1, numericId2);
assertThat(result.get(1).get(0)._number).isEqualTo(numericId2);
}
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void moreChangesIndicatorDoesNotWronglyCopyToUnrelatedChanges() throws Exception {
+ String queryWithMoreChanges = "is:wip limit:1 repo:" + project.get();
+ String queryWithNoMoreChanges = "is:open limit:10 repo:" + project.get();
+ createChange().getChangeId();
+ String cId2 = createChange().getChangeId();
+ String cId3 = createChange().getChangeId();
+ gApi.changes().id(cId2).setWorkInProgress();
+ gApi.changes().id(cId3).setWorkInProgress();
+
+ // Run the capped query first
+ QueryChanges queryChanges = queryChangesProvider.get();
+ queryChanges.addQuery(queryWithMoreChanges);
+ queryChanges.addQuery(queryWithNoMoreChanges);
+ List<List<ChangeInfo>> result =
+ (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0)).hasSize(1);
+ assertThat(result.get(1)).hasSize(3);
+ // _moreChanges is set on the first response, but not on the second.
+ assertThat(result.get(0).get(0)._moreChanges).isTrue();
+ assertNoChangeHasMoreChangesSet(result.get(1));
+
+ // Run the capped query second
+ QueryChanges queryChanges2 = queryChangesProvider.get();
+ queryChanges2.addQuery(queryWithNoMoreChanges);
+ queryChanges2.addQuery(queryWithMoreChanges);
+ List<List<ChangeInfo>> result2 =
+ (List<List<ChangeInfo>>) queryChanges2.apply(TopLevelResource.INSTANCE).value();
+ assertThat(result2).hasSize(2);
+ assertThat(result2.get(0)).hasSize(3);
+ assertThat(result2.get(1)).hasSize(1);
+ // _moreChanges is set on the second response, but not on the first.
+ assertNoChangeHasMoreChangesSet(result2.get(0));
+ assertThat(result2.get(1).get(0)._moreChanges).isTrue();
+ }
+
+ private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
+ for (ChangeInfo info : results) {
+ assertThat(info._moreChanges).isNull();
+ }
+ }
}