All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.bazaarvoice.emodb.blob.client.BlobStoreClient Maven / Gradle / Ivy

There is a newer version: 6.5.190
Show newest version
package com.bazaarvoice.emodb.blob.client;

import com.bazaarvoice.emodb.auth.apikey.ApiKeyRequest;
import com.bazaarvoice.emodb.blob.api.AuthBlobStore;
import com.bazaarvoice.emodb.blob.api.Blob;
import com.bazaarvoice.emodb.blob.api.BlobMetadata;
import com.bazaarvoice.emodb.blob.api.BlobNotFoundException;
import com.bazaarvoice.emodb.blob.api.DefaultBlob;
import com.bazaarvoice.emodb.blob.api.DefaultBlobMetadata;
import com.bazaarvoice.emodb.blob.api.Range;
import com.bazaarvoice.emodb.blob.api.RangeNotSatisfiableException;
import com.bazaarvoice.emodb.blob.api.RangeSpecification;
import com.bazaarvoice.emodb.blob.api.StreamSupplier;
import com.bazaarvoice.emodb.blob.api.Table;
import com.bazaarvoice.emodb.client.EmoClient;
import com.bazaarvoice.emodb.client.EmoClientException;
import com.bazaarvoice.emodb.client.EmoResource;
import com.bazaarvoice.emodb.client.EmoResponse;
import com.bazaarvoice.emodb.client.uri.EmoUriBuilder;
import com.bazaarvoice.emodb.common.api.ServiceUnavailableException;
import com.bazaarvoice.emodb.common.api.UnauthorizedException;
import com.bazaarvoice.emodb.common.json.RisonHelper;
import com.bazaarvoice.emodb.sor.api.TableOptions;
import com.bazaarvoice.emodb.sor.api.Audit;
import com.bazaarvoice.emodb.sor.api.TableExistsException;
import com.bazaarvoice.emodb.sor.api.UnknownTableException;
import com.bazaarvoice.emodb.sor.api.UnknownPlacementException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.Maps;
import com.google.common.io.ByteStreams;
import com.google.common.io.Closeables;
import com.google.common.net.HttpHeaders;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import dev.failsafe.Failsafe;
import dev.failsafe.RetryPolicy;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.Hex;

import javax.annotation.Nullable;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.time.Duration;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.requireNonNull;

/**
 * Blob store client implementation that routes API calls to the EmoDB service.  The actual HTTP communication
 * is managed by the {@link EmoClient} implementation to allow for flexible usage by variety of HTTP client
 * implementations, such as Jersey.
 */
//TODO add documentation for using the client once it is validated
public class BlobStoreClient implements AuthBlobStore {

    /**
     * Must match the service name in the EmoService class.
     */
    /*package*/ static final String BASE_SERVICE_NAME = "emodb-blob-1";

    /**
     * Must match the @Path annotation on the BlobStoreResource class.
     */
    public static final String SERVICE_PATH = "/blob/1";

    private static final String X_BV_PREFIX = "X-BV-";    // HTTP header prefix for BlobMetadata other than attributes
    private static final String X_BVA_PREFIX = "X-BVA-";  // HTTP header prefix for BlobMetadata attributes

    /**
     * Regex for parsing the HTTP Content-Range header.
     */
    private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("^bytes (\\d+)-(\\d+)/\\d+$");

    private static final int HTTP_PARTIAL_CONTENT = 206;

    /**
     * Delay after which streaming connections are automatically closed if the caller doesn't begin reading the stream.
     * The caller can still read the contents after this time elapses but will incur a new round-trip request/response.
     */
    private static final Duration BLOB_CONNECTION_CLOSED_TIMEOUT = Duration.ofSeconds(6);

    private final EmoClient _client;
    private final UriBuilder _blobStore;
    private final ScheduledExecutorService _connectionManagementService;
    private  RetryPolicy _retryPolicy;


    public BlobStoreClient(URI endPoint, EmoClient client,
                           @Nullable ScheduledExecutorService connectionManagementService,
                           RetryPolicy retryPolicy) {
        _client = requireNonNull(client, "client");
        _blobStore = EmoUriBuilder.fromUri(endPoint);


        if (connectionManagementService != null) {
            _connectionManagementService = connectionManagementService;
        } else {
            // Create a default single-threaded executor service
            _connectionManagementService = Executors.newSingleThreadScheduledExecutor(
                    new ThreadFactoryBuilder().setNameFormat("blob-store-client-connection-reaper-%d").build());
        }

        _retryPolicy = requireNonNull(retryPolicy);
    }

    @Override
    public Iterator listTables(String apiKey, @Nullable String fromTableExclusive, long limit) {
        checkArgument(limit > 0, "Limit must be >0");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table")
                    .queryParam("from", (fromTableExclusive != null) ? new Object[]{fromTableExclusive} : new Object[0])
                    .queryParam("limit", limit)
                    .build();

            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(new TypeReference>() {
                            }));
        } catch (EmoClientException e) {
            throw convertException(e);
        }

    }

    @Override
    public void createTable(String apiKey, String table, TableOptions options, Map attributes, Audit audit)
            throws TableExistsException {
        requireNonNull(table, "table");
        requireNonNull(options, "options");
        requireNonNull(attributes, "attributes");
        requireNonNull(audit, "audit");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table)
                    .queryParam("options", RisonHelper.asORison(options))
                    .queryParam("audit", RisonHelper.asORison(audit))
                    .build();

            Failsafe.with(_retryPolicy)
                    .run(() -> _client.resource(uri)
                            .type(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .put(attributes));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public void dropTable(String apiKey, String table, Audit audit) throws UnknownTableException {
        requireNonNull(table, "table");
        requireNonNull(audit, "audit");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table)
                    .queryParam("audit", RisonHelper.asORison(audit))
                    .build();

            Failsafe.with(_retryPolicy)
                    .run(() -> _client.resource(uri)
                            .type(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .delete());
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public void purgeTableUnsafe(String apiKey, String table, Audit audit) {
        requireNonNull(table, "table");
        requireNonNull(audit, "audit");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table, "purge")
                    .queryParam("audit", RisonHelper.asORison(audit))
                    .build();
            Failsafe.with(_retryPolicy)
                    .run(() -> _client.resource(uri)
                            .type(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .post());
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public boolean getTableExists(String apiKey, String table) {
        requireNonNull(table, "table");
        URI uri = _blobStore.clone()
                .segment("_table", table)
                .build();

        boolean exists = Failsafe.with(_retryPolicy)
                .get(() -> { EmoResponse response = _client.resource(uri)
                          .accept(MediaType.APPLICATION_JSON_TYPE)
                          .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                          .head();
                    if (response.getStatus() == Response.Status.OK.getStatusCode()) {
                        return true;
                    } else if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode() &&
                            UnknownTableException.class.getName().equals(response.getFirstHeader("X-BV-Exception"))) {
                        return false;
                    } else {
                        throw convertException(new EmoClientException(response));
                    }});
        return exists;
    }

    @Override
    public boolean isTableAvailable(String apiKey, String table) {
        requireNonNull(table, "table");
        return getTableMetadata(apiKey, table).getAvailability() != null;
    }

    @Override
    public Table getTableMetadata(String apiKey, String table) {
        requireNonNull(table, "table");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table, "metadata")
                    .build();
            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(Table.class));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public Map getTableAttributes(String apiKey, String table) throws UnknownTableException {
        requireNonNull(table, "table");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table)
                    .build();
            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(new TypeReference>() {
                            }));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public void setTableAttributes(String apiKey, String table, Map attributes, Audit audit) {
        requireNonNull(table, "table");
        requireNonNull(attributes, "attributes");
        requireNonNull(audit, "audit");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table, "attributes")
                    .queryParam("audit", RisonHelper.asORison(audit))
                    .build();
            Failsafe.with(_retryPolicy)
                    .run(() -> _client.resource(uri)
                            .type(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .put(attributes));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public TableOptions getTableOptions(String apiKey, String table) throws UnknownTableException {
        requireNonNull(table, "table");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table, "options")
                    .build();
            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(TableOptions.class));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public long getTableApproximateSize(String apiKey, String table) {
        requireNonNull(table, "table");
        try {
            URI uri = _blobStore.clone()
                    .segment("_table", table, "size")
                    .build();
            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(Long.class));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public BlobMetadata getMetadata(String apiKey, String table, String blobId) throws BlobNotFoundException {
        requireNonNull(table, "table");
        requireNonNull(blobId, "blobId");
        try {
            BlobMetadata result = Failsafe.with(_retryPolicy)
                    .get(() -> {
                        EmoResponse response = _client.resource(toUri(table, blobId))
                                .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                                .head();
                        if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode() &&
                                BlobNotFoundException.class.getName().equals(response.getFirstHeader("X-BV-Exception"))) {
                            throw new BlobNotFoundException(blobId, new EmoClientException(response));

                        } else if (response.getStatus() != Response.Status.OK.getStatusCode()) {
                            throw new EmoClientException(response);

                        }
                        return parseMetadataHeaders(blobId, response);
                    });
            return result;
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public Iterator scanMetadata(String apiKey, String table, @Nullable String fromBlobIdExclusive, long limit) {
        requireNonNull(table, "table");
        checkArgument(limit > 0, "Limit must be >0");
        try {
            URI uri = _blobStore.clone()
                    .segment(table)
                    .queryParam("from", (fromBlobIdExclusive != null) ? new Object[]{fromBlobIdExclusive} : new Object[0])
                    .queryParam("limit", limit)
                    .build();
            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(new TypeReference>() {
                            }));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public Blob get(String apiKey, String table, String blobId) throws BlobNotFoundException {
        return get(apiKey, table, blobId, null);
    }

    @Override
    public Blob get(String apiKey, String table, String blobId, @Nullable RangeSpecification rangeSpec)
            throws BlobNotFoundException, RangeNotSatisfiableException {
        BlobRequest request = new BlobRequest(apiKey, table, blobId, rangeSpec);
        BlobResponse response = get(request);

        // Since we cannot guarantee if/when the caller will close the blob input stream schedule the stream
        // to be closed shortly in the future.  If the caller tries to read the stream after this happens
        // it will force a new round-trip to the server.

        // Create a weak reference so that if the returned blob is fully dereferenced then the response can be
        // finalized (which will ensure the stream is closed) and garbage collected without the scheduled runnable
        // holding the only remaining reference.
        final WeakReference weakResponse = new WeakReference<>(response);

        assert _connectionManagementService != null;
        _connectionManagementService.schedule(
                new Runnable() {
                    @Override
                    public void run() {
                        BlobResponse response = weakResponse.get();
                        if (response != null) {
                            response.ensureInputStreamClosed();
                        }
                    }
                },
                BLOB_CONNECTION_CLOSED_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
        return new DefaultBlob(response.getMetadata(), response.getRange(), streamSupplier(request, response));
    }

    private BlobResponse get(BlobRequest blobRequest)
            throws BlobNotFoundException, RangeNotSatisfiableException {
        requireNonNull(blobRequest, "blobRequest");
        String table = requireNonNull(blobRequest.getTable(), "table");
        String blobId = requireNonNull(blobRequest.getBlobId(), "blobId");
        RangeSpecification rangeSpec = blobRequest.getRangeSpecification();
        String apiKey = blobRequest.getApiKey();
        try {
            EmoResource request = _client.resource(toUri(table, blobId));
            if (rangeSpec != null) {
                request.header(HttpHeaders.RANGE, rangeSpec);
            }
            BlobResponse blobResponse = Failsafe.with(_retryPolicy)
                    .get(() -> {
                        EmoResponse response = request
                                .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                                .get(EmoResponse.class);

                        int status = response.getStatus();
                        if (status != Response.Status.OK.getStatusCode() && status != HTTP_PARTIAL_CONTENT) {
                            throw new EmoClientException(response);
                        }

                        BlobMetadata metadata = parseMetadataHeaders(blobId, response);
                        InputStream input = response.getEntityInputStream();
                        boolean rangeApplied = true;

                        // Parse range-related data.
                        Range range;
                        String contentRange = response.getFirstHeader(HttpHeaders.CONTENT_RANGE);
                        if (status == Response.Status.OK.getStatusCode()) {
                            checkState(contentRange == null, "Unexpected HTTP 200 response with Content-Range header.");
                            if (rangeSpec == null) {
                                // Normal GET request without a Range header
                                range = new Range(0, metadata.getLength());
                            } else {
                                // Server ignored the Range header.  Maybe a proxy stripped it out?
                                range = rangeSpec.getRange(metadata.getLength());
                                rangeApplied = false;
                            }
                        } else if (status == HTTP_PARTIAL_CONTENT) {
                            // Normal GET request with a Range header and a 206 Partial Content response
                            checkState(rangeSpec != null, "Unexpected HTTP 206 response to request w/out a Range header.");
                            checkState(contentRange != null, "Unexpected HTTP 206 response w/out Content-Range header.");
                            range = parseContentRange(contentRange);
                        } else {
                            throw new IllegalStateException();  // Shouldn't get here
                        }

                        return new BlobResponse(metadata, range, rangeApplied, input);
                    });
            return blobResponse;
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    private RuntimeException convertException(EmoClientException e) {
        EmoResponse response = e.getResponse();
        String exceptionType = response.getFirstHeader("X-BV-Exception");

        if (response.getStatus() == Response.Status.BAD_REQUEST.getStatusCode() &&
                IllegalArgumentException.class.getName().equals(exceptionType)) {
            return new IllegalArgumentException(response.getEntity(String.class), e);

        } else if (response.getStatus() == Response.Status.CONFLICT.getStatusCode() &&
                TableExistsException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(TableExistsException.class).initCause(e);
            } else {
                return (RuntimeException) new TableExistsException().initCause(e);
            }

        } else if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode() &&
                UnknownTableException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(UnknownTableException.class).initCause(e);
            } else {
                return (RuntimeException) new UnknownTableException().initCause(e);
            }

        } else if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode() &&
                BlobNotFoundException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(BlobNotFoundException.class).initCause(e);
            } else {
                return (RuntimeException) new BlobNotFoundException().initCause(e);
            }
        } else if (response.getStatus() == Response.Status.NOT_FOUND.getStatusCode() &&
                UnknownPlacementException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(UnknownPlacementException.class).initCause(e);
            } else {
                return (RuntimeException) new UnknownPlacementException().initCause(e);
            }

        } else if (response.getStatus() == 416 /* REQUESTED_RANGE_NOT_SATIFIABLE */ &&
                RangeNotSatisfiableException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(RangeNotSatisfiableException.class).initCause(e);
            } else {
                return (RuntimeException) new RangeNotSatisfiableException(null, -1, -1).initCause(e);
            }

        } else if (response.getStatus() == Response.Status.MOVED_PERMANENTLY.getStatusCode() &&
                UnsupportedOperationException.class.getName().equals(exceptionType)) {
            return new UnsupportedOperationException("Permanent redirect: " + response.getLocation(), e);

        } else if (response.getStatus() == Response.Status.FORBIDDEN.getStatusCode() &&
                UnauthorizedException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(UnauthorizedException.class).initCause(e);
            } else {
                return (RuntimeException) new UnauthorizedException().initCause(e);
            }
        } else if (response.getStatus() == Response.Status.SERVICE_UNAVAILABLE.getStatusCode() &&
                ServiceUnavailableException.class.getName().equals(exceptionType)) {
            if (response.hasEntity()) {
                return (RuntimeException) response.getEntity(ServiceUnavailableException.class).initCause(e);
            } else {
                return (RuntimeException) new ServiceUnavailableException().initCause(e);
            }
        }

        return e;
    }

    /**
     * Parses an HTTP "Content-Range" header in an HTTP 206 Partial Content response.
     */
    private Range parseContentRange(String contentRange) {
        Matcher matcher = CONTENT_RANGE_PATTERN.matcher(contentRange);
        checkState(matcher.matches(), "Unexpected Content-Range header: %s", contentRange);
        long start = Long.parseLong(matcher.group(1));
        long end = Long.parseLong(matcher.group(2));  // Inclusive
        return new Range(start, end - start + 1);
    }

    /**
     * Parses HTTP headers into a {@link BlobMetadata} object.
     */
    private BlobMetadata parseMetadataHeaders(String blobId, EmoResponse response) {
        // The server always sets X-BV-Length.  It's similar to Content-Length but proxies etc. shouldn't mess with it.
        String lengthString = response.getFirstHeader(X_BV_PREFIX + "Length");
        checkState(lengthString != null, "BlobStore request is missing expected required X-BV-Length header.");
        long length = Long.parseLong(lengthString);

        // Extract signature hash values.
        String md5 = base64ToHex(response.getFirstHeader(HttpHeaders.CONTENT_MD5));
        String sha1 = stripQuotes(response.getFirstHeader(HttpHeaders.ETAG));

        // Extract attribute map specified when the blob was first uploaded.
        Map attributes = Maps.newHashMap();
        for (Map.Entry> entry : response.getHeaders()) {
            if (entry.getKey().startsWith(X_BVA_PREFIX)) {
                attributes.put(entry.getKey().substring(X_BVA_PREFIX.length()), entry.getValue().get(0));
            }
        }

        return new DefaultBlobMetadata(blobId, response.getLastModified(), length, md5, sha1, attributes);
    }

    private StreamSupplier streamSupplier(final BlobRequest request, final BlobResponse response) {
        return out -> {
            InputStream in = response.getInputStream();
            if (in == null) {
                // The original stream has already been consumed.  Re-open a new stream from the server.
                in = get(request).getInputStream();
            }

            try {
                ByteStreams.copy(in, out);
            } finally {
                Closeables.close(in, true);
            }
        };
    }

    private String base64ToHex(String base64) {
        return (base64 != null) ? Hex.encodeHexString(Base64.decodeBase64(base64)) : null;
    }

    private String stripQuotes(String quoted) {
        return (quoted != null) ? quoted.replaceAll("\"", "") : null;
    }

    @Override
    public void put(String apiKey, String table, String blobId, Supplier in,
                    Map attributes)
            throws IOException {
        requireNonNull(table, "table");
        requireNonNull(blobId, "blobId");
        requireNonNull(in, "in");
        requireNonNull(attributes, "attributes");
        try {
            // Encode the ttl as a URL query parameter
            URI uri = _blobStore.clone()
                    .segment(table, blobId)
                    .build();
            // Encode the rest of the attributes as request headers
            EmoResource request = _client.resource(uri);
            for (Map.Entry entry : attributes.entrySet()) {
                request.header(X_BVA_PREFIX + entry.getKey(), entry.getValue());
            }
            // Upload the object
            Failsafe.with(_retryPolicy)
                    .run(() -> request.type(MediaType.APPLICATION_OCTET_STREAM_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .put(in.get()));
        } catch (EmoClientException e) {
            throw convertException(e);
        }

    }

    @Override
    public void delete(String apiKey, String table, String blobId) {
        requireNonNull(table, "table");
        requireNonNull(blobId, "blobId");
        try {
            Failsafe.with(_retryPolicy)
                    .run(() -> _client.resource(toUri(table, blobId))
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .delete());
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    @Override
    public Collection getTablePlacements(String apiKey) {
        try {
            URI uri = _blobStore.clone()
                    .segment("_tableplacement")
                    .build();
            return Failsafe.with(_retryPolicy)
                    .get(() -> _client.resource(uri)
                            .accept(MediaType.APPLICATION_JSON_TYPE)
                            .header(ApiKeyRequest.AUTHENTICATION_HEADER, apiKey)
                            .get(new TypeReference>() {
                            }));
        } catch (EmoClientException e) {
            throw convertException(e);
        }
    }

    private URI toUri(String table, String blobId) {
        return _blobStore.clone().segment(table, blobId).build();
    }


    /**
     * Helper object to encapsulate the parameters for a read blob request.
     */
    private static class BlobRequest {
        final String _apiKey;
        final String _table;
        final String _blobId;
        @Nullable
        final RangeSpecification _rangeSpec;

        private BlobRequest(String apiKey, String table, String blobId, RangeSpecification rangeSpec) {
            _apiKey = apiKey;
            _table = table;
            _blobId = blobId;
            _rangeSpec = rangeSpec;
        }

        private String getApiKey() {
            return _apiKey;
        }

        private String getTable() {
            return _table;
        }

        private String getBlobId() {
            return _blobId;
        }

        @Nullable
        private RangeSpecification getRangeSpecification() {
            return _rangeSpec;
        }
    }

    /**
     * Helper object to encapsulate the response for a read blob request and provide guaranteed closure for the
     * underlying resources.
     */
    private static class BlobResponse {
        final private BlobMetadata _metadata;
        final private Range _range;
        final private boolean _rangeApplied;
        final private InputStream _stream;
        private final AtomicBoolean _inputStreamConsumed = new AtomicBoolean(false);

        private BlobResponse(BlobMetadata metadata, Range range, boolean rangeApplied, InputStream stream) {
            _metadata = metadata;
            _range = range;
            _rangeApplied = rangeApplied;
            _stream = stream;
        }

        private BlobMetadata getMetadata() {
            return _metadata;
        }

        private Range getRange() {
            return _range;
        }

        private boolean claimInputStream() {
            return _inputStreamConsumed.compareAndSet(false, true);
        }

        @Nullable
        public InputStream getInputStream() throws IOException {
            if (!claimInputStream()) {
                // The input stream has already been consumed
                return null;
            }

            if (_rangeApplied) {
                // Either the entire blob was requested or the requested range was applied by the server
                return _stream;
            }

            // The client requested a sub-range of the blob but the server ignored it.  Manipulate the input stream
            // to return only the client requested range.
            ByteStreams.skipFully(_stream, _range.getOffset());
            return ByteStreams.limit(_stream, _range.getLength());
        }

        public void ensureInputStreamClosed() {
            // Only close the stream if no caller ever attempted to read it.
            if (claimInputStream()) {
                try {
                    Closeables.close(_stream, true);
                } catch (IOException e) {
                    // Already caught and logged
                }
            }
        }

        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            ensureInputStreamClosed();
        }
    }
}