blob: 0ba96c39716fdd813434f42974346e0ab70f06c8 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF 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
*
* http://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 org.apache.solr.cloud.api.collections;
import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import org.apache.solr.client.solrj.RoutedAliasTypes;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.cloud.Aliases;
import org.apache.solr.common.cloud.ZkStateReader;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.update.AddUpdateCommand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.apache.solr.common.SolrException.ErrorCode.BAD_REQUEST;
public class CategoryRoutedAlias extends RoutedAlias {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private static final String COLLECTION_INFIX = "__CRA__";
// This constant is terribly annoying but a great many things fall apart if we allow an alias with
// no collections to be created. So this kludge seems better than reworking every request path that
// expects a collection but also works with an alias to handle or error out on empty alias. The
// collection with this constant as a suffix is automatically removed after the alias begins to
// receive data.
public static final String UNINITIALIZED = "NEW_CATEGORY_ROUTED_ALIAS_WAITING_FOR_DATA_TEMP";
@SuppressWarnings("WeakerAccess")
public static final String ROUTER_MAX_CARDINALITY = "router.maxCardinality";
/**
* Parameters required for creating a category routed alias
*/
@SuppressWarnings("WeakerAccess")
public static final Set<String> REQUIRED_ROUTER_PARAMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
CommonParams.NAME,
ROUTER_TYPE_NAME,
ROUTER_FIELD,
ROUTER_MAX_CARDINALITY
)));
public static final String ROUTER_MUST_MATCH = "router.mustMatch";
/**
* Optional parameters for creating a category routed alias excluding parameters for collection creation.
*/
@SuppressWarnings("WeakerAccess")
public static final Set<String> OPTIONAL_ROUTER_PARAMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
ROUTER_MAX_CARDINALITY,
ROUTER_MUST_MATCH)));
private Aliases aliases;
private final String aliasName;
private final Map<String, String> aliasMetadata;
private final Integer maxCardinality;
private final Pattern mustMatch;
CategoryRoutedAlias(String aliasName, Map<String, String> aliasMetadata) {
this.aliasName = aliasName;
this.aliasMetadata = aliasMetadata;
this.maxCardinality = parseMaxCardinality(aliasMetadata.get(ROUTER_MAX_CARDINALITY));
final String mustMatch = this.aliasMetadata.get(ROUTER_MUST_MATCH);
this.mustMatch = mustMatch == null ? null : compileMustMatch(mustMatch);
}
@Override
public boolean updateParsedCollectionAliases(ZkStateReader zkStateReader, boolean contextualize) {
final Aliases aliases = zkStateReader.getAliases(); // note: might be different from last request
if (this.aliases != aliases) {
if (this.aliases != null) {
if (log.isDebugEnabled()) {
log.debug("Observing possibly updated alias: {}", getAliasName());
}
}
// slightly inefficient, but not easy to make changes to the return value of parseCollections
this.aliases = aliases;
return true;
}
return false;
}
@Override
public String getAliasName() {
return aliasName;
}
@Override
public String getRouteField() {
return aliasMetadata.get(ROUTER_FIELD);
}
@Override
public RoutedAliasTypes getRoutedAliasType() {
return RoutedAliasTypes.CATEGORY;
}
@Override
public void validateRouteValue(AddUpdateCommand cmd) throws SolrException {
if (this.aliases == null) {
updateParsedCollectionAliases(cmd.getReq().getCore().getCoreContainer().getZkController().zkStateReader, false);
}
Object fieldValue = cmd.getSolrInputDocument().getFieldValue(getRouteField());
// possible future enhancement: allow specification of an "unknown" category name to where we can send
// docs that are uncategorized.
if (fieldValue == null) {
throw new SolrException(BAD_REQUEST, "Route value is null");
}
String dataValue = String.valueOf(fieldValue);
String candidateCollectionName = buildCollectionNameFromValue(dataValue);
List<String> cols = getCollectionList(this.aliases);
if (cols.contains(candidateCollectionName)) {
return;
}
// this check will become very important for future work
int infix = candidateCollectionName.indexOf(COLLECTION_INFIX);
int valueStart = infix + COLLECTION_INFIX.length();
if (candidateCollectionName.substring(valueStart).contains(COLLECTION_INFIX)) {
throw new SolrException(BAD_REQUEST, "No portion of the route value may resolve to the 7 character sequence " +
"__CRA__");
}
if (mustMatch != null && !mustMatch.matcher(dataValue).matches()) {
throw new SolrException(BAD_REQUEST, "Route value " + dataValue
+ " does not match " + ROUTER_MUST_MATCH + ": " + mustMatch);
}
if (cols.stream()
.filter(x -> !x.contains(UNINITIALIZED)).count() >= maxCardinality) {
throw new SolrException(BAD_REQUEST, "Max cardinality " + maxCardinality
+ " reached for Category Routed Alias: " + getAliasName());
}
}
/**
* Calculate a safe collection name from a data value. Any non-word character is
* replace with an underscore
*
* @param dataValue a value from the route field for a particular document
* @return the suffix value for it's corresponding collection name.
*/
private String safeKeyValue(String dataValue) {
return dataValue.trim().replaceAll("\\W", "_");
}
String buildCollectionNameFromValue(String value) {
return aliasName + COLLECTION_INFIX + safeKeyValue(value);
}
private Integer parseMaxCardinality(String maxCardinality) {
try {
return Integer.valueOf(maxCardinality);
} catch (NumberFormatException e) {
throw new SolrException(BAD_REQUEST, ROUTER_MAX_CARDINALITY + " must be a valid Integer"
+ ", instead got: " + maxCardinality);
}
}
private Pattern compileMustMatch(String mustMatch) {
try {
return Pattern.compile(mustMatch);
} catch (PatternSyntaxException e) {
throw new SolrException(BAD_REQUEST, ROUTER_MUST_MATCH + " must be a valid regular"
+ " expression, instead got: " + mustMatch);
}
}
@Override
public String computeInitialCollectionName() {
return buildCollectionNameFromValue(UNINITIALIZED);
}
@Override
String[] formattedRouteValues(SolrInputDocument doc) {
String routeField = getRouteField();
String fieldValue = (String) doc.getFieldValue(routeField);
return new String[] {safeKeyValue(fieldValue)};
}
@Override
public Map<String, String> getAliasMetadata() {
return aliasMetadata;
}
@Override
public Set<String> getRequiredParams() {
return REQUIRED_ROUTER_PARAMS;
}
@Override
public Set<String> getOptionalParams() {
return OPTIONAL_ROUTER_PARAMS;
}
@Override
public CandidateCollection findCandidateGivenValue(AddUpdateCommand cmd) {
Object value = cmd.getSolrInputDocument().getFieldValue(getRouteField());
String targetColName = buildCollectionNameFromValue(String.valueOf(value));
ZkStateReader zkStateReader = cmd.getReq().getCore().getCoreContainer().getZkController().zkStateReader;
updateParsedCollectionAliases(zkStateReader, true);
List<String> collectionList = getCollectionList(this.aliases);
if (collectionList.contains(targetColName)) {
return new CandidateCollection(CreationType.NONE, targetColName);
} else {
return new CandidateCollection(CreationType.SYNCHRONOUS, targetColName);
}
}
@Override
protected String getHeadCollectionIfOrdered(AddUpdateCommand cmd) {
return buildCollectionNameFromValue(String.valueOf(cmd.getSolrInputDocument().getFieldValue(getRouteField())));
}
@Override
protected List<Action> calculateActions(String targetCol) {
List<String> collectionList = getCollectionList(aliases);
if (!collectionList.contains(targetCol)) {
ArrayList<Action> actionList = new ArrayList<>();
actionList.add(new Action(this,ActionType.ENSURE_EXISTS, targetCol));
for (String s : collectionList) {
// can't remove the uninitialized on the first pass otherwise there is a risk of momentarily having
// an empty alias if thread scheduling plays tricks on us.
if (s.contains(UNINITIALIZED) && collectionList.size() > 1) {
actionList.add(new Action(this,ActionType.ENSURE_REMOVED, s));
}
}
return actionList;
} else {
return Collections.emptyList();
}
}
}