44package com .azure .storage .blob .specialized ;
55
66import com .azure .core .http .HttpHeaders ;
7+ import com .azure .core .http .HttpHeaderName ;
8+ import com .azure .core .test .http .MockHttpResponse ;
79import com .azure .core .test .utils .TestUtils ;
810import com .azure .core .util .BinaryData ;
911import com .azure .storage .blob .BlobTestBase ;
1012import com .azure .storage .blob .models .BlobDownloadAsyncResponse ;
1113import com .azure .storage .blob .models .BlobDownloadHeaders ;
1214import com .azure .storage .blob .models .BlobDownloadResponse ;
15+ import com .azure .storage .blob .models .BlobErrorCode ;
1316import com .azure .storage .blob .models .BlobRange ;
1417import com .azure .storage .blob .models .BlobRequestConditions ;
18+ import com .azure .storage .blob .models .BlobStorageException ;
1519import com .azure .storage .blob .models .PageRange ;
1620import com .azure .storage .common .implementation .Constants ;
1721import org .junit .jupiter .api .AfterEach ;
3539import java .util .stream .Stream ;
3640
3741import static org .junit .jupiter .api .Assertions .assertEquals ;
42+ import static org .junit .jupiter .api .Assertions .assertThrows ;
3843import static org .mockito .ArgumentMatchers .any ;
3944import static org .mockito .ArgumentMatchers .anyBoolean ;
4045import static org .mockito .ArgumentMatchers .eq ;
46+ import static org .mockito .Mockito .never ;
4147import static org .mockito .Mockito .times ;
4248import 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