/**
* 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.resource.download;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Timer;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.activation.MimeType;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.io.CountingOutputStream;
import com.google.common.io.FileBackedOutputStream;
import ddf.catalog.cache.impl.CacheKey;
import ddf.catalog.cache.impl.ResourceCacheImpl;
import ddf.catalog.data.Metacard;
import ddf.catalog.event.retrievestatus.DownloadStatusInfo;
import ddf.catalog.event.retrievestatus.DownloadsStatusEventListener;
import ddf.catalog.event.retrievestatus.DownloadsStatusEventPublisher;
import ddf.catalog.event.retrievestatus.DownloadsStatusEventPublisher.ProductRetrievalStatus;
import ddf.catalog.operation.ResourceResponse;
import ddf.catalog.operation.impl.ResourceResponseImpl;
import ddf.catalog.resource.Resource;
import ddf.catalog.resource.ResourceNotFoundException;
import ddf.catalog.resource.ResourceNotSupportedException;
import ddf.catalog.resource.data.ReliableResource;
import ddf.catalog.resource.download.DownloadManagerState.DownloadState;
import ddf.catalog.resource.impl.ResourceImpl;
import ddf.catalog.resourceretriever.ResourceRetriever;
public class ReliableResourceDownloader implements Runnable {
public static final String BYTES_SKIPPED = "BytesSkipped";
private static final Logger LOGGER = LoggerFactory.getLogger(ReliableResourceDownloader.class);
private static final int DEFAULT_FILE_BACKED_OUTPUT_STREAM_THRESHOLD =
32 * ReliableResourceDownloaderConfig.KB;
private final Object lock = new Object();
private ReliableResourceCallable reliableResourceCallable;
private Future<ReliableResourceStatus> downloadFuture;
private ExecutorService downloadExecutor = Executors.newSingleThreadExecutor();
private AtomicBoolean downloadStarted;
private InputStream resourceInputStream;
private ReliableResourceInputStream streamReadByClient;
private FileOutputStream fos;
private FileBackedOutputStream fbos;
private CountingOutputStream countingFbos;
private ReliableResource reliableResource;
private DownloadManagerState downloadState;
private Metacard metacard;
private String downloadIdentifier;
private ResourceResponse resourceResponse;
private ReliableResourceDownloaderConfig downloaderConfig;
private DownloadsStatusEventListener eventListener;
private ResourceCacheImpl resourceCache;
private DownloadsStatusEventPublisher eventPublisher;
private String filePath;
private ResourceRetriever retriever;
/**
* Only set to true if cacheEnabled is true *AND* product being downloaded is not already
* pending caching, e.g., another client has already started downloading and caching it.
*/
private boolean doCaching;
public ReliableResourceDownloader(ReliableResourceDownloaderConfig downloaderConfig,
AtomicBoolean downloadStarted, String downloadIdentifier,
ResourceResponse resourceResponse, ResourceRetriever retriever) {
this.downloadStarted = downloadStarted;
this.downloaderConfig = downloaderConfig;
this.downloadIdentifier = downloadIdentifier;
this.resourceResponse = resourceResponse;
this.retriever = retriever;
this.downloadState = new DownloadManagerState();
this.downloadState.setDownloadState(DownloadManagerState.DownloadState.NOT_STARTED);
// Do not enable caching yet - wait until determine if this product about to be downloaded
// is already pending caching by another download in progress
this.downloadState.setCacheEnabled(false);
this.eventListener = downloaderConfig.getEventListener();
this.eventPublisher = downloaderConfig.getEventPublisher();
this.resourceCache = downloaderConfig.getResourceCache();
this.downloadState.setContinueCaching(this.downloaderConfig.isCacheWhenCanceled());
}
public ResourceResponse setupDownload(Metacard metacard,
DownloadStatusInfo downloadStatusInfo) {
Resource resource = resourceResponse.getResource();
MimeType mimeType = resource.getMimeType();
String resourceName = resource.getName();
fbos = new FileBackedOutputStream(DEFAULT_FILE_BACKED_OUTPUT_STREAM_THRESHOLD);
countingFbos = new CountingOutputStream(fbos);
streamReadByClient = new ReliableResourceInputStream(fbos,
countingFbos,
downloadState,
downloadIdentifier,
resourceResponse);
this.metacard = metacard;
// Create new ResourceResponse to return that will encapsulate the
// ReliableResourceInputStream that will be read by the client simultaneously as the product
// is cached to disk (if caching is enabled)
ResourceImpl newResource = new ResourceImpl(streamReadByClient, mimeType, resourceName);
resourceResponse = new ResourceResponseImpl(resourceResponse.getRequest(),
resourceResponse.getProperties(),
newResource);
// Get handle to retrieved product's InputStream
resourceInputStream = resource.getInputStream();
eventListener.setDownloadMap(downloadIdentifier, resourceResponse);
downloadStatusInfo.addDownloadInfo(downloadIdentifier, this, resourceResponse);
if (downloaderConfig.isCacheEnabled()) {
CacheKey keyMaker = null;
String key = null;
try {
keyMaker = new CacheKey(metacard, resourceResponse.getRequest());
key = keyMaker.generateKey();
} catch (IllegalArgumentException e) {
LOGGER.info("Cannot create cache key for resource with metacard ID = {}",
metacard.getId());
return resourceResponse;
}
if (!resourceCache.isPending(key)) {
// Fully qualified path to cache file that will be written to.
// Example:
// <INSTALL-DIR>/data/product-cache/<source-id>-<metacard-id>
// <INSTALL-DIR>/data/product-cache/ddf.distribution-abc123
filePath = FilenameUtils.concat(resourceCache.getProductCacheDirectory(), key);
if (filePath == null) {
LOGGER.info(
"Unable to create cache for cache directory {} and key {} - no caching will be done.",
resourceCache.getProductCacheDirectory(),
key);
return resourceResponse;
}
reliableResource = new ReliableResource(key,
filePath,
mimeType,
resourceName,
metacard);
resourceCache.addPendingCacheEntry(reliableResource);
try {
fos = FileUtils.openOutputStream(new File(filePath));
doCaching = true;
this.downloadState.setCacheEnabled(true);
} catch (IOException e) {
LOGGER.info("Unable to open cache file {} - no caching will be done.",
filePath);
}
} else {
LOGGER.debug("Cache key {} is already pending caching", key);
}
}
return resourceResponse;
}
@Override
public void run() {
long bytesRead = 0;
ReliableResourceStatus reliableResourceStatus = null;
int retryAttempts = 0;
downloaderConfig.getEventPublisher().postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.STARTED,
metacard,
null,
0L,
downloadIdentifier);
try {
reliableResourceCallable = new ReliableResourceCallable(resourceInputStream,
countingFbos,
fos,
downloaderConfig.getChunkSize(),
lock);
downloadFuture = null;
ResourceRetrievalMonitor resourceRetrievalMonitor = null;
this.downloadState.setDownloadState(DownloadManagerState.DownloadState.IN_PROGRESS);
while (retryAttempts < downloaderConfig.getMaxRetryAttempts()) {
if (reliableResourceCallable == null) {
// This usually occurs on retry attempts to download and the
// ReliableResourceCallable cannot be successfully created. In this case, a
// partial cache file may have been created from the previous caching attempt(s)
// and needs to be deleted from the product cache directory.
LOGGER.debug("ReliableResourceCallable is null - cannot download resource");
retryAttempts++;
LOGGER.debug("Download attempt {}", retryAttempts);
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.RETRYING,
metacard,
String.format("Attempt %d of %d.",
retryAttempts,
downloaderConfig.getMaxRetryAttempts()),
(null == reliableResourceStatus) ?
null : reliableResourceStatus.getBytesRead(),
downloadIdentifier);
delay();
reliableResourceCallable = retrieveResource(bytesRead);
continue;
}
retryAttempts++;
LOGGER.debug("Download attempt {}", retryAttempts);
try {
downloadExecutor = Executors.newSingleThreadExecutor();
downloadFuture = downloadExecutor.submit(reliableResourceCallable);
// Update callable and its Future in the ReliableResourceInputStream being read
// by the client so that if client cancels this download the proper Callable and
// Future are canceled.
streamReadByClient.setCallableAndItsFuture(reliableResourceCallable,
downloadFuture);
// Monitor to watch that bytes are continually being read from the resource's
// InputStream. This monitor is used to detect if there are long pauses or
// network connection loss during the product retrieval. If such a "gap" is
// detected, the Callable will be canceled and a new download attempt (retry)
// will be started.
final Timer downloadTimer = new Timer();
resourceRetrievalMonitor = new ResourceRetrievalMonitor(downloadFuture,
reliableResourceCallable,
downloaderConfig.getMonitorPeriodMS(),
eventPublisher,
resourceResponse,
metacard,
downloadIdentifier);
LOGGER.debug("Configuring resourceRetrievalMonitor to run every {} ms",
downloaderConfig.getMonitorPeriodMS());
downloadTimer.scheduleAtFixedRate(resourceRetrievalMonitor,
downloaderConfig.getMonitorInitialDelayMS(),
downloaderConfig.getMonitorPeriodMS());
downloadStarted.set(Boolean.TRUE);
reliableResourceStatus = downloadFuture.get();
} catch (InterruptedException | CancellationException | ExecutionException e) {
LOGGER.info("{} - Unable to store product file {}",
e.getClass()
.getSimpleName(),
filePath,
e);
reliableResourceStatus = reliableResourceCallable.getReliableResourceStatus();
}
LOGGER.debug("reliableResourceStatus = {}", reliableResourceStatus);
if (DownloadStatus.RESOURCE_DOWNLOAD_COMPLETE.equals(reliableResourceStatus.getDownloadStatus())) {
LOGGER.debug("Cancelling resourceRetrievalMonitor");
resourceRetrievalMonitor.cancel();
if (downloadState.getDownloadState() != DownloadState.CANCELED) {
LOGGER.debug("Sending Product Retrieval Complete event");
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.COMPLETE,
metacard,
null,
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
} else {
LOGGER.debug(
"Client had canceled download and caching completed - do NOT send ProductRetrievalCompleted notification");
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.COMPLETE,
metacard,
null,
reliableResourceStatus.getBytesRead(),
downloadIdentifier,
false,
true);
}
if (doCaching) {
LOGGER.debug("Setting reliableResource size");
reliableResource.setSize(reliableResourceStatus.getBytesRead());
LOGGER.debug("Adding caching key = {} to cache map",
reliableResource.getKey());
resourceCache.put(reliableResource);
}
break;
} else {
bytesRead = reliableResourceStatus.getBytesRead();
LOGGER.debug("Download not complete, only read {} bytes", bytesRead);
if (fos != null) {
fos.flush();
}
// Synchronized so that the Callable is not shutdown while in the middle of
// writing to the
// FileBackedOutputStream and cache file (need to keep both of these in sync
// with number of bytes
// written to each of them).
synchronized (lock) {
// Simply doing Future.cancel(true) or a plain shutdown() is not enough.
// The downloadExecutor (or its underlying Future/thread) is holding on
// to a resource or is blocking on a read - undetermined at this point,
// but shutdownNow() along with re-instantiating the executor at top of
// while loop fixes this.
downloadExecutor.shutdownNow();
}
if (DownloadStatus.PRODUCT_INPUT_STREAM_EXCEPTION.equals(reliableResourceStatus.getDownloadStatus())) {
// Detected exception when reading from product's InputStream - re-retrieve
// product from the Source and retry caching it
LOGGER.info("Handling product InputStream exception");
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.RETRYING,
metacard,
String.format("Attempt %d of %d.",
retryAttempts,
downloaderConfig.getMaxRetryAttempts()),
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
IOUtils.closeQuietly(resourceInputStream);
resourceInputStream = null;
delay();
reliableResourceCallable = retrieveResource(bytesRead);
} else if (DownloadStatus.CACHED_FILE_OUTPUT_STREAM_EXCEPTION.equals(
reliableResourceStatus.getDownloadStatus())) {
// Detected exception when writing the product data to the product cache
// directory - assume this OutputStream cannot be fixed (e.g., disk full)
// and just continue streaming product to the client, i.e., writing to the
// FileBackedOutputStream
LOGGER.info("Handling FileOutputStream exception");
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.RETRYING,
metacard,
String.format("Attempt %d of %d.",
retryAttempts,
downloaderConfig.getMaxRetryAttempts()),
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
if (doCaching) {
deleteCacheFile(fos);
resourceCache.removePendingCacheEntry(reliableResource.getKey());
// Disable caching since the cache file being written to had issues
downloaderConfig.setCacheEnabled(false);
doCaching = false;
downloadState.setCacheEnabled(downloaderConfig.isCacheEnabled());
downloadState.setContinueCaching(doCaching);
}
reliableResourceCallable = new ReliableResourceCallable(resourceInputStream,
countingFbos,
downloaderConfig.getChunkSize(),
lock);
reliableResourceCallable.setBytesRead(bytesRead);
} else if (DownloadStatus.CLIENT_OUTPUT_STREAM_EXCEPTION.equals(
reliableResourceStatus.getDownloadStatus())) {
// Detected exception when writing product data to the output stream
// (FileBackedOutputStream) that
// is being read by the client - assume this is unrecoverable, but continue
// to cache the file
LOGGER.info("Handling FileBackedOutputStream exception");
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.CANCELLED,
metacard,
"",
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
IOUtils.closeQuietly(fbos);
IOUtils.closeQuietly(countingFbos);
LOGGER.debug("Cancelling resourceRetrievalMonitor");
resourceRetrievalMonitor.cancel();
reliableResourceCallable = new ReliableResourceCallable(resourceInputStream,
fos,
downloaderConfig.getChunkSize(),
lock);
reliableResourceCallable.setBytesRead(bytesRead);
} else if (DownloadStatus.RESOURCE_DOWNLOAD_CANCELED.equals(
reliableResourceStatus.getDownloadStatus())) {
LOGGER.info("Handling client cancellation of product download");
downloadState.setDownloadState(DownloadState.CANCELED);
LOGGER.debug("Cancelling resourceRetrievalMonitor");
resourceRetrievalMonitor.cancel();
eventListener.removeDownloadIdentifier(downloadIdentifier);
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.CANCELLED,
metacard,
"",
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
if (doCaching && downloaderConfig.isCacheWhenCanceled()) {
LOGGER.debug("Continuing to cache product");
reliableResourceCallable = new ReliableResourceCallable(
resourceInputStream,
fos,
downloaderConfig.getChunkSize(),
lock);
reliableResourceCallable.setBytesRead(bytesRead);
} else {
break;
}
} else if (DownloadStatus.RESOURCE_DOWNLOAD_INTERRUPTED.equals(
reliableResourceStatus.getDownloadStatus())) {
// Caching has been interrupted (possibly resourceRetrievalMonitor detected
// too much time being taken to retrieve a chunk of product data from the
// InputStream) - re-retrieve product from the Source, skip forward in the
// product InputStream the number of bytes already read successfully, and
// retry caching it
LOGGER.info(
"Handling interrupt of product caching - closing source InputStream");
// Set InputStream used on previous attempt to null so that any attempt to
// close it will not fail (CXF's DelegatingInputStream, which is the
// underlying InputStream being used, does a consume() which is a read() as
// part of its close() operation and this will result in a blocking read)
resourceInputStream = null;
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.RETRYING,
metacard,
String.format("Attempt %d of %d.",
retryAttempts,
downloaderConfig.getMaxRetryAttempts()),
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
delay();
reliableResourceCallable = retrieveResource(bytesRead);
}
}
}
if (null != reliableResourceStatus && !DownloadStatus.RESOURCE_DOWNLOAD_COMPLETE.equals(
reliableResourceStatus.getDownloadStatus())) {
if (doCaching) {
deleteCacheFile(fos);
}
if (!DownloadStatus.RESOURCE_DOWNLOAD_CANCELED.equals(reliableResourceStatus.getDownloadStatus())) {
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.FAILED,
metacard,
"Unable to retrieve product file.",
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
}
}
} catch (IOException e) {
LOGGER.info("Unable to store product file {}", filePath, e);
downloadState.setDownloadState(DownloadState.FAILED);
eventPublisher.postRetrievalStatus(resourceResponse,
ProductRetrievalStatus.FAILED,
metacard,
"Unable to store product file.",
reliableResourceStatus.getBytesRead(),
downloadIdentifier);
} finally {
cleanupAfterDownload(reliableResourceStatus);
downloadExecutor.shutdown();
}
}
private ReliableResourceCallable retrieveResource(long bytesRead) {
ReliableResourceCallable reliableResourceCallable = null;
try {
LOGGER.debug("Attempting to re-retrieve resource, skipping {} bytes", bytesRead);
// Re-fetch product from the Source after setting up values to indicate the number of
// bytes to skip. This prevents the same bytes being read again and put in the
// PipedOutputStream that the client is still reading from and in the file being cached
// to. It also allows for range headers to be used in the request so that already read
// bytes do not need to be re-retrieved.
ResourceResponse resourceResponse = retriever.retrieveResource(bytesRead);
LOGGER.debug("Name of re-retrieved resource = {}",
resourceResponse.getResource()
.getName());
resourceInputStream = resourceResponse.getResource()
.getInputStream();
reliableResourceCallable = new ReliableResourceCallable(resourceInputStream,
countingFbos,
fos,
downloaderConfig.getChunkSize(),
lock);
// So that Callable can account for bytes read in previous download attempt(s)
reliableResourceCallable.setBytesRead(bytesRead);
} catch (ResourceNotFoundException | ResourceNotSupportedException | IOException e) {
LOGGER.info("Unable to re-retrieve product; cannot download product file {}", filePath);
}
return reliableResourceCallable;
}
private void deleteCacheFile(FileOutputStream fos) {
LOGGER.debug("Deleting partially cached file {}", filePath);
IOUtils.closeQuietly(fos);
// Delete the cache file since it will no longer be written to and it currently has
// incomplete or corrupted data in it
boolean result = FileUtils.deleteQuietly(new File(filePath));
LOGGER.debug("result of deleting partial cache file = {}", result);
}
private void cleanupAfterDownload(ReliableResourceStatus reliableResourceStatus) {
if (reliableResourceStatus != null) {
// If caching was not successful, then remove this product from the pending cache list
// (Otherwise partially cached files will remain in pending list and returned to
// subsequent clients)
if (reliableResourceStatus.getDownloadStatus()
!= DownloadStatus.RESOURCE_DOWNLOAD_COMPLETE) {
if (doCaching) {
resourceCache.removePendingCacheEntry(reliableResource.getKey());
}
if (reliableResourceStatus.getDownloadStatus()
== DownloadStatus.RESOURCE_DOWNLOAD_CANCELED) {
this.downloadState.setDownloadState(DownloadManagerState.DownloadState.CANCELED);
} else {
this.downloadState.setDownloadState(DownloadManagerState.DownloadState.FAILED);
}
closeFileBackedOutputStream();
} else {
this.downloadState.setDownloadState(DownloadManagerState.DownloadState.COMPLETED);
// FileBackedOutputStream should be closed by ReliableResourceInputStream for
// successful downloads since client reading from this InputStream will lag when
// Callable finishes reading product's InputStream
}
}
IOUtils.closeQuietly(countingFbos);
if (doCaching) {
IOUtils.closeQuietly(fos);
}
LOGGER.debug("Closing source InputStream");
IOUtils.closeQuietly(resourceInputStream);
LOGGER.debug("Closed source InputStream");
}
private void delay() {
try {
LOGGER.debug("Waiting {} ms before attempting to re-retrieve and cache product {}",
downloaderConfig.getDelayBetweenAttemptsMS(),
filePath);
Thread.sleep(downloaderConfig.getDelayBetweenAttemptsMS());
} catch (InterruptedException e1) {
}
}
/**
* Closes FileBackedOutputStream and deletes its underlying tmp file (if any)
*/
private void closeFileBackedOutputStream() {
try {
LOGGER.debug("Resetting FileBackedOutputStream");
fbos.reset();
} catch (IOException e) {
LOGGER.info(
"Unable to reset FileBackedOutputStream - its tmp file may still be in <INSTALL_DIR>/data/tmp");
}
}
public Long getReliableResourceInputStreamBytesCached() {
return streamReadByClient.getBytesCached();
}
public String getReliableResourceInputStreamState() {
return streamReadByClient.getDownloadState()
.getDownloadState()
.name();
}
public String getResourceSize() {
return metacard.getResourceSize();
}
public ResourceResponse getResourceResponse() {
return resourceResponse;
}
@VisibleForTesting
void setFileOutputStream(FileOutputStream fos) {
this.fos = fos;
}
@VisibleForTesting
void setCountingOutputStream(CountingOutputStream countingFbos) {
this.countingFbos = countingFbos;
}
}