Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.locks.ReentrantLock;
Expand All @@ -52,21 +55,29 @@ final class ObjectReadSessionImpl implements ObjectReadSession {

private final ConcurrentIdentityMap<ObjectReadSessionStream, ObjectReadSessionState> children;

// Fast open reads: reads sent in the request that opened the stream, keyed by the config each was
// created from. The first readAs with an equal config is given the read instead of sending a new
// one.
private final ConcurrentMap<ReadProjectionConfig<?>, ObjectReadSessionStreamRead<?>>
fastOpenReads;
Comment on lines +58 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If fastOpenConfigs contains duplicate configs (e.g., requesting the same range multiple times), using a simple Map where the key is the config will cause subsequent duplicate configs to overwrite previous ones. This results in only the last fast-open read being preserved, while the others are leaked/wasted and subsequent readAs calls for those configs will unnecessarily trigger new requests.

To support duplicate configs correctly and safely under concurrent access, we can map each config to a thread-safe queue (such as ConcurrentLinkedQueue) of outstanding reads.

Suggested change
// Fast open reads: reads sent in the request that opened the stream, keyed by the config each was
// created from. The first readAs with an equal config is given the read instead of sending a new
// one.
private final ConcurrentMap<ReadProjectionConfig<?>, ObjectReadSessionStreamRead<?>>
fastOpenReads;
// Fast open reads: reads sent in the request that opened the stream, keyed by the config each was
// created from. The first readAs with an equal config is given the read instead of sending a new
// one. Multiple identical configs are supported via a queue.
private final ConcurrentMap<ReadProjectionConfig<?>, java.util.Queue<ObjectReadSessionStreamRead<?>>>
fastOpenReads;


private volatile boolean open;

ObjectReadSessionImpl(
ScheduledExecutorService executor,
ZeroCopyBidiStreamingCallable<BidiReadObjectRequest, BidiReadObjectResponse> callable,
ObjectReadSessionStream stream,
ObjectReadSessionState state,
RetryContextProvider retryContextProvider) {
RetryContextProvider retryContextProvider,
Map<ReadProjectionConfig<?>, ObjectReadSessionStreamRead<?>> fastOpenReads) {
this.executor = executor;
this.callable = callable;
this.stream = stream;
this.state = state;
this.resource = state.getMetadata();
this.retryContextProvider = retryContextProvider;
this.children = new ConcurrentIdentityMap<>();
this.fastOpenReads = new ConcurrentHashMap<>(fastOpenReads);
this.open = true;
}
Comment on lines 66 to 82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the constructor to accept the queue-based map of fast-open reads.

Suggested change
ObjectReadSessionImpl(
ScheduledExecutorService executor,
ZeroCopyBidiStreamingCallable<BidiReadObjectRequest, BidiReadObjectResponse> callable,
ObjectReadSessionStream stream,
ObjectReadSessionState state,
RetryContextProvider retryContextProvider) {
RetryContextProvider retryContextProvider,
Map<ReadProjectionConfig<?>, ObjectReadSessionStreamRead<?>> fastOpenReads) {
this.executor = executor;
this.callable = callable;
this.stream = stream;
this.state = state;
this.resource = state.getMetadata();
this.retryContextProvider = retryContextProvider;
this.children = new ConcurrentIdentityMap<>();
this.fastOpenReads = new ConcurrentHashMap<>(fastOpenReads);
this.open = true;
}
ObjectReadSessionImpl(
ScheduledExecutorService executor,
ZeroCopyBidiStreamingCallable<BidiReadObjectRequest, BidiReadObjectResponse> callable,
ObjectReadSessionStream stream,
ObjectReadSessionState state,
RetryContextProvider retryContextProvider,
Map<ReadProjectionConfig<?>, java.util.Queue<ObjectReadSessionStreamRead<?>>> fastOpenReads) {
this.executor = executor;
this.callable = callable;
this.stream = stream;
this.state = state;
this.resource = state.getMetadata();
this.retryContextProvider = retryContextProvider;
this.children = new ConcurrentIdentityMap<>();
this.fastOpenReads = new ConcurrentHashMap<>(fastOpenReads);
this.open = true;
}


Expand All @@ -80,6 +91,14 @@ public <Projection> Projection readAs(ReadProjectionConfig<Projection> config) {
checkState(open, "Session already closed");
switch (config.getType()) {
case STREAM_READ:
ObjectReadSessionStreamRead<?> fastOpenRead = fastOpenReads.remove(config);
if (fastOpenRead != null) {
// configs are final classes with a class-exact equals, so an equal config has the same
// Projection type
@SuppressWarnings("unchecked")
Projection projection = (Projection) fastOpenRead.project();
return projection;
}
Comment on lines +94 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update readAs to poll from the queue of fast-open reads for the given config.

Suggested change
ObjectReadSessionStreamRead<?> fastOpenRead = fastOpenReads.remove(config);
if (fastOpenRead != null) {
// configs are final classes with a class-exact equals, so an equal config has the same
// Projection type
@SuppressWarnings("unchecked")
Projection projection = (Projection) fastOpenRead.project();
return projection;
}
java.util.Queue<ObjectReadSessionStreamRead<?>> queue = fastOpenReads.get(config);
ObjectReadSessionStreamRead<?> fastOpenRead = queue != null ? queue.poll() : null;
if (fastOpenRead != null) {
// configs are final classes with a class-exact equals, so an equal config has the same
// Projection type
@SuppressWarnings("unchecked")
Projection projection = (Projection) fastOpenRead.project();
return projection;
}

long readId = state.newReadId();
ObjectReadSessionStreamRead<Projection> read =
config.cast().newRead(readId, retryContextProvider.create());
Expand Down Expand Up @@ -113,6 +132,11 @@ public void close() throws IOException {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedIOException();
} finally {
// a fast open read nobody asked for may already be complete and out of the state, so
// stream.close() above does not reach it; close it here to release its buffers
GrpcUtils.closeAll(fastOpenReads.values());
fastOpenReads.clear();
}
Comment on lines +135 to 140

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the finally block in close() to close all remaining fast-open reads in the queues.

Suggested change
} finally {
// a fast open read nobody asked for may already be complete and out of the state, so
// stream.close() above does not reach it; close it here to release its buffers
GrpcUtils.closeAll(fastOpenReads.values());
fastOpenReads.clear();
}
} finally {
// a fast open read nobody asked for may already be complete and out of the state, so
// stream.close() above does not reach it; close it here to release its buffers
fastOpenReads.values().forEach(GrpcUtils::closeAll);
fastOpenReads.clear();
}

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@
import com.google.cloud.storage.ReadProjectionConfig.ProjectionType;
import com.google.cloud.storage.RetryContext.RetryContextProvider;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.storage.v2.BidiReadObjectRequest;
import com.google.storage.v2.BidiReadObjectResponse;
import java.io.IOException;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

Expand Down Expand Up @@ -66,6 +69,9 @@ ApiFuture<ObjectReadSession> readSession(BidiReadObjectRequest req, GrpcCallCont
* Open a new {@link ObjectReadSession} using fast open: the ranges in {@code fastOpenConfigs} are
* sent in the same request that opens the session, saving a round trip before the first bytes.
*
* <p>The first {@code readAs} with a config equal to one of these is given that fast open read
* instead of sending a new one.
*
* @param fastOpenConfigs the reads to send with the open, for example {@code
* ReadProjectionConfigs.asFutureBytes().withRangeSpec(RangeSpec.of(0, 1024))}; empty to only
* open the session
Expand All @@ -77,6 +83,7 @@ ApiFuture<ObjectReadSession> readSession(
checkArgument(
req.getReadRangesList().isEmpty(),
"ranges included in the initial request are not supported");
checkArgument(fastOpenConfigs.size() <= 1, "at most one fast open read is supported");
ObjectReadSessionState state = new ObjectReadSessionState(ctx, req);

ZeroCopyBidiStreamingCallable<BidiReadObjectRequest, BidiReadObjectResponse> callable =
Expand All @@ -86,18 +93,21 @@ ApiFuture<ObjectReadSession> readSession(

// Add each fast open read to the session state. The open request below is built from the
// state, so these reads are sent as part of the open.
Map<ReadProjectionConfig<?>, ObjectReadSessionStreamRead<?>> fastOpenReads = new HashMap<>();
for (ReadProjectionConfig<?> config : fastOpenConfigs) {
long readId = state.newReadId();
ObjectReadSessionStreamRead<?> read =
config.cast().newRead(readId, retryContextProvider.create());
state.putOutstandingRead(readId, read);
fastOpenReads.put(config, read);
}
Comment on lines +96 to 103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update readSession to group the fast-open reads by config into a queue to support duplicate configs.

Suggested change
Map<ReadProjectionConfig<?>, ObjectReadSessionStreamRead<?>> fastOpenReads = new HashMap<>();
for (ReadProjectionConfig<?> config : fastOpenConfigs) {
long readId = state.newReadId();
ObjectReadSessionStreamRead<?> read =
config.cast().newRead(readId, retryContextProvider.create());
state.putOutstandingRead(readId, read);
fastOpenReads.put(config, read);
}
Map<ReadProjectionConfig<?>, java.util.Queue<ObjectReadSessionStreamRead<?>>> fastOpenReads = new HashMap<>();
for (ReadProjectionConfig<?> config : fastOpenConfigs) {
long readId = state.newReadId();
ObjectReadSessionStreamRead<?> read =
config.cast().newRead(readId, retryContextProvider.create());
state.putOutstandingRead(readId, read);
fastOpenReads.computeIfAbsent(config, k -> new java.util.concurrent.ConcurrentLinkedQueue<>()).add(read);
}


ApiFuture<ObjectReadSession> objectReadSessionFuture =
ApiFutures.transform(
stream,
nowOpen ->
new ObjectReadSessionImpl(executor, callable, stream, state, retryContextProvider),
new ObjectReadSessionImpl(
executor, callable, stream, state, retryContextProvider, fastOpenReads),
executor);
stream.send(state.getOpenArguments().getReq());
return objectReadSessionFuture;
Expand Down Expand Up @@ -132,7 +142,7 @@ <Projection> ApiFuture<FastOpenObjectReadSession<Projection>> fastOpenReadSessio
nowOpen ->
new FastOpenObjectReadSession<>(
new ObjectReadSessionImpl(
executor, callable, stream, state, retryContextProvider),
executor, callable, stream, state, retryContextProvider, ImmutableMap.of()),
read,
stream),
executor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@

import static com.google.cloud.storage.ByteSizeConstants._2MiB;
import static com.google.cloud.storage.ITObjectReadSessionFakeTest.getReadRange;
import static com.google.cloud.storage.ITObjectReadSessionFakeTest.read;
import static com.google.cloud.storage.PackagePrivateMethodWorkarounds.maybeGetStorageDataClient;
import static com.google.cloud.storage.TestUtils.xxd;
import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.Assert.assertThrows;

import com.google.api.gax.grpc.GrpcCallContext;
import com.google.cloud.storage.ITObjectReadSessionFakeTest.FakeStorage;
Expand All @@ -35,6 +38,7 @@
import com.google.storage.v2.ObjectRangeData;
import io.grpc.stub.StreamObserver;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -66,6 +70,8 @@ public final class ITFastOpenFakeTest {

private static final ReadAsFutureBytes READ_10_TO_20 =
ReadProjectionConfigs.asFutureBytes().withRangeSpec(RangeSpec.of(10, 10));
private static final ReadAsFutureBytes READ_20_TO_30 =
ReadProjectionConfigs.asFutureBytes().withRangeSpec(RangeSpec.of(20, 10));

/** Every request the fake server received, in order. */
private final List<BidiReadObjectRequest> requestsReceived =
Expand Down Expand Up @@ -104,6 +110,114 @@ public void openRequest_withoutFastOpenReads_isUnchanged() throws Exception {
assertThat(requestsReceived).containsExactly(OPEN);
}

@Test
public void readAs_withTheFastOpenConfig_doesNotSendAnotherRequest() throws Exception {
FakeStorage server =
fakeServer(ImmutableMap.of(OPEN_WITH_READ_OF_10_TO_20, metadataAndBytes(1, 10, 10)));

try (FakeServer fakeServer = FakeServer.of(server);
Storage storage = fakeServer.getGrpcStorageOptions().getService()) {
StorageDataClient dataClient = maybeGetStorageDataClient(storage);

try (ObjectReadSession session =
dataClient.readSession(OPEN, CTX, ImmutableList.of(READ_10_TO_20)).get(5, SECONDS)) {
byte[] actual = session.readAs(READ_10_TO_20).get(2, SECONDS);
assertThat(xxd(actual)).isEqualTo(xxd(slice(10, 10)));
}
}

assertThat(requestsReceived).containsExactly(OPEN_WITH_READ_OF_10_TO_20);
}

@Test
public void readAs_withTheFastOpenConfigAgain_sendsANewRead() throws Exception {
BidiReadObjectRequest secondReadOf10To20 = read(2, 10, 10);
FakeStorage server =
fakeServer(
ImmutableMap.of(
OPEN_WITH_READ_OF_10_TO_20, metadataAndBytes(1, 10, 10),
secondReadOf10To20, bytes(2, 10, 10)));

try (FakeServer fakeServer = FakeServer.of(server);
Storage storage = fakeServer.getGrpcStorageOptions().getService()) {
StorageDataClient dataClient = maybeGetStorageDataClient(storage);

try (ObjectReadSession session =
dataClient.readSession(OPEN, CTX, ImmutableList.of(READ_10_TO_20)).get(5, SECONDS)) {
byte[] fastOpen = session.readAs(READ_10_TO_20).get(2, SECONDS);
byte[] second = session.readAs(READ_10_TO_20).get(2, SECONDS);
assertThat(xxd(fastOpen)).isEqualTo(xxd(slice(10, 10)));
assertThat(xxd(second)).isEqualTo(xxd(slice(10, 10)));
}
}

assertThat(requestsReceived)
.containsExactly(OPEN_WITH_READ_OF_10_TO_20, secondReadOf10To20)
.inOrder();
}

@Test
public void readAs_withADifferentConfig_sendsANewRead_andKeepsTheFastOpenRead() throws Exception {
BidiReadObjectRequest readOf20To30 = read(2, 20, 10);
FakeStorage server =
fakeServer(
ImmutableMap.of(
OPEN_WITH_READ_OF_10_TO_20, metadataAndBytes(1, 10, 10),
readOf20To30, bytes(2, 20, 10)));

try (FakeServer fakeServer = FakeServer.of(server);
Storage storage = fakeServer.getGrpcStorageOptions().getService()) {
StorageDataClient dataClient = maybeGetStorageDataClient(storage);

try (ObjectReadSession session =
dataClient.readSession(OPEN, CTX, ImmutableList.of(READ_10_TO_20)).get(5, SECONDS)) {
byte[] other = session.readAs(READ_20_TO_30).get(2, SECONDS);
byte[] fastOpen = session.readAs(READ_10_TO_20).get(2, SECONDS);
assertThat(xxd(other)).isEqualTo(xxd(slice(20, 10)));
assertThat(xxd(fastOpen)).isEqualTo(xxd(slice(10, 10)));
}
}

assertThat(requestsReceived)
.containsExactly(OPEN_WITH_READ_OF_10_TO_20, readOf20To30)
.inOrder();
}

@Test
public void close_withAnUnclaimedFastOpenRead_closesTheSession() throws Exception {
FakeStorage server =
fakeServer(ImmutableMap.of(OPEN_WITH_READ_OF_10_TO_20, metadataAndBytes(1, 10, 10)));

try (FakeServer fakeServer = FakeServer.of(server);
Storage storage = fakeServer.getGrpcStorageOptions().getService()) {
StorageDataClient dataClient = maybeGetStorageDataClient(storage);

ObjectReadSession session =
dataClient.readSession(OPEN, CTX, ImmutableList.of(READ_10_TO_20)).get(5, SECONDS);
session.close();

assertThrows(IllegalStateException.class, () -> session.readAs(READ_10_TO_20));
}

assertThat(requestsReceived).containsExactly(OPEN_WITH_READ_OF_10_TO_20);
}

@Test
public void readSession_withMoreThanOneFastOpenConfig_isRejected() throws Exception {
FakeStorage server = fakeServer(ImmutableMap.of());

try (FakeServer fakeServer = FakeServer.of(server);
Storage storage = fakeServer.getGrpcStorageOptions().getService()) {
StorageDataClient dataClient = maybeGetStorageDataClient(storage);

assertThrows(
IllegalArgumentException.class,
() -> dataClient.readSession(OPEN, CTX, ImmutableList.of(READ_10_TO_20, READ_20_TO_30)));
}

assertThat(requestsReceived).isEmpty();
}

/**
* A fake server that answers each request in {@code responses} and adds it to {@link
* #requestsReceived}. Any other request is answered with an error.
Expand Down Expand Up @@ -138,4 +252,13 @@ private static BidiReadObjectResponse metadataAndBytes(int readId, int offset, i
.setRangeEnd(true))
.build();
}

/** The response to a read request of {@code [offset, offset + length)}. */
private static BidiReadObjectResponse bytes(int readId, int offset, int length) {
return metadataAndBytes(readId, offset, length).toBuilder().clearMetadata().build();
}

private static byte[] slice(int offset, int length) {
return Arrays.copyOfRange(ALL_OBJECT_BYTES, offset, offset + length);
}
}
Loading