Skip to content

Commit 78bb953

Browse files
ibrandesCopilot
andauthored
Storage - SeekableByteChannel Unnecessary Request Bugfix (Azure#49526)
* fixes and tests * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * adding test recordings * formatting * style * addressing comments * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 65f8ef8 commit 78bb953

4 files changed

Lines changed: 254 additions & 3 deletions

File tree

sdk/storage/azure-storage-blob/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
### Breaking Changes
88

99
### Bugs Fixed
10+
- Fixed an issue where `BlobClientBase.openSeekableByteChannelRead` issued an unnecessary HTTP request (resulting
11+
in an HTTP 416 response) after the entire blob had already been returned in the initial range download. When the
12+
channel is opened with ETag consistency control (the default), the read behavior now short-circuits to end-of-file
13+
once the known resource length is reached, avoiding the extra round trip.
14+
- Fixed an issue where a transport-level failure while streaming the body of the trailing HTTP 416 response from
15+
`BlobClientBase.openSeekableByteChannelRead` (for example "Connection reset by peer") could propagate out of the
16+
channel even though all of the blob's content had already been delivered to the caller. The read behavior now
17+
logs a warning and signals end-of-file when such an error occurs at or past the known end of the resource.
1018

1119
### Other Changes
1220

sdk/storage/azure-storage-blob/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "java",
44
"TagPrefix": "java/storage/azure-storage-blob",
5-
"Tag": "java/storage/azure-storage-blob_47f4243e59"
5+
"Tag": "java/storage/azure-storage-blob_447cc62a15"
66
}

sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/StorageSeekableByteChannelBlobReadBehavior.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ public int read(ByteBuffer dst, long sourceOffset) throws IOException {
7171
initialBufferPosition = null;
7272
}
7373

74+
// If the request is at or past the known end of the resource and the blob content is locked via an
75+
// If-Match ETag, the blob cannot have grown without invalidating the precondition. Short-circuit and
76+
// signal end-of-file rather than issuing a request that the service will reject with HTTP 416. This
77+
// avoids an unnecessary round trip (and the failure modes that come with streaming the 416 response
78+
// body, such as the connection being reset by the service).
79+
if (sourceOffset >= resourceLength && isEtagLocked()) {
80+
return -1;
81+
}
82+
7483
int initialPosition = dst.position();
7584

7685
try (ByteBufferBackedOutputStreamUtil dstStream = new ByteBufferBackedOutputStreamUtil(dst)) {
@@ -90,7 +99,34 @@ public int read(ByteBuffer dst, long sourceOffset) throws IOException {
9099
return sourceOffset < resourceLength ? 0 : -1;
91100
}
92101
throw LOGGER.logExceptionAsError(e);
102+
} catch (RuntimeException e) {
103+
// Reading the body of an HTTP 416 response can fail with transport-level errors (for example
104+
// 'Connection reset by peer' wrapped in reactor's ReactiveException). When the requested offset is
105+
// already at or past the known end of the resource, no data could have been returned anyway, so log
106+
// the failure and signal end-of-file instead of propagating an exception to the caller that has
107+
// already received all of the blob's content.
108+
if (sourceOffset >= resourceLength && dst.position() == initialPosition) {
109+
LOGGER.warning("Ignoring error encountered while issuing a read at or past the end of the blob; "
110+
+ "treating as end-of-file because the resource length is already known.", e);
111+
return -1;
112+
}
113+
throw LOGGER.logExceptionAsError(e);
114+
}
115+
}
116+
117+
/**
118+
* @return Whether the request conditions on this behavior lock the blob's content via an If-Match ETag.
119+
*/
120+
private boolean isEtagLocked() {
121+
if (requestConditions == null) {
122+
return false;
123+
}
124+
125+
String ifMatch = requestConditions.getIfMatch();
126+
if (CoreUtils.isNullOrEmpty(ifMatch)) {
127+
return false;
93128
}
129+
return !"*".equals(ifMatch.trim());
94130
}
95131

96132
/**

sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/specialized/StorageSeekableByteChannelBlobReadBehaviorTests.java

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44
package com.azure.storage.blob.specialized;
55

66
import com.azure.core.http.HttpHeaders;
7+
import com.azure.core.http.HttpHeaderName;
8+
import com.azure.core.test.http.MockHttpResponse;
79
import com.azure.core.test.utils.TestUtils;
810
import com.azure.core.util.BinaryData;
911
import com.azure.storage.blob.BlobTestBase;
1012
import com.azure.storage.blob.models.BlobDownloadAsyncResponse;
1113
import com.azure.storage.blob.models.BlobDownloadHeaders;
1214
import com.azure.storage.blob.models.BlobDownloadResponse;
15+
import com.azure.storage.blob.models.BlobErrorCode;
1316
import com.azure.storage.blob.models.BlobRange;
1417
import com.azure.storage.blob.models.BlobRequestConditions;
18+
import com.azure.storage.blob.models.BlobStorageException;
1519
import com.azure.storage.blob.models.PageRange;
1620
import com.azure.storage.common.implementation.Constants;
1721
import org.junit.jupiter.api.AfterEach;
@@ -35,9 +39,11 @@
3539
import java.util.stream.Stream;
3640

3741
import static org.junit.jupiter.api.Assertions.assertEquals;
42+
import static org.junit.jupiter.api.Assertions.assertThrows;
3843
import static org.mockito.ArgumentMatchers.any;
3944
import static org.mockito.ArgumentMatchers.anyBoolean;
4045
import static org.mockito.ArgumentMatchers.eq;
46+
import static org.mockito.Mockito.never;
4147
import static org.mockito.Mockito.times;
4248
import static org.mockito.Mockito.verify;
4349

@@ -58,6 +64,10 @@ public void cleanup() {
5864
cc.deleteIfExists();
5965
}
6066

67+
private static BlobClientBase mockClient() {
68+
return Mockito.mock(BlobClientBase.class);
69+
}
70+
6171
private BlobDownloadResponse createMockDownloadResponse(String contentRange) {
6272
String contentRangeHeader = "Content-Range";
6373
Map<String, String> headers = new HashMap<>();
@@ -70,7 +80,7 @@ private BlobDownloadResponse createMockDownloadResponse(String contentRange) {
7080
@MethodSource("readCallsToClientCorrectlySupplier")
7181
public void readCallsToClientCorrectly(long offset, int bufferSize, BlobRequestConditions conditions)
7282
throws IOException {
73-
BlobClientBase client = Mockito.mock(BlobClientBase.class);
83+
BlobClientBase client = mockClient();
7484
ArgumentCaptor<BlobRange> blobRangeCaptor = ArgumentCaptor.forClass(BlobRange.class);
7585
Mockito.when(client.downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any()))
7686
.thenReturn(
@@ -100,7 +110,7 @@ private static Stream<Arguments> readCallsToClientCorrectlySupplier() {
100110
@MethodSource("readUsesCacheCorrectlySupplier")
101111
void readUsesCacheCorrectly(long offset, int bufferSize, int cacheSize) throws Exception {
102112
// given: "Behavior with a starting cached response"
103-
BlobClientBase client = Mockito.mock(BlobClientBase.class);
113+
BlobClientBase client = mockClient();
104114
ByteBuffer initialCache = getRandomData(cacheSize);
105115
StorageSeekableByteChannelBlobReadBehavior behavior
106116
= new StorageSeekableByteChannelBlobReadBehavior(client, initialCache, offset, Constants.MB, null);
@@ -273,4 +283,201 @@ void readDetectsBlobGrowth() throws IOException {
273283
assertEquals(buffer.capacity(), buffer.position());
274284
TestUtils.assertArraysEqual(data, halfLength, buffer.array(), 0, data.length - halfLength);
275285
}
286+
287+
/**
288+
* The companion to {@link #readDetectsBlobGrowth()}: a blob can also shrink (e.g. it is overwritten with smaller
289+
* content) while a channel is open. When a read targets an offset that is now past the (shrunk) end of the blob,
290+
* the service responds with HTTP 416 {@code InvalidRange}. The behavior must surface the new, smaller resource
291+
* length from the 416 response's {@code Content-Range} header and signal end-of-file.
292+
*/
293+
@Test
294+
void readDetectsBlobShrink() throws IOException {
295+
// Given: data
296+
int halfLength = 512;
297+
byte[] data = getRandomByteArray(2 * halfLength);
298+
299+
// Blob initially at full size
300+
String blockId1 = new String(Base64.getEncoder().encode("blockId1".getBytes()));
301+
String blockId2 = new String(Base64.getEncoder().encode("blockId2".getBytes()));
302+
blockBlobClient.stageBlock(blockId1, BinaryData.fromBytes(Arrays.copyOfRange(data, 0, halfLength)));
303+
blockBlobClient.stageBlock(blockId2, BinaryData.fromBytes(Arrays.copyOfRange(data, halfLength, data.length)));
304+
blockBlobClient.commitBlockList(Arrays.asList(blockId1, blockId2));
305+
306+
// behavior to read blob, initialized with the full resource length
307+
StorageSeekableByteChannelBlobReadBehavior behavior = new StorageSeekableByteChannelBlobReadBehavior(
308+
blockBlobClient, ByteBuffer.allocate(0), -1, 2 * halfLength, null);
309+
310+
// first half of blob read successfully
311+
ByteBuffer buffer = ByteBuffer.allocate(halfLength);
312+
int read = behavior.read(buffer, 0);
313+
314+
// behavior state as expected
315+
assertEquals(halfLength, read);
316+
assertEquals(2 * halfLength, behavior.getResourceLength());
317+
assertEquals(buffer.capacity(), buffer.position());
318+
TestUtils.assertArraysEqual(data, 0, buffer.array(), 0, halfLength);
319+
320+
// blob overwritten to half its previous size
321+
blockBlobClient.commitBlockList(Collections.singletonList(blockId1), true);
322+
323+
// behavior reads at what used to be the middle of the blob, but is now past the end
324+
buffer.clear();
325+
read = behavior.read(buffer, halfLength);
326+
327+
// gracefully signal end of blob and update length to the new, smaller size
328+
assertEquals(-1, read);
329+
assertEquals(halfLength, behavior.getResourceLength());
330+
331+
// buffer unfilled
332+
assertEquals(0, buffer.position());
333+
}
334+
335+
/**
336+
* Deterministically exercises the {@code InvalidRange} (HTTP 416) handling that detects shrink. When the service
337+
* reports a smaller total size on the 416 {@code Content-Range} header, the behavior must adopt that length and
338+
* either signal EOF (offset at/past the new end) or report zero bytes read (offset still within the new bounds).
339+
*/
340+
@Test
341+
public void readUpdatesLengthOnInvalidRangeResponse() throws IOException {
342+
BlobClientBase client = mockClient();
343+
344+
// Channel was opened believing the blob was 2 KB; the service now reports it is only 1 KB.
345+
long staleLength = 2 * Constants.KB;
346+
long shrunkLength = Constants.KB;
347+
348+
// Case 1: offset is at/past the new end -> EOF (-1) and length updated.
349+
Mockito.when(client.downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any()))
350+
.thenThrow(createInvalidRangeException("bytes */" + shrunkLength));
351+
352+
StorageSeekableByteChannelBlobReadBehavior behaviorAtEnd
353+
= new StorageSeekableByteChannelBlobReadBehavior(client, ByteBuffer.allocate(0), -1, staleLength, null);
354+
355+
int readAtEnd = behaviorAtEnd.read(ByteBuffer.allocate(Constants.KB), shrunkLength);
356+
357+
assertEquals(-1, readAtEnd);
358+
assertEquals(shrunkLength, behaviorAtEnd.getResourceLength());
359+
360+
// Case 2: offset is still within the new bounds -> zero bytes read (0) and length updated.
361+
StorageSeekableByteChannelBlobReadBehavior behaviorWithin
362+
= new StorageSeekableByteChannelBlobReadBehavior(client, ByteBuffer.allocate(0), -1, staleLength, null);
363+
364+
int readWithin = behaviorWithin.read(ByteBuffer.allocate(Constants.KB), shrunkLength - 100);
365+
366+
assertEquals(0, readWithin);
367+
assertEquals(shrunkLength, behaviorWithin.getResourceLength());
368+
}
369+
370+
private static BlobStorageException createInvalidRangeException(String contentRange) {
371+
HttpHeaders headers = new HttpHeaders()
372+
.set(Constants.HeaderConstants.ERROR_CODE_HEADER_NAME, BlobErrorCode.INVALID_RANGE.toString())
373+
.set(HttpHeaderName.CONTENT_RANGE, contentRange);
374+
return new BlobStorageException("The range specified is invalid.", new MockHttpResponse(null, 416, headers),
375+
null);
376+
}
377+
378+
/**
379+
* When the request conditions lock the blob via an If-Match ETag, a read at or past the known end of the
380+
* resource must short-circuit to EOF without issuing a service call (which the service would reject with an
381+
* HTTP 416 response).
382+
*/
383+
@Test
384+
public void readPastEndShortCircuitsWhenETagLocked() throws IOException {
385+
BlobClientBase client = mockClient();
386+
387+
long resourceLength = Constants.KB;
388+
BlobRequestConditions conditions = new BlobRequestConditions().setIfMatch("0xETAG");
389+
StorageSeekableByteChannelBlobReadBehavior behavior = new StorageSeekableByteChannelBlobReadBehavior(client,
390+
ByteBuffer.allocate(0), -1, resourceLength, conditions);
391+
392+
// when: "Reading at the known end of the resource"
393+
int readAtEnd = behavior.read(ByteBuffer.allocate(Constants.KB), resourceLength);
394+
395+
// then: "EOF is signaled without any service call"
396+
assertEquals(-1, readAtEnd);
397+
verify(client, never()).downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any());
398+
399+
// when: "Reading past the known end of the resource"
400+
int readPastEnd = behavior.read(ByteBuffer.allocate(Constants.KB), resourceLength + Constants.KB);
401+
402+
// then: "EOF is still signaled without any service call"
403+
assertEquals(-1, readPastEnd);
404+
verify(client, never()).downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any());
405+
406+
// If-Match="*" is an existence precondition (not a specific ETag lock), so growth-detection behavior should be
407+
// preserved and a request should still be issued.
408+
Mockito.when(client.downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any()))
409+
.thenReturn(createMockDownloadResponse(
410+
"bytes " + resourceLength + "-" + (resourceLength + Constants.KB - 1) + "/" + 2 * Constants.KB));
411+
412+
BlobRequestConditions ifMatchStar = new BlobRequestConditions().setIfMatch("*");
413+
StorageSeekableByteChannelBlobReadBehavior starBehavior = new StorageSeekableByteChannelBlobReadBehavior(client,
414+
ByteBuffer.allocate(0), -1, resourceLength, ifMatchStar);
415+
416+
starBehavior.read(ByteBuffer.allocate(Constants.KB), resourceLength);
417+
418+
verify(client, times(1)).downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any());
419+
}
420+
421+
/**
422+
* When the blob is not ETag-locked, a read past the end must still issue a request so the behavior can
423+
* detect blob growth (existing contract preserved).
424+
*/
425+
@Test
426+
public void readPastEndIssuesRequestWhenNotETagLocked() throws IOException {
427+
BlobClientBase client = mockClient();
428+
long resourceLength = Constants.KB;
429+
Mockito.when(client.downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any()))
430+
.thenReturn(createMockDownloadResponse(
431+
"bytes " + resourceLength + "-" + (resourceLength + Constants.KB - 1) + "/" + 2 * Constants.KB));
432+
433+
StorageSeekableByteChannelBlobReadBehavior behavior
434+
= new StorageSeekableByteChannelBlobReadBehavior(client, ByteBuffer.allocate(0), -1, resourceLength, null);
435+
436+
// when: "Reading past the known end of the resource without an ETag lock"
437+
behavior.read(ByteBuffer.allocate(Constants.KB), resourceLength);
438+
439+
// then: "A service call is issued (existing growth-detection behavior preserved)"
440+
verify(client, times(1)).downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any());
441+
}
442+
443+
/**
444+
* If the underlying download throws a non-{@code BlobStorageException} (for example, the connection is
445+
* reset while streaming the body of a 416 response) and the caller is already at or past the known end of
446+
* the resource, the behavior should swallow the error, log a warning, and signal EOF rather than throwing.
447+
*/
448+
@Test
449+
public void readPastEndSwallowsTransportErrorAndSignalsEof() throws IOException {
450+
BlobClientBase client = mockClient();
451+
long resourceLength = Constants.KB;
452+
Mockito.when(client.downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any()))
453+
.thenThrow(new RuntimeException("Connection reset by peer"));
454+
455+
StorageSeekableByteChannelBlobReadBehavior behavior
456+
= new StorageSeekableByteChannelBlobReadBehavior(client, ByteBuffer.allocate(0), -1, resourceLength, null);
457+
458+
// when: "Reading past the known end and the download fails with a transport-level error"
459+
int read = behavior.read(ByteBuffer.allocate(Constants.KB), resourceLength);
460+
461+
// then: "EOF is signaled instead of the exception propagating"
462+
assertEquals(-1, read);
463+
verify(client, times(1)).downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any());
464+
}
465+
466+
/**
467+
* Non-{@code BlobStorageException} failures that occur while reading within the known bounds of the resource
468+
* must continue to surface as exceptions, since some bytes the caller asked for could not be retrieved.
469+
*/
470+
@Test
471+
public void readBeforeEndOfBlobPropagatesTransportError() {
472+
BlobClientBase client = mockClient();
473+
long resourceLength = Constants.KB;
474+
Mockito.when(client.downloadStreamWithResponse(any(), any(), any(), any(), anyBoolean(), any(), any()))
475+
.thenThrow(new RuntimeException("Connection reset by peer"));
476+
477+
StorageSeekableByteChannelBlobReadBehavior behavior
478+
= new StorageSeekableByteChannelBlobReadBehavior(client, ByteBuffer.allocate(0), -1, resourceLength, null);
479+
480+
// Reading within the known resource range should still surface the error.
481+
assertThrows(RuntimeException.class, () -> behavior.read(ByteBuffer.allocate(Constants.KB), 0));
482+
}
276483
}

0 commit comments

Comments
 (0)