[ 
https://issues.apache.org/jira/browse/HADOOP-19941?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18102403#comment-18102403
 ] 

ASF GitHub Bot commented on HADOOP-19941:
-----------------------------------------

bhattmanish98 commented on code in PR #8611:
URL: https://github.com/apache/hadoop/pull/8611#discussion_r3728047203


##########
hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/services/TestAbfsBlobClientPhotonHeaders.java:
##########
@@ -0,0 +1,182 @@
+/**
+ * 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.hadoop.fs.azurebfs.services;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.ipc.ArrowStreamWriter;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.jupiter.api.Test;
+
+import org.apache.hadoop.fs.azurebfs.AbfsConfiguration;
+import org.apache.hadoop.fs.azurebfs.contracts.exceptions.AbfsDriverException;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.APPLICATION_APACHE_ARROW_STREAM;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.APPLICATION_XML;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.ACCEPT;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.CONTENT_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+import static org.apache.hadoop.test.LambdaTestUtils.intercept;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Unit tests for the Photon (Apache Arrow based ListBlob) request header
+ * behavior of {@link AbfsBlobClient#applyPhotonRequestHeadersIfEnabled(List)}.
+ */
+public class TestAbfsBlobClientPhotonHeaders {
+
+  /** Row count large enough to exhaust the tiny allocator limit. */
+  private static final int OVER_LIMIT_ROW_COUNT = 2000;
+
+  private AbfsBlobClient clientWithPhoton(final boolean photonEnabled) {
+    AbfsConfiguration configuration = mock(AbfsConfiguration.class);
+    doReturn(photonEnabled).when(configuration).isPhotonEnabled();
+    AbfsBlobClient client = mock(AbfsBlobClient.class,
+        withSettings().defaultAnswer(CALLS_REAL_METHODS));
+    doReturn(configuration).when(client).getAbfsConfiguration();
+    return client;
+  }
+
+  private List<AbfsHttpHeader> defaultHeaders() {
+    List<AbfsHttpHeader> headers = new ArrayList<>();
+    headers.add(new AbfsHttpHeader(ACCEPT, "application/json, 
application/xml"));
+    return headers;
+  }
+
+  private String acceptValue(final List<AbfsHttpHeader> headers) {
+    return headers.stream()
+        .filter(header -> ACCEPT.equals(header.getName()))
+        .map(AbfsHttpHeader::getValue)
+        .findFirst()
+        .orElse(null);
+  }
+
+  /**
+   * Verify Arrow request headers are added when Photon is enabled.
+   */
+  @Test
+  public void testAcceptHeaderOverriddenWhenPhotonEnabled() {
+    AbfsBlobClient client = clientWithPhoton(true);
+    List<AbfsHttpHeader> headers = defaultHeaders();
+
+    boolean photonRequested = 
client.applyPhotonRequestHeadersIfEnabled(headers);
+
+    assertThat(photonRequested)
+        .as("Arrow should be requested when Photon is enabled")
+        .isTrue();
+    long acceptCount = headers.stream()
+        .filter(header -> ACCEPT.equals(header.getName()))
+        .count();
+    assertThat(acceptCount).isEqualTo(1);
+    assertThat(acceptValue(headers))
+        .isEqualTo(APPLICATION_APACHE_ARROW_STREAM + ", " + APPLICATION_XML);
+  }
+
+  /**
+   * Verify the existing Accept header is left unchanged when Photon is 
disabled.
+   */
+  @Test
+  public void testAcceptHeaderUnchangedWhenPhotonDisabled() {
+    AbfsBlobClient client = clientWithPhoton(false);
+    List<AbfsHttpHeader> headers = defaultHeaders();
+
+    boolean photonRequested = 
client.applyPhotonRequestHeadersIfEnabled(headers);
+
+    assertThat(photonRequested)
+        .as("Arrow should not be requested when Photon is disabled")
+        .isFalse();
+    assertThat(acceptValue(headers))
+        .isEqualTo("application/json, application/xml");
+  }
+
+  /**
+   * Verify that an Arrow ListBlobs response whose parsing exceeds the 
configured
+   * allocator memory limit surfaces an {@link AbfsDriverException} carrying 
the
+   * Arrow parsing error message, exercising the Arrow branch of
+   * {@link AbfsBlobClient#parseListPathResults} end to end.
+   */
+  @Test
+  public void testArrowOverAllocatorLimitSurfacesDriverException()
+      throws Exception {
+    byte[] arrowStream = buildArrowNameStream(OVER_LIMIT_ROW_COUNT);
+
+    AbfsConfiguration configuration = mock(AbfsConfiguration.class);
+    doReturn(1024L).when(configuration).getPhotonArrowMemoryLimit();
+
+    AbfsBlobClient client = mock(AbfsBlobClient.class,
+        withSettings().defaultAnswer(CALLS_REAL_METHODS));
+    doReturn(configuration).when(client).getAbfsConfiguration();
+    doReturn(new URL("https://account.blob.core.windows.net/container";))
+        .when(client).getBaseUrl();
+
+    AbfsHttpOperation result = mock(AbfsHttpOperation.class);
+    doReturn(APPLICATION_APACHE_ARROW_STREAM)
+        .when(result).getResponseHeaderIgnoreCase(CONTENT_TYPE);
+    doReturn(new ByteArrayInputStream(arrowStream))
+        .when(result).getListResultStream();
+
+    AbfsDriverException ex = intercept(AbfsDriverException.class,
+        () -> client.parseListPathResults(result, null));
+    assertThat(ex.getErrorMessage()).contains(ERR_ARROW_LIST_PARSING);
+  }
+
+  /**
+   * Build a valid single-column ("Name") Arrow IPC stream carrying the given
+   * number of rows, used to drive the allocator limit test.
+   */
+  private static byte[] buildArrowNameStream(final int rows) throws 
IOException {
+    Field nameField = new Field("Name",
+        FieldType.nullable(new ArrowType.Utf8()), null);
+    Schema schema = new Schema(Collections.singletonList(nameField));
+    try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
+        VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) {
+      VarCharVector nameVector = (VarCharVector) root.getVector("Name");
+      nameVector.allocateNew(rows);
+      for (int i = 0; i < rows; i++) {
+        nameVector.setSafe(i, ("some-reasonably-long-blob-name-" + i)
+            .getBytes(StandardCharsets.UTF_8));
+      }
+      root.setRowCount(rows);
+      writer.start();
+      writer.writeBatch();
+      writer.end();
+      return out.toByteArray();

Review Comment:
   Moved ByteArrayOutputStream outside the try-with-resources and return 
out.toByteArray() after the block, so the stream is guaranteed flushed/closed.



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);
+        int rowCount = root.getRowCount();
+        for (int row = 0; row < rowCount; row++) {
+          BlobListResultEntrySchema entry = buildEntry(columns, row);
+          if (entry != null) {
+            listResultSchema.addBlobListEntry(entry);
+          }
+        }
+      }
+      LOG.debug("Photon ListBlobs parsed {} blobs with {} as continuation 
token",
+          listResultSchema.paths().size(), listResultSchema.getNextMarker());
+      return listResultSchema;
+    } catch (IOException ex) {
+      throw ex;
+    } catch (RuntimeException ex) {
+      // Wrap Arrow runtime failures (e.g. corrupted stream) as IOException so
+      // callers can surface them through the existing error-handling model.
+      throw new IOException(ex);
+    }
+  }
+
+  @Override
+  public String getParsingErrorMessage() {
+    return ERR_ARROW_LIST_PARSING;
+  }
+
+  /**
+   * Wrap an {@link InputStream} in a {@link ReadableByteChannel} that is
+   * <em>not</em> interruptible.
+   *
+   * <p>{@link ArrowStreamReader}, when handed an {@code InputStream}, 
internally
+   * adapts it with {@link java.nio.channels.Channels#newChannel(InputStream)},
+   * which returns a channel extending
+   * {@link java.nio.channels.spi.AbstractInterruptibleChannel}. If the parsing
+   * thread's interrupt flag is set (e.g. Hadoop task cancellation, speculative
+   * execution kills, or an executor {@code shutdownNow()}), that channel 
aborts
+   * the read with a {@link java.nio.channels.ClosedByInterruptException},
+   * turning a benign interrupt into a hard listing failure. The XML SAX parser
+   * reads from a plain {@code InputStream} and is immune to this, so the Arrow
+   * path must not be more fragile.</p>
+   *
+   * <p>The list response body is already fully buffered in memory before it
+   * reaches the parser, so an interruptible channel offers no benefit here; 
this
+   * plain adapter simply reads from the buffer without consulting the thread's
+   * interrupt status.</p>
+   *
+   * @param in the buffered response stream.
+   * @return a non-interruptible channel over {@code in}.
+   */
+  private static ReadableByteChannel nonInterruptibleChannel(
+      final InputStream in) {
+    return new ReadableByteChannel() {
+      private volatile boolean open = true;
+      // Fixed staging buffer reused across reads for direct ByteBuffers. Arrow
+      // parsing drives this channel from a single thread, so a per-instance
+      // buffer is safe and avoids per-read allocation/GC churn.
+      private final byte[] staging = new byte[NON_INTERRUPTIBLE_READ_CHUNK];
+
+      @Override
+      public int read(final ByteBuffer dst) throws IOException {
+        final int toRead = dst.remaining();
+        if (toRead == 0) {
+          return 0;
+        }
+        if (dst.hasArray()) {
+          final int n = in.read(dst.array(),
+              dst.arrayOffset() + dst.position(), toRead);
+          if (n > 0) {
+            dst.position(dst.position() + n);
+          }
+          return n;
+        }
+        // For a direct (non-array-backed) ByteBuffer we must stage bytes in a
+        // heap buffer first. ArrowStreamReader can request very large reads, 
so
+        // read at most one staging-buffer worth per call and let the reader
+        // issue further reads for the remainder (its readFully loops on 
partial
+        // reads). This bounds allocation and reuses a single buffer.
+        final int chunk = Math.min(toRead, staging.length);
+        final int n = in.read(staging, 0, chunk);
+        if (n > 0) {
+          dst.put(staging, 0, n);
+        }
+        return n;
+      }
+
+      @Override
+      public boolean isOpen() {
+        return open;
+      }
+
+      @Override
+      public void close() throws IOException {
+        open = false;
+        in.close();
+      }
+    };
+  }
+
+  /**
+   * Convert a single Arrow row into a {@link BlobListResultEntrySchema}. Rows
+   * without a usable name are skipped.
+   */
+  private BlobListResultEntrySchema buildEntry(
+      final BatchColumns columns, final int row) {
+    String name = readString(columns.name, row);
+    if (name == null || name.isEmpty()) {
+      return null;
+    }
+    // Directory names may carry a trailing slash; strip it to match XML 
parser.
+    if (name.endsWith(FORWARD_SLASH)) {
+      name = name.substring(0, name.length() - 1);
+    }
+
+    BlobListResultEntrySchema entry = new BlobListResultEntrySchema();
+    entry.setName(name);
+    entry.setPath(new Path(ROOT_PATH + name));
+    entry.setUrl(url + ROOT_PATH + name);
+
+    entry.setETag(readString(columns.etag, row));
+    // The Arrow (Photon) response serializes timestamps as native Arrow
+    // timestamp vectors (e.g. Creation-Time / Last-Modified as TimeStampSec),
+    // whose object form is an ISO-8601 local date-time (e.g. 
2026-07-06T10:31:19)
+    // in UTC. Normalize to RFC 1123 GMT so both paths produce identical values
+    // and DateTimeUtils.parseLastModifiedTime yields the correct time.
+    entry.setLastModifiedTime(readTimestampAsRfc1123(columns.lastModified, 
row));
+    entry.setCreationTime(readTimestampAsRfc1123(columns.creationTime, row));
+    entry.setOwner(readString(columns.owner, row));
+    entry.setGroup(readString(columns.group, row));
+    entry.setPermission(readString(columns.permission, row));
+    entry.setAcl(readString(columns.acl, row));
+
+    // Blob user metadata (including the hdi_isfolder directory marker) is
+    // carried in a single Arrow map column; mirror the XML parser which
+    // populates the metadata map from the <Metadata> element.
+    Map<String, String> metadata = readMetadata(columns.metadata, row);
+    if (!metadata.isEmpty()) {
+      entry.setMetadata(metadata);
+    }
+
+    setContentLength(entry, columns.contentLength, row);
+    setCopyProperties(entry, columns, row);
+    entry.setIsDirectory(resolveIsDirectory(columns, metadata, row));
+    return entry;
+  }
+
+  /**
+   * Populate the copy-related properties, mirroring the XML parser's handling
+   * of the {@code Properties} copy elements. Absent columns leave the
+   * corresponding field unset, exactly as the XML path does when the elements
+   * are missing.
+   */
+  private void setCopyProperties(final BlobListResultEntrySchema entry,
+      final BatchColumns columns, final int row) {
+    entry.setCopyId(readString(columns.copyId, row));
+    entry.setCopyStatus(readString(columns.copyStatus, row));
+    entry.setCopySourceUrl(readString(columns.copySource, row));
+    entry.setCopyProgress(readString(columns.copyProgress, row));
+    entry.setCopyStatusDescription(
+        readString(columns.copyStatusDescription, row));
+    // XML stores the completion time as epoch millis parsed from an RFC 1123
+    // string; normalize the Arrow timestamp to the same RFC 1123 form first so
+    // both paths yield identical epoch values.
+    String copyCompletion =
+        readTimestampAsRfc1123(columns.copyCompletionTime, row);
+    if (copyCompletion != null) {
+      entry.setCopyCompletionTime(
+          DateTimeUtils.parseLastModifiedTime(copyCompletion));
+    }
+  }
+
+  /**
+   * Populate the content length from the content-length column. The Blob
+   * endpoint surfaces it as a native unsigned 64-bit integer
+   * ({@code UInt8Vector}); other numeric vector types and a numeric string
+   * column are also tolerated so additive schema changes do not break parsing.
+   */
+  private void setContentLength(final BlobListResultEntrySchema entry,
+      final FieldVector contentLength, final int row) {
+    Long value = readLong(contentLength, row);
+    if (value != null) {
+      entry.setContentLength(value);
+    }
+  }
+
+  /**
+   * Determine whether the row represents a directory. Mirrors the XML parser
+   * ({@code BlobListXmlParser}), which flags an entry as a directory when any 
of
+   * the following hold:
+   * <ul>
+   *   <li>the entry is a {@code BlobPrefix} - surfaced in the Arrow response 
as
+   *   a {@code ResourceType} of {@code blobprefix} - i.e. an implicit
+   *   directory;</li>
+   *   <li>a {@code ResourceType} of {@code directory};</li>
+   *   <li>an explicit boolean {@code IsDirectory} indicator;</li>
+   *   <li>the {@code hdi_isfolder} user metadata being {@code true}.</li>
+   * </ul>
+   * The implicit-directory ({@code blobprefix}) and {@code hdi_isfolder} cases
+   * are essential: an implicit directory has no marker blob at all, while an
+   * empty directory created by {@code mkdir} is a zero-byte marker blob whose
+   * only directory indicator is the {@code hdi_isfolder=true} metadata entry.
+   * The metadata key is matched case-insensitively, matching the XML parser
+   * ({@code equalsIgnoreCase}). Without honoring these, such directories are
+   * misclassified as files, diverging from the XML listing path.
+   */
+  private boolean resolveIsDirectory(final BatchColumns columns,
+      final Map<String, String> metadata, final int row) {
+    String resourceType = readString(columns.resourceType, row);
+    if (DIRECTORY.equalsIgnoreCase(resourceType)
+        || ARROW_RESOURCE_TYPE_BLOB_PREFIX.equalsIgnoreCase(resourceType)) {
+      return true;
+    }
+    if (readBoolean(columns.isDirectory, row)) {
+      return true;
+    }
+    String hdiIsFolder = metadataValueIgnoreCase(metadata, 
XML_TAG_HDI_ISFOLDER);
+    if (hdiIsFolder != null && Boolean.parseBoolean(hdiIsFolder.trim())) {
+      return true;
+    }
+    // Fallback for a schema that flattens the marker metadata into a dedicated
+    // column instead of the metadata map.
+    return readBoolean(columns.hdiIsFolder, row);
+  }
+
+  /**
+   * Case-insensitive lookup of a metadata value by key, mirroring the XML
+   * parser's {@code equalsIgnoreCase} matching of the {@code hdi_isfolder}
+   * marker (the service preserves the casing used when the metadata was set).
+   */
+  private static String metadataValueIgnoreCase(
+      final Map<String, String> metadata, final String key) {
+    String value = metadata.get(key);
+    if (value != null || metadata.containsKey(key)) {
+      return value;
+    }
+    for (Map.Entry<String, String> entry : metadata.entrySet()) {
+      if (key.equalsIgnoreCase(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Read the user metadata key/value pairs from the Arrow map column for the
+   * given row. Keys are stored verbatim (e.g. {@code hdi_isfolder}) to match 
the
+   * XML parser. Returns an empty (mutable) map when the column is absent or 
the
+   * value is null.
+   */
+  private Map<String, String> readMetadata(final MapVector metadata,
+      final int row) {
+    Map<String, String> result = new HashMap<>();
+    if (metadata == null || row >= metadata.getValueCount()
+        || metadata.isNull(row)) {
+      return result;
+    }
+    List<?> entries = metadata.getObject(row);
+    if (entries == null) {
+      return result;
+    }
+    for (Object element : entries) {
+      if (element instanceof Map) {
+        Map<?, ?> keyValue = (Map<?, ?>) element;
+        Object key = keyValue.get(MapVector.KEY_NAME);
+        Object value = keyValue.get(MapVector.VALUE_NAME);
+        if (key != null) {
+          result.put(key.toString(), value == null ? null : value.toString());
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Read a timestamp column for the given row and convert it to the RFC 1123
+   * GMT representation used by the XML path. Native Arrow timestamp vectors
+   * expose an ISO-8601 local date-time via {@link FieldVector#getObject(int)};
+   * a textual (VarChar) timestamp column is also tolerated. Returns
+   * {@code null} when the column is absent or the value is null.
+   */
+  private String readTimestampAsRfc1123(final FieldVector vector,
+      final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value == null) {
+      return null;
+    }
+    return DateTimeUtils.formatArrowDateTimeToRfc1123(value.toString());

Review Comment:
   readTimestampAsRfc1123 now detects a Number (TZ vectors' epoch) and converts 
it via the column's ArrowType.Timestamp unit (sec/milli/micro/nano) → Instant → 
new DateTimeUtils.formatInstantToRfc1123. Fixed the Javadoc and added 
testTimeZoneTimestampNormalizedToRfc1123.



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);
+        int rowCount = root.getRowCount();
+        for (int row = 0; row < rowCount; row++) {
+          BlobListResultEntrySchema entry = buildEntry(columns, row);
+          if (entry != null) {
+            listResultSchema.addBlobListEntry(entry);
+          }
+        }
+      }
+      LOG.debug("Photon ListBlobs parsed {} blobs with {} as continuation 
token",
+          listResultSchema.paths().size(), listResultSchema.getNextMarker());
+      return listResultSchema;
+    } catch (IOException ex) {
+      throw ex;
+    } catch (RuntimeException ex) {
+      // Wrap Arrow runtime failures (e.g. corrupted stream) as IOException so
+      // callers can surface them through the existing error-handling model.
+      throw new IOException(ex);
+    }
+  }
+
+  @Override
+  public String getParsingErrorMessage() {
+    return ERR_ARROW_LIST_PARSING;
+  }
+
+  /**
+   * Wrap an {@link InputStream} in a {@link ReadableByteChannel} that is
+   * <em>not</em> interruptible.
+   *
+   * <p>{@link ArrowStreamReader}, when handed an {@code InputStream}, 
internally
+   * adapts it with {@link java.nio.channels.Channels#newChannel(InputStream)},
+   * which returns a channel extending
+   * {@link java.nio.channels.spi.AbstractInterruptibleChannel}. If the parsing
+   * thread's interrupt flag is set (e.g. Hadoop task cancellation, speculative
+   * execution kills, or an executor {@code shutdownNow()}), that channel 
aborts
+   * the read with a {@link java.nio.channels.ClosedByInterruptException},
+   * turning a benign interrupt into a hard listing failure. The XML SAX parser
+   * reads from a plain {@code InputStream} and is immune to this, so the Arrow
+   * path must not be more fragile.</p>
+   *
+   * <p>The list response body is already fully buffered in memory before it
+   * reaches the parser, so an interruptible channel offers no benefit here; 
this
+   * plain adapter simply reads from the buffer without consulting the thread's
+   * interrupt status.</p>
+   *
+   * @param in the buffered response stream.
+   * @return a non-interruptible channel over {@code in}.
+   */
+  private static ReadableByteChannel nonInterruptibleChannel(
+      final InputStream in) {
+    return new ReadableByteChannel() {
+      private volatile boolean open = true;
+      // Fixed staging buffer reused across reads for direct ByteBuffers. Arrow
+      // parsing drives this channel from a single thread, so a per-instance
+      // buffer is safe and avoids per-read allocation/GC churn.
+      private final byte[] staging = new byte[NON_INTERRUPTIBLE_READ_CHUNK];
+
+      @Override
+      public int read(final ByteBuffer dst) throws IOException {
+        final int toRead = dst.remaining();
+        if (toRead == 0) {
+          return 0;
+        }
+        if (dst.hasArray()) {
+          final int n = in.read(dst.array(),
+              dst.arrayOffset() + dst.position(), toRead);
+          if (n > 0) {
+            dst.position(dst.position() + n);
+          }
+          return n;
+        }
+        // For a direct (non-array-backed) ByteBuffer we must stage bytes in a
+        // heap buffer first. ArrowStreamReader can request very large reads, 
so
+        // read at most one staging-buffer worth per call and let the reader
+        // issue further reads for the remainder (its readFully loops on 
partial
+        // reads). This bounds allocation and reuses a single buffer.
+        final int chunk = Math.min(toRead, staging.length);
+        final int n = in.read(staging, 0, chunk);
+        if (n > 0) {
+          dst.put(staging, 0, n);
+        }
+        return n;
+      }
+
+      @Override
+      public boolean isOpen() {
+        return open;
+      }
+
+      @Override
+      public void close() throws IOException {
+        open = false;
+        in.close();
+      }
+    };
+  }
+
+  /**
+   * Convert a single Arrow row into a {@link BlobListResultEntrySchema}. Rows
+   * without a usable name are skipped.
+   */
+  private BlobListResultEntrySchema buildEntry(
+      final BatchColumns columns, final int row) {
+    String name = readString(columns.name, row);
+    if (name == null || name.isEmpty()) {
+      return null;
+    }
+    // Directory names may carry a trailing slash; strip it to match XML 
parser.
+    if (name.endsWith(FORWARD_SLASH)) {
+      name = name.substring(0, name.length() - 1);
+    }
+
+    BlobListResultEntrySchema entry = new BlobListResultEntrySchema();
+    entry.setName(name);
+    entry.setPath(new Path(ROOT_PATH + name));
+    entry.setUrl(url + ROOT_PATH + name);
+
+    entry.setETag(readString(columns.etag, row));
+    // The Arrow (Photon) response serializes timestamps as native Arrow
+    // timestamp vectors (e.g. Creation-Time / Last-Modified as TimeStampSec),
+    // whose object form is an ISO-8601 local date-time (e.g. 
2026-07-06T10:31:19)
+    // in UTC. Normalize to RFC 1123 GMT so both paths produce identical values
+    // and DateTimeUtils.parseLastModifiedTime yields the correct time.
+    entry.setLastModifiedTime(readTimestampAsRfc1123(columns.lastModified, 
row));
+    entry.setCreationTime(readTimestampAsRfc1123(columns.creationTime, row));
+    entry.setOwner(readString(columns.owner, row));
+    entry.setGroup(readString(columns.group, row));
+    entry.setPermission(readString(columns.permission, row));
+    entry.setAcl(readString(columns.acl, row));
+
+    // Blob user metadata (including the hdi_isfolder directory marker) is
+    // carried in a single Arrow map column; mirror the XML parser which
+    // populates the metadata map from the <Metadata> element.
+    Map<String, String> metadata = readMetadata(columns.metadata, row);
+    if (!metadata.isEmpty()) {
+      entry.setMetadata(metadata);
+    }
+
+    setContentLength(entry, columns.contentLength, row);
+    setCopyProperties(entry, columns, row);
+    entry.setIsDirectory(resolveIsDirectory(columns, metadata, row));
+    return entry;
+  }
+
+  /**
+   * Populate the copy-related properties, mirroring the XML parser's handling
+   * of the {@code Properties} copy elements. Absent columns leave the
+   * corresponding field unset, exactly as the XML path does when the elements
+   * are missing.
+   */
+  private void setCopyProperties(final BlobListResultEntrySchema entry,
+      final BatchColumns columns, final int row) {
+    entry.setCopyId(readString(columns.copyId, row));
+    entry.setCopyStatus(readString(columns.copyStatus, row));
+    entry.setCopySourceUrl(readString(columns.copySource, row));
+    entry.setCopyProgress(readString(columns.copyProgress, row));
+    entry.setCopyStatusDescription(
+        readString(columns.copyStatusDescription, row));
+    // XML stores the completion time as epoch millis parsed from an RFC 1123
+    // string; normalize the Arrow timestamp to the same RFC 1123 form first so
+    // both paths yield identical epoch values.
+    String copyCompletion =
+        readTimestampAsRfc1123(columns.copyCompletionTime, row);
+    if (copyCompletion != null) {
+      entry.setCopyCompletionTime(
+          DateTimeUtils.parseLastModifiedTime(copyCompletion));
+    }
+  }
+
+  /**
+   * Populate the content length from the content-length column. The Blob
+   * endpoint surfaces it as a native unsigned 64-bit integer
+   * ({@code UInt8Vector}); other numeric vector types and a numeric string
+   * column are also tolerated so additive schema changes do not break parsing.
+   */
+  private void setContentLength(final BlobListResultEntrySchema entry,
+      final FieldVector contentLength, final int row) {
+    Long value = readLong(contentLength, row);
+    if (value != null) {
+      entry.setContentLength(value);
+    }
+  }
+
+  /**
+   * Determine whether the row represents a directory. Mirrors the XML parser
+   * ({@code BlobListXmlParser}), which flags an entry as a directory when any 
of
+   * the following hold:
+   * <ul>
+   *   <li>the entry is a {@code BlobPrefix} - surfaced in the Arrow response 
as
+   *   a {@code ResourceType} of {@code blobprefix} - i.e. an implicit
+   *   directory;</li>
+   *   <li>a {@code ResourceType} of {@code directory};</li>
+   *   <li>an explicit boolean {@code IsDirectory} indicator;</li>
+   *   <li>the {@code hdi_isfolder} user metadata being {@code true}.</li>
+   * </ul>
+   * The implicit-directory ({@code blobprefix}) and {@code hdi_isfolder} cases
+   * are essential: an implicit directory has no marker blob at all, while an
+   * empty directory created by {@code mkdir} is a zero-byte marker blob whose
+   * only directory indicator is the {@code hdi_isfolder=true} metadata entry.
+   * The metadata key is matched case-insensitively, matching the XML parser
+   * ({@code equalsIgnoreCase}). Without honoring these, such directories are
+   * misclassified as files, diverging from the XML listing path.
+   */
+  private boolean resolveIsDirectory(final BatchColumns columns,
+      final Map<String, String> metadata, final int row) {
+    String resourceType = readString(columns.resourceType, row);
+    if (DIRECTORY.equalsIgnoreCase(resourceType)
+        || ARROW_RESOURCE_TYPE_BLOB_PREFIX.equalsIgnoreCase(resourceType)) {
+      return true;
+    }
+    if (readBoolean(columns.isDirectory, row)) {
+      return true;
+    }
+    String hdiIsFolder = metadataValueIgnoreCase(metadata, 
XML_TAG_HDI_ISFOLDER);
+    if (hdiIsFolder != null && Boolean.parseBoolean(hdiIsFolder.trim())) {
+      return true;
+    }
+    // Fallback for a schema that flattens the marker metadata into a dedicated
+    // column instead of the metadata map.
+    return readBoolean(columns.hdiIsFolder, row);
+  }
+
+  /**
+   * Case-insensitive lookup of a metadata value by key, mirroring the XML
+   * parser's {@code equalsIgnoreCase} matching of the {@code hdi_isfolder}
+   * marker (the service preserves the casing used when the metadata was set).
+   */
+  private static String metadataValueIgnoreCase(
+      final Map<String, String> metadata, final String key) {
+    String value = metadata.get(key);
+    if (value != null || metadata.containsKey(key)) {
+      return value;
+    }
+    for (Map.Entry<String, String> entry : metadata.entrySet()) {
+      if (key.equalsIgnoreCase(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Read the user metadata key/value pairs from the Arrow map column for the
+   * given row. Keys are stored verbatim (e.g. {@code hdi_isfolder}) to match 
the
+   * XML parser. Returns an empty (mutable) map when the column is absent or 
the
+   * value is null.
+   */
+  private Map<String, String> readMetadata(final MapVector metadata,
+      final int row) {
+    Map<String, String> result = new HashMap<>();
+    if (metadata == null || row >= metadata.getValueCount()
+        || metadata.isNull(row)) {
+      return result;
+    }
+    List<?> entries = metadata.getObject(row);
+    if (entries == null) {
+      return result;
+    }
+    for (Object element : entries) {
+      if (element instanceof Map) {
+        Map<?, ?> keyValue = (Map<?, ?>) element;
+        Object key = keyValue.get(MapVector.KEY_NAME);
+        Object value = keyValue.get(MapVector.VALUE_NAME);
+        if (key != null) {
+          result.put(key.toString(), value == null ? null : value.toString());
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Read a timestamp column for the given row and convert it to the RFC 1123
+   * GMT representation used by the XML path. Native Arrow timestamp vectors
+   * expose an ISO-8601 local date-time via {@link FieldVector#getObject(int)};
+   * a textual (VarChar) timestamp column is also tolerated. Returns
+   * {@code null} when the column is absent or the value is null.
+   */
+  private String readTimestampAsRfc1123(final FieldVector vector,
+      final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value == null) {
+      return null;
+    }
+    return DateTimeUtils.formatArrowDateTimeToRfc1123(value.toString());
+  }
+
+  /**
+   * Read a numeric column for the given row as a {@code long}. Supports native
+   * Arrow integer vectors (whose object form is a {@link Number}) and a 
numeric
+   * {@link VarCharVector}. Returns {@code null} when the column is absent, the
+   * value is null, or a textual value is not parseable.
+   */
+  private Long readLong(final FieldVector vector, final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value instanceof Number) {
+      return ((Number) value).longValue();

Review Comment:
   setContentLength now rejects negatives (unsigned overflow / "-1") with a 
LOG.debug, leaving the default 0 rather than a negative FileStatus length. 
Added testNegativeContentLengthRejected.



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);
+        int rowCount = root.getRowCount();
+        for (int row = 0; row < rowCount; row++) {
+          BlobListResultEntrySchema entry = buildEntry(columns, row);
+          if (entry != null) {
+            listResultSchema.addBlobListEntry(entry);
+          }
+        }
+      }
+      LOG.debug("Photon ListBlobs parsed {} blobs with {} as continuation 
token",
+          listResultSchema.paths().size(), listResultSchema.getNextMarker());
+      return listResultSchema;
+    } catch (IOException ex) {
+      throw ex;
+    } catch (RuntimeException ex) {
+      // Wrap Arrow runtime failures (e.g. corrupted stream) as IOException so
+      // callers can surface them through the existing error-handling model.
+      throw new IOException(ex);
+    }
+  }
+
+  @Override
+  public String getParsingErrorMessage() {
+    return ERR_ARROW_LIST_PARSING;
+  }
+
+  /**
+   * Wrap an {@link InputStream} in a {@link ReadableByteChannel} that is
+   * <em>not</em> interruptible.
+   *
+   * <p>{@link ArrowStreamReader}, when handed an {@code InputStream}, 
internally
+   * adapts it with {@link java.nio.channels.Channels#newChannel(InputStream)},
+   * which returns a channel extending
+   * {@link java.nio.channels.spi.AbstractInterruptibleChannel}. If the parsing
+   * thread's interrupt flag is set (e.g. Hadoop task cancellation, speculative
+   * execution kills, or an executor {@code shutdownNow()}), that channel 
aborts
+   * the read with a {@link java.nio.channels.ClosedByInterruptException},
+   * turning a benign interrupt into a hard listing failure. The XML SAX parser
+   * reads from a plain {@code InputStream} and is immune to this, so the Arrow
+   * path must not be more fragile.</p>
+   *
+   * <p>The list response body is already fully buffered in memory before it
+   * reaches the parser, so an interruptible channel offers no benefit here; 
this
+   * plain adapter simply reads from the buffer without consulting the thread's
+   * interrupt status.</p>
+   *
+   * @param in the buffered response stream.
+   * @return a non-interruptible channel over {@code in}.
+   */
+  private static ReadableByteChannel nonInterruptibleChannel(
+      final InputStream in) {
+    return new ReadableByteChannel() {
+      private volatile boolean open = true;
+      // Fixed staging buffer reused across reads for direct ByteBuffers. Arrow
+      // parsing drives this channel from a single thread, so a per-instance
+      // buffer is safe and avoids per-read allocation/GC churn.
+      private final byte[] staging = new byte[NON_INTERRUPTIBLE_READ_CHUNK];
+
+      @Override
+      public int read(final ByteBuffer dst) throws IOException {
+        final int toRead = dst.remaining();
+        if (toRead == 0) {
+          return 0;
+        }
+        if (dst.hasArray()) {
+          final int n = in.read(dst.array(),
+              dst.arrayOffset() + dst.position(), toRead);
+          if (n > 0) {
+            dst.position(dst.position() + n);
+          }
+          return n;
+        }
+        // For a direct (non-array-backed) ByteBuffer we must stage bytes in a
+        // heap buffer first. ArrowStreamReader can request very large reads, 
so
+        // read at most one staging-buffer worth per call and let the reader
+        // issue further reads for the remainder (its readFully loops on 
partial
+        // reads). This bounds allocation and reuses a single buffer.
+        final int chunk = Math.min(toRead, staging.length);
+        final int n = in.read(staging, 0, chunk);
+        if (n > 0) {
+          dst.put(staging, 0, n);
+        }
+        return n;
+      }
+
+      @Override
+      public boolean isOpen() {
+        return open;
+      }
+
+      @Override
+      public void close() throws IOException {
+        open = false;
+        in.close();
+      }
+    };
+  }
+
+  /**
+   * Convert a single Arrow row into a {@link BlobListResultEntrySchema}. Rows
+   * without a usable name are skipped.
+   */
+  private BlobListResultEntrySchema buildEntry(
+      final BatchColumns columns, final int row) {
+    String name = readString(columns.name, row);
+    if (name == null || name.isEmpty()) {
+      return null;
+    }
+    // Directory names may carry a trailing slash; strip it to match XML 
parser.
+    if (name.endsWith(FORWARD_SLASH)) {
+      name = name.substring(0, name.length() - 1);
+    }
+
+    BlobListResultEntrySchema entry = new BlobListResultEntrySchema();
+    entry.setName(name);
+    entry.setPath(new Path(ROOT_PATH + name));
+    entry.setUrl(url + ROOT_PATH + name);
+
+    entry.setETag(readString(columns.etag, row));
+    // The Arrow (Photon) response serializes timestamps as native Arrow
+    // timestamp vectors (e.g. Creation-Time / Last-Modified as TimeStampSec),
+    // whose object form is an ISO-8601 local date-time (e.g. 
2026-07-06T10:31:19)
+    // in UTC. Normalize to RFC 1123 GMT so both paths produce identical values
+    // and DateTimeUtils.parseLastModifiedTime yields the correct time.
+    entry.setLastModifiedTime(readTimestampAsRfc1123(columns.lastModified, 
row));
+    entry.setCreationTime(readTimestampAsRfc1123(columns.creationTime, row));
+    entry.setOwner(readString(columns.owner, row));
+    entry.setGroup(readString(columns.group, row));
+    entry.setPermission(readString(columns.permission, row));
+    entry.setAcl(readString(columns.acl, row));
+
+    // Blob user metadata (including the hdi_isfolder directory marker) is
+    // carried in a single Arrow map column; mirror the XML parser which
+    // populates the metadata map from the <Metadata> element.
+    Map<String, String> metadata = readMetadata(columns.metadata, row);
+    if (!metadata.isEmpty()) {
+      entry.setMetadata(metadata);
+    }
+
+    setContentLength(entry, columns.contentLength, row);
+    setCopyProperties(entry, columns, row);
+    entry.setIsDirectory(resolveIsDirectory(columns, metadata, row));
+    return entry;
+  }
+
+  /**
+   * Populate the copy-related properties, mirroring the XML parser's handling
+   * of the {@code Properties} copy elements. Absent columns leave the
+   * corresponding field unset, exactly as the XML path does when the elements
+   * are missing.
+   */
+  private void setCopyProperties(final BlobListResultEntrySchema entry,
+      final BatchColumns columns, final int row) {
+    entry.setCopyId(readString(columns.copyId, row));
+    entry.setCopyStatus(readString(columns.copyStatus, row));
+    entry.setCopySourceUrl(readString(columns.copySource, row));
+    entry.setCopyProgress(readString(columns.copyProgress, row));
+    entry.setCopyStatusDescription(
+        readString(columns.copyStatusDescription, row));
+    // XML stores the completion time as epoch millis parsed from an RFC 1123
+    // string; normalize the Arrow timestamp to the same RFC 1123 form first so
+    // both paths yield identical epoch values.
+    String copyCompletion =
+        readTimestampAsRfc1123(columns.copyCompletionTime, row);
+    if (copyCompletion != null) {
+      entry.setCopyCompletionTime(
+          DateTimeUtils.parseLastModifiedTime(copyCompletion));
+    }
+  }
+
+  /**
+   * Populate the content length from the content-length column. The Blob
+   * endpoint surfaces it as a native unsigned 64-bit integer
+   * ({@code UInt8Vector}); other numeric vector types and a numeric string
+   * column are also tolerated so additive schema changes do not break parsing.
+   */
+  private void setContentLength(final BlobListResultEntrySchema entry,
+      final FieldVector contentLength, final int row) {
+    Long value = readLong(contentLength, row);
+    if (value != null) {
+      entry.setContentLength(value);
+    }
+  }
+
+  /**
+   * Determine whether the row represents a directory. Mirrors the XML parser
+   * ({@code BlobListXmlParser}), which flags an entry as a directory when any 
of
+   * the following hold:
+   * <ul>
+   *   <li>the entry is a {@code BlobPrefix} - surfaced in the Arrow response 
as
+   *   a {@code ResourceType} of {@code blobprefix} - i.e. an implicit
+   *   directory;</li>
+   *   <li>a {@code ResourceType} of {@code directory};</li>
+   *   <li>an explicit boolean {@code IsDirectory} indicator;</li>
+   *   <li>the {@code hdi_isfolder} user metadata being {@code true}.</li>
+   * </ul>
+   * The implicit-directory ({@code blobprefix}) and {@code hdi_isfolder} cases
+   * are essential: an implicit directory has no marker blob at all, while an
+   * empty directory created by {@code mkdir} is a zero-byte marker blob whose
+   * only directory indicator is the {@code hdi_isfolder=true} metadata entry.
+   * The metadata key is matched case-insensitively, matching the XML parser
+   * ({@code equalsIgnoreCase}). Without honoring these, such directories are
+   * misclassified as files, diverging from the XML listing path.
+   */
+  private boolean resolveIsDirectory(final BatchColumns columns,
+      final Map<String, String> metadata, final int row) {
+    String resourceType = readString(columns.resourceType, row);
+    if (DIRECTORY.equalsIgnoreCase(resourceType)
+        || ARROW_RESOURCE_TYPE_BLOB_PREFIX.equalsIgnoreCase(resourceType)) {
+      return true;
+    }
+    if (readBoolean(columns.isDirectory, row)) {
+      return true;
+    }
+    String hdiIsFolder = metadataValueIgnoreCase(metadata, 
XML_TAG_HDI_ISFOLDER);
+    if (hdiIsFolder != null && Boolean.parseBoolean(hdiIsFolder.trim())) {
+      return true;
+    }
+    // Fallback for a schema that flattens the marker metadata into a dedicated
+    // column instead of the metadata map.
+    return readBoolean(columns.hdiIsFolder, row);
+  }
+
+  /**
+   * Case-insensitive lookup of a metadata value by key, mirroring the XML
+   * parser's {@code equalsIgnoreCase} matching of the {@code hdi_isfolder}
+   * marker (the service preserves the casing used when the metadata was set).
+   */
+  private static String metadataValueIgnoreCase(
+      final Map<String, String> metadata, final String key) {
+    String value = metadata.get(key);
+    if (value != null || metadata.containsKey(key)) {
+      return value;
+    }
+    for (Map.Entry<String, String> entry : metadata.entrySet()) {
+      if (key.equalsIgnoreCase(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Read the user metadata key/value pairs from the Arrow map column for the
+   * given row. Keys are stored verbatim (e.g. {@code hdi_isfolder}) to match 
the
+   * XML parser. Returns an empty (mutable) map when the column is absent or 
the
+   * value is null.
+   */
+  private Map<String, String> readMetadata(final MapVector metadata,
+      final int row) {
+    Map<String, String> result = new HashMap<>();
+    if (metadata == null || row >= metadata.getValueCount()
+        || metadata.isNull(row)) {
+      return result;
+    }
+    List<?> entries = metadata.getObject(row);

Review Comment:
   Thanks — you're right that MapVector.getObject(row) materialises a 
JsonStringArrayList of JsonStringHashMaps plus boxed Text per key/value, and 
that the instanceof Map / toString() unwrapping then throws all of it away. I 
dug into the trade-off:
   
   Pros of the direct key/value VarCharVector read
   - Eliminates the intermediate allocations on the metadata path: for R rows 
with M entries each, we drop the per-row list, the R×M JsonStringHashMaps, and 
the R×2M boxed Text objects.
   - Lower allocation rate → less young-gen GC churn → marginally better 
throughput on large, metadata-heavy listings.
   - No extra off-heap cost; we'd read straight from the existing map offset 
buffer + child key/value vectors.
   
   Cons / limits
   - It doesn't reduce retained/peak heap. The data we actually keep — the 
returned Map<String,String> + key/value Strings per entry — is identical either 
way and lives until FileStatus conversion. So this is a transient-garbage 
optimization, not a footprint reduction. To shrink retained memory we'd have to 
stop materialising the full metadata map (e.g. extract only hdi_isfolder), 
which breaks XML parity.
   - The win scales with M. Typical blobs carry 0–2 user-metadata entries, so 
in the common case we're only skipping ~one tiny map per row — negligible. It's 
meaningful only when listings are both very large and metadata-rich.
   - Higher complexity/risk. It couples the parser to Arrow internals 
(MapVector → StructVector → child key/value VarCharVectors, per-row ranges via 
the offset buffer) with hand-rolled null/empty-map and multi-batch handling — 
precisely the kind of low-level code that's easy to get subtly wrong. And since 
we still need all keys for XML parity, we don't even get to short-circuit the 
traversal.
   
   Proposal: keep the current readable getObject() version for this PR 
(correctness + parity), and track the offset-buffer read as a follow-up gated 
on a benchmark — if a profile of a large (≥50k), metadata-heavy listing shows 
readMetadata as a real hotspot/GC driver, I'll switch it and post the 
before/after numbers. Happy to file the JIRA and link it here. WDYT?



##########
hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/contract/TestArrowListBlobParser.java:
##########
@@ -0,0 +1,974 @@
+/**
+ * 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.hadoop.fs.azurebfs.contract;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.channels.Channels;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BigIntVector;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.TimeStampSecVector;
+import org.apache.arrow.vector.UInt8Vector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.complex.impl.UnionMapWriter;
+import org.apache.arrow.vector.ipc.ArrowStreamWriter;
+import org.apache.arrow.vector.types.TimeUnit;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.arrow.vector.util.Text;
+import org.junit.jupiter.api.Test;
+
+import org.apache.hadoop.fs.azurebfs.contracts.services.ArrowListBlobParser;
+import 
org.apache.hadoop.fs.azurebfs.contracts.services.BlobListResultEntrySchema;
+import org.apache.hadoop.fs.azurebfs.contracts.services.BlobListResultSchema;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link ArrowListBlobParser}, the Photon (Apache Arrow based)
+ * ListBlobs response parser.
+ */
+public class TestArrowListBlobParser {

Review Comment:
   Added: TZ timestamp, two batches in one stream, negative UInt8 length, 
non-numeric length, hdi_isfolder="1" column, and a name of just "/".



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);

Review Comment:
   Now throws IOException (with ERR_ARROW_LIST_PARSING) when columns.name == 
null. Updated testMissingMandatoryNameColumn to expect the throw and added 
testNullNameValueRowSkipped to confirm a present-but-null name value is still 
row-skipped.



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);
+        int rowCount = root.getRowCount();
+        for (int row = 0; row < rowCount; row++) {
+          BlobListResultEntrySchema entry = buildEntry(columns, row);
+          if (entry != null) {
+            listResultSchema.addBlobListEntry(entry);
+          }
+        }
+      }
+      LOG.debug("Photon ListBlobs parsed {} blobs with {} as continuation 
token",
+          listResultSchema.paths().size(), listResultSchema.getNextMarker());
+      return listResultSchema;
+    } catch (IOException ex) {
+      throw ex;
+    } catch (RuntimeException ex) {
+      // Wrap Arrow runtime failures (e.g. corrupted stream) as IOException so
+      // callers can surface them through the existing error-handling model.
+      throw new IOException(ex);
+    }
+  }
+
+  @Override
+  public String getParsingErrorMessage() {
+    return ERR_ARROW_LIST_PARSING;
+  }
+
+  /**
+   * Wrap an {@link InputStream} in a {@link ReadableByteChannel} that is
+   * <em>not</em> interruptible.
+   *
+   * <p>{@link ArrowStreamReader}, when handed an {@code InputStream}, 
internally
+   * adapts it with {@link java.nio.channels.Channels#newChannel(InputStream)},
+   * which returns a channel extending
+   * {@link java.nio.channels.spi.AbstractInterruptibleChannel}. If the parsing
+   * thread's interrupt flag is set (e.g. Hadoop task cancellation, speculative
+   * execution kills, or an executor {@code shutdownNow()}), that channel 
aborts
+   * the read with a {@link java.nio.channels.ClosedByInterruptException},
+   * turning a benign interrupt into a hard listing failure. The XML SAX parser
+   * reads from a plain {@code InputStream} and is immune to this, so the Arrow
+   * path must not be more fragile.</p>
+   *
+   * <p>The list response body is already fully buffered in memory before it
+   * reaches the parser, so an interruptible channel offers no benefit here; 
this
+   * plain adapter simply reads from the buffer without consulting the thread's
+   * interrupt status.</p>
+   *
+   * @param in the buffered response stream.
+   * @return a non-interruptible channel over {@code in}.
+   */
+  private static ReadableByteChannel nonInterruptibleChannel(
+      final InputStream in) {
+    return new ReadableByteChannel() {
+      private volatile boolean open = true;
+      // Fixed staging buffer reused across reads for direct ByteBuffers. Arrow
+      // parsing drives this channel from a single thread, so a per-instance
+      // buffer is safe and avoids per-read allocation/GC churn.
+      private final byte[] staging = new byte[NON_INTERRUPTIBLE_READ_CHUNK];
+
+      @Override
+      public int read(final ByteBuffer dst) throws IOException {
+        final int toRead = dst.remaining();
+        if (toRead == 0) {
+          return 0;
+        }
+        if (dst.hasArray()) {
+          final int n = in.read(dst.array(),
+              dst.arrayOffset() + dst.position(), toRead);
+          if (n > 0) {
+            dst.position(dst.position() + n);
+          }
+          return n;
+        }
+        // For a direct (non-array-backed) ByteBuffer we must stage bytes in a
+        // heap buffer first. ArrowStreamReader can request very large reads, 
so
+        // read at most one staging-buffer worth per call and let the reader
+        // issue further reads for the remainder (its readFully loops on 
partial
+        // reads). This bounds allocation and reuses a single buffer.
+        final int chunk = Math.min(toRead, staging.length);
+        final int n = in.read(staging, 0, chunk);
+        if (n > 0) {
+          dst.put(staging, 0, n);
+        }
+        return n;
+      }
+
+      @Override
+      public boolean isOpen() {
+        return open;
+      }
+
+      @Override
+      public void close() throws IOException {
+        open = false;
+        in.close();
+      }
+    };
+  }
+
+  /**
+   * Convert a single Arrow row into a {@link BlobListResultEntrySchema}. Rows
+   * without a usable name are skipped.
+   */
+  private BlobListResultEntrySchema buildEntry(
+      final BatchColumns columns, final int row) {
+    String name = readString(columns.name, row);
+    if (name == null || name.isEmpty()) {
+      return null;
+    }
+    // Directory names may carry a trailing slash; strip it to match XML 
parser.
+    if (name.endsWith(FORWARD_SLASH)) {
+      name = name.substring(0, name.length() - 1);
+    }
+
+    BlobListResultEntrySchema entry = new BlobListResultEntrySchema();
+    entry.setName(name);
+    entry.setPath(new Path(ROOT_PATH + name));
+    entry.setUrl(url + ROOT_PATH + name);
+
+    entry.setETag(readString(columns.etag, row));
+    // The Arrow (Photon) response serializes timestamps as native Arrow
+    // timestamp vectors (e.g. Creation-Time / Last-Modified as TimeStampSec),
+    // whose object form is an ISO-8601 local date-time (e.g. 
2026-07-06T10:31:19)
+    // in UTC. Normalize to RFC 1123 GMT so both paths produce identical values
+    // and DateTimeUtils.parseLastModifiedTime yields the correct time.
+    entry.setLastModifiedTime(readTimestampAsRfc1123(columns.lastModified, 
row));
+    entry.setCreationTime(readTimestampAsRfc1123(columns.creationTime, row));
+    entry.setOwner(readString(columns.owner, row));
+    entry.setGroup(readString(columns.group, row));
+    entry.setPermission(readString(columns.permission, row));
+    entry.setAcl(readString(columns.acl, row));
+
+    // Blob user metadata (including the hdi_isfolder directory marker) is
+    // carried in a single Arrow map column; mirror the XML parser which
+    // populates the metadata map from the <Metadata> element.
+    Map<String, String> metadata = readMetadata(columns.metadata, row);
+    if (!metadata.isEmpty()) {
+      entry.setMetadata(metadata);
+    }
+
+    setContentLength(entry, columns.contentLength, row);
+    setCopyProperties(entry, columns, row);
+    entry.setIsDirectory(resolveIsDirectory(columns, metadata, row));
+    return entry;
+  }
+
+  /**
+   * Populate the copy-related properties, mirroring the XML parser's handling
+   * of the {@code Properties} copy elements. Absent columns leave the
+   * corresponding field unset, exactly as the XML path does when the elements
+   * are missing.
+   */
+  private void setCopyProperties(final BlobListResultEntrySchema entry,
+      final BatchColumns columns, final int row) {
+    entry.setCopyId(readString(columns.copyId, row));
+    entry.setCopyStatus(readString(columns.copyStatus, row));
+    entry.setCopySourceUrl(readString(columns.copySource, row));
+    entry.setCopyProgress(readString(columns.copyProgress, row));
+    entry.setCopyStatusDescription(
+        readString(columns.copyStatusDescription, row));
+    // XML stores the completion time as epoch millis parsed from an RFC 1123
+    // string; normalize the Arrow timestamp to the same RFC 1123 form first so
+    // both paths yield identical epoch values.
+    String copyCompletion =
+        readTimestampAsRfc1123(columns.copyCompletionTime, row);
+    if (copyCompletion != null) {
+      entry.setCopyCompletionTime(
+          DateTimeUtils.parseLastModifiedTime(copyCompletion));
+    }
+  }
+
+  /**
+   * Populate the content length from the content-length column. The Blob
+   * endpoint surfaces it as a native unsigned 64-bit integer
+   * ({@code UInt8Vector}); other numeric vector types and a numeric string
+   * column are also tolerated so additive schema changes do not break parsing.
+   */
+  private void setContentLength(final BlobListResultEntrySchema entry,
+      final FieldVector contentLength, final int row) {
+    Long value = readLong(contentLength, row);
+    if (value != null) {
+      entry.setContentLength(value);
+    }
+  }
+
+  /**
+   * Determine whether the row represents a directory. Mirrors the XML parser
+   * ({@code BlobListXmlParser}), which flags an entry as a directory when any 
of
+   * the following hold:
+   * <ul>
+   *   <li>the entry is a {@code BlobPrefix} - surfaced in the Arrow response 
as
+   *   a {@code ResourceType} of {@code blobprefix} - i.e. an implicit
+   *   directory;</li>
+   *   <li>a {@code ResourceType} of {@code directory};</li>
+   *   <li>an explicit boolean {@code IsDirectory} indicator;</li>
+   *   <li>the {@code hdi_isfolder} user metadata being {@code true}.</li>
+   * </ul>
+   * The implicit-directory ({@code blobprefix}) and {@code hdi_isfolder} cases
+   * are essential: an implicit directory has no marker blob at all, while an
+   * empty directory created by {@code mkdir} is a zero-byte marker blob whose
+   * only directory indicator is the {@code hdi_isfolder=true} metadata entry.
+   * The metadata key is matched case-insensitively, matching the XML parser
+   * ({@code equalsIgnoreCase}). Without honoring these, such directories are
+   * misclassified as files, diverging from the XML listing path.
+   */
+  private boolean resolveIsDirectory(final BatchColumns columns,
+      final Map<String, String> metadata, final int row) {
+    String resourceType = readString(columns.resourceType, row);
+    if (DIRECTORY.equalsIgnoreCase(resourceType)
+        || ARROW_RESOURCE_TYPE_BLOB_PREFIX.equalsIgnoreCase(resourceType)) {
+      return true;
+    }
+    if (readBoolean(columns.isDirectory, row)) {
+      return true;
+    }
+    String hdiIsFolder = metadataValueIgnoreCase(metadata, 
XML_TAG_HDI_ISFOLDER);
+    if (hdiIsFolder != null && Boolean.parseBoolean(hdiIsFolder.trim())) {
+      return true;
+    }
+    // Fallback for a schema that flattens the marker metadata into a dedicated
+    // column instead of the metadata map.
+    return readBoolean(columns.hdiIsFolder, row);
+  }
+
+  /**
+   * Case-insensitive lookup of a metadata value by key, mirroring the XML
+   * parser's {@code equalsIgnoreCase} matching of the {@code hdi_isfolder}
+   * marker (the service preserves the casing used when the metadata was set).
+   */
+  private static String metadataValueIgnoreCase(
+      final Map<String, String> metadata, final String key) {
+    String value = metadata.get(key);
+    if (value != null || metadata.containsKey(key)) {
+      return value;
+    }
+    for (Map.Entry<String, String> entry : metadata.entrySet()) {
+      if (key.equalsIgnoreCase(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Read the user metadata key/value pairs from the Arrow map column for the
+   * given row. Keys are stored verbatim (e.g. {@code hdi_isfolder}) to match 
the
+   * XML parser. Returns an empty (mutable) map when the column is absent or 
the
+   * value is null.
+   */
+  private Map<String, String> readMetadata(final MapVector metadata,
+      final int row) {
+    Map<String, String> result = new HashMap<>();
+    if (metadata == null || row >= metadata.getValueCount()
+        || metadata.isNull(row)) {
+      return result;
+    }
+    List<?> entries = metadata.getObject(row);
+    if (entries == null) {
+      return result;
+    }
+    for (Object element : entries) {
+      if (element instanceof Map) {
+        Map<?, ?> keyValue = (Map<?, ?>) element;
+        Object key = keyValue.get(MapVector.KEY_NAME);
+        Object value = keyValue.get(MapVector.VALUE_NAME);
+        if (key != null) {
+          result.put(key.toString(), value == null ? null : value.toString());
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Read a timestamp column for the given row and convert it to the RFC 1123
+   * GMT representation used by the XML path. Native Arrow timestamp vectors
+   * expose an ISO-8601 local date-time via {@link FieldVector#getObject(int)};
+   * a textual (VarChar) timestamp column is also tolerated. Returns
+   * {@code null} when the column is absent or the value is null.
+   */
+  private String readTimestampAsRfc1123(final FieldVector vector,
+      final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value == null) {
+      return null;
+    }
+    return DateTimeUtils.formatArrowDateTimeToRfc1123(value.toString());
+  }
+
+  /**
+   * Read a numeric column for the given row as a {@code long}. Supports native
+   * Arrow integer vectors (whose object form is a {@link Number}) and a 
numeric
+   * {@link VarCharVector}. Returns {@code null} when the column is absent, the
+   * value is null, or a textual value is not parseable.
+   */
+  private Long readLong(final FieldVector vector, final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value instanceof Number) {
+      return ((Number) value).longValue();
+    }
+    if (value != null) {
+      String text = value.toString().trim();
+      if (!text.isEmpty()) {
+        try {
+          return Long.parseLong(text);
+        } catch (NumberFormatException ignored) {
+          // Leave unset if the value is not numeric.
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Read a boolean-valued column for the given row. Supports both a native
+   * Arrow {@link BitVector} and a {@link VarCharVector} carrying a textual
+   * {@code true}/{@code 1} value (matching how the XML parser interprets the
+   * {@code hdi_isfolder} metadata via {@link Boolean#valueOf(String)}). 
Returns
+   * {@code false} when the column is absent or the value is null/empty.
+   */
+  private boolean readBoolean(final FieldVector vector, final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return false;
+    }
+    if (vector instanceof BitVector) {
+      return ((BitVector) vector).get(row) != 0;
+    }
+    if (vector instanceof VarCharVector) {
+      String value = readString((VarCharVector) vector, row);
+      if (value != null && !value.isEmpty()) {
+        String trimmed = value.trim();
+        return Boolean.parseBoolean(trimmed) || "1".equals(trimmed);

Review Comment:
   Extracted NUMERIC_TRUE = "1".



##########
hadoop-tools/hadoop-azure/src/test/java/org/apache/hadoop/fs/azurebfs/services/TestAggregateMetricsManager.java:
##########
@@ -667,6 +667,31 @@ private void runProgramAndCaptureOutput(String program,
     }
   }
 
+  /**
+   * Resolves a JDK tool (e.g. {@code javac}, {@code java}) from the JDK that 
is
+   * currently running this test, rather than relying on whichever version is
+   * first on the {@code PATH}. The spawned {@code javac} must read the 
compiled
+   * classes of this project (and its dependencies) off the classpath; those 
are
+   * emitted by the same JDK that runs the tests. If a mismatched compiler is
+   * picked from the {@code PATH} (e.g. a Java 8 {@code javac} against Java 17
+   * bytecode), compilation fails with "class file has wrong version". 
Anchoring
+   * on {@code java.home} keeps the toolchain consistent. Falls back to the 
bare
+   * tool name when the JDK layout cannot be resolved.
+   *
+   * @param name the tool name, e.g. {@code javac} or {@code java}.
+   * @return an absolute path to the tool inside the running JDK, or {@code 
name}.
+   */
+  private static String jdkTool(String name) {
+    String javaHome = System.getProperty("java.home");

Review Comment:
   Added JAVA_HOME_PROPERTY and JDK_BIN_DIR constants.



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);
+        int rowCount = root.getRowCount();
+        for (int row = 0; row < rowCount; row++) {
+          BlobListResultEntrySchema entry = buildEntry(columns, row);
+          if (entry != null) {
+            listResultSchema.addBlobListEntry(entry);
+          }
+        }
+      }
+      LOG.debug("Photon ListBlobs parsed {} blobs with {} as continuation 
token",
+          listResultSchema.paths().size(), listResultSchema.getNextMarker());
+      return listResultSchema;
+    } catch (IOException ex) {
+      throw ex;
+    } catch (RuntimeException ex) {
+      // Wrap Arrow runtime failures (e.g. corrupted stream) as IOException so
+      // callers can surface them through the existing error-handling model.
+      throw new IOException(ex);
+    }
+  }
+
+  @Override
+  public String getParsingErrorMessage() {
+    return ERR_ARROW_LIST_PARSING;
+  }
+
+  /**
+   * Wrap an {@link InputStream} in a {@link ReadableByteChannel} that is
+   * <em>not</em> interruptible.
+   *
+   * <p>{@link ArrowStreamReader}, when handed an {@code InputStream}, 
internally
+   * adapts it with {@link java.nio.channels.Channels#newChannel(InputStream)},
+   * which returns a channel extending
+   * {@link java.nio.channels.spi.AbstractInterruptibleChannel}. If the parsing
+   * thread's interrupt flag is set (e.g. Hadoop task cancellation, speculative
+   * execution kills, or an executor {@code shutdownNow()}), that channel 
aborts
+   * the read with a {@link java.nio.channels.ClosedByInterruptException},
+   * turning a benign interrupt into a hard listing failure. The XML SAX parser
+   * reads from a plain {@code InputStream} and is immune to this, so the Arrow
+   * path must not be more fragile.</p>
+   *
+   * <p>The list response body is already fully buffered in memory before it
+   * reaches the parser, so an interruptible channel offers no benefit here; 
this
+   * plain adapter simply reads from the buffer without consulting the thread's
+   * interrupt status.</p>
+   *
+   * @param in the buffered response stream.
+   * @return a non-interruptible channel over {@code in}.
+   */
+  private static ReadableByteChannel nonInterruptibleChannel(
+      final InputStream in) {
+    return new ReadableByteChannel() {
+      private volatile boolean open = true;
+      // Fixed staging buffer reused across reads for direct ByteBuffers. Arrow
+      // parsing drives this channel from a single thread, so a per-instance
+      // buffer is safe and avoids per-read allocation/GC churn.
+      private final byte[] staging = new byte[NON_INTERRUPTIBLE_READ_CHUNK];
+
+      @Override
+      public int read(final ByteBuffer dst) throws IOException {
+        final int toRead = dst.remaining();
+        if (toRead == 0) {
+          return 0;
+        }
+        if (dst.hasArray()) {
+          final int n = in.read(dst.array(),
+              dst.arrayOffset() + dst.position(), toRead);
+          if (n > 0) {
+            dst.position(dst.position() + n);
+          }
+          return n;
+        }
+        // For a direct (non-array-backed) ByteBuffer we must stage bytes in a
+        // heap buffer first. ArrowStreamReader can request very large reads, 
so
+        // read at most one staging-buffer worth per call and let the reader
+        // issue further reads for the remainder (its readFully loops on 
partial
+        // reads). This bounds allocation and reuses a single buffer.
+        final int chunk = Math.min(toRead, staging.length);
+        final int n = in.read(staging, 0, chunk);
+        if (n > 0) {
+          dst.put(staging, 0, n);
+        }
+        return n;
+      }
+
+      @Override
+      public boolean isOpen() {
+        return open;
+      }
+
+      @Override
+      public void close() throws IOException {
+        open = false;
+        in.close();
+      }
+    };
+  }
+
+  /**
+   * Convert a single Arrow row into a {@link BlobListResultEntrySchema}. Rows
+   * without a usable name are skipped.
+   */
+  private BlobListResultEntrySchema buildEntry(
+      final BatchColumns columns, final int row) {
+    String name = readString(columns.name, row);
+    if (name == null || name.isEmpty()) {
+      return null;
+    }
+    // Directory names may carry a trailing slash; strip it to match XML 
parser.
+    if (name.endsWith(FORWARD_SLASH)) {
+      name = name.substring(0, name.length() - 1);
+    }
+
+    BlobListResultEntrySchema entry = new BlobListResultEntrySchema();
+    entry.setName(name);
+    entry.setPath(new Path(ROOT_PATH + name));
+    entry.setUrl(url + ROOT_PATH + name);
+
+    entry.setETag(readString(columns.etag, row));
+    // The Arrow (Photon) response serializes timestamps as native Arrow
+    // timestamp vectors (e.g. Creation-Time / Last-Modified as TimeStampSec),
+    // whose object form is an ISO-8601 local date-time (e.g. 
2026-07-06T10:31:19)
+    // in UTC. Normalize to RFC 1123 GMT so both paths produce identical values
+    // and DateTimeUtils.parseLastModifiedTime yields the correct time.
+    entry.setLastModifiedTime(readTimestampAsRfc1123(columns.lastModified, 
row));
+    entry.setCreationTime(readTimestampAsRfc1123(columns.creationTime, row));
+    entry.setOwner(readString(columns.owner, row));
+    entry.setGroup(readString(columns.group, row));
+    entry.setPermission(readString(columns.permission, row));
+    entry.setAcl(readString(columns.acl, row));
+
+    // Blob user metadata (including the hdi_isfolder directory marker) is
+    // carried in a single Arrow map column; mirror the XML parser which
+    // populates the metadata map from the <Metadata> element.
+    Map<String, String> metadata = readMetadata(columns.metadata, row);
+    if (!metadata.isEmpty()) {
+      entry.setMetadata(metadata);
+    }
+
+    setContentLength(entry, columns.contentLength, row);
+    setCopyProperties(entry, columns, row);
+    entry.setIsDirectory(resolveIsDirectory(columns, metadata, row));
+    return entry;
+  }
+
+  /**
+   * Populate the copy-related properties, mirroring the XML parser's handling
+   * of the {@code Properties} copy elements. Absent columns leave the
+   * corresponding field unset, exactly as the XML path does when the elements
+   * are missing.
+   */
+  private void setCopyProperties(final BlobListResultEntrySchema entry,
+      final BatchColumns columns, final int row) {
+    entry.setCopyId(readString(columns.copyId, row));
+    entry.setCopyStatus(readString(columns.copyStatus, row));
+    entry.setCopySourceUrl(readString(columns.copySource, row));
+    entry.setCopyProgress(readString(columns.copyProgress, row));
+    entry.setCopyStatusDescription(
+        readString(columns.copyStatusDescription, row));
+    // XML stores the completion time as epoch millis parsed from an RFC 1123
+    // string; normalize the Arrow timestamp to the same RFC 1123 form first so
+    // both paths yield identical epoch values.
+    String copyCompletion =
+        readTimestampAsRfc1123(columns.copyCompletionTime, row);
+    if (copyCompletion != null) {
+      entry.setCopyCompletionTime(
+          DateTimeUtils.parseLastModifiedTime(copyCompletion));
+    }
+  }
+
+  /**
+   * Populate the content length from the content-length column. The Blob
+   * endpoint surfaces it as a native unsigned 64-bit integer
+   * ({@code UInt8Vector}); other numeric vector types and a numeric string
+   * column are also tolerated so additive schema changes do not break parsing.
+   */
+  private void setContentLength(final BlobListResultEntrySchema entry,
+      final FieldVector contentLength, final int row) {
+    Long value = readLong(contentLength, row);
+    if (value != null) {
+      entry.setContentLength(value);
+    }
+  }
+
+  /**
+   * Determine whether the row represents a directory. Mirrors the XML parser
+   * ({@code BlobListXmlParser}), which flags an entry as a directory when any 
of
+   * the following hold:
+   * <ul>
+   *   <li>the entry is a {@code BlobPrefix} - surfaced in the Arrow response 
as
+   *   a {@code ResourceType} of {@code blobprefix} - i.e. an implicit
+   *   directory;</li>
+   *   <li>a {@code ResourceType} of {@code directory};</li>
+   *   <li>an explicit boolean {@code IsDirectory} indicator;</li>
+   *   <li>the {@code hdi_isfolder} user metadata being {@code true}.</li>
+   * </ul>
+   * The implicit-directory ({@code blobprefix}) and {@code hdi_isfolder} cases
+   * are essential: an implicit directory has no marker blob at all, while an
+   * empty directory created by {@code mkdir} is a zero-byte marker blob whose
+   * only directory indicator is the {@code hdi_isfolder=true} metadata entry.
+   * The metadata key is matched case-insensitively, matching the XML parser
+   * ({@code equalsIgnoreCase}). Without honoring these, such directories are
+   * misclassified as files, diverging from the XML listing path.
+   */
+  private boolean resolveIsDirectory(final BatchColumns columns,
+      final Map<String, String> metadata, final int row) {
+    String resourceType = readString(columns.resourceType, row);
+    if (DIRECTORY.equalsIgnoreCase(resourceType)
+        || ARROW_RESOURCE_TYPE_BLOB_PREFIX.equalsIgnoreCase(resourceType)) {
+      return true;
+    }
+    if (readBoolean(columns.isDirectory, row)) {
+      return true;
+    }
+    String hdiIsFolder = metadataValueIgnoreCase(metadata, 
XML_TAG_HDI_ISFOLDER);
+    if (hdiIsFolder != null && Boolean.parseBoolean(hdiIsFolder.trim())) {
+      return true;
+    }
+    // Fallback for a schema that flattens the marker metadata into a dedicated
+    // column instead of the metadata map.
+    return readBoolean(columns.hdiIsFolder, row);
+  }
+
+  /**
+   * Case-insensitive lookup of a metadata value by key, mirroring the XML
+   * parser's {@code equalsIgnoreCase} matching of the {@code hdi_isfolder}
+   * marker (the service preserves the casing used when the metadata was set).
+   */
+  private static String metadataValueIgnoreCase(
+      final Map<String, String> metadata, final String key) {
+    String value = metadata.get(key);
+    if (value != null || metadata.containsKey(key)) {
+      return value;
+    }
+    for (Map.Entry<String, String> entry : metadata.entrySet()) {
+      if (key.equalsIgnoreCase(entry.getKey())) {
+        return entry.getValue();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Read the user metadata key/value pairs from the Arrow map column for the
+   * given row. Keys are stored verbatim (e.g. {@code hdi_isfolder}) to match 
the
+   * XML parser. Returns an empty (mutable) map when the column is absent or 
the
+   * value is null.
+   */
+  private Map<String, String> readMetadata(final MapVector metadata,
+      final int row) {
+    Map<String, String> result = new HashMap<>();
+    if (metadata == null || row >= metadata.getValueCount()
+        || metadata.isNull(row)) {
+      return result;
+    }
+    List<?> entries = metadata.getObject(row);
+    if (entries == null) {
+      return result;
+    }
+    for (Object element : entries) {
+      if (element instanceof Map) {
+        Map<?, ?> keyValue = (Map<?, ?>) element;
+        Object key = keyValue.get(MapVector.KEY_NAME);
+        Object value = keyValue.get(MapVector.VALUE_NAME);
+        if (key != null) {
+          result.put(key.toString(), value == null ? null : value.toString());
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Read a timestamp column for the given row and convert it to the RFC 1123
+   * GMT representation used by the XML path. Native Arrow timestamp vectors
+   * expose an ISO-8601 local date-time via {@link FieldVector#getObject(int)};
+   * a textual (VarChar) timestamp column is also tolerated. Returns
+   * {@code null} when the column is absent or the value is null.
+   */
+  private String readTimestampAsRfc1123(final FieldVector vector,
+      final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value == null) {
+      return null;
+    }
+    return DateTimeUtils.formatArrowDateTimeToRfc1123(value.toString());
+  }
+
+  /**
+   * Read a numeric column for the given row as a {@code long}. Supports native
+   * Arrow integer vectors (whose object form is a {@link Number}) and a 
numeric
+   * {@link VarCharVector}. Returns {@code null} when the column is absent, the
+   * value is null, or a textual value is not parseable.
+   */
+  private Long readLong(final FieldVector vector, final int row) {
+    if (vector == null || row >= vector.getValueCount()
+        || vector.isNull(row)) {
+      return null;
+    }
+    Object value = vector.getObject(row);
+    if (value instanceof Number) {
+      return ((Number) value).longValue();
+    }
+    if (value != null) {
+      String text = value.toString().trim();
+      if (!text.isEmpty()) {
+        try {
+          return Long.parseLong(text);
+        } catch (NumberFormatException ignored) {
+          // Leave unset if the value is not numeric.

Review Comment:
   Added LOG.debug on the NumberFormatException in readLong. This matches the 
XML path, which also leaves an unparseable <Content-Length> unset (→0). Added 
testNonNumericContentLengthIgnored.



##########
hadoop-tools/hadoop-azure/src/main/java/org/apache/hadoop/fs/azurebfs/contracts/services/ArrowListBlobParser.java:
##########
@@ -0,0 +1,590 @@
+/**
+ * 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.hadoop.fs.azurebfs.contracts.services;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.MapVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.azurebfs.utils.DateTimeUtils;
+
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ACL;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CONTENT_LENGTH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_COMPLETION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_ID;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_PROGRESS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_SOURCE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_COPY_STATUS_DESCRIPTION;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_CREATION_TIME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_ETAG;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_GROUP;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_IS_DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_LAST_MODIFIED;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_METADATA;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_NAME;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_OWNER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_PERMISSIONS;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_COL_RESOURCE_TYPE;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_METADATA_NEXT_MARKER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ARROW_RESOURCE_TYPE_BLOB_PREFIX;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.DIRECTORY;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.FORWARD_SLASH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.ROOT_PATH;
+import static 
org.apache.hadoop.fs.azurebfs.constants.AbfsHttpConstants.XML_TAG_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.constants.HttpHeaderConfigurations.X_MS_META_HDI_ISFOLDER;
+import static 
org.apache.hadoop.fs.azurebfs.services.AbfsErrors.ERR_ARROW_LIST_PARSING;
+
+/**
+ * Photon response parser implementation for Apache Arrow based ListBlobs
+ * responses. The parser reads the Arrow IPC stream returned by the service,
+ * iterates through the record batches and converts each row into a
+ * {@link BlobListResultEntrySchema}, producing a {@link BlobListResultSchema}
+ * that keeps the downstream FileStatus conversion path unchanged.
+ * <p>
+ * Arrow specific objects are confined to this class; the output contract is
+ * {@link BlobListResultSchema} so existing listing behavior is preserved.
+ * <p>
+ * Column matching is case-insensitive. Unknown columns are ignored and missing
+ * columns are treated as absent values so that additive schema changes do not
+ * break parsing. The continuation token is read from the Arrow schema custom
+ * metadata (key {@code NextMarker}).
+ */
+public class ArrowListBlobParser implements ListBlobResponseParser {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ArrowListBlobParser.class);
+
+  /**
+   * Upper bound, in bytes, on the heap staging buffer allocated per read when
+   * copying into a direct (non-array-backed) {@link ByteBuffer}. Caps 
transient
+   * allocations for large reader requests; the reader simply issues additional
+   * reads for anything beyond this chunk.
+   */
+  private static final int NON_INTERRUPTIBLE_READ_CHUNK = 8192;
+
+  /**
+   * Base URL for which the ListBlobs API is called, used to build the absolute
+   * URL for each entry (mirrors the XML parser behavior).
+   */
+  private final String url;
+
+  /**
+   * Maximum off-heap (direct) memory in bytes the Arrow allocator may use 
while
+   * parsing a single response. Bounds the memory an oversized or malformed
+   * response can consume; exceeding it fails the parse with an
+   * {@link org.apache.arrow.memory.OutOfMemoryException} that is surfaced as 
an
+   * {@link IOException}.
+   */
+  private final long memoryLimitBytes;
+
+  /**
+   * @param url base URL for which the ListBlobs API is called.
+   * @param memoryLimitBytes maximum off-heap memory in bytes the Arrow 
allocator
+   *                         may use while parsing a single response.
+   */
+  public ArrowListBlobParser(final String url, final long memoryLimitBytes) {
+    this.url = url;
+    this.memoryLimitBytes = memoryLimitBytes;
+  }
+
+  @Override
+  public BlobListResultSchema parse(final InputStream responseStream)
+      throws IOException {
+    BlobListResultSchema listResultSchema = new BlobListResultSchema();
+    try (BufferAllocator allocator = new RootAllocator(memoryLimitBytes);
+        ArrowStreamReader reader =
+            new ArrowStreamReader(nonInterruptibleChannel(responseStream),
+                allocator)) {
+      VectorSchemaRoot root = reader.getVectorSchemaRoot();
+      Schema schema = root.getSchema();
+
+      // Continuation token is carried in the Arrow schema custom metadata.
+      // The service emits an empty string when there is no continuation token,
+      // whereas the XML path leaves it null; normalize an absent or empty
+      // marker to null so both formats produce identical values.
+      Map<String, String> customMetadata = schema.getCustomMetadata();
+      if (customMetadata != null) {
+        String nextMarker = customMetadata.get(ARROW_METADATA_NEXT_MARKER);
+        listResultSchema.setNextMarker(
+            (nextMarker == null || nextMarker.isEmpty()) ? null : nextMarker);
+      }
+
+      while (reader.loadNextBatch()) {
+        BatchColumns columns = BatchColumns.resolve(root);

Review Comment:
   Resolved once before the while loop (same VectorSchemaRoot/FieldVector 
instances persist across batches). Added testTwoBatchesInOneStream to exercise 
the multi-batch path.





> ABFS: Support Photon (Apache Arrow) based ListBlobs on Blob endpoint with XML 
> fallback
> --------------------------------------------------------------------------------------
>
>                 Key: HADOOP-19941
>                 URL: https://issues.apache.org/jira/browse/HADOOP-19941
>             Project: Hadoop Common
>          Issue Type: New Feature
>          Components: fs/azure
>            Reporter: Manish Bhatt
>            Assignee: Manish Bhatt
>            Priority: Major
>              Labels: pull-request-available
>
> Add config-gated support (fs.azure.photon.enabled, default off) for consuming 
> ListBlobs responses in the Apache Arrow (Photon) format on the ABFS Blob 
> endpoint. When enabled, ABFS advertises Arrow via an Accept header; the 
> response Content-Type selects an Arrow or the existing XML parser, both 
> producing identical FileStatus results, so downstream behaviour is unchanged. 
> Includes automatic, transparent fallback to XML, full parsing parity 
> (metadata, directory markers, implicit directories, copy properties, native 
> timestamp/length vectors), interrupt-safe Arrow parsing, and Photon telemetry 
> (request, response, fallback, parse-failure counts and listing latency). No 
> public API changes. Covered by unit and integration tests.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to