Add an option to the query changes API to allow skipping faulty records
Gerrit uses a secondary index to serve requests for the dashboard.
Currently if the server fails to parse at least one record from the
index (for example because of a field containing an invalid value) the
entire request would fail. This leaves the dashboard unusable.
In this change, we add a new option named 'allow-incomplete-results' to
the 'Query Changes' API. If this option is set, the index can tolerate
handling the parsing of faulty records and create a canonical ChangeData
marked as failed to get parsed from the index. In ChangeJson, we handle
this case by creating a canonical empty entity where we only set the
{project, branch, change_id, number, owner} fields and set the subject
to "***ERROR***".
It is up to the specific index implementation to handle this option.
This is not yet handled in Lucene or Elastic Search.
Release-Notes: skip
Google-Bug-Id: b/234334827
Change-Id: I0eacf0576e929dd38535c33888382aa18190ebb3
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 25db398..ea95680 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -161,6 +161,15 @@
are filtered out. REST requests with the skip-visibility option are rejected when the current
user doesn't have the ADMINISTRATE_SERVER capability.
+The `allow-incomplete-results` query parameter can be used. This is a boolean
+parameter that can optionally be set to true. If set, the server can tolerate
+handling faulty records when parsed from the change index, for example if a
+field contained a value of a wrong format. For faulty records, the server
+will return a canonical empty record where only the fields {project, branch,
+change_id, _number, owner} are set and the subject will be set to
+"\*\**ERROR***". All other fields will be empty.
+Note that the handling of this parameter is up to the index implementation.
+
Clients are allowed to specify more than one query by setting the `q`
parameter multiple times. In this case the result is an array of
arrays, one per query in the same order the queries were given in.
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index d8741f5..ec392d8 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -16,6 +16,7 @@
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeInput;
@@ -81,6 +82,7 @@
private int limit;
private int start;
private boolean isNoLimit;
+ private boolean allowIncompleteResults;
private Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
private ListMultimap<String, String> pluginOptions = ArrayListMultimap.create();
@@ -106,6 +108,12 @@
return this;
}
+ @UsedAt(UsedAt.Project.GOOGLE)
+ public QueryRequest withAllowIncompleteResults(boolean allow) {
+ this.allowIncompleteResults = allow;
+ return this;
+ }
+
/** Set an option on the request, appending to existing options. */
public QueryRequest withOption(ListChangesOption options) {
this.options.add(options);
@@ -152,6 +160,11 @@
return start;
}
+ @UsedAt(UsedAt.Project.GOOGLE)
+ public boolean getAllowIncompleteResults() {
+ return allowIncompleteResults;
+ }
+
public Set<ListChangesOption> getOptions() {
return options;
}
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index ba1c8bd..8b48fc0 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -36,6 +36,7 @@
"//lib/antlr:java-runtime",
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
+ "//lib/errorprone:annotations",
"//lib/flogger:api",
],
)
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index 29ab6d0..bee8fa1 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -52,6 +52,38 @@
int pageSizeMultiplier,
int limit,
Set<String> fields) {
+ return create(
+ config,
+ start,
+ searchAfter,
+ pageSize,
+ pageSizeMultiplier,
+ limit,
+ /* allowIncompleteResults= */ false,
+ fields);
+ }
+
+ public static QueryOptions create(
+ IndexConfig config,
+ int start,
+ int pageSize,
+ int pageSizeMultiplier,
+ int limit,
+ boolean allowIncompleteResults,
+ Set<String> fields) {
+ return create(
+ config, start, null, pageSize, pageSizeMultiplier, limit, allowIncompleteResults, fields);
+ }
+
+ public static QueryOptions create(
+ IndexConfig config,
+ int start,
+ Object searchAfter,
+ int pageSize,
+ int pageSizeMultiplier,
+ int limit,
+ boolean allowIncompleteResults,
+ Set<String> fields) {
checkArgument(start >= 0, "start must be nonnegative: %s", start);
checkArgument(limit > 0, "limit must be positive: %s", limit);
if (searchAfter != null) {
@@ -64,6 +96,7 @@
pageSize,
pageSizeMultiplier,
limit,
+ allowIncompleteResults,
ImmutableSet.copyOf(fields));
}
@@ -77,7 +110,15 @@
Math.min(
Math.min(Ints.saturatedCast((long) pageSize() + start()), config().maxPageSize()),
backendLimit);
- return create(config(), 0, null, pageSize, pageSizeMultiplier(), limit, fields());
+ return create(
+ config(),
+ 0,
+ null,
+ pageSize,
+ pageSizeMultiplier(),
+ limit,
+ allowIncompleteResults(),
+ fields());
}
public abstract IndexConfig config();
@@ -93,28 +134,62 @@
public abstract int limit();
+ /**
+ * When set to true, entities that fail to get parsed from the index are replaced with a canonical
+ * erroneous record. If false, parsing would throw an exception.
+ */
+ public abstract boolean allowIncompleteResults();
+
public abstract ImmutableSet<String> fields();
public QueryOptions withPageSize(int pageSize) {
return create(
- config(), start(), searchAfter(), pageSize, pageSizeMultiplier(), limit(), fields());
+ config(),
+ start(),
+ searchAfter(),
+ pageSize,
+ pageSizeMultiplier(),
+ limit(),
+ allowIncompleteResults(),
+ fields());
}
public QueryOptions withLimit(int newLimit) {
return create(
- config(), start(), searchAfter(), pageSize(), pageSizeMultiplier(), newLimit, fields());
+ config(),
+ start(),
+ searchAfter(),
+ pageSize(),
+ pageSizeMultiplier(),
+ newLimit,
+ allowIncompleteResults(),
+ fields());
}
public QueryOptions withStart(int newStart) {
return create(
- config(), newStart, searchAfter(), pageSize(), pageSizeMultiplier(), limit(), fields());
+ config(),
+ newStart,
+ searchAfter(),
+ pageSize(),
+ pageSizeMultiplier(),
+ limit(),
+ allowIncompleteResults(),
+ fields());
}
public QueryOptions withSearchAfter(Object newSearchAfter) {
// Index search-after APIs don't use 'start', so set it to 0 to be safe. ElasticSearch for
// example, expects it to be 0 when using search-after APIs.
return create(
- config(), start(), newSearchAfter, pageSize(), pageSizeMultiplier(), limit(), fields())
+ config(),
+ start(),
+ newSearchAfter,
+ pageSize(),
+ pageSizeMultiplier(),
+ limit(),
+ allowIncompleteResults(),
+ fields())
.withStart(0);
}
@@ -126,6 +201,7 @@
pageSize(),
pageSizeMultiplier(),
limit(),
+ allowIncompleteResults(),
filter.apply(this));
}
}
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 5dfcef3..d847a06 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -26,6 +26,7 @@
import com.google.common.collect.Ordering;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.Index;
@@ -92,6 +93,7 @@
private boolean enforceVisibility = true;
private int userProvidedLimit;
private boolean isNoLimit;
+ private boolean allowIncompleteResults;
private Set<String> requestedFields;
protected QueryProcessor(
@@ -163,6 +165,12 @@
return this;
}
+ @CanIgnoreReturnValue
+ public QueryProcessor<T> setAllowIncompleteResults(boolean allowIncompleteResults) {
+ this.allowIncompleteResults = allowIncompleteResults;
+ return this;
+ }
+
public QueryProcessor<T> setRequestedFields(Set<String> fields) {
requestedFields = fields;
return this;
@@ -271,6 +279,7 @@
// ask for one more result from the query.
// NOTE: This is consistent to the behaviour before the introduction of pagination.`
limit == getBackendSupportedLimit() ? limit : Ints.saturatedCast((long) limit + 1),
+ allowIncompleteResults,
getRequestedFields());
logger.atFine().log("Query options: %s", opts);
// Apply index-specific rewrite first
@@ -358,9 +367,16 @@
int pageSize,
int pageSizeMultiplier,
int limit,
+ boolean allowIncompleteResults,
Set<String> requestedFields) {
return QueryOptions.create(
- indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
+ indexConfig,
+ start,
+ pageSize,
+ pageSizeMultiplier,
+ limit,
+ allowIncompleteResults,
+ requestedFields);
}
/**
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index 6b107f1..1666820 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -146,6 +146,7 @@
qc.setLimit(q.getLimit());
qc.setStart(q.getStart());
qc.setNoLimit(q.getNoLimit());
+ qc.setAllowIncompleteResults(q.getAllowIncompleteResults());
for (ListChangesOption option : q.getOptions()) {
qc.addOption(option);
}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 81f4a17..d83962f 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -518,6 +518,13 @@
// (2) Reusing
boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
ChangeData cd = changes.get(i);
+ if (cd.hasFailedParsingFromIndex()) {
+ Optional<ChangeInfo> faultyChangeInfo = createFaultyChangeInfo(cd);
+ if (faultyChangeInfo.isPresent()) {
+ changeInfos.add(faultyChangeInfo.get());
+ }
+ continue;
+ }
ChangeInfo info = cache.get(cd.getId());
if (info != null && isCacheable) {
changeInfos.add(info);
@@ -990,4 +997,25 @@
}
return ImmutableListMultimap.of();
}
+
+ /**
+ * Create an empty {@link ChangeInfo} designating a faulty record if {@link
+ * ChangeData#hasFailedParsingFromIndex()} is true.
+ *
+ * <p>Few fields are populated: project, branch, changeId, _number, subject, owner.
+ */
+ private static Optional<ChangeInfo> createFaultyChangeInfo(ChangeData cd) {
+ ChangeInfo info = new ChangeInfo();
+ Change c = cd.change();
+ if (c == null) {
+ return Optional.empty();
+ }
+ info.project = c.getProject().get();
+ info.branch = c.getDest().shortName();
+ info.changeId = c.getKey().get();
+ info._number = c.getId().get();
+ info.subject = "***ERROR***";
+ info.owner = new AccountInfo(c.getOwner().get());
+ return Optional.of(info);
+ }
}
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index c5fa45a..00642a9 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -64,6 +64,24 @@
int pageSizeMultiplier,
int limit,
Set<String> fields) {
+ return createOptions(
+ config,
+ start,
+ pageSize,
+ pageSizeMultiplier,
+ limit,
+ /* allowIncompleteResults= */ false,
+ fields);
+ }
+
+ public static QueryOptions createOptions(
+ IndexConfig config,
+ int start,
+ int pageSize,
+ int pageSizeMultiplier,
+ int limit,
+ boolean allowIncompleteResults,
+ Set<String> fields) {
// Always include project and change id since both are needed to load the change from NoteDb.
if (!fields.contains(CHANGE_SPEC.getName())
&& !(fields.contains(PROJECT_SPEC.getName())
@@ -72,7 +90,8 @@
fields.add(PROJECT_SPEC.getName());
fields.add(NUMERIC_ID_STR_SPEC.getName());
}
- return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
+ return QueryOptions.create(
+ config, start, pageSize, pageSizeMultiplier, limit, allowIncompleteResults, fields);
}
@VisibleForTesting
@@ -84,6 +103,7 @@
opts.pageSize(),
opts.pageSizeMultiplier(),
opts.limit(),
+ opts.allowIncompleteResults(),
opts.fields());
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 392d00a..ab9d690 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -37,6 +37,7 @@
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.BranchNameKey;
@@ -444,6 +445,7 @@
private String gerritServerId;
private String changeServerId;
private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
+ private Boolean failedParsingFromIndex = false;
@Inject
private ChangeData(
@@ -532,6 +534,15 @@
return allUsersName;
}
+ @UsedAt(UsedAt.Project.GOOGLE)
+ public void setFailedParsingFromIndex(Boolean val) {
+ this.failedParsingFromIndex = val;
+ }
+
+ public boolean hasFailedParsingFromIndex() {
+ return failedParsingFromIndex;
+ }
+
@VisibleForTesting
public void setCurrentFilePaths(List<String> filePaths) {
PatchSet ps = currentPatchSet();
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 3097224..2979170 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -124,9 +124,16 @@
int pageSize,
int pageSizeMultiplier,
int limit,
+ boolean allowIncompleteResults,
Set<String> requestedFields) {
return IndexedChangeQuery.createOptions(
- indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
+ indexConfig,
+ start,
+ pageSize,
+ pageSizeMultiplier,
+ limit,
+ allowIncompleteResults,
+ requestedFields);
}
@Override
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 6ce4b39..4d279b0 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -60,6 +60,7 @@
private Integer start;
private Boolean noLimit;
private Boolean skipVisibility;
+ private Boolean allowIncompleteResults;
@Option(
name = "--query",
@@ -112,6 +113,11 @@
skipVisibility = on;
}
+ @Option(name = "--allow-incomplete-results", usage = "Return partial results")
+ public void setAllowIncompleteResults(boolean allowIncompleteResults) {
+ this.allowIncompleteResults = allowIncompleteResults;
+ }
+
@Override
public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
dynamicBeans.put(plugin, dynamicBean);
@@ -181,6 +187,9 @@
if (skipVisibility != null) {
queryProcessor.enforceVisibility(!skipVisibility);
}
+ if (allowIncompleteResults != null) {
+ queryProcessor.setAllowIncompleteResults(allowIncompleteResults);
+ }
dynamicBeans.forEach((p, b) -> queryProcessor.setDynamicBean(p, b));
if (queries == null || queries.isEmpty()) {