/**
* Copyright (c) Codice Foundation
* <p>
* This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or any later version.
* <p>
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
* even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details. A copy of the GNU Lesser General Public License
* is distributed along with this program and can be found at
* <http://www.gnu.org/licenses/lgpl.html>.
*/
package ddf.catalog.source.solr;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.NumberUtils;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrRequest.METHOD;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.client.solrj.response.SolrPingResponse;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrException;
import org.codice.solr.factory.impl.ConfigurationStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import ddf.catalog.data.ContentType;
import ddf.catalog.data.Metacard;
import ddf.catalog.data.MetacardCreationException;
import ddf.catalog.data.Result;
import ddf.catalog.data.impl.AttributeImpl;
import ddf.catalog.data.impl.MetacardImpl;
import ddf.catalog.data.impl.ResultImpl;
import ddf.catalog.filter.FilterAdapter;
import ddf.catalog.operation.CreateRequest;
import ddf.catalog.operation.CreateResponse;
import ddf.catalog.operation.DeleteRequest;
import ddf.catalog.operation.DeleteResponse;
import ddf.catalog.operation.QueryRequest;
import ddf.catalog.operation.SourceResponse;
import ddf.catalog.operation.Update;
import ddf.catalog.operation.UpdateRequest;
import ddf.catalog.operation.UpdateResponse;
import ddf.catalog.operation.impl.CreateResponseImpl;
import ddf.catalog.operation.impl.DeleteResponseImpl;
import ddf.catalog.operation.impl.UpdateImpl;
import ddf.catalog.operation.impl.UpdateResponseImpl;
import ddf.catalog.source.CatalogProvider;
import ddf.catalog.source.IngestException;
import ddf.catalog.source.SourceMonitor;
import ddf.catalog.source.UnsupportedQueryException;
import ddf.catalog.util.impl.MaskableImpl;
/**
* {@link CatalogProvider} implementation using Apache Solr
*/
public class SolrCatalogProvider extends MaskableImpl implements CatalogProvider {
private static final Logger LOGGER = LoggerFactory.getLogger(SolrCatalogProvider.class);
private static final String COULD_NOT_COMPLETE_DELETE_REQUEST_MESSAGE =
"Could not complete delete request.";
private static final String DESCRIBABLE_PROPERTIES_FILE = "/describable.properties";
private static final String QUOTE = "\"";
private static final String REQUEST_MUST_NOT_BE_NULL_MESSAGE = "Request must not be null";
public static final int MAX_BOOLEAN_CLAUSES = 1024;
private static Properties describableProperties = new Properties();
static {
try (InputStream propertiesStream = ddf.catalog.source.solr.SolrCatalogProvider.class.getResourceAsStream(
DESCRIBABLE_PROPERTIES_FILE)) {
describableProperties.load(propertiesStream);
} catch (IOException e) {
LOGGER.info("Failed to load describable properties", e);
}
}
private DynamicSchemaResolver resolver;
private SolrClient solr;
private SolrMetacardClientImpl client;
private FilterAdapter filterAdapter;
/**
* Cache of Metacards that have been updated on Solr but might not be visible in the
* near real time index yet. Cache is checked when ID queries fail from Solr.
*/
private Cache<String, Metacard> pendingNrtIndex = CacheBuilder.newBuilder()
.expireAfterWrite(NumberUtils.toInt(System.getProperty("solr.provider.pending-nrt-ttl"),
5), TimeUnit.SECONDS)
.build();
/**
* Constructor that creates a new instance and allows for a custom {@link DynamicSchemaResolver}
*
* @param solrClient Solr client
* @param adapter injected implementation of FilterAdapter
* @param resolver Solr schema resolver
*/
public SolrCatalogProvider(SolrClient solrClient, FilterAdapter adapter,
SolrFilterDelegateFactory solrFilterDelegateFactory, DynamicSchemaResolver resolver) {
if (solrClient == null) {
throw new IllegalArgumentException("SolrClient cannot be null.");
}
this.solr = solrClient;
this.filterAdapter = adapter;
this.resolver = resolver;
LOGGER.debug("Constructing {} with Solr client [{}]",
SolrCatalogProvider.class.getName(),
solr);
resolver.addFieldsFromClient(solrClient);
this.client = new ProviderSolrMetacardClient(solrClient,
adapter,
solrFilterDelegateFactory,
resolver);
}
/**
* Convenience constructor that creates a new ddf.catalog.source.solr.DynamicSchemaResolver
*
* @param solrClient Solr client
* @param adapter injected implementation of FilterAdapter
*/
public SolrCatalogProvider(SolrClient solrClient, FilterAdapter adapter,
SolrFilterDelegateFactory solrFilterDelegateFactory) {
this(solrClient, adapter, solrFilterDelegateFactory, new DynamicSchemaResolver());
}
@Override
public Set<ContentType> getContentTypes() {
return client.getContentTypes();
}
@Override
public boolean isAvailable() {
try {
SolrPingResponse ping = solr.ping();
return "OK".equals(ping.getResponse()
.get("status"));
} catch (Exception e) {
/*
* if we get any type of exception, whether declared by Solr or not, we do not want to
* fail, we just want to return false
*/
LOGGER.debug("Solr ping failed.", e);
LOGGER.warn("Solr ping request/response failed while checking availability. Verify Solr is available and correctly configured.");
}
return false;
}
@Override
public boolean isAvailable(SourceMonitor callback) {
return isAvailable();
}
@Override
public String getDescription() {
return describableProperties.getProperty("description");
}
@Override
public String getOrganization() {
return describableProperties.getProperty("organization");
}
@Override
public String getTitle() {
return describableProperties.getProperty("name");
}
@Override
public String getVersion() {
return describableProperties.getProperty("version");
}
@Override
public void maskId(String id) {
LOGGER.trace("Sitename changed from [{}] to [{}]", getId(), id);
super.maskId(id);
}
@Override
public SourceResponse query(QueryRequest request) throws UnsupportedQueryException {
SourceResponse response = client.query(request);
queryPendingNrtIndex(request, response);
return response;
}
private void queryPendingNrtIndex(QueryRequest request, SourceResponse response)
throws UnsupportedQueryException {
if (request == null || request.getQuery() == null) {
return;
}
Set<String> ids = filterAdapter.adapt(request.getQuery(),
new MetacardIdEqualityFilterDelegate());
if (ids.size() == 0) {
return;
}
for (Result result : response.getResults()) {
ids.remove(result.getMetacard()
.getId());
}
List<Result> pendingResults = pendingNrtIndex.getAllPresent(ids)
.values()
.stream()
.filter(Objects::nonNull)
.map(ResultImpl::new)
.collect(Collectors.toList());
response.getResults().addAll(pendingResults);
}
@Override
public CreateResponse create(CreateRequest request) throws IngestException {
if (request == null) {
throw new IngestException(REQUEST_MUST_NOT_BE_NULL_MESSAGE);
}
List<Metacard> metacards = request.getMetacards();
List<Metacard> output = new ArrayList<>();
if (metacards == null) {
return new CreateResponseImpl(request, null, output);
}
for (Metacard metacard : metacards) {
boolean isSourceIdSet =
(metacard.getSourceId() != null && !"".equals(metacard.getSourceId()));
/*
* If an ID is not provided, then one is generated so that documents are unique. Solr
* will not accept documents unless the id is unique.
*/
if (metacard.getId() == null || metacard.getId()
.equals("")) {
if (isSourceIdSet) {
throw new IngestException("Metacard from a separate distribution must have ID");
}
metacard.setAttribute(new AttributeImpl(Metacard.ID, generatePrimaryKey()));
}
if (!isSourceIdSet) {
metacard.setSourceId(getId());
}
output.add(metacard);
}
try {
client.add(output, isForcedAutoCommit());
} catch (SolrServerException | SolrException | IOException | MetacardCreationException e) {
LOGGER.info("Solr could not ingest metacard(s) during create.", e);
throw new IngestException("Could not ingest metacard(s).");
}
pendingNrtIndex.putAll(output.stream()
.collect(Collectors.toMap(Metacard::getId, m -> copyMetacard(m))));
return new CreateResponseImpl(request, request.getProperties(), output);
}
@Override
public UpdateResponse update(UpdateRequest updateRequest) throws IngestException {
if (updateRequest == null) {
throw new IngestException(REQUEST_MUST_NOT_BE_NULL_MESSAGE);
}
List<Entry<Serializable, Metacard>> updates = updateRequest.getUpdates();
// the list of updates, both new and old metacards
ArrayList<Update> updateList = new ArrayList<>();
String attributeName = updateRequest.getAttributeName();
// need an attribute name in order to do query
if (attributeName == null) {
throw new IngestException("Attribute name cannot be null. "
+ "Please provide the name of the attribute.");
}
List<String> identifiers = new ArrayList<>();
// if we have nothing to update, send the empty list
if (updates == null || updates.size() == 0) {
return new UpdateResponseImpl(updateRequest, null, new ArrayList<>());
}
/* 1. QUERY */
// Loop to get all identifiers
for (Entry<Serializable, Metacard> updateEntry : updates) {
identifiers.add(updateEntry.getKey()
.toString());
}
/* 1a. Create the old Metacard Query */
String attributeQuery = getQuery(attributeName, identifiers);
SolrQuery query = new SolrQuery(attributeQuery);
// Set number of rows to the result size + 1. The default row size in Solr is 10, so this
// needs to be set in situations where the number of metacards to update is > 10. Since there
// could be more results in the query response than the number of metacards in the update request,
// 1 is added to the row size, so we can still determine whether we found more metacards than
// updated metacards provided
query.setRows(updates.size() + 1);
QueryResponse idResults = null;
/* 1b. Execute Query */
try {
idResults = solr.query(query, METHOD.POST);
} catch (SolrServerException | IOException e) {
LOGGER.info("Failed to query for metacard(s) before update.", e);
}
// map of old metacards to be populated
Map<Serializable, Metacard> idToMetacardMap = new HashMap<>();
/* 1c. Populate list of old metacards */
if (idResults != null && idResults.getResults() != null && idResults.getResults()
.size() != 0) {
LOGGER.debug("Found {} current metacard(s).",
idResults.getResults()
.size());
// CHECK updates size assertion
if (idResults.getResults().size() > updates.size()) {
throw new IngestException(
"Found more metacards than updated metacards provided. Please ensure your attribute values match unique records.");
}
for (SolrDocument doc : idResults.getResults()) {
Metacard old;
try {
old = client.createMetacard(doc);
} catch (MetacardCreationException e) {
LOGGER.info("Unable to create metacard(s) from Solr responses during update.", e);
throw new IngestException("Could not create metacard(s).");
}
if (!idToMetacardMap.containsKey(old.getAttribute(attributeName)
.getValue())) {
idToMetacardMap.put(old.getAttribute(attributeName)
.getValue(), old);
} else {
throw new IngestException(
"The attribute value given [" + old.getAttribute(attributeName)
.getValue()
+ "] matched multiple records. Attribute values must at most match only one unique Metacard.");
}
}
}
if (Metacard.ID.equals(attributeName)) {
idToMetacardMap.putAll(pendingNrtIndex.getAllPresent(identifiers));
}
if (idToMetacardMap.size() == 0) {
LOGGER.debug("No results found for given attribute values.");
// return an empty list
return new UpdateResponseImpl(updateRequest, null, new ArrayList<>());
}
/* 2. Update the cards */
List<Metacard> newMetacards = new ArrayList<>();
for (Entry<Serializable, Metacard> updateEntry : updates) {
String localKey = updateEntry.getKey()
.toString();
/* 2a. Prepare new Metacard */
MetacardImpl newMetacard = new MetacardImpl(updateEntry.getValue());
// Find the exact oldMetacard that corresponds with this newMetacard
Metacard oldMetacard = idToMetacardMap.get(localKey);
// We need to skip because of partial updates such as one entry
// matched but another did not
if (oldMetacard != null) {
// overwrite the id, in case it has not been done properly/already
newMetacard.setId(oldMetacard.getId());
newMetacard.setSourceId(getId());
newMetacards.add(newMetacard);
updateList.add(new UpdateImpl(newMetacard, oldMetacard));
}
}
try {
client.add(newMetacards, isForcedAutoCommit());
} catch (SolrServerException | SolrException | IOException | MetacardCreationException e) {
LOGGER.info("Failed to update metacard(s) with Solr.", e);
throw new IngestException("Failed to update metacard(s).");
}
pendingNrtIndex.putAll(updateList.stream()
.collect(Collectors.toMap(u -> u.getNewMetacard()
.getId(), u -> copyMetacard(u.getNewMetacard()))));
return new UpdateResponseImpl(updateRequest, updateRequest.getProperties(), updateList);
}
@Override
public DeleteResponse delete(DeleteRequest deleteRequest) throws IngestException {
if (deleteRequest == null) {
throw new IngestException(REQUEST_MUST_NOT_BE_NULL_MESSAGE);
}
List<Metacard> deletedMetacards = new ArrayList<>();
String attributeName = deleteRequest.getAttributeName();
if (StringUtils.isBlank(attributeName)) {
throw new IngestException(
"Attribute name cannot be empty. Please provide the name of the attribute.");
}
@SuppressWarnings("unchecked")
List<? extends Serializable> identifiers = deleteRequest.getAttributeValues();
if (identifiers == null || identifiers.size() == 0) {
return new DeleteResponseImpl(deleteRequest, null, deletedMetacards);
}
if (identifiers.size() <= MAX_BOOLEAN_CLAUSES) {
deleteListOfMetacards(deletedMetacards, identifiers, attributeName);
} else {
List<? extends Serializable> identifierPaged;
int currPagingSize;
for (
currPagingSize = MAX_BOOLEAN_CLAUSES;
currPagingSize < identifiers.size(); currPagingSize += MAX_BOOLEAN_CLAUSES) {
identifierPaged = identifiers.subList(currPagingSize - MAX_BOOLEAN_CLAUSES,
currPagingSize);
deleteListOfMetacards(deletedMetacards, identifierPaged, attributeName);
}
identifierPaged = identifiers.subList(currPagingSize - MAX_BOOLEAN_CLAUSES,
identifiers.size());
deleteListOfMetacards(deletedMetacards, identifierPaged, attributeName);
}
addPendingNrtDeletedMetacards(deletedMetacards, identifiers);
pendingNrtIndex.invalidateAll(deletedMetacards.stream()
.map(Metacard::getId)
.collect(Collectors.toList()));
return new DeleteResponseImpl(deleteRequest, null, deletedMetacards);
}
private void deleteListOfMetacards(List<Metacard> deletedMetacards,
List<? extends Serializable> identifiers, String attributeName) throws IngestException {
String fieldName = attributeName + SchemaFields.TEXT_SUFFIX;
SolrDocumentList docs = getSolrDocumentList(identifiers, fieldName);
createListOfDeletedMetacards(deletedMetacards, docs);
try {
// the assumption is if something was deleted, it should be gone
// right away, such as expired data, etc.
// so we force the commit
client.deleteByIds(fieldName, identifiers, true);
} catch (SolrServerException | IOException e) {
LOGGER.info("Failed to delete metacards by ID(s).", e);
throw new IngestException(COULD_NOT_COMPLETE_DELETE_REQUEST_MESSAGE);
}
}
private void createListOfDeletedMetacards(List<Metacard> deletedMetacards,
SolrDocumentList docs) throws IngestException {
for (SolrDocument doc : docs) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("SOLR DOC: {}",
doc.getFieldValue(Metacard.ID + SchemaFields.TEXT_SUFFIX));
}
try {
deletedMetacards.add(client.createMetacard(doc));
} catch (MetacardCreationException e) {
LOGGER.info("Metacard creation exception creating metacards during delete", e);
throw new IngestException(COULD_NOT_COMPLETE_DELETE_REQUEST_MESSAGE);
}
}
}
private void addPendingNrtDeletedMetacards(List<Metacard> deletedMetacards,
List<? extends Serializable> identifiers) {
if (deletedMetacards.size() < identifiers.size() && pendingNrtIndex.getAllPresent(
identifiers)
.size() > 0) {
Map<String, Metacard> matches = pendingNrtIndex.getAllPresent(identifiers);
deletedMetacards.forEach(m -> matches.remove(m.getId()));
deletedMetacards.addAll(matches.values());
}
}
private Metacard copyMetacard(Metacard original) {
MetacardImpl copy = new MetacardImpl(original, original.getMetacardType());
copy.setSourceId(original.getSourceId());
return copy;
}
private SolrDocumentList getSolrDocumentList(List<? extends Serializable> identifierPaged,
String fieldName) throws IngestException {
SolrQuery query = new SolrQuery(client.getIdentifierQuery(fieldName, identifierPaged));
query.setRows(identifierPaged.size());
QueryResponse solrResponse;
try {
solrResponse = solr.query(query, METHOD.POST);
} catch (SolrServerException | IOException e) {
LOGGER.info("Failed to get list of Solr documents for delete.", e);
throw new IngestException(COULD_NOT_COMPLETE_DELETE_REQUEST_MESSAGE);
}
return solrResponse.getResults();
}
private String getQuery(String attributeName, List<String> ids) throws IngestException {
StringBuilder queryBuilder = new StringBuilder();
List<String> mappedNames = resolver.getAnonymousField(attributeName);
if (mappedNames.isEmpty()) {
throw new IngestException("Could not resolve attribute name [" + attributeName + "]");
}
for (int i = 0; i < ids.size(); i++) {
String id = ids.get(i);
if (i > 0) {
queryBuilder.append(" OR ");
}
queryBuilder.append(mappedNames.get(0))
.append(":")
.append(QUOTE)
.append(id)
.append(QUOTE);
}
String query = queryBuilder.toString();
LOGGER.debug("query = [{}]", query);
return query;
}
private String generatePrimaryKey() {
return UUID.randomUUID()
.toString()
.replaceAll("-", "");
}
public boolean isForcedAutoCommit() {
return ConfigurationStore.getInstance()
.isForceAutoCommit();
}
public void shutdown() {
LOGGER.debug("Closing down Solr client.");
try {
solr.close();
} catch (IOException e) {
LOGGER.info("Failed to close Solr client during shutdown.", e);
}
}
private class ProviderSolrMetacardClient extends SolrMetacardClientImpl {
public ProviderSolrMetacardClient(SolrClient client, FilterAdapter catalogFilterAdapter,
SolrFilterDelegateFactory solrFilterDelegateFactory,
DynamicSchemaResolver dynamicSchemaResolver) {
super(client, catalogFilterAdapter, solrFilterDelegateFactory, dynamicSchemaResolver);
}
@Override
public MetacardImpl createMetacard(SolrDocument doc) throws MetacardCreationException {
MetacardImpl metacard = super.createMetacard(doc);
metacard.setSourceId(getId());
return metacard;
}
}
}