/** * 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.cache.solr.impl; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.CompletionService; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.Phaser; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import org.codice.ddf.platform.util.Exceptions; import org.opengis.filter.expression.PropertyName; import org.opengis.filter.sort.SortBy; import org.opengis.filter.sort.SortOrder; import org.slf4j.LoggerFactory; import org.slf4j.ext.XLogger; import ddf.catalog.data.Metacard; import ddf.catalog.data.Result; import ddf.catalog.federation.FederationStrategy; import ddf.catalog.operation.CreateResponse; import ddf.catalog.operation.DeleteResponse; import ddf.catalog.operation.ProcessingDetails; import ddf.catalog.operation.Query; import ddf.catalog.operation.QueryRequest; import ddf.catalog.operation.QueryResponse; import ddf.catalog.operation.SourceResponse; import ddf.catalog.operation.Update; import ddf.catalog.operation.UpdateResponse; import ddf.catalog.operation.impl.ProcessingDetailsImpl; import ddf.catalog.operation.impl.QueryImpl; import ddf.catalog.operation.impl.QueryRequestImpl; import ddf.catalog.operation.impl.QueryResponseImpl; import ddf.catalog.plugin.PluginExecutionException; import ddf.catalog.plugin.PostFederatedQueryPlugin; import ddf.catalog.plugin.PostIngestPlugin; import ddf.catalog.plugin.PreFederatedQueryPlugin; import ddf.catalog.plugin.StopProcessingException; import ddf.catalog.source.Source; import ddf.catalog.source.UnsupportedQueryException; import ddf.catalog.util.impl.DistanceResultComparator; import ddf.catalog.util.impl.RelevanceResultComparator; import ddf.catalog.util.impl.TemporalResultComparator; /** * This class represents a {@link ddf.catalog.federation.FederationStrategy} based on sorting {@link ddf.catalog.data.Metacard}s. The * sorting is based on the {@link ddf.catalog.operation.Query}'s {@link org.opengis.filter.sort.SortBy} propertyName. The possible sorting values * are {@link ddf.catalog.data.Metacard.EFFECTIVE}, {@link ddf.catalog.data.Result.TEMPORAL}, {@link ddf.catalog.data.Result.DISTANCE}, or * {@link ddf.catalog.data.Result.RELEVANCE} . The supported ordering includes {@link org.opengis.filter.sort.SortOrder.DESCENDING} and * {@link org.opengis.filter.sort.SortOrder.ASCENDING}. For this class to function properly a sort value and sort order must * be provided. * * @see ddf.catalog.data.Metacard * @see ddf.catalog.operation.Query * @see org.opengis.filter.sort.SortBy */ public class CachingFederationStrategy implements FederationStrategy, PostIngestPlugin { /** * The default comparator for sorting by {@link ddf.catalog.data.Result.RELEVANCE}, {@link org.opengis.filter.sort.SortOrder.DESCENDING} */ protected static final Comparator<Result> DEFAULT_COMPARATOR = new RelevanceResultComparator( SortOrder.DESCENDING); protected static final String QUERY_MODE = "mode"; protected static final String CACHE_QUERY_MODE = "cache"; protected static final String NATIVE_QUERY_MODE = "native"; protected static final String INDEX_QUERY_MODE = "index"; private static final int DEFAULT_MAX_START_INDEX = 50000; private static XLogger logger = new XLogger( LoggerFactory.getLogger(CachingFederationStrategy.class)); private final SolrCache cache; private final ExecutorService cacheExecutorService = Executors.newFixedThreadPool(8); /** * The {@link List} of pre-federated query plugins to execute on the query request before the * query is executed on the {@link Source}. */ protected List<PreFederatedQueryPlugin> preQuery; /** * The {@link List} of post-federated query plugins to execute on the query request after the * query is executed on the {@link Source}. */ protected List<PostFederatedQueryPlugin> postQuery; private ExecutorService queryExecutorService; private int maxStartIndex; private CacheCommitPhaser cacheCommitPhaser = new CacheCommitPhaser(); private CacheBulkProcessor cacheBulkProcessor; private boolean isCachingEverything = false; /** * Instantiates an {@code AbstractFederationStrategy} with the provided {@link ExecutorService}. * * @param queryExecutorService * the {@link ExecutorService} for queries */ public CachingFederationStrategy(ExecutorService queryExecutorService, List<PreFederatedQueryPlugin> preQuery, List<PostFederatedQueryPlugin> postQuery, SolrCache cache) { this.queryExecutorService = queryExecutorService; this.preQuery = preQuery; this.postQuery = postQuery; this.maxStartIndex = DEFAULT_MAX_START_INDEX; this.cache = cache; cacheBulkProcessor = new CacheBulkProcessor(cache); } @Override public QueryResponse federate(List<Source> sources, QueryRequest queryRequest) { Set<String> sourceIds = new HashSet<String>(); for (Source source : sources) { sourceIds.add(source.getId()); } QueryRequest modifiedQueryRequest = new QueryRequestImpl(queryRequest.getQuery(), queryRequest.isEnterprise(), sourceIds, queryRequest.getProperties()); if (queryRequest.getProperties().containsKey(QUERY_MODE) && CACHE_QUERY_MODE .equals(queryRequest.getProperties().get(QUERY_MODE))) { return queryCache(modifiedQueryRequest); } else { return sourceFederate(sources, modifiedQueryRequest); } } private QueryResponse queryCache(QueryRequest queryRequest) { final QueryResponseImpl queryResponse = new QueryResponseImpl(queryRequest); try { SourceResponse result = cache.query(queryRequest); queryResponse.setHits(result.getHits()); queryResponse.setProperties(result.getProperties()); queryResponse.addResults(result.getResults(), true); } catch (UnsupportedQueryException e) { queryResponse.getProcessingDetails().add(new ProcessingDetailsImpl("cache", e)); } return queryResponse; } private QueryResponse sourceFederate(List<Source> sources, final QueryRequest queryRequest) { if (logger.isDebugEnabled()) { for (Source source : sources) { if (source != null) { logger.debug("source to query: {}", source.getId()); } } } Query originalQuery = queryRequest.getQuery(); int offset = originalQuery.getStartIndex(); final int pageSize = originalQuery.getPageSize(); // limit offset to max value if (offset > this.maxStartIndex) { offset = this.maxStartIndex; } final QueryResponseImpl queryResponseQueue = new QueryResponseImpl(queryRequest, null); Map<Future<SourceResponse>, Source> futures = new HashMap<Future<SourceResponse>, Source>(); Query modifiedQuery = getModifiedQuery(originalQuery, sources.size(), offset, pageSize); QueryRequest modifiedQueryRequest = new QueryRequestImpl(modifiedQuery, queryRequest.isEnterprise(), queryRequest.getSourceIds(), queryRequest.getProperties()); CompletionService<SourceResponse> queryCompletion = new ExecutorCompletionService<SourceResponse>( queryExecutorService); // Do NOT call source.isAvailable() when checking sources for (final Source source : sources) { if (source != null) { if (!futures.containsValue(source)) { logger.debug("running query on source: {}", source.getId()); try { for (PreFederatedQueryPlugin service : preQuery) { try { modifiedQueryRequest = service .process(source, modifiedQueryRequest); } catch (PluginExecutionException e) { logger.warn("Error executing PreFederatedQueryPlugin", e); } } } catch (StopProcessingException e) { logger.warn("Plugin stopped processing", e); } futures.put(queryCompletion .submit(new CallableSourceResponse(source, modifiedQueryRequest)), source); } else { logger.warn("Duplicate source found with name {}. Ignoring second one.", source.getId()); } } } QueryResponseImpl offsetResults = null; // If there are offsets and more than one source, we have to get all the // results back and then // transfer them into a different Queue. That is what the // OffsetResultHandler does. if (offset > 1 && sources.size() > 1) { offsetResults = new QueryResponseImpl(queryRequest, null); queryExecutorService .submit(new OffsetResultHandler(queryResponseQueue, offsetResults, pageSize, offset)); } queryExecutorService.submit(createMonitor(queryCompletion, futures, queryResponseQueue, modifiedQueryRequest)); QueryResponse queryResponse = null; if (offset > 1 && sources.size() > 1) { queryResponse = offsetResults; logger.debug("returning offsetResults"); } else { queryResponse = queryResponseQueue; logger.debug("returning returnResults: {}", queryResponse); } try { for (PostFederatedQueryPlugin service : postQuery) { try { queryResponse = service.process(queryResponse); } catch (PluginExecutionException e) { logger.warn("Error executing PostFederatedQueryPlugin", e); } } } catch (StopProcessingException e) { logger.warn("Plugin stopped processing", e); } logger.debug("returning Query Results: {}", queryResponse); return queryResponse; } private Query getModifiedQuery(Query originalQuery, int numberOfSources, int offset, int pageSize) { Query query = null; // If offset is not specified, our offset is 1 if (offset > 1 && numberOfSources > 1) { final int modifiedOffset = 1; int modifiedPageSize = computeModifiedPageSize(offset, pageSize); logger.debug("Creating new query for federated sources to query each source from {} " + "to {}.", modifiedOffset, modifiedPageSize); logger.debug("original offset: {}", offset); logger.debug("original page size: {}", pageSize); logger.debug("modified offset: {}", modifiedOffset); logger.debug("modified page size: {}", modifiedPageSize); /** * Federated sources always query from offset of 1. When all query results are received * from all federated sources and merged together - then the offset is applied. * */ query = new QueryImpl(originalQuery, modifiedOffset, modifiedPageSize, originalQuery.getSortBy(), originalQuery.requestsTotalResultsCount(), originalQuery.getTimeoutMillis()); } else { query = originalQuery; } return query; } /** * Base 1 offset, hence page size is one less. */ private int computeModifiedPageSize(int offset, int pageSize) { return offset + pageSize - 1; } @Override public CreateResponse process(CreateResponse input) throws PluginExecutionException { return input; } @Override public UpdateResponse process(UpdateResponse input) throws PluginExecutionException { logger.debug("Post ingest processing of UpdateResponse."); List<Metacard> metacards = new ArrayList<Metacard>(input.getUpdatedMetacards().size()); for (Update update : input.getUpdatedMetacards()) { metacards.add(update.getNewMetacard()); } logger.debug("Updating metacard(s) in cache."); cache.create(metacards); logger.debug("Updating metacard(s) in cache complete."); return input; } @Override public DeleteResponse process(DeleteResponse input) throws PluginExecutionException { logger.debug("Post ingest processing of DeleteResponse."); logger.debug("Deleting metacard(s) in cache."); cache.delete(input.getRequest()); logger.debug("Deletion of metacard(s) in cache complete."); return input; } private List<Metacard> getMetacards(List<Result> results) { List<Metacard> metacards = new ArrayList<Metacard>(results.size()); for (Result result : results) { metacards.add(result.getMetacard()); } return metacards; } /** * To be set via Spring/Blueprint * * @param maxStartIndex * the new default max start index value */ public void setMaxStartIndex(int maxStartIndex) { this.maxStartIndex = DEFAULT_MAX_START_INDEX; if (maxStartIndex > 0) { this.maxStartIndex = maxStartIndex; } else { logger.debug("Invalid max start index input. Reset to default value: {}", this.maxStartIndex); } } public void setUrl(String url) { cache.updateServer(url); } public void setExpirationIntervalInMinutes(long expirationIntervalInMinutes) { cache.setExpirationIntervalInMinutes(expirationIntervalInMinutes); } public void setExpirationAgeInMinutes(long expirationAgeInMinutes) { cache.setExpirationAgeInMinutes(expirationAgeInMinutes); } public void setCachingEverything(boolean cachingEverything) { this.isCachingEverything = cachingEverything; } protected Runnable createMonitor(final CompletionService<SourceResponse> completionService, final Map<Future<SourceResponse>, Source> futures, final QueryResponseImpl returnResults, final QueryRequest request) { return new SortedQueryMonitor(completionService, futures, returnResults, request); } public void shutdown() { cacheCommitPhaser.shutdown(); cacheBulkProcessor.shutdown(); } private static class OffsetResultHandler implements Runnable { private QueryResponseImpl originalResults = null; private QueryResponseImpl offsetResultQueue = null; private int pageSize = 0; private int offset = 1; private OffsetResultHandler(QueryResponseImpl originalResults, QueryResponseImpl offsetResultQueue, int pageSize, int offset) { this.originalResults = originalResults; this.offsetResultQueue = offsetResultQueue; this.pageSize = pageSize; this.offset = offset; } @Override public void run() { int queryResultIndex = 1; int resultsSent = 0; Result result; while (resultsSent < pageSize && originalResults.hasMoreResults() && (result = originalResults.take()) != null) { if (queryResultIndex >= offset) { offsetResultQueue.addResult(result, false); resultsSent++; } queryResultIndex++; } logger.debug("Closing Queue and setting the total count"); offsetResultQueue.setHits(originalResults.getHits()); offsetResultQueue.closeResultQueue(); } } /** * Runnable that makes one party arrive to a phaser on each run */ private static class PhaseAdvancer implements Runnable { private final Phaser phaser; public PhaseAdvancer(Phaser phaser) { this.phaser = phaser; } @Override public void run() { phaser.arriveAndAwaitAdvance(); } } private class CallableSourceResponse implements Callable<SourceResponse> { private final QueryRequest request; private final Source source; public CallableSourceResponse(Source source, QueryRequest request) { this.source = source; this.request = request; } @Override public SourceResponse call() throws Exception { final SourceResponse sourceResponse = source .query(new QueryRequestImpl(request.getQuery(), request.getProperties())); if (INDEX_QUERY_MODE.equals(request.getPropertyValue(QUERY_MODE))) { cacheCommitPhaser.add(sourceResponse.getResults()); } else if (!NATIVE_QUERY_MODE.equals(request.getPropertyValue(QUERY_MODE))) { if (isCachingEverything) { cacheExecutorService.submit(new Runnable() { @Override public void run() { try { cacheBulkProcessor.add(sourceResponse.getResults()); } catch (Throwable throwable) { logger.warn("Unable to add results for bulk processing", throwable); } } }); } } return sourceResponse; } } private class SortedQueryMonitor implements Runnable { private final QueryRequest request; private final CompletionService<SourceResponse> completionService; private QueryResponseImpl returnResults; private Map<Future<SourceResponse>, Source> futures; private Query query; public SortedQueryMonitor(CompletionService<SourceResponse> completionService, Map<Future<SourceResponse>, Source> futures, QueryResponseImpl returnResults, QueryRequest request) { this.completionService = completionService; this.returnResults = returnResults; this.request = request; this.query = request.getQuery(); this.futures = futures; } @Override public void run() { SortBy sortBy = query.getSortBy(); // Prepare the Comparators that we will use Comparator<Result> coreComparator = DEFAULT_COMPARATOR; if (sortBy != null && sortBy.getPropertyName() != null) { PropertyName sortingProp = sortBy.getPropertyName(); String sortType = sortingProp.getPropertyName(); SortOrder sortOrder = (sortBy.getSortOrder() == null) ? SortOrder.DESCENDING : sortBy.getSortOrder(); logger.debug("Sorting type: {}", sortType); logger.debug("Sorting order: {}", sortBy.getSortOrder()); // Temporal searches are currently sorted by the effective time if (Metacard.EFFECTIVE.equals(sortType) || Result.TEMPORAL.equals(sortType)) { coreComparator = new TemporalResultComparator(sortOrder); } else if (Result.DISTANCE.equals(sortType)) { coreComparator = new DistanceResultComparator(sortOrder); } else if (Result.RELEVANCE.equals(sortType)) { coreComparator = new RelevanceResultComparator(sortOrder); } } List<Result> resultList = new ArrayList<Result>(); long totalHits = 0; Set<ProcessingDetails> processingDetails = returnResults.getProcessingDetails(); long deadline = System.currentTimeMillis() + query.getTimeoutMillis(); Map<String, Serializable> returnProperties = returnResults.getProperties(); for (int i = futures.size(); i > 0; i--) { String sourceId = "Unknown Source"; try { Future<SourceResponse> future; if (query.getTimeoutMillis() < 1) { future = completionService.take(); } else { future = completionService .poll(getTimeRemaining(deadline), TimeUnit.MILLISECONDS); if (future == null) { timeoutRemainingSources(processingDetails); break; } } Source source = futures.remove(future); if (source != null) { sourceId = source.getId(); } SourceResponse sourceResponse = future.get(); if (sourceResponse == null) { logger.info("Source {} returned null response", sourceId); processingDetails.add(new ProcessingDetailsImpl(sourceId, new NullPointerException())); } else { resultList.addAll(sourceResponse.getResults()); totalHits += sourceResponse.getHits(); Map<String, Serializable> properties = sourceResponse.getProperties(); returnProperties.putAll(properties); } } catch (InterruptedException e) { interruptRemainingSources(processingDetails, e); break; } catch (ExecutionException e) { logger.warn("Couldn't get results from completed federated query. {}", sourceId + ", " + Exceptions.getFullMessage(e), e); processingDetails.add(new ProcessingDetailsImpl(sourceId, new Exception(Exceptions.getFullMessage(e)))); } } logger.debug("All sources finished returning results: {}", resultList.size()); if (INDEX_QUERY_MODE.equals(request.getPropertyValue(QUERY_MODE))) { QueryResponse result = queryCache(request); returnResults.setHits(totalHits); returnResults.addResults(result.getResults(), true); } else { Collections.sort(resultList, coreComparator); returnResults.setHits(totalHits); int maxResults = Integer.MAX_VALUE; if (query.getPageSize() > 0) { maxResults = query.getPageSize(); } returnResults.addResults(resultList.size() > maxResults ? resultList.subList(0, maxResults) : resultList, true); } } private void timeoutRemainingSources(Set<ProcessingDetails> processingDetails) { for (Source expiredSource : futures.values()) { if (expiredSource != null) { logger.info("Search timed out for {}", expiredSource.getId()); processingDetails.add(new ProcessingDetailsImpl(expiredSource.getId(), new TimeoutException())); } } } private void interruptRemainingSources(Set<ProcessingDetails> processingDetails, InterruptedException interruptedException) { for (Source interruptedSource : futures.values()) { if (interruptedSource != null) { logger.info("Search interrupted for {}", interruptedSource.getId()); processingDetails.add(new ProcessingDetailsImpl(interruptedSource.getId(), interruptedException)); } } } private long getTimeRemaining(long deadline) { long timeLeft; if (System.currentTimeMillis() > deadline) { timeLeft = 0; } else { timeLeft = deadline - System.currentTimeMillis(); } return timeLeft; } } /** * Phaser that forces all added metacards to commit to the cache on phase advance */ private class CacheCommitPhaser extends Phaser { private final ScheduledExecutorService phaseScheduler = Executors .newSingleThreadScheduledExecutor(); public CacheCommitPhaser() { // There will always be at least one party which will be the PhaseAdvancer super(1); // PhaseAdvancer blocks waiting for next phase advance, delay 1 second between advances // this is used to block queries that request to be indexed before continuing // committing Solr more often than 1 second can cause performance issues and exceptions phaseScheduler.scheduleWithFixedDelay(new PhaseAdvancer(this), 1, 1, TimeUnit.SECONDS); } @Override protected boolean onAdvance(int phase, int registeredParties) { // registeredParties should be 1 since all parties other than the PhaseAdvancer // will arriveAndDeregister in the add method cache.forceCommit(); return super.onAdvance(phase, registeredParties); } /** * Adds results to cache and blocks for next phase advance * * @param results metacards to add to cache */ public void add(List<Result> results) { // block next phase this.register(); // add results to cache cache.create(getMetacards(results)); // unblock phase and wait for all other parties to unblock phase this.awaitAdvance(this.arriveAndDeregister()); } public void shutdown() { this.forceTermination(); phaseScheduler.shutdown(); } } }