This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 620b8c55bd fix azure blob storage read position tracking, fixes #8838 
(#8839)
620b8c55bd is described below

commit 620b8c55bdd963c0503641b2dfbd7444843c030f
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Sat Oct 10 11:31:27 2026 +0200

    fix azure blob storage read position tracking, fixes #8838 (#8839)
---
 .../org/apache/hop/vfs/azure/BlobInputStream.java  |  87 +++++++-----
 .../hop/vfs/azure/BlobInputStreamPositionTest.java | 158 +++++++++++++++++++++
 2 files changed, 208 insertions(+), 37 deletions(-)

diff --git 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/BlobInputStream.java
 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/BlobInputStream.java
index 5eef08bf1f..252d0761fd 100644
--- 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/BlobInputStream.java
+++ 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/BlobInputStream.java
@@ -19,73 +19,84 @@
 package org.apache.hop.vfs.azure;
 
 import 
com.azure.storage.file.datalake.models.DataLakeFileOpenInputStreamResult;
+import com.azure.storage.file.datalake.models.PathProperties;
 import java.io.IOException;
 import java.io.InputStream;
 
+/**
+ * Reads an Azure file and never returns more than {@code fileSize} bytes: a 
blob padded to a page
+ * boundary reads as zeroes past its real size. Every read and skip advances 
one position counter,
+ * which drives the clamp and {@link #available()}.
+ */
 public class BlobInputStream extends InputStream {
 
-  private InputStream inputStream;
-  private long fileSize;
-  private long totalRead = 0;
+  private final InputStream inputStream;
+  private final long fileSize;
+  private long position = 0;
+  private long markedPosition = 0;
 
+  /**
+   * @param fileSize the size known to the caller, used only when the opened 
stream reports none; it
+   *     can be stale (list cache), while the stream's own properties describe 
the version read
+   */
   public BlobInputStream(DataLakeFileOpenInputStreamResult inputStream, long 
fileSize) {
     this.inputStream = inputStream.getInputStream();
-    this.fileSize = fileSize;
+    PathProperties properties = inputStream.getProperties();
+    this.fileSize = properties != null ? properties.getFileSize() : fileSize;
+  }
+
+  private long remaining() {
+    return Math.max(0L, fileSize - position);
   }
 
   @Override
   public int read() throws IOException {
-    int c = inputStream.read();
-    totalRead++;
-    if (totalRead > fileSize) {
+    if (remaining() == 0) {
       return -1;
     }
+    int c = inputStream.read();
+    if (c >= 0) {
+      position++;
+    }
     return c;
   }
 
   @Override
   public int read(byte[] bytes) throws IOException {
-    int readSize = inputStream.read(bytes);
-    if (readSize > 0) {
-      totalRead += readSize;
-    }
-    return readSize;
+    return read(bytes, 0, bytes.length);
   }
 
   @Override
-  public int read(byte[] bytes, int i, int i1) throws IOException {
-    int readSize = inputStream.read(bytes, i, i1);
-    if (readSize > 0 && totalRead + readSize > fileSize) {
-      // This blob is padded to a page boundary
-      // Just return the remainder.  The rest are 0 anyway
-      //
-      int actuallyRead = (int) (fileSize - totalRead);
-      totalRead = fileSize;
-      return actuallyRead;
+  public int read(byte[] bytes, int offset, int length) throws IOException {
+    if (length == 0) {
+      return 0;
+    }
+    long remaining = remaining();
+    if (remaining == 0) {
+      return -1;
+    }
+    int readSize = inputStream.read(bytes, offset, (int) Math.min(length, 
remaining));
+    if (readSize > 0) {
+      position += readSize;
     }
     return readSize;
   }
 
   @Override
-  public long skip(long l) throws IOException {
-    long skippedLength = inputStream.skip(l);
-    if (skippedLength > 0) {
-      totalRead += skippedLength;
-      // This blob is padded to a page boundary
-      // Just return the remainder.  The rest are 0 anyway
-      //
-      if (totalRead + skippedLength > fileSize) {
-        int actuallyRead = (int) (fileSize - totalRead);
-        totalRead = fileSize;
-        return actuallyRead;
-      }
+  public long skip(long length) throws IOException {
+    if (length <= 0) {
+      return 0;
+    }
+    long skipped = inputStream.skip(Math.min(length, remaining()));
+    if (skipped > 0) {
+      position += skipped;
     }
-    return skippedLength;
+    return Math.max(0L, skipped);
   }
 
   @Override
   public int available() throws IOException {
-    return (int) (fileSize - totalRead);
+    return (int) Math.min(Integer.MAX_VALUE, remaining());
   }
 
   @Override
@@ -94,13 +105,15 @@ public class BlobInputStream extends InputStream {
   }
 
   @Override
-  public synchronized void mark(int i) {
-    inputStream.mark(i);
+  public synchronized void mark(int readLimit) {
+    inputStream.mark(readLimit);
+    markedPosition = position;
   }
 
   @Override
   public synchronized void reset() throws IOException {
     inputStream.reset();
+    position = markedPosition;
   }
 
   @Override
diff --git 
a/plugins/tech/azure/src/test/java/org/apache/hop/vfs/azure/BlobInputStreamPositionTest.java
 
b/plugins/tech/azure/src/test/java/org/apache/hop/vfs/azure/BlobInputStreamPositionTest.java
new file mode 100644
index 0000000000..cc2b7079d7
--- /dev/null
+++ 
b/plugins/tech/azure/src/test/java/org/apache/hop/vfs/azure/BlobInputStreamPositionTest.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.azure;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import 
com.azure.storage.file.datalake.models.DataLakeFileOpenInputStreamResult;
+import com.azure.storage.file.datalake.models.PathProperties;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Position bookkeeping of {@link BlobInputStream}: skip, available and the 
clamp to the file size
+ * must agree with the bytes actually consumed from the underlying stream.
+ */
+class BlobInputStreamPositionTest {
+
+  private static final int SIZE = 100;
+
+  private static byte[] content(int length) {
+    byte[] bytes = new byte[length];
+    for (int i = 0; i < length; i++) {
+      bytes[i] = (byte) i;
+    }
+    return bytes;
+  }
+
+  private static BlobInputStream wrap(InputStream source, long fileSize) {
+    DataLakeFileOpenInputStreamResult result = 
mock(DataLakeFileOpenInputStreamResult.class);
+    when(result.getInputStream()).thenReturn(source);
+    return new BlobInputStream(result, fileSize);
+  }
+
+  private static BlobInputStream wrap(byte[] bytes) {
+    return wrap(new ByteArrayInputStream(bytes), bytes.length);
+  }
+
+  @Test
+  void bulkReadsReturnTheWholeFile() throws IOException {
+    byte[] bytes = content(SIZE);
+    try (BlobInputStream in = wrap(bytes)) {
+      assertArrayEquals(bytes, in.readAllBytes());
+    }
+  }
+
+  @Test
+  void availableShrinksAfterBulkReads() throws IOException {
+    try (BlobInputStream in = wrap(content(SIZE))) {
+      assertEquals(30, in.read(new byte[30], 0, 30));
+      assertEquals(SIZE - 30, in.available());
+    }
+  }
+
+  @Test
+  void skipReturnsTheNumberOfBytesSkipped() throws IOException {
+    try (BlobInputStream in = wrap(content(SIZE))) {
+      assertEquals(60, in.skip(60));
+      assertEquals(SIZE - 60, in.available());
+    }
+  }
+
+  @Test
+  void bulkReadAfterSkipContinuesAtTheNewPosition() throws IOException {
+    byte[] bytes = content(SIZE);
+    try (BlobInputStream in = wrap(bytes)) {
+      in.skip(60);
+      assertArrayEquals(Arrays.copyOfRange(bytes, 60, SIZE), 
in.readAllBytes());
+    }
+  }
+
+  @Test
+  void singleByteReadAfterSkipContinuesAtTheNewPosition() throws IOException {
+    try (BlobInputStream in = wrap(content(SIZE))) {
+      in.skip(60);
+      assertEquals(60, in.read());
+    }
+  }
+
+  @Test
+  void skipPastTheEndStopsAtTheEnd() throws IOException {
+    try (BlobInputStream in = wrap(content(SIZE))) {
+      assertEquals(40, in.read(new byte[40], 0, 40));
+      assertEquals(SIZE - 40, in.skip(1000));
+      assertEquals(0, in.available());
+      assertEquals(-1, in.read());
+    }
+  }
+
+  /** The way VFS consumes it: wrapped in a BufferedInputStream 
(FileContentInputStream). */
+  @Test
+  void bufferedSkipThenReadReturnsTheRemainingBytes() throws IOException {
+    byte[] bytes = content(SIZE);
+    try (InputStream in = new BufferedInputStream(wrap(bytes))) {
+      in.skipNBytes(60);
+      assertArrayEquals(Arrays.copyOfRange(bytes, 60, SIZE), 
in.readAllBytes());
+    }
+  }
+
+  /** The clamp exists for blobs padded past their real size; padding must 
never leak out. */
+  @Test
+  void paddingBeyondTheFileSizeIsNotReturned() throws IOException {
+    byte[] padded = Arrays.copyOf(content(SIZE), 128);
+    try (BlobInputStream in = wrap(new ByteArrayInputStream(padded), SIZE)) {
+      assertArrayEquals(content(SIZE), in.readAllBytes());
+      assertEquals(-1, in.read());
+    }
+  }
+
+  /** A cached size can be older than the file; the opened stream's own size 
wins. */
+  @Test
+  void sizeReportedByTheOpenedStreamWinsOverAStaleSize() throws IOException {
+    byte[] bytes = content(SIZE);
+    PathProperties properties = mock(PathProperties.class);
+    when(properties.getFileSize()).thenReturn((long) SIZE);
+    DataLakeFileOpenInputStreamResult result = 
mock(DataLakeFileOpenInputStreamResult.class);
+    when(result.getInputStream()).thenReturn(new ByteArrayInputStream(bytes));
+    when(result.getProperties()).thenReturn(properties);
+    try (BlobInputStream in = new BlobInputStream(result, 50)) {
+      assertArrayEquals(bytes, in.readAllBytes());
+    }
+  }
+
+  @Test
+  void paddingIsNotReturnedAcrossSeveralSmallReads() throws IOException {
+    byte[] padded = Arrays.copyOf(content(SIZE), 128);
+    try (BlobInputStream in = wrap(new ByteArrayInputStream(padded), SIZE)) {
+      byte[] buffer = new byte[32];
+      int total = 0;
+      int n;
+      while ((n = in.read(buffer, 0, buffer.length)) > 0) {
+        total += n;
+      }
+      assertEquals(SIZE, total);
+    }
+  }
+}

Reply via email to