getMap(Batch batch, String name, int index) {
+ Column column = batch.getColumn(name);
+ if (column == null || column.isNull(index)) {
+ return null;
+ }
+ if (!(column instanceof MapColumn)) {
+ throw new BlobListArrowParseException("ListBlobs Arrow parse failure: field '" + name
+ + "' has unsupported map column type '" + column.getClass().getSimpleName() + "'.");
+ }
+ return ((MapColumn) column).get(index);
+ }
+
+ //endregion
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobListArrowStreamReader.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobListArrowStreamReader.java
new file mode 100644
index 000000000000..d813c21b43a4
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobListArrowStreamReader.java
@@ -0,0 +1,554 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.storage.blob.implementation.models.BlobListArrowParseException;
+import org.apache.arrow.flatbuf.Buffer;
+import org.apache.arrow.flatbuf.Endianness;
+import org.apache.arrow.flatbuf.Field;
+import org.apache.arrow.flatbuf.FieldNode;
+import org.apache.arrow.flatbuf.Int;
+import org.apache.arrow.flatbuf.KeyValue;
+import org.apache.arrow.flatbuf.Message;
+import org.apache.arrow.flatbuf.MessageHeader;
+import org.apache.arrow.flatbuf.RecordBatch;
+import org.apache.arrow.flatbuf.Schema;
+import org.apache.arrow.flatbuf.TimeUnit;
+import org.apache.arrow.flatbuf.Timestamp;
+import org.apache.arrow.flatbuf.Type;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal Apache Arrow IPC stream reader scoped to the needs of the ListBlobs Arrow response.
+ *
+ * This reader intentionally supports only the subset of the Arrow IPC format emitted by the Storage ListBlobs
+ * endpoint: a single schema message followed by zero or more uncompressed, little-endian record batches whose
+ * columns are UTF-8 strings, booleans, integers, second-precision timestamps, and map<string,string>. Anything
+ * outside that subset (dictionaries, compression, big-endian, unsupported types) fails fast with
+ * {@link BlobListArrowParseException}.
+ *
+ * It depends only on the {@code arrow-format} flatbuffer definitions for metadata decoding and reads record batch
+ * bodies directly, so it does not require the {@code arrow-vector} runtime.
+ */
+final class BlobListArrowStreamReader {
+
+ private static final int CONTINUATION_MARKER = 0xFFFFFFFF;
+
+ private BlobListArrowStreamReader() {
+ }
+
+ /**
+ * The decoded contents of an Arrow IPC stream.
+ */
+ static final class Parsed {
+ private final Map schemaMetadata;
+ private final List batches;
+
+ Parsed(Map schemaMetadata, List batches) {
+ this.schemaMetadata = schemaMetadata;
+ this.batches = batches;
+ }
+
+ Map getSchemaMetadata() {
+ return schemaMetadata;
+ }
+
+ List getBatches() {
+ return batches;
+ }
+ }
+
+ /**
+ * A single decoded record batch: a row count and columns addressable by field name.
+ */
+ static final class Batch {
+ private final int rowCount;
+ private final Map columns;
+
+ Batch(int rowCount, Map columns) {
+ this.rowCount = rowCount;
+ this.columns = columns;
+ }
+
+ int getRowCount() {
+ return rowCount;
+ }
+
+ Column getColumn(String name) {
+ return columns.get(name);
+ }
+ }
+
+ /**
+ * Reads and decodes an Arrow IPC stream.
+ *
+ * @param stream the Arrow IPC stream.
+ * @return the decoded schema metadata and record batches.
+ * @throws BlobListArrowParseException if the stream is malformed or uses an unsupported feature.
+ */
+ static Parsed read(InputStream stream) {
+ byte[] bytes;
+ try {
+ bytes = readAll(stream);
+ } catch (IOException e) {
+ throw new BlobListArrowParseException("ListBlobs Arrow parse failure: unable to read IPC stream.", e);
+ }
+
+ ByteBuffer body = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+
+ Map schemaMetadata = null;
+ List fields = null;
+ List batches = new ArrayList<>();
+
+ int pos = 0;
+ int length = bytes.length;
+ while (pos + 4 <= length) {
+ int marker = body.getInt(pos);
+ pos += 4;
+
+ int metadataLength;
+ if (marker == CONTINUATION_MARKER) {
+ if (pos + 4 > length) {
+ break;
+ }
+ metadataLength = body.getInt(pos);
+ pos += 4;
+ } else {
+ // Pre-0.15 streams used a bare length prefix without the continuation marker.
+ metadataLength = marker;
+ }
+
+ if (metadataLength == 0) {
+ // End-of-stream marker.
+ break;
+ }
+ if (metadataLength < 0 || pos + metadataLength > length) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: message metadata length is out of bounds.");
+ }
+
+ ByteBuffer messageBuffer
+ = ByteBuffer.wrap(bytes, pos, metadataLength).slice().order(ByteOrder.LITTLE_ENDIAN);
+ Message message = Message.getRootAsMessage(messageBuffer);
+ pos += metadataLength;
+
+ long bodyLength = message.bodyLength();
+ if (bodyLength < 0 || pos + bodyLength > length) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: message body length is out of bounds.");
+ }
+ int bodyStart = pos;
+ pos += (int) bodyLength;
+
+ byte headerType = message.headerType();
+ if (headerType == MessageHeader.Schema) {
+ Schema schema = (Schema) message.header(new Schema());
+ if (schema == null) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: schema message header is missing.");
+ }
+ if (schema.endianness() != Endianness.Little) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: only little-endian streams are supported.");
+ }
+ schemaMetadata = readKeyValueMetadata(schema);
+ fields = readFields(schema);
+ } else if (headerType == MessageHeader.RecordBatch) {
+ if (fields == null) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: record batch encountered before schema.");
+ }
+ RecordBatch recordBatch = (RecordBatch) message.header(new RecordBatch());
+ if (recordBatch == null) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: record batch message header is missing.");
+ }
+ if (recordBatch.compression() != null) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: compressed record batches are not supported.");
+ }
+ batches.add(buildBatch(fields, recordBatch, body, bodyStart));
+ } else if (headerType == MessageHeader.DictionaryBatch) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: dictionary-encoded streams are not supported.");
+ }
+ // Other header types (Tensor, SparseTensor) are not expected and are ignored.
+ }
+
+ if (fields == null) {
+ throw new BlobListArrowParseException("ListBlobs Arrow parse failure: stream contained no schema.");
+ }
+
+ return new Parsed(schemaMetadata == null ? new HashMap<>() : schemaMetadata, batches);
+ }
+
+ private static Batch buildBatch(List fields, RecordBatch recordBatch, ByteBuffer body, int bodyStart) {
+ BatchCursor cursor = new BatchCursor(recordBatch, body, bodyStart);
+ Map columns = new LinkedHashMap<>();
+ for (ArrowField field : fields) {
+ columns.put(field.name, buildColumn(field, cursor));
+ }
+ return new Batch((int) recordBatch.length(), columns);
+ }
+
+ private static Column buildColumn(ArrowField field, BatchCursor cursor) {
+ FieldNode node = cursor.nextNode();
+ int valueCount = (int) node.length();
+
+ switch (field.typeType) {
+ case Type.Utf8:
+ case Type.Binary: {
+ BufferRegion validity = cursor.nextBuffer();
+ BufferRegion offsets = cursor.nextBuffer();
+ BufferRegion data = cursor.nextBuffer();
+ return new StringColumn(valueCount, validity, offsets, data, cursor.body, cursor.bodyStart);
+ }
+
+ case Type.Bool: {
+ BufferRegion validity = cursor.nextBuffer();
+ BufferRegion data = cursor.nextBuffer();
+ return new BoolColumn(valueCount, validity, data, cursor.body, cursor.bodyStart);
+ }
+
+ case Type.Int: {
+ BufferRegion validity = cursor.nextBuffer();
+ BufferRegion data = cursor.nextBuffer();
+ return new IntColumn(valueCount, validity, data, field.bitWidth, field.signed, cursor.body,
+ cursor.bodyStart);
+ }
+
+ case Type.Timestamp: {
+ BufferRegion validity = cursor.nextBuffer();
+ BufferRegion data = cursor.nextBuffer();
+ return new TimestampColumn(valueCount, validity, data, cursor.body, cursor.bodyStart);
+ }
+
+ case Type.Map: {
+ BufferRegion validity = cursor.nextBuffer();
+ BufferRegion offsets = cursor.nextBuffer();
+ // Map has a single Struct child ("entries") with key and value children.
+ ArrowField entries = field.children.get(0);
+ StructColumn struct = (StructColumn) buildColumn(entries, cursor);
+ Column keyColumn = struct.children.get(0);
+ Column valueColumn = struct.children.get(1);
+ if (!(keyColumn instanceof StringColumn) || !(valueColumn instanceof StringColumn)) {
+ throw new BlobListArrowParseException("ListBlobs Arrow parse failure: field '" + field.name
+ + "' map entries must be string keys and values.");
+ }
+ return new MapColumn(valueCount, validity, offsets, (StringColumn) keyColumn,
+ (StringColumn) valueColumn, cursor.body, cursor.bodyStart);
+ }
+
+ case Type.Struct_: {
+ cursor.nextBuffer(); // struct validity buffer
+ List children = new ArrayList<>(field.children.size());
+ for (ArrowField child : field.children) {
+ children.add(buildColumn(child, cursor));
+ }
+ return new StructColumn(children);
+ }
+
+ default:
+ throw new BlobListArrowParseException("ListBlobs Arrow parse failure: field '" + field.name
+ + "' has unsupported Arrow type '" + Type.name(field.typeType) + "'.");
+ }
+ }
+
+ private static List readFields(Schema schema) {
+ int count = schema.fieldsLength();
+ List fields = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ fields.add(readField(schema.fields(i)));
+ }
+ return fields;
+ }
+
+ private static ArrowField readField(Field field) {
+ ArrowField arrowField = new ArrowField();
+ arrowField.name = field.name();
+ arrowField.typeType = field.typeType();
+
+ if (arrowField.typeType == Type.Int) {
+ Int intType = (Int) field.type(new Int());
+ if (intType != null) {
+ arrowField.bitWidth = intType.bitWidth();
+ arrowField.signed = intType.isSigned();
+ }
+ } else if (arrowField.typeType == Type.Timestamp) {
+ Timestamp timestamp = (Timestamp) field.type(new Timestamp());
+ if (timestamp != null && timestamp.unit() != TimeUnit.SECOND) {
+ throw new BlobListArrowParseException("ListBlobs Arrow parse failure: field '" + arrowField.name
+ + "' uses an unsupported timestamp unit '" + TimeUnit.name(timestamp.unit()) + "'.");
+ }
+ }
+
+ int childCount = field.childrenLength();
+ arrowField.children = new ArrayList<>(childCount);
+ for (int i = 0; i < childCount; i++) {
+ arrowField.children.add(readField(field.children(i)));
+ }
+ return arrowField;
+ }
+
+ private static Map readKeyValueMetadata(Schema schema) {
+ int count = schema.customMetadataLength();
+ if (count == 0) {
+ return new HashMap<>();
+ }
+ Map metadata = new HashMap<>();
+ for (int i = 0; i < count; i++) {
+ KeyValue keyValue = schema.customMetadata(i);
+ metadata.put(keyValue.key(), keyValue.value());
+ }
+ return metadata;
+ }
+
+ private static byte[] readAll(InputStream stream) throws IOException {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int read;
+ while ((read = stream.read(chunk)) != -1) {
+ buffer.write(chunk, 0, read);
+ }
+ return buffer.toByteArray();
+ }
+
+ /**
+ * A parsed schema field with the minimal type information required for decoding.
+ */
+ private static final class ArrowField {
+ private String name;
+ private byte typeType;
+ private int bitWidth;
+ private boolean signed;
+ private List children = new ArrayList<>();
+ }
+
+ /**
+ * Sequentially hands out the field nodes and buffers of a record batch in pre-order.
+ */
+ private static final class BatchCursor {
+ private final RecordBatch recordBatch;
+ private final ByteBuffer body;
+ private final int bodyStart;
+ private int nodeIndex;
+ private int bufferIndex;
+
+ BatchCursor(RecordBatch recordBatch, ByteBuffer body, int bodyStart) {
+ this.recordBatch = recordBatch;
+ this.body = body;
+ this.bodyStart = bodyStart;
+ }
+
+ FieldNode nextNode() {
+ if (nodeIndex >= recordBatch.nodesLength()) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: record batch is missing expected field nodes.");
+ }
+ return recordBatch.nodes(nodeIndex++);
+ }
+
+ BufferRegion nextBuffer() {
+ if (bufferIndex >= recordBatch.buffersLength()) {
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: record batch is missing expected buffers.");
+ }
+ Buffer buffer = recordBatch.buffers(bufferIndex++);
+ return new BufferRegion(buffer.offset(), buffer.length());
+ }
+ }
+
+ /**
+ * Offset and length of a single buffer within the record batch body.
+ */
+ private static final class BufferRegion {
+ private final long offset;
+ private final long length;
+
+ BufferRegion(long offset, long length) {
+ this.offset = offset;
+ this.length = length;
+ }
+ }
+
+ /**
+ * Base class for decoded columns.
+ */
+ abstract static class Column {
+ final int valueCount;
+ final BufferRegion validity;
+ final ByteBuffer body;
+ final int bodyStart;
+
+ Column(int valueCount, BufferRegion validity, ByteBuffer body, int bodyStart) {
+ this.valueCount = valueCount;
+ this.validity = validity;
+ this.body = body;
+ this.bodyStart = bodyStart;
+ }
+
+ boolean isNull(int index) {
+ if (validity == null || validity.length == 0) {
+ return false;
+ }
+ int bytePosition = bodyStart + (int) validity.offset + (index >> 3);
+ int bit = (body.get(bytePosition) >> (index & 7)) & 1;
+ return bit == 0;
+ }
+ }
+
+ /**
+ * UTF-8 string column (Arrow Utf8/Binary).
+ */
+ static final class StringColumn extends Column {
+ private final BufferRegion offsets;
+ private final BufferRegion data;
+
+ StringColumn(int valueCount, BufferRegion validity, BufferRegion offsets, BufferRegion data, ByteBuffer body,
+ int bodyStart) {
+ super(valueCount, validity, body, bodyStart);
+ this.offsets = offsets;
+ this.data = data;
+ }
+
+ String get(int index) {
+ int start = body.getInt(bodyStart + (int) offsets.offset + index * 4);
+ int end = body.getInt(bodyStart + (int) offsets.offset + (index + 1) * 4);
+ int dataStart = bodyStart + (int) data.offset + start;
+ byte[] valueBytes = new byte[end - start];
+ for (int i = 0; i < valueBytes.length; i++) {
+ valueBytes[i] = body.get(dataStart + i);
+ }
+ return new String(valueBytes, StandardCharsets.UTF_8);
+ }
+ }
+
+ /**
+ * Boolean column stored as a bitmap (Arrow Bool).
+ */
+ static final class BoolColumn extends Column {
+ private final BufferRegion data;
+
+ BoolColumn(int valueCount, BufferRegion validity, BufferRegion data, ByteBuffer body, int bodyStart) {
+ super(valueCount, validity, body, bodyStart);
+ this.data = data;
+ }
+
+ boolean get(int index) {
+ int bytePosition = bodyStart + (int) data.offset + (index >> 3);
+ int bit = (body.get(bytePosition) >> (index & 7)) & 1;
+ return bit == 1;
+ }
+ }
+
+ /**
+ * Integer column (Arrow Int) of width 8/16/32/64, signed or unsigned, returned as a long.
+ */
+ static final class IntColumn extends Column {
+ private final BufferRegion data;
+ private final int bitWidth;
+ private final boolean signed;
+
+ IntColumn(int valueCount, BufferRegion validity, BufferRegion data, int bitWidth, boolean signed,
+ ByteBuffer body, int bodyStart) {
+ super(valueCount, validity, body, bodyStart);
+ this.data = data;
+ this.bitWidth = bitWidth;
+ this.signed = signed;
+ }
+
+ long get(int index) {
+ int base = bodyStart + (int) data.offset;
+ switch (bitWidth) {
+ case 64:
+ return body.getLong(base + index * 8);
+
+ case 32: {
+ int value = body.getInt(base + index * 4);
+ return signed ? value : (value & 0xFFFFFFFFL);
+ }
+
+ case 16: {
+ short value = body.getShort(base + index * 2);
+ return signed ? value : (value & 0xFFFF);
+ }
+
+ case 8: {
+ byte value = body.get(base + index);
+ return signed ? value : (value & 0xFF);
+ }
+
+ default:
+ throw new BlobListArrowParseException(
+ "ListBlobs Arrow parse failure: unsupported integer bit width '" + bitWidth + "'.");
+ }
+ }
+ }
+
+ /**
+ * Second-precision timestamp column (Arrow Timestamp, SECOND unit).
+ */
+ static final class TimestampColumn extends Column {
+ private final BufferRegion data;
+
+ TimestampColumn(int valueCount, BufferRegion validity, BufferRegion data, ByteBuffer body, int bodyStart) {
+ super(valueCount, validity, body, bodyStart);
+ this.data = data;
+ }
+
+ long getEpochSeconds(int index) {
+ return body.getLong(bodyStart + (int) data.offset + index * 8);
+ }
+ }
+
+ /**
+ * Struct column holding ordered child columns (used internally for map entries).
+ */
+ static final class StructColumn extends Column {
+ private final List children;
+
+ StructColumn(List children) {
+ super(0, null, null, 0);
+ this.children = children;
+ }
+ }
+
+ /**
+ * Map<string,string> column (Arrow Map of Struct<key:utf8,value:utf8>).
+ */
+ static final class MapColumn extends Column {
+ private final BufferRegion offsets;
+ private final StringColumn keys;
+ private final StringColumn values;
+
+ MapColumn(int valueCount, BufferRegion validity, BufferRegion offsets, StringColumn keys, StringColumn values,
+ ByteBuffer body, int bodyStart) {
+ super(valueCount, validity, body, bodyStart);
+ this.offsets = offsets;
+ this.keys = keys;
+ this.values = values;
+ }
+
+ Map get(int index) {
+ int start = body.getInt(bodyStart + (int) offsets.offset + index * 4);
+ int end = body.getInt(bodyStart + (int) offsets.offset + (index + 1) * 4);
+ Map map = new HashMap<>();
+ for (int entry = start; entry < end; entry++) {
+ map.put(keys.get(entry), values.get(entry));
+ }
+ return map.isEmpty() ? null : map;
+ }
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/ModelHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/ModelHelper.java
index cb859dfa595d..6bd5deb8cc55 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/ModelHelper.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/ModelHelper.java
@@ -47,6 +47,7 @@
import com.azure.storage.blob.models.PageBlobCopyIncrementalRequestConditions;
import com.azure.storage.blob.models.PageRange;
import com.azure.storage.blob.models.ParallelTransferOptions;
+import com.azure.storage.blob.models.StorageResponseSerializationFormat;
import com.azure.storage.blob.models.TaggedBlobItem;
import com.azure.storage.common.Utility;
import com.azure.storage.common.implementation.Constants;
@@ -85,6 +86,14 @@ public final class ModelHelper {
*/
public static final int PAGE_BYTES = 512;
+ /**
+ * The format that {@link StorageResponseSerializationFormat#AUTO} currently resolves to on the
+ * wire. Changing this constant is a behavioral change (and should be noted in the CHANGELOG)
+ * but it is not a public API change.
+ */
+ private static final StorageResponseSerializationFormat DEFAULT_SERIALIZATION_FORMAT
+ = StorageResponseSerializationFormat.XML;
+
/**
* Determines whether the passed authority is IP style, that is, it is of the format {@code :}.
*
@@ -663,6 +672,22 @@ public static BlobStorageException mapToBlobStorageException(BlobStorageExceptio
internal.getResponse(), code, headerName), internal.getResponse(), internal.getValue());
}
+ /**
+ * Resolves a user-supplied {@link StorageResponseSerializationFormat} to the concrete value
+ * to send on the wire. Treats {@code null} and {@link StorageResponseSerializationFormat#AUTO}
+ * identically — both yield {@link #DEFAULT_SERIALIZATION_FORMAT}.
+ *
+ * @param format the format requested by the caller, or {@code null} if unset.
+ * @return the concrete {@link StorageResponseSerializationFormat} to send on the wire.
+ */
+ public static StorageResponseSerializationFormat
+ resolveSerializationFormat(StorageResponseSerializationFormat format) {
+ if (format == null || format == StorageResponseSerializationFormat.AUTO) {
+ return DEFAULT_SERIALIZATION_FORMAT;
+ }
+ return format;
+ }
+
private ModelHelper() {
}
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobDownloadHeaders.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobDownloadHeaders.java
index 6a372f5b2c74..dfd93a535fc6 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobDownloadHeaders.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobDownloadHeaders.java
@@ -825,97 +825,6 @@ public BlobDownloadHeaders setEncryptionScope(String encryptionScope) {
return this;
}
- /**
- * Gets the access tier of the blob.
- *
- * @return the access tier of the blob. This is only set for Page blobs on a premium storage account or for Block
- * blobs on blob storage or general purpose V2 account.
- */
- public AccessTier getAccessTier() {
- String accessTier = internalHeaders.getXMsAccessTier();
- return accessTier == null ? null : AccessTier.fromString(accessTier);
- }
-
- /**
- * Sets the access tier of the blob.
- *
- * @param accessTier the access tier of the blob.
- * @return the BlobDownloadHeaders object itself.
- */
- public BlobDownloadHeaders setAccessTier(AccessTier accessTier) {
- internalHeaders.setXMsAccessTier(accessTier == null ? null : accessTier.toString());
- return this;
- }
-
- /**
- * Gets the status of the tier being inferred for the blob.
- *
- * @return the status of the tier being inferred for the blob. This is only set for Page blobs on a premium storage
- * account or for Block blobs on blob storage or general purpose V2 account.
- */
- public Boolean isAccessTierInferred() {
- return Boolean.TRUE.equals(internalHeaders.isXMsAccessTierInferred());
- }
-
- /**
- * Sets the status of the tier being inferred for the blob.
- *
- * @param accessTierInferred the status of the tier being inferred for the blob.
- * @return the BlobDownloadHeaders object itself.
- */
- public BlobDownloadHeaders setAccessTierInferred(Boolean accessTierInferred) {
- internalHeaders.setXMsAccessTierInferred(accessTierInferred);
- return this;
- }
-
- /**
- * Gets the time when the access tier for the blob was last changed.
- *
- * @return the time when the access tier for the blob was last changed.
- */
- public OffsetDateTime getAccessTierChangeTime() {
- return internalHeaders.getXMsAccessTierChangeTime();
- }
-
- /**
- * Sets the time when the access tier for the blob was last changed.
- *
- * @param accessTierChangeTime the time when the access tier for the blob was last changed.
- * @return the BlobDownloadHeaders object itself.
- */
- public BlobDownloadHeaders setAccessTierChangeTime(OffsetDateTime accessTierChangeTime) {
- internalHeaders.setXMsAccessTierChangeTime(accessTierChangeTime);
- return this;
- }
-
- /**
- * Gets the underlying access tier of the blob when its access tier is {@link AccessTier#SMART}.
- *
- * This value is only populated when {@link #getAccessTier()} returns {@link AccessTier#SMART}. In that case, it
- * represents the concrete access tier (for example {@link AccessTier#HOT} or {@link AccessTier#COOL}) that the
- * service has selected for the blob. For all other access tiers, this property is {@code null} and should be
- * ignored.
- *
- * @return the underlying access tier chosen by the service when the blob's access tier is {@link AccessTier#SMART},
- * or {@code null} if the blob is not using the smart access tier.
- */
- public AccessTier getSmartAccessTier() {
- String smartAccessTier = internalHeaders.getXMsSmartAccessTier();
- return smartAccessTier == null ? null : AccessTier.fromString(smartAccessTier);
- }
-
- /**
- * Sets the underlying access tier of the blob when its access tier is {@link AccessTier#SMART}.
- *
- * @param smartAccessTier the underlying access tier chosen by the service when the blob's access tier is
- * {@link AccessTier#SMART}.
- * @return the BlobDownloadHeaders object itself.
- */
- public BlobDownloadHeaders setSmartAccessTier(AccessTier smartAccessTier) {
- internalHeaders.setXMsSmartAccessTier(smartAccessTier == null ? null : smartAccessTier.toString());
- return this;
- }
-
/**
* Get the blobContentMD5 property: If the blob has a MD5 hash, and if request contains range header (Range or
* x-ms-range), this response header is returned with the value of the whole blob's MD5 value. This value may or may
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/ListBlobsOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/ListBlobsOptions.java
index 47f29c2ab2b3..e434681ac6e9 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/ListBlobsOptions.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/ListBlobsOptions.java
@@ -19,6 +19,8 @@ public final class ListBlobsOptions {
private String prefix;
private String startFrom;
private Integer maxResultsPerPage;
+ private StorageResponseSerializationFormat storageResponseSerializationFormat;
+ private String endBefore;
/**
* Constructs an unpopulated {@link ListBlobsOptions}.
@@ -74,7 +76,7 @@ public ListBlobsOptions setPrefix(String prefix) {
* This parameter is similar to the prefix filter: it allows listing blobs starting from the specified path, rather than from the beginning of the container.
* For non-recursive lists, only one entity level is supported.
*
- * @return the marker indicating where to start listing blobs
+ * @return the marker indicating where to start listing blobs (inclusive)
*/
public String getStartFrom() {
return startFrom;
@@ -84,7 +86,7 @@ public String getStartFrom() {
* Sets an optional parameter that specifies an absolute path within the container. This parameter is similar to the prefix filter: it allows listing blobs starting from the specified path, rather than from the beginning of the container.
* For non-recursive lists, only one entity level is supported.
*
- * @param startFrom The marker indicating where to start listing blobs
+ * @param startFrom The marker indicating where to start listing blobs (inclusive)
* @return the updated ListBlobsOptions object
*/
public ListBlobsOptions setStartFrom(String startFrom) {
@@ -92,6 +94,49 @@ public ListBlobsOptions setStartFrom(String startFrom) {
return this;
}
+ /**
+ * Gets the endBefore value. Only supported with Arrow listings. The listing will end before this path (exclusive).
+ *
+ * @return the endBefore value.
+ */
+ public String getEndBefore() {
+ return endBefore;
+ }
+
+ /**
+ * Sets the endBefore value. Only supported with Arrow listings. The listing will end before this path (exclusive).
+ *
+ * @param endBefore the endBefore value to set.
+ * @return the updated ListBlobsOptions object.
+ */
+ public ListBlobsOptions setEndBefore(String endBefore) {
+ this.endBefore = endBefore;
+ return this;
+ }
+
+ /**
+ * Gets the response serialization format the service should use when listing blobs.
+ *
+ * @return the {@link StorageResponseSerializationFormat}, or {@code null} if unset
+ * (equivalent to {@link StorageResponseSerializationFormat#AUTO}).
+ */
+ public StorageResponseSerializationFormat getStorageResponseSerializationFormat() {
+ return storageResponseSerializationFormat;
+ }
+
+ /**
+ * Sets the response serialization format the service should use when listing blobs.
+ *
+ * @param storageResponseSerializationFormat the format to request. {@code null} and
+ * {@link StorageResponseSerializationFormat#AUTO} both let the SDK pick.
+ * @return the updated {@link ListBlobsOptions} object.
+ */
+ public ListBlobsOptions
+ setStorageResponseSerializationFormat(StorageResponseSerializationFormat storageResponseSerializationFormat) {
+ this.storageResponseSerializationFormat = storageResponseSerializationFormat;
+ return this;
+ }
+
/**
* Specifies the maximum number of blobs to return, including all BlobPrefix elements. If the request does not
* specify maxResultsPerPage or specifies a value greater than 5,000, the server will return up to 5,000 items.
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/StorageResponseSerializationFormat.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/StorageResponseSerializationFormat.java
new file mode 100644
index 000000000000..e5017accedbc
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/StorageResponseSerializationFormat.java
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+/**
+ * Defines the serialization format the service uses for list-blobs responses.
+ */
+public enum StorageResponseSerializationFormat {
+ /**
+ * Let the SDK choose the serialization format that is most appropriate for the request.
+ *
+ * The exact format selected by {@code AUTO} is an implementation detail and may change
+ * between SDK releases. Choose {@link #XML} or {@link #ARROW} explicitly if you require
+ * a specific format.
+ */
+ AUTO,
+
+ /**
+ * XML response format.
+ */
+ XML,
+
+ /**
+ * Apache Arrow response format.
+ */
+ ARROW
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/module-info.java b/sdk/storage/azure-storage-blob/src/main/java/module-info.java
index 597a417add7f..7eae7cff59b4 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/module-info.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/module-info.java
@@ -5,6 +5,7 @@
requires transitive com.azure.storage.common;
requires com.azure.storage.internal.avro;
+ requires org.apache.arrow.format;
exports com.azure.storage.blob;
exports com.azure.storage.blob.models;
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
index 71c474ba295c..50a9eb63ef21 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
@@ -543,36 +543,6 @@ public void downloadAllNullBinaryData() {
// headers.getLastAccessedTime() /* TODO (gapra): re-enable when last access time enabled. */
}
- @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-10-06")
- @Test
- public void downloadSmartAccessTierHeaders() {
- ByteArrayOutputStream stream = new ByteArrayOutputStream();
- bc.setAccessTier(AccessTier.SMART);
-
- BlobDownloadResponse response = bc.downloadStreamWithResponse(stream, null, null, null, false, null, null);
- ByteBuffer body = ByteBuffer.wrap(stream.toByteArray());
-
- assertEquals(DATA.getDefaultData(), body);
- assertSmartAccessTierHeaders(response.getDeserializedHeaders());
- }
-
- @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-10-06")
- @Test
- public void downloadContentSmartAccessTierHeaders() {
- bc.setAccessTier(AccessTier.SMART);
- BlobDownloadContentResponse response = bc.downloadContentWithResponse(null, null, null, null);
-
- TestUtils.assertArraysEqual(DATA.getDefaultBytes(), response.getValue().toBytes());
- assertSmartAccessTierHeaders(response.getDeserializedHeaders());
- }
-
- private static void assertSmartAccessTierHeaders(BlobDownloadHeaders headers) {
- assertEquals(AccessTier.SMART, headers.getAccessTier());
- assertNotNull(headers.getSmartAccessTier());
- assertFalse(headers.isAccessTierInferred());
- assertNotEquals(OffsetDateTime.now(), headers.getAccessTierChangeTime());
- }
-
@Test
public void downloadEmptyFile() {
AppendBlobClient bc = cc.getBlobClient("emptyAppendBlob").getAppendBlobClient();
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
index ea01df338d18..049e4254e92a 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
@@ -382,33 +382,6 @@ public void downloadAllNullBinaryData() {
.verifyComplete();
}
- @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-10-06")
- @Test
- public void downloadSmartAccessTierHeaders() {
- Mono response = bc.setAccessTier(AccessTier.SMART)
- .then(bc.downloadStreamWithResponse(null, null, null, false))
- .flatMap(r -> {
- assertSmartAccessTierHeaders(r.getDeserializedHeaders());
- return FluxUtil.collectBytesInByteBufferStream(r.getValue());
- })
- .flatMap(r -> {
- TestUtils.assertArraysEqual(DATA.getDefaultBytes(), r);
- return bc.downloadContentWithResponse(null, null);
- });
-
- StepVerifier.create(response).assertNext(r -> {
- assertSmartAccessTierHeaders(r.getDeserializedHeaders());
- TestUtils.assertArraysEqual(DATA.getDefaultBytes(), r.getValue().toBytes());
- }).verifyComplete();
- }
-
- private static void assertSmartAccessTierHeaders(BlobDownloadHeaders headers) {
- assertEquals(AccessTier.SMART, headers.getAccessTier());
- assertNotNull(headers.getSmartAccessTier());
- assertFalse(headers.isAccessTierInferred());
- assertNotEquals(OffsetDateTime.now(), headers.getAccessTierChangeTime());
- }
-
@Test
public void downloadEmptyFile() {
AppendBlobAsyncClient bc = ccAsync.getBlobAsyncClient("emptyAppendBlob").getAppendBlobAsyncClient();
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
index f46116acdbb5..54ae007c7165 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
@@ -34,6 +34,7 @@
import com.azure.storage.blob.models.PublicAccessType;
import com.azure.storage.blob.models.RehydratePriority;
import com.azure.storage.blob.models.StorageAccountInfo;
+import com.azure.storage.blob.models.StorageResponseSerializationFormat;
import com.azure.storage.blob.models.TaggedBlobItem;
import com.azure.storage.blob.options.BlobContainerCreateOptions;
import com.azure.storage.blob.options.BlobParallelUploadOptions;
@@ -50,6 +51,10 @@
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion;
import com.azure.storage.common.test.shared.policy.InvalidServiceVersionPipelinePolicy;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
+import org.apache.arrow.vector.VectorSchemaRoot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -58,7 +63,19 @@
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import com.azure.core.http.HttpPipeline;
+import com.azure.storage.blob.implementation.AzureBlobStorageImpl;
+import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder;
+import com.azure.storage.blob.implementation.models.ContainersListBlobFlatSegmentApacheArrowHeaders;
+import com.azure.storage.blob.implementation.models.ContainersListBlobHierarchySegmentApacheArrowHeaders;
+import com.azure.storage.blob.implementation.util.ArrowBlobListDeserializer;
+import com.azure.storage.blob.implementation.util.ModelHelper;
+import com.azure.storage.blob.models.ListBlobsIncludeItem;
+import com.azure.core.http.rest.ResponseBase;
+
import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.ArrayList;
import java.net.URL;
import java.time.OffsetDateTime;
import java.util.Arrays;
@@ -2128,4 +2145,313 @@ public void getBlobContainerUrlEncodesContainerName() {
// then:
// assertThrows(BlobStorageException.class, () ->
// }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowBasic() {
+ // Upload a test blob
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+ List blobs = cc.listBlobs(options, null).stream().collect(Collectors.toList());
+
+ assertEquals(1, blobs.size());
+ assertEquals(blobName, blobs.get(0).getName());
+ assertNotNull(blobs.get(0).getProperties());
+ assertEquals(DATA.getDefaultDataSize(), blobs.get(0).getProperties().getContentLength());
+ assertEquals(BlobType.BLOCK_BLOB, blobs.get(0).getProperties().getBlobType());
+ assertNotNull(blobs.get(0).getProperties().getLastModified());
+ assertNotNull(blobs.get(0).getProperties().getETag());
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowWithTags() {
+ // Upload a blob and set tags
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ Map tags = new HashMap<>();
+ tags.put("tagkey", "tagvalue");
+ cc.getBlobClient(blobName).setTags(tags);
+
+ // List with Arrow + retrieveTags
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setDetails(new BlobListDetails().setRetrieveTags(true));
+ List blobs = cc.listBlobs(options, null).stream().collect(Collectors.toList());
+
+ assertEquals(1, blobs.size());
+ assertEquals(blobName, blobs.get(0).getName());
+ assertNotNull(blobs.get(0).getTags());
+ assertEquals("tagvalue", blobs.get(0).getTags().get("tagkey"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("listBlobsFlatRehydratePrioritySupplier")
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowRehydratePriority(RehydratePriority rehydratePriority) {
+ String name = generateBlobName();
+ BlockBlobClient bc = cc.getBlobClient(name).getBlockBlobClient();
+
+ bc.upload(DATA.getDefaultInputStream(), 7);
+
+ if (rehydratePriority != null) {
+ bc.setAccessTier(AccessTier.ARCHIVE);
+ bc.setAccessTierWithResponse(new BlobSetAccessTierOptions(AccessTier.HOT).setPriority(rehydratePriority),
+ null, null);
+ }
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+ BlobItem item = cc.listBlobs(options, null).iterator().next();
+
+ assertEquals(rehydratePriority, item.getProperties().getRehydratePriority());
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowWithMetadata() {
+ String blobName = generateBlobName();
+ Map metadata = new HashMap<>();
+ metadata.put("testkey", "testvalue");
+ cc.getBlobClient(blobName)
+ .getBlockBlobClient()
+ .uploadWithResponse(DATA.getDefaultInputStream(), DATA.getDefaultDataSize(), null, metadata, null, null,
+ null, null, null);
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setDetails(new BlobListDetails().setRetrieveMetadata(true));
+ List blobs = cc.listBlobs(options, null).stream().collect(Collectors.toList());
+
+ assertEquals(1, blobs.size());
+ assertNotNull(blobs.get(0).getMetadata());
+ assertEquals("testvalue", blobs.get(0).getMetadata().get("testkey"));
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowPagination() {
+ // Upload 3 blobs
+ for (int i = 0; i < 4; i++) {
+ cc.getBlobClient("blob" + i)
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+ }
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setMaxResultsPerPage(1);
+ List allBlobs = new ArrayList<>();
+ for (PagedResponse page : cc.listBlobs(options, null).iterableByPage()) {
+ assertTrue(page.getValue().size() <= 1);
+ allBlobs.addAll(page.getValue());
+ }
+
+ cc.listBlobs().iterableByPage(2).forEach(page -> {
+ assertEquals(2, page.getValue().size());
+ });
+
+ assertEquals(4, allBlobs.size());
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowNullUseArrowUsesXml() {
+ // Default apacheArrowEnabled is null — should use XML path without error
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ ListBlobsOptions options = new ListBlobsOptions();
+ assertNull(options.getStorageResponseSerializationFormat());
+
+ List blobs = cc.listBlobs(options, null).stream().collect(Collectors.toList());
+ assertEquals(1, blobs.size());
+ assertEquals(blobName, blobs.get(0).getName());
+ }
+
+ @LiveOnly
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowEncryptedBlob() {
+ // Upload a blob with CPK (customer-provided key)
+ String blobName = generateBlobName();
+ CustomerProvidedKey cpk = new CustomerProvidedKey(Base64.getEncoder().encodeToString(getRandomKey()));
+ BlobClient cpkClient = cc.getBlobClient(blobName).getCustomerProvidedKeyClient(cpk);
+ cpkClient.getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+ List blobs = cc.listBlobs(options, null).stream().collect(Collectors.toList());
+
+ assertEquals(1, blobs.size());
+ assertEquals(blobName, blobs.get(0).getName());
+ // CPK blob should have server-encrypted = true
+ assertTrue(blobs.get(0).getProperties().isServerEncrypted());
+ // Metadata should be null (no metadata was set)
+ assertNull(blobs.get(0).getMetadata());
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowDeserializer() throws Exception {
+ String blobName = generateBlobName();
+ Map metadata = new HashMap<>();
+ metadata.put("testkey", "testvalue");
+ cc.getBlobClient(blobName)
+ .getBlockBlobClient()
+ .uploadWithResponse(DATA.getDefaultInputStream(), 7, null, metadata, null, null, null, null, null);
+
+ AzureBlobStorageImpl impl = new AzureBlobStorageImplBuilder().pipeline(cc.getHttpPipeline())
+ .url(cc.getAccountUrl())
+ .version(BlobServiceVersion.getLatest().getVersion())
+ .buildClient();
+
+ // Call the Arrow endpoint
+ ArrayList include = new ArrayList<>();
+ include.add(ListBlobsIncludeItem.METADATA);
+
+ ResponseBase response = impl.getContainers()
+ .listBlobFlatSegmentApacheArrowWithResponse(containerName, null, null, null, include, null, null, null,
+ null, com.azure.core.util.Context.NONE);
+
+ // Verify Content-Type is Arrow
+ String contentType = response.getDeserializedHeaders().getContentType();
+ assertTrue(contentType.contains("application/vnd.apache.arrow.stream"),
+ "Expected Arrow content type but got: " + contentType);
+
+ // Deserialize using ArrowBlobListDeserializer
+ ArrowBlobListDeserializer.ArrowListBlobsResult result
+ = ArrowBlobListDeserializer.deserialize(response.getValue());
+
+ // Verify pagination — single blob, no next page
+ assertNull(result.getNextMarker());
+
+ // Verify we got exactly one blob
+ assertEquals(1, result.getBlobItems().size());
+
+ com.azure.storage.blob.implementation.models.BlobItemInternal item = result.getBlobItems().get(0);
+
+ // Name
+ assertNotNull(item.getName());
+ assertEquals(blobName, item.getName().getContent());
+
+ // Properties
+ assertNotNull(item.getProperties());
+ assertEquals(7L, (long) item.getProperties().getContentLength());
+ assertEquals("application/octet-stream", item.getProperties().getContentType());
+ assertNotNull(item.getProperties().getETag());
+ assertNotNull(item.getProperties().getLastModified());
+ assertNotNull(item.getProperties().getCreationTime());
+ assertEquals(BlobType.BLOCK_BLOB, item.getProperties().getBlobType());
+ assertEquals(AccessTier.HOT, item.getProperties().getAccessTier());
+ assertTrue(item.getProperties().isAccessTierInferred());
+ assertTrue(item.getProperties().isServerEncrypted());
+ assertEquals(LeaseStateType.AVAILABLE, item.getProperties().getLeaseState());
+ assertEquals(LeaseStatusType.UNLOCKED, item.getProperties().getLeaseStatus());
+ assertNotNull(item.getProperties().getContentMd5());
+
+ // Metadata
+ assertNotNull(item.getMetadata());
+ assertEquals("testvalue", item.getMetadata().get("testkey"));
+
+ // Verify ModelHelper can convert to public BlobItem
+ BlobItem publicItem = ModelHelper.populateBlobItem(item);
+ assertEquals(blobName, publicItem.getName());
+ assertEquals(7L, (long) publicItem.getProperties().getContentLength());
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsByHierarchyArrowBasic() {
+ // Upload blobs in a directory structure
+ cc.getBlobClient("dir/blob1")
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+ cc.getBlobClient("dir/blob2")
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+ cc.getBlobClient("topblob")
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+ List items = cc.listBlobsByHierarchy("/", options, null).stream().collect(Collectors.toList());
+
+ // Root level: one prefix "dir/" and one blob "topblob"
+ assertEquals(2, items.size());
+
+ BlobItem prefixItem = items.stream().filter(BlobItem::isPrefix).findFirst().orElse(null);
+ BlobItem blobItem = items.stream().filter(i -> !i.isPrefix()).findFirst().orElse(null);
+
+ assertNotNull(prefixItem);
+ assertEquals("dir/", prefixItem.getName());
+ assertTrue(prefixItem.isPrefix());
+
+ assertNotNull(blobItem);
+ assertEquals("topblob", blobItem.getName());
+ assertFalse(blobItem.isPrefix());
+ assertNotNull(blobItem.getProperties());
+ assertEquals(DATA.getDefaultDataSize(), blobItem.getProperties().getContentLength());
+ assertEquals(BlobType.BLOCK_BLOB, blobItem.getProperties().getBlobType());
+ assertNotNull(blobItem.getProperties().getLastModified());
+ assertNotNull(blobItem.getProperties().getETag());
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsByHierarchyArrowWithMetadata() {
+ String blobName = generateBlobName();
+ Map metadata = new HashMap<>();
+ metadata.put("testkey", "testvalue");
+ cc.getBlobClient("dir/" + blobName)
+ .getBlockBlobClient()
+ .uploadWithResponse(DATA.getDefaultInputStream(), DATA.getDefaultDataSize(), null, metadata, null, null,
+ null, null, null);
+ cc.getBlobClient("topblob")
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setPrefix("dir/")
+ .setDetails(new BlobListDetails().setRetrieveMetadata(true));
+ List blobs = cc.listBlobsByHierarchy("/", options, null).stream().collect(Collectors.toList());
+
+ assertEquals(1, blobs.size());
+ assertFalse(blobs.get(0).isPrefix());
+ assertNotNull(blobs.get(0).getMetadata());
+ assertEquals("testvalue", blobs.get(0).getMetadata().get("testkey"));
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsByHierarchyArrowPagination() {
+ // Upload blobs across multiple directories
+ for (int i = 0; i < 3; i++) {
+ cc.getBlobClient("dir" + i + "/blob")
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+ }
+ cc.getBlobClient("topblob")
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setMaxResultsPerPage(1);
+ List allItems = new ArrayList<>();
+ for (PagedResponse page : cc.listBlobsByHierarchy("/", options, null).iterableByPage()) {
+ assertTrue(page.getValue().size() <= 1);
+ allItems.addAll(page.getValue());
+ }
+
+ // 3 prefixes + 1 blob = 4 items
+ assertEquals(4, allItems.size());
+ }
+
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
index 04ebc06dc2b6..6e574cda0e27 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
@@ -10,8 +10,14 @@
import com.azure.core.test.TestMode;
import com.azure.core.test.utils.MockTokenCredential;
import com.azure.core.util.Context;
+import com.azure.core.util.FluxUtil;
import com.azure.core.util.polling.PollerFlux;
import com.azure.identity.DefaultAzureCredentialBuilder;
+import com.azure.storage.blob.implementation.AzureBlobStorageImpl;
+import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder;
+import com.azure.storage.blob.implementation.models.BlobItemInternal;
+import com.azure.storage.blob.implementation.util.ArrowBlobListDeserializer;
+import com.azure.storage.blob.implementation.util.ModelHelper;
import com.azure.storage.blob.models.*;
import com.azure.storage.blob.options.BlobContainerCreateOptions;
import com.azure.storage.blob.options.BlobParallelUploadOptions;
@@ -41,6 +47,7 @@
import reactor.util.function.Tuple2;
import java.net.URL;
+import java.io.ByteArrayInputStream;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.*;
@@ -2142,4 +2149,334 @@ public void getBlobContainerUrlEncodesContainerName() {
assertTrue(containerClient.getBlobContainerUrl().contains("my%20container"));
}
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowBasic() {
+ // Upload a test blob
+ String blobName = generateBlobName();
+ BlockBlobAsyncClient bc = ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient();
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+
+ StepVerifier
+ .create(
+ bc.upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()).thenMany(ccAsync.listBlobs(options, null)))
+ .assertNext(item -> {
+ assertEquals(blobName, item.getName());
+ assertNotNull(item.getProperties());
+ assertEquals(DATA.getDefaultDataSize(), item.getProperties().getContentLength());
+ assertEquals(BlobType.BLOCK_BLOB, item.getProperties().getBlobType());
+ assertNotNull(item.getProperties().getLastModified());
+ assertNotNull(item.getProperties().getETag());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowWithMetadata() {
+ String blobName = generateBlobName();
+ Map metadata = new HashMap<>();
+ metadata.put("testkey", "testvalue");
+ BlockBlobAsyncClient bc = ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient();
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setDetails(new BlobListDetails().setRetrieveMetadata(true));
+
+ StepVerifier.create(
+ bc.uploadWithResponse(DATA.getDefaultFlux(), DATA.getDefaultDataSize(), null, metadata, null, null, null)
+ .thenMany(ccAsync.listBlobs(options, null)))
+ .assertNext(item -> {
+ assertNotNull(item.getMetadata());
+ assertEquals("testvalue", item.getMetadata().get("testkey"));
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowPagination() {
+ // Upload 4 blobs
+ Flux uploads = Flux.range(0, 4)
+ .flatMap(i -> ccAsync.getBlobAsyncClient("blob" + i)
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()));
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setMaxResultsPerPage(1);
+
+ Mono> result = uploads.then(ccAsync.listBlobs(options, null).byPage().doOnNext(page -> {
+ assertTrue(page.getValue().size() <= 1);
+ }).flatMap(page -> Flux.fromIterable(page.getValue())).collectList());
+
+ StepVerifier.create(result).assertNext(allBlobs -> assertEquals(4, allBlobs.size())).verifyComplete();
+
+ // Mirror the sync test's secondary assertion: requesting page size 2 yields exactly 2 blobs per page.
+ StepVerifier.create(ccAsync.listBlobs().byPage(2)).thenConsumeWhile(page -> {
+ assertEquals(2, page.getValue().size());
+ return true;
+ }).verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowNullUseArrowUsesXml() {
+ // Default apacheArrowEnabled is null — should use XML path without error
+ String blobName = generateBlobName();
+ BlockBlobAsyncClient bc = ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient();
+
+ ListBlobsOptions options = new ListBlobsOptions();
+ assertNull(options.getStorageResponseSerializationFormat());
+
+ StepVerifier
+ .create(
+ bc.upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()).thenMany(ccAsync.listBlobs(options, null)))
+ .assertNext(item -> assertEquals(blobName, item.getName()))
+ .verifyComplete();
+ }
+
+ @LiveOnly
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowEncryptedBlob() {
+ // Upload a blob with CPK (customer-provided key)
+ String blobName = generateBlobName();
+ CustomerProvidedKey cpk = new CustomerProvidedKey(Base64.getEncoder().encodeToString(getRandomKey()));
+ BlockBlobAsyncClient cpkClient
+ = ccAsync.getBlobAsyncClient(blobName).getCustomerProvidedKeyAsyncClient(cpk).getBlockBlobAsyncClient();
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+
+ StepVerifier.create(cpkClient.upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .thenMany(ccAsync.listBlobs(options, null))).assertNext(item -> {
+ assertEquals(blobName, item.getName());
+ // CPK blob should have server-encrypted = true
+ assertTrue(item.getProperties().isServerEncrypted());
+ // Metadata should be null (no metadata was set)
+ assertNull(item.getMetadata());
+ }).verifyComplete();
+ }
+
+ @ParameterizedTest
+ @MethodSource("listBlobsFlatRehydratePrioritySupplier")
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowRehydratePriority(RehydratePriority rehydratePriority) {
+ String name = generateBlobName();
+ BlockBlobAsyncClient bc = ccAsync.getBlobAsyncClient(name).getBlockBlobAsyncClient();
+
+ Mono> rehydrate = Mono.empty();
+
+ if (rehydratePriority != null) {
+ rehydrate = bc.setAccessTier(AccessTier.ARCHIVE)
+ .then(bc.setAccessTierWithResponse(
+ new BlobSetAccessTierOptions(AccessTier.HOT).setPriority(rehydratePriority)));
+ }
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+
+ Flux response = bc.upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .then(rehydrate)
+ .thenMany(ccAsync.listBlobs(options, null));
+
+ StepVerifier.create(response)
+ .assertNext(r -> assertEquals(rehydratePriority, r.getProperties().getRehydratePriority()))
+ .verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowDeserializer() {
+ String blobName = generateBlobName();
+ Map metadata = new HashMap<>();
+ metadata.put("testkey", "testvalue");
+
+ BlockBlobAsyncClient bc = ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient();
+
+ AzureBlobStorageImpl impl = new AzureBlobStorageImplBuilder().pipeline(ccAsync.getHttpPipeline())
+ .url(ccAsync.getAccountUrl())
+ .version(BlobServiceVersion.getLatest().getVersion())
+ .buildClient();
+
+ List include = new ArrayList<>();
+ include.add(ListBlobsIncludeItem.METADATA);
+
+ Mono testMono
+ = bc.uploadWithResponse(DATA.getDefaultFlux(), 7, null, metadata, null, null, null)
+ .then(impl.getContainers()
+ .listBlobFlatSegmentApacheArrowWithResponseAsync(containerName, null, null, null, include, null,
+ null, null, null))
+ .flatMap(response -> {
+ // Verify Content-Type is Arrow
+ String contentType = response.getDeserializedHeaders().getContentType();
+ assertTrue(contentType.contains("application/vnd.apache.arrow.stream"),
+ "Expected Arrow content type but got: " + contentType);
+
+ // Collect the Flux body into a byte[] and feed it to the deserializer.
+ return FluxUtil.collectBytesInByteBufferStream(response.getValue())
+ .map(bytes -> ArrowBlobListDeserializer.deserialize(new ByteArrayInputStream(bytes)));
+ });
+
+ StepVerifier.create(testMono).assertNext(result -> {
+ // Verify pagination — single blob, no next page
+ assertNull(result.getNextMarker());
+
+ // Verify we got exactly one blob
+ assertEquals(1, result.getBlobItems().size());
+
+ BlobItemInternal item = result.getBlobItems().get(0);
+
+ // Name
+ assertNotNull(item.getName());
+ assertEquals(blobName, item.getName().getContent());
+
+ // Properties
+ assertNotNull(item.getProperties());
+ assertEquals(7L, (long) item.getProperties().getContentLength());
+ assertEquals("application/octet-stream", item.getProperties().getContentType());
+ assertNotNull(item.getProperties().getETag());
+ assertNotNull(item.getProperties().getLastModified());
+ assertNotNull(item.getProperties().getCreationTime());
+ assertEquals(BlobType.BLOCK_BLOB, item.getProperties().getBlobType());
+ assertEquals(AccessTier.HOT, item.getProperties().getAccessTier());
+ assertTrue(item.getProperties().isAccessTierInferred());
+ assertTrue(item.getProperties().isServerEncrypted());
+ assertEquals(LeaseStateType.AVAILABLE, item.getProperties().getLeaseState());
+ assertEquals(LeaseStatusType.UNLOCKED, item.getProperties().getLeaseStatus());
+ assertNotNull(item.getProperties().getContentMd5());
+
+ // Metadata
+ assertNotNull(item.getMetadata());
+ assertEquals("testvalue", item.getMetadata().get("testkey"));
+
+ // Verify ModelHelper can convert to public BlobItem
+ BlobItem publicItem = ModelHelper.populateBlobItem(item);
+ assertEquals(blobName, publicItem.getName());
+ assertEquals(7L, (long) publicItem.getProperties().getContentLength());
+ }).verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsByHierarchyArrowBasic() {
+ // Upload blobs in a directory structure
+ Flux uploads = Flux.concat(
+ ccAsync.getBlobAsyncClient("dir/blob1")
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()),
+ ccAsync.getBlobAsyncClient("dir/blob2")
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()),
+ ccAsync.getBlobAsyncClient("topblob")
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()));
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW);
+
+ Mono> items
+ = uploads.then(ccAsync.listBlobsByHierarchy("/", options).collect(Collectors.toList()));
+
+ StepVerifier.create(items).assertNext(list -> {
+ // Root level: one prefix "dir/" and one blob "topblob"
+ assertEquals(2, list.size());
+
+ BlobItem prefixItem = list.stream().filter(BlobItem::isPrefix).findFirst().orElse(null);
+ BlobItem blobItem = list.stream().filter(i -> !i.isPrefix()).findFirst().orElse(null);
+
+ assertNotNull(prefixItem);
+ assertEquals("dir/", prefixItem.getName());
+ assertTrue(prefixItem.isPrefix());
+
+ assertNotNull(blobItem);
+ assertEquals("topblob", blobItem.getName());
+ assertFalse(blobItem.isPrefix());
+ assertNotNull(blobItem.getProperties());
+ assertEquals(DATA.getDefaultDataSize(), blobItem.getProperties().getContentLength());
+ assertEquals(BlobType.BLOCK_BLOB, blobItem.getProperties().getBlobType());
+ assertNotNull(blobItem.getProperties().getLastModified());
+ assertNotNull(blobItem.getProperties().getETag());
+ }).verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsByHierarchyArrowWithMetadata() {
+ String blobName = generateBlobName();
+ Map metadata = new HashMap<>();
+ metadata.put("testkey", "testvalue");
+
+ Mono> uploads = ccAsync.getBlobAsyncClient("dir/" + blobName)
+ .getBlockBlobAsyncClient()
+ .uploadWithResponse(DATA.getDefaultFlux(), DATA.getDefaultDataSize(), null, metadata, null, null, null)
+ .then(ccAsync.getBlobAsyncClient("topblob")
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()));
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setPrefix("dir/")
+ .setDetails(new BlobListDetails().setRetrieveMetadata(true));
+
+ StepVerifier.create(uploads.thenMany(ccAsync.listBlobsByHierarchy("/", options))).assertNext(item -> {
+ assertFalse(item.isPrefix());
+ assertNotNull(item.getMetadata());
+ assertEquals("testvalue", item.getMetadata().get("testkey"));
+ }).verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsByHierarchyArrowPagination() {
+ // Upload blobs across multiple directories
+ Flux uploads = Flux.concat(
+ Flux.range(0, 3)
+ .flatMap(i -> ccAsync.getBlobAsyncClient("dir" + i + "/blob")
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())),
+ ccAsync.getBlobAsyncClient("topblob")
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()));
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setMaxResultsPerPage(1);
+
+ Mono> result
+ = uploads.then(ccAsync.listBlobsByHierarchy("/", options).byPage().doOnNext(page -> {
+ assertTrue(page.getValue().size() <= 1);
+ }).flatMap(page -> Flux.fromIterable(page.getValue())).collectList());
+
+ // 3 prefixes + 1 blob = 4 items
+ StepVerifier.create(result).assertNext(allItems -> assertEquals(4, allItems.size())).verifyComplete();
+ }
+
+ @Test
+ @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06")
+ public void listBlobsArrowWithTags() {
+ // Upload a blob and set tags
+ String blobName = generateBlobName();
+ BlockBlobAsyncClient bc = ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient();
+
+ Map tags = new HashMap<>();
+ tags.put("tagkey", "tagvalue");
+
+ ListBlobsOptions options
+ = new ListBlobsOptions().setStorageResponseSerializationFormat(StorageResponseSerializationFormat.ARROW)
+ .setDetails(new BlobListDetails().setRetrieveTags(true));
+
+ Mono> upload = bc.upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .then(ccAsync.getBlobAsyncClient(blobName).setTags(tags));
+
+ StepVerifier.create(upload.thenMany(ccAsync.listBlobs(options, null))).assertNext(item -> {
+ assertEquals(blobName, item.getName());
+ assertNotNull(item.getTags());
+ assertEquals("tagvalue", item.getTags().get("tagkey"));
+ }).verifyComplete();
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/ArrowBlobListDeserializerTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/ArrowBlobListDeserializerTests.java
new file mode 100644
index 000000000000..e23bf2cbfae0
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/ArrowBlobListDeserializerTests.java
@@ -0,0 +1,34 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.storage.blob.implementation.models.BlobListArrowParseException;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ArrowBlobListDeserializerTests {
+ @Test
+ public void parseNullStreamFailsFast() {
+ BlobListArrowParseException exception
+ = assertThrows(BlobListArrowParseException.class, () -> ArrowBlobListDeserializer.deserialize(null));
+
+ assertTrue(exception.getMessage().startsWith("ListBlobs Arrow parse failure:"));
+ }
+
+ @Test
+ public void parseInvalidPayloadFailsFast() {
+ ByteArrayInputStream invalidPayload
+ = new ByteArrayInputStream("not-an-arrow-stream".getBytes(StandardCharsets.UTF_8));
+
+ BlobListArrowParseException exception = assertThrows(BlobListArrowParseException.class,
+ () -> ArrowBlobListDeserializer.deserialize(invalidPayload));
+
+ assertTrue(exception.getMessage().startsWith("ListBlobs Arrow parse failure:"));
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobListArrowStreamReaderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobListArrowStreamReaderTests.java
new file mode 100644
index 000000000000..b03027eeb841
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobListArrowStreamReaderTests.java
@@ -0,0 +1,184 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.storage.blob.implementation.models.BlobItemInternal;
+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.FieldVector;
+import org.apache.arrow.vector.TimeStampSecVector;
+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.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.arrow.vector.util.Text;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Parity tests that build a real Arrow IPC payload with the official {@code arrow-vector} writer and validate that the
+ * internal {@link BlobListArrowStreamReader} / {@link ArrowBlobListDeserializer} decode it identically. This proves the
+ * custom reader has the same implementation as the Apache Arrow parser
+ */
+public class BlobListArrowStreamReaderTests {
+
+ @Test
+ public void parsesRealArrowPayload() throws Exception {
+ byte[] payload;
+ try (BufferAllocator allocator = new RootAllocator()) {
+ payload = buildPayload(allocator);
+ }
+
+ ArrowBlobListDeserializer.ArrowListBlobsResult result
+ = ArrowBlobListDeserializer.deserialize(new ByteArrayInputStream(payload));
+
+ // Schema metadata
+ assertEquals("nextPage", result.getNextMarker());
+ assertEquals(Integer.valueOf(2), result.getNumberOfRecords());
+
+ // Two rows: one blob, one prefix
+ List items = result.getBlobItems();
+ assertEquals(2, items.size());
+
+ BlobItemInternal blob = items.get(0);
+ assertNotNull(blob.getName());
+ assertEquals("blob1", blob.getName().getContent());
+ assertNull(blob.isPrefix());
+ assertEquals(Boolean.FALSE, blob.isDeleted());
+ assertNotNull(blob.getProperties());
+ assertEquals(7L, (long) blob.getProperties().getContentLength());
+ assertEquals("application/octet-stream", blob.getProperties().getContentType());
+ assertNotNull(blob.getProperties().getCreationTime());
+ assertEquals(1000L, blob.getProperties().getCreationTime().toEpochSecond());
+
+ Map metadata = blob.getMetadata();
+ assertNotNull(metadata);
+ assertEquals("v1", metadata.get("k1"));
+ assertEquals("v2", metadata.get("k2"));
+
+ BlobItemInternal prefix = items.get(1);
+ assertNotNull(prefix.getName());
+ assertEquals("dir/", prefix.getName().getContent());
+ assertTrue(prefix.isPrefix());
+ }
+
+ @Test
+ public void parsesEmptyMetadataAsNull() throws Exception {
+ byte[] payload;
+ try (BufferAllocator allocator = new RootAllocator()) {
+ payload = buildPayload(allocator);
+ }
+
+ ArrowBlobListDeserializer.ArrowListBlobsResult result
+ = ArrowBlobListDeserializer.deserialize(new ByteArrayInputStream(payload));
+ // Row 1 (prefix) had no metadata entries; ensure prefix path doesn't surface an (empty) metadata map.
+ assertNull(result.getBlobItems().get(1).getMetadata());
+ assertFalse(result.getBlobItems().isEmpty());
+ }
+
+ /**
+ * Builds an Arrow IPC stream with a representative ListBlobs schema: string, integer, boolean, second-precision
+ * timestamp, content-type string and a map<string,string> metadata column, plus schema-level NextMarker and
+ * NumberOfRecords metadata.
+ */
+ private static byte[] buildPayload(BufferAllocator allocator) throws Exception {
+ VarCharVector name = new VarCharVector("Name", allocator);
+ VarCharVector resourceType = new VarCharVector("ResourceType", allocator);
+ BigIntVector contentLength = new BigIntVector("Content-Length", allocator);
+ VarCharVector contentType = new VarCharVector("Content-Type", allocator);
+ BitVector deleted = new BitVector("Deleted", allocator);
+ TimeStampSecVector creationTime = new TimeStampSecVector("Creation-Time", allocator);
+ MapVector metadata = MapVector.empty("Metadata", allocator, false);
+
+ name.allocateNew();
+ resourceType.allocateNew();
+ contentLength.allocateNew();
+ contentType.allocateNew();
+ deleted.allocateNew();
+ creationTime.allocateNew();
+
+ // Row 0: a real blob.
+ name.setSafe(0, "blob1".getBytes(StandardCharsets.UTF_8));
+ // resourceType[0] left null -> not a prefix.
+ contentLength.setSafe(0, 7L);
+ contentType.setSafe(0, "application/octet-stream".getBytes(StandardCharsets.UTF_8));
+ deleted.setSafe(0, 0);
+ creationTime.setSafe(0, 1000L);
+
+ // Row 1: a virtual directory (prefix).
+ name.setSafe(1, "dir/".getBytes(StandardCharsets.UTF_8));
+ resourceType.setSafe(1, "blobprefix".getBytes(StandardCharsets.UTF_8));
+ // remaining columns null for the prefix row.
+
+ name.setValueCount(2);
+ resourceType.setValueCount(2);
+ contentLength.setValueCount(2);
+ contentType.setValueCount(2);
+ deleted.setValueCount(2);
+ creationTime.setValueCount(2);
+
+ UnionMapWriter mapWriter = metadata.getWriter();
+ mapWriter.setPosition(0);
+ mapWriter.startMap();
+ writeEntry(mapWriter, "k1", "v1");
+ writeEntry(mapWriter, "k2", "v2");
+ mapWriter.endMap();
+ // Row 1 metadata left null.
+ metadata.setValueCount(2);
+
+ List vectors = new ArrayList<>();
+ vectors.add(name);
+ vectors.add(resourceType);
+ vectors.add(contentLength);
+ vectors.add(contentType);
+ vectors.add(deleted);
+ vectors.add(creationTime);
+ vectors.add(metadata);
+
+ List fields = new ArrayList<>();
+ for (FieldVector vector : vectors) {
+ fields.add(vector.getField());
+ }
+
+ Map schemaMetadata = new LinkedHashMap<>();
+ schemaMetadata.put("NextMarker", "nextPage");
+ schemaMetadata.put("NumberOfRecords", "2");
+ Schema schema = new Schema(fields, schemaMetadata);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (VectorSchemaRoot root = new VectorSchemaRoot(schema, vectors, 2);
+ ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) {
+ writer.start();
+ writer.writeBatch();
+ writer.end();
+ }
+
+ return out.toByteArray();
+ }
+
+ private static void writeEntry(UnionMapWriter mapWriter, String key, String value) {
+ mapWriter.startEntry();
+ mapWriter.key().varChar().writeVarChar(new Text(key));
+ mapWriter.value().varChar().writeVarChar(new Text(value));
+ mapWriter.endEntry();
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md
index 98afe0c616dc..0019d026a0d0 100644
--- a/sdk/storage/azure-storage-blob/swagger/README.md
+++ b/sdk/storage/azure-storage-blob/swagger/README.md
@@ -16,7 +16,7 @@ autorest
### Code generation settings
``` yaml
use: '@autorest/java@4.1.63'
-input-file: https://raw.githubusercontent.com/seanmcc-msft/azure-rest-api-specs/eb29a830edf5db50758e7d044160c7f18077f7f7/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json
+input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/f85584d452061985a5fc21a67b8fc0b46b75188a/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json
java: true
output-folder: ../
namespace: com.azure.storage.blob
@@ -591,6 +591,24 @@ directive:
delete $["x-ms-pageable"];
```
+### Delete Container_ListBlobFlatSegment_ApacheArrow x-ms-pageable as response is raw Arrow stream
+``` yaml
+directive:
+- from: swagger-document
+ where: $["x-ms-paths"]["/{containerName}?restype=container&comp=list&flat&arrow"].get
+ transform: >
+ delete $["x-ms-pageable"];
+```
+
+### Delete Container_ListBlobHierarchySegment_ApacheArrow x-ms-pageable as response is raw Arrow stream
+``` yaml
+directive:
+- from: swagger-document
+ where: $["x-ms-paths"]["/{containerName}?restype=container&comp=list&hierarchy&arrow"].get
+ transform: >
+ delete $["x-ms-pageable"];
+```
+
### BlobDeleteType expandable string enum
``` yaml
directive:
@@ -708,4 +726,3 @@ directive:
];
```
-
diff --git a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/TestEnvironment.java b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/TestEnvironment.java
index 5d9bc1c9dfac..6134a8dbf9ee 100644
--- a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/TestEnvironment.java
+++ b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/TestEnvironment.java
@@ -107,8 +107,8 @@ private static TestAccount readTestAccountFromEnvironment(String prefix, TestMod
+ "AccountKey=%s;EndpointSuffix=core.windows.net", name, key);
}
}
- String blobEndpoint = String.format(SCHEME + "://%s.blob.core.windows.net", name);
- String blobEndpointSecondary = String.format(SCHEME + "://%s-secondary.blob.core.windows.net", name);
+ String blobEndpoint = String.format(SCHEME + "://%s." + "blob."+ "preprod." +"core.windows.net", name);
+ String blobEndpointSecondary = String.format(SCHEME + "://%s-secondary." + "preprod." +"core.windows.net", name);
String dataLakeEndpoint = String.format(SCHEME + "://%s.dfs.core.windows.net", name);
String queueEndpoint = String.format(SCHEME + "://%s.queue.core.windows.net", name);
String fileEndpoint = String.format(SCHEME + "://%s.file.core.windows.net", name);