This is an automated email from the ASF dual-hosted git repository.
clintropolis pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new d11b956bd6c feat: google cloud storage SegmentRangeReader for partial
loads (#20454)
d11b956bd6c is described below
commit d11b956bd6c39472c0225a4c17497a4224e0670d
Author: Clint Wylie <[email protected]>
AuthorDate: Wed Sep 30 20:41:01 2026 -0700
feat: google cloud storage SegmentRangeReader for partial loads (#20454)
---
extensions-core/azure-extensions/pom.xml | 7 +
.../storage/azure/AzureDataSegmentPusherTest.java | 55 +++--
.../storage/google/GoogleDataSegmentPusher.java | 20 +-
.../druid/storage/google/GoogleLoadSpec.java | 41 ++++
.../storage/google/GoogleSegmentRangeReader.java | 132 ++++++++++++
.../google/GoogleDataSegmentPusherTest.java | 96 ++++++++-
.../druid/storage/google/GoogleLoadSpecTest.java | 91 +++++++++
.../google/GoogleSegmentRangeReaderTest.java | 223 +++++++++++++++++++++
.../druid/storage/s3/S3DataSegmentPusherTest.java | 58 +++++-
9 files changed, 700 insertions(+), 23 deletions(-)
diff --git a/extensions-core/azure-extensions/pom.xml
b/extensions-core/azure-extensions/pom.xml
index 4147e7f8ce2..dd91b981a3c 100644
--- a/extensions-core/azure-extensions/pom.xml
+++ b/extensions-core/azure-extensions/pom.xml
@@ -132,6 +132,13 @@
<scope>provided</scope>
</dependency>
<!-- Tests -->
+ <dependency>
+ <groupId>org.apache.druid</groupId>
+ <artifactId>druid-processing</artifactId>
+ <version>${project.parent.version}</version>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
diff --git
a/extensions-core/azure-extensions/src/test/java/org/apache/druid/storage/azure/AzureDataSegmentPusherTest.java
b/extensions-core/azure-extensions/src/test/java/org/apache/druid/storage/azure/AzureDataSegmentPusherTest.java
index 6bc3f316647..d8a32f634e1 100644
---
a/extensions-core/azure-extensions/src/test/java/org/apache/druid/storage/azure/AzureDataSegmentPusherTest.java
+++
b/extensions-core/azure-extensions/src/test/java/org/apache/druid/storage/azure/AzureDataSegmentPusherTest.java
@@ -24,9 +24,13 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.Files;
+import org.apache.druid.data.input.MapBasedInputRow;
+import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.MapUtils;
import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.segment.IndexBuilder;
+import org.apache.druid.segment.IndexIO;
import org.apache.druid.segment.loading.DeepStorageSegmentConfig;
import org.apache.druid.timeline.DataSegment;
import org.apache.druid.timeline.partition.LinearShardSpec;
@@ -47,6 +51,7 @@ import java.util.regex.Pattern;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -292,30 +297,56 @@ public class AzureDataSegmentPusherTest extends
EasyMockSupport
AzureDataSegmentPusher pusher =
new AzureDataSegmentPusher(azureStorage, azureAccountConfig,
segmentConfigWithPrefix, NO_ZIP_CONFIG);
- // version.bin = [0, 0, 0, 0x0A] → IndexIO.V10_VERSION, a single
range-readable druid.segment
- Files.write(new byte[]{0x0, 0x0, 0x0, 0x0A},
tempPath.resolve("version.bin").toFile());
+ // A real V10 segment rather than a hand-made file, so the layout under
test is whatever IndexMergerV10 actually
+ // writes: one druid.segment whose leading byte is the version
getVersionFromDir reads.
+ final File segmentDir = buildV10Segment(tempPath);
+ final File[] segmentFiles = segmentDir.listFiles();
+ assertNotNull(segmentFiles);
final String expectedDir = PREFIX + "/" +
pusher.getStorageDir(SEGMENT_TO_PUSH, false);
- azureStorage.uploadBlockBlob(
- EasyMock.anyObject(File.class),
- EasyMock.eq(CONTAINER_NAME),
- EasyMock.eq(expectedDir + "/version.bin"),
- EasyMock.eq(MAX_TRIES)
- );
- EasyMock.expectLastCall();
+ final ImmutableList.Builder<String> listing = ImmutableList.builder();
+ for (final File file : segmentFiles) {
+ azureStorage.uploadBlockBlob(
+ EasyMock.anyObject(File.class),
+ EasyMock.eq(CONTAINER_NAME),
+ EasyMock.eq(expectedDir + "/" + file.getName()),
+ EasyMock.eq(MAX_TRIES)
+ );
+ EasyMock.expectLastCall();
+ listing.add(expectedDir + "/" + file.getName());
+ }
+ // everything under the path was written by this push, so there is nothing
to clean up
EasyMock.expect(azureStorage.listBlobs(CONTAINER_NAME, expectedDir + "/",
null, MAX_TRIES))
- .andReturn(ImmutableList.of(expectedDir + "/version.bin"));
+ .andReturn(listing.build());
replayAll();
- DataSegment segment = pusher.push(tempPath.toFile(), SEGMENT_TO_PUSH,
false);
+ DataSegment segment = pusher.push(segmentDir, SEGMENT_TO_PUSH, false);
- assertEquals(10, (int) segment.getBinaryVersion());
+ // a V10 segment is a single file, and that is what makes it range-readable
+ assertEquals(1, segmentFiles.length);
+ assertEquals(IndexIO.V10_FILE_NAME, segmentFiles[0].getName());
+ assertEquals(IndexIO.V10_VERSION, (int) segment.getBinaryVersion());
assertEquals(Boolean.TRUE, segment.getLoadSpec().get("rangeable"));
verifyAll();
}
+ private static File buildV10Segment(Path tempPath)
+ {
+ return IndexBuilder.create()
+ .useV10()
+ .tmpDir(tempPath.resolve("v10").toFile())
+ .rows(ImmutableList.of(
+ new MapBasedInputRow(
+ DateTimes.of("2015-01-01"),
+ ImmutableList.of("dim"),
+ ImmutableMap.of("dim", "a")
+ )
+ ))
+ .buildMMappedIndexFile();
+ }
+
@Test
public void test_pushZip_doesNotStampRangeable(@TempDir Path tempPath)
throws Exception
{
diff --git
a/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleDataSegmentPusher.java
b/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleDataSegmentPusher.java
index 144e43a1331..f98c2fa3f22 100644
---
a/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleDataSegmentPusher.java
+++
b/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleDataSegmentPusher.java
@@ -28,6 +28,7 @@ import org.apache.druid.java.util.common.IOE;
import org.apache.druid.java.util.common.RE;
import org.apache.druid.java.util.common.RetryUtils;
import org.apache.druid.java.util.common.logger.Logger;
+import org.apache.druid.segment.IndexIO;
import org.apache.druid.segment.SegmentUtils;
import org.apache.druid.segment.loading.DataSegmentPusher;
import org.apache.druid.segment.loading.DeepStorageSegmentConfig;
@@ -181,8 +182,11 @@ public class GoogleDataSegmentPusher implements
DataSegmentPusher
deleteStaleObjects(dirPath, pushedPaths);
+ // V10 unzipped is rangeable: a single druid.segment with a range-readable
header. V9 unzipped is a directory of
+ // separate smoosh files the range-read path can't consume.
+ final boolean rangeable = version == IndexIO.V10_VERSION;
return segment.withSize(size)
- .withLoadSpec(makeLoadSpec(config.getBucket(), dirPath))
+ .withLoadSpec(makeLoadSpec(config.getBucket(), dirPath,
rangeable))
.withBinaryVersion(version);
}
@@ -246,4 +250,18 @@ public class GoogleDataSegmentPusher implements
DataSegmentPusher
);
}
+ /**
+ * Variant that stamps {@link GoogleLoadSpec#RANGEABLE} so {@link
GoogleLoadSpec#openRangeReader()} can decide
+ * range-read eligibility. Used by the unzipped push path where the binary
version is known at write time.
+ */
+ private Map<String, Object> makeLoadSpec(String bucket, String path, boolean
rangeable)
+ {
+ return ImmutableMap.of(
+ "type", GoogleStorageDruidModule.SCHEME,
+ "bucket", bucket,
+ "path", path,
+ GoogleLoadSpec.RANGEABLE, rangeable
+ );
+ }
+
}
diff --git
a/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleLoadSpec.java
b/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleLoadSpec.java
index 4629762a7ba..3a3d95a3f4f 100644
---
a/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleLoadSpec.java
+++
b/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleLoadSpec.java
@@ -21,29 +21,43 @@ package org.apache.druid.storage.google;
import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.google.common.base.Preconditions;
import org.apache.druid.segment.loading.LoadSpec;
import org.apache.druid.segment.loading.SegmentLoadingException;
+import org.apache.druid.segment.loading.SegmentRangeReader;
+import org.apache.druid.utils.CompressionUtils;
+import javax.annotation.Nullable;
import java.io.File;
@JsonTypeName(GoogleStorageDruidModule.SCHEME)
public class GoogleLoadSpec implements LoadSpec
{
+ static final String RANGEABLE = "rangeable";
+
@JsonProperty
private final String bucket;
@JsonProperty
private final String path;
+ /**
+ * Stamped at push time when {@link GoogleDataSegmentPusher} writes a
segment in a layout that supports byte-range
+ * reads. Only {@code Boolean.TRUE} enables {@link #openRangeReader()};
absence or {@code false} means full-download.
+ */
+ @Nullable
+ private final Boolean rangeable;
+
private final GoogleDataSegmentPuller puller;
@JsonCreator
public GoogleLoadSpec(
@JsonProperty("bucket") String bucket,
@JsonProperty("path") String path,
+ @JsonProperty(RANGEABLE) @Nullable Boolean rangeable,
@JacksonInject GoogleDataSegmentPuller puller
)
{
@@ -51,6 +65,7 @@ public class GoogleLoadSpec implements LoadSpec
Preconditions.checkNotNull(path);
this.bucket = bucket;
this.path = path;
+ this.rangeable = rangeable;
this.puller = puller;
}
@@ -59,4 +74,30 @@ public class GoogleLoadSpec implements LoadSpec
{
return new LoadSpecResult(puller.getSegmentFiles(bucket, path,
file).size());
}
+
+ /**
+ * Returns a {@link GoogleSegmentRangeReader} when the segment was stamped
{@link #rangeable} {@code true} at push
+ * time and isn't a zip; otherwise {@code null}.
+ */
+ @Nullable
+ @Override
+ public SegmentRangeReader openRangeReader()
+ {
+ if (CompressionUtils.isZip(path) || !Boolean.TRUE.equals(rangeable)) {
+ return null;
+ }
+ return new GoogleSegmentRangeReader(puller.storage, bucket, path);
+ }
+
+ /**
+ * Returns the range-reads-supported flag stamped at push time, or {@code
null} for legacy segments pushed before
+ * this field existed (which will load via the full-download path).
+ */
+ @JsonProperty(RANGEABLE)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @Nullable
+ public Boolean getRangeable()
+ {
+ return rangeable;
+ }
}
diff --git
a/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleSegmentRangeReader.java
b/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleSegmentRangeReader.java
new file mode 100644
index 00000000000..a42311ff01f
--- /dev/null
+++
b/extensions-core/google-extensions/src/main/java/org/apache/druid/storage/google/GoogleSegmentRangeReader.java
@@ -0,0 +1,132 @@
+/*
+ * 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.druid.storage.google;
+
+import com.google.common.base.Preconditions;
+import org.apache.druid.data.input.impl.RetryingInputStream;
+import org.apache.druid.data.input.impl.prefetch.ObjectOpenFunction;
+import org.apache.druid.segment.loading.SegmentRangeReader;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * {@link SegmentRangeReader} backed by Google Cloud Storage range reads. The
segment is expected to be stored as raw
+ * (unzipped) objects under a common path, i.e. the layout produced by {@code
GoogleDataSegmentPusher.pushNoZip} where
+ * each segment file is uploaded as {@code pathPrefix + file.getName()}. Each
{@link #readRange} call resolves the
+ * target object as {@code pathPrefix + filename} and opens a read channel
limited to {@code [offset, offset + length)}.
+ * <p>
+ * The returned stream is wrapped in a {@link RetryingInputStream} with the
{@link GoogleUtils#GOOGLE_RETRY} predicate,
+ * the same retry policy {@link GoogleDataSegmentPuller} uses for full-segment
downloads. The GCS client retries a
+ * failed request on its own, but only the {@code RetryingInputStream} can
resume a read that failed partway through:
+ * it reopens at the byte offset already consumed, so a transient mid-stream
error becomes a fresh range read for the
+ * remaining bytes rather than a restart of the whole range.
+ */
+public class GoogleSegmentRangeReader implements SegmentRangeReader
+{
+ private final GoogleStorage storage;
+ private final String bucket;
+ private final String pathPrefix;
+
+ public GoogleSegmentRangeReader(GoogleStorage storage, String bucket, String
pathPrefix)
+ {
+ this.storage = Preconditions.checkNotNull(storage, "storage");
+ this.bucket = Preconditions.checkNotNull(bucket, "bucket");
+ this.pathPrefix = Preconditions.checkNotNull(pathPrefix, "pathPrefix");
+ }
+
+ @Override
+ public InputStream readRange(String filename, long offset, long length)
throws IOException
+ {
+ Preconditions.checkNotNull(filename, "filename");
+ Preconditions.checkArgument(offset >= 0, "offset must be non-negative, got
[%s]", offset);
+ Preconditions.checkArgument(length >= 0, "length must be non-negative, got
[%s]", length);
+
+ if (length == 0) {
+ // SegmentFileBuilderV10 allows zero-length internal-file entries,
short-circuit
+ return new ByteArrayInputStream(new byte[0]);
+ }
+ return new RetryingInputStream<>(
+ new RangeRequest(pathPrefix + filename, offset, length),
+ new RangeOpenFunction(storage, bucket),
+ GoogleUtils.GOOGLE_RETRY,
+ null
+ );
+ }
+
+ /**
+ * Immutable description of a range read. Held as the {@code object} of
{@link RetryingInputStream} so retries can
+ * reopen with knowledge of the original offset and length without
rebuilding the request from scratch.
+ */
+ private static final class RangeRequest
+ {
+ final String objectPath;
+ final long offset;
+ final long length;
+
+ RangeRequest(String objectPath, long offset, long length)
+ {
+ this.objectPath = objectPath;
+ this.offset = offset;
+ this.length = length;
+ }
+ }
+
+ /**
+ * Opens (or reopens, on retry) a GCS range read for a {@link RangeRequest}.
The {@code start} argument is the number
+ * of bytes already successfully consumed from the logical stream, so the
range still owed is
+ * {@code [request.offset + start, request.offset + request.length)}.
+ */
+ private static final class RangeOpenFunction implements
ObjectOpenFunction<RangeRequest>
+ {
+ private final GoogleStorage storage;
+ private final String bucket;
+
+ RangeOpenFunction(GoogleStorage storage, String bucket)
+ {
+ this.storage = storage;
+ this.bucket = bucket;
+ }
+
+ @Override
+ public InputStream open(RangeRequest request) throws IOException
+ {
+ return open(request, 0L);
+ }
+
+ @Override
+ public InputStream open(RangeRequest request, long start) throws
IOException
+ {
+ final long remaining = request.length - start;
+ if (remaining <= 0) {
+ // Logically nothing left to read, only reachable if a retry fires
after the consumer drained the entire
+ // range successfully, which shouldn't happen in practice. Returning
empty keeps us robust either way.
+ return new ByteArrayInputStream(new byte[0]);
+ }
+ // A StorageException is deliberately left to propagate as it is.
GoogleUtils.isRetryable inspects only the
+ // throwable it is handed (it does not walk the cause chain) and answers
`instanceof IOException` for anything
+ // it does not recognize, so wrapping this would report every failure as
retryable and spend ten backing-off
+ // attempts on a permission error. Raw, the predicate calls
StorageException#isRetryable and decides correctly;
+ // RetryingInputStream hands the caller an IOException around it either
way.
+ return storage.getInputStream(bucket, request.objectPath, request.offset
+ start, remaining);
+ }
+ }
+}
diff --git
a/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleDataSegmentPusherTest.java
b/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleDataSegmentPusherTest.java
index 012c4c3c1c8..8d9e7af6e69 100644
---
a/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleDataSegmentPusherTest.java
+++
b/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleDataSegmentPusherTest.java
@@ -22,7 +22,11 @@ package org.apache.druid.storage.google;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Files;
+import org.apache.druid.data.input.MapBasedInputRow;
+import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.segment.IndexBuilder;
+import org.apache.druid.segment.IndexIO;
import org.apache.druid.segment.loading.DeepStorageSegmentConfig;
import org.apache.druid.timeline.DataSegment;
import org.apache.druid.timeline.SegmentId;
@@ -148,17 +152,105 @@ public class GoogleDataSegmentPusherTest extends
EasyMockSupport
DataSegment segment = pusher.push(tempFolder, segmentToPush, false);
- // the trailing slash is what marks the path as a directory of files
rather than a single object
+ // the trailing slash is what marks the path as a directory of files
rather than a single object, and V1 (the test
+ // fixture) is not V10, so rangeable is stamped false
Assertions.assertEquals(ImmutableMap.of(
"type", GoogleStorageDruidModule.SCHEME,
"bucket", BUCKET,
- "path", expectedDir + "/"
+ "path", expectedDir + "/",
+ "rangeable", false
), segment.getLoadSpec());
Assertions.assertEquals(2 * data.length, segment.getSize());
verifyAll();
}
+ @Test
+ public void testPushNoZipV10StampsRangeableTrue() throws Exception
+ {
+ // A real V10 segment rather than a hand-made file, so the layout under
test is whatever IndexMergerV10 actually
+ // writes: one druid.segment whose leading byte is the version
getVersionFromDir reads.
+ final File segmentDir = IndexBuilder.create()
+ .useV10()
+ .tmpDir(new File(tempFolder, "v10"))
+ .rows(ImmutableList.of(
+ new MapBasedInputRow(
+ DateTimes.of("2015-01-01"),
+ ImmutableList.of("dim"),
+ ImmutableMap.of("dim", "a")
+ )
+ ))
+ .buildMMappedIndexFile();
+
+ final File[] segmentFiles = segmentDir.listFiles();
+ Assertions.assertNotNull(segmentFiles);
+ long segmentSize = 0;
+ for (final File file : segmentFiles) {
+ segmentSize += file.length();
+ }
+
+ DataSegment segmentToPush = newSegmentToPush(segmentSize);
+
+ GoogleDataSegmentPusher pusher =
createMockBuilder(GoogleDataSegmentPusher.class)
+ .withConstructor(storage, googleAccountConfig, INPUT_DATA_CONFIG,
NO_ZIP_CONFIG)
+ .addMockedMethod("insert", File.class, String.class, String.class)
+ .createMock();
+
+ final String expectedDir = PREFIX + "/" +
pusher.getStorageDir(segmentToPush, false);
+ final ImmutableList.Builder<GoogleStorageObjectMetadata> listing =
ImmutableList.builder();
+ for (final File file : segmentFiles) {
+ pusher.insert(
+ EasyMock.anyObject(File.class),
+ EasyMock.anyString(),
+ EasyMock.eq(expectedDir + "/" + file.getName())
+ );
+ EasyMock.expectLastCall();
+ listing.add(new GoogleStorageObjectMetadata(BUCKET, expectedDir + "/" +
file.getName(), file.length(), 0L));
+ }
+ // everything under the path was written by this push, so there is nothing
to clean up
+ EasyMock.expect(storage.list(EasyMock.eq(BUCKET), EasyMock.eq(expectedDir
+ "/"), EasyMock.anyObject(), EasyMock.anyObject()))
+ .andReturn(new GoogleStorageObjectPage(listing.build(), null));
+
+ replayAll();
+
+ DataSegment segment = pusher.push(segmentDir, segmentToPush, false);
+
+ // a V10 segment is a single file, and that is what makes it range-readable
+ Assertions.assertEquals(1, segmentFiles.length);
+ Assertions.assertEquals(IndexIO.V10_FILE_NAME, segmentFiles[0].getName());
+ Assertions.assertEquals(IndexIO.V10_VERSION, (int)
segment.getBinaryVersion());
+ Assertions.assertEquals(Boolean.TRUE,
segment.getLoadSpec().get("rangeable"));
+
+ verifyAll();
+ }
+
+ @Test
+ public void testPushZipDoesNotStampRangeable() throws Exception
+ {
+ // The zip path uses the no-flag makeLoadSpec overload; openRangeReader
returns null on the zip short circuit
+ // regardless, but the loadSpec stays compact for zipped segments by
omitting the field entirely.
+ final byte[] data = new byte[]{0x0, 0x0, 0x0, 0x1};
+ Files.write(data, new File(tempFolder, "version.bin"));
+
+ DataSegment segmentToPush = newSegmentToPush(data.length);
+
+ GoogleDataSegmentPusher pusher =
createMockBuilder(GoogleDataSegmentPusher.class)
+ .withConstructor(storage, googleAccountConfig, INPUT_DATA_CONFIG,
ZIP_CONFIG)
+ .addMockedMethod("insert", File.class, String.class, String.class)
+ .createMock();
+
+ pusher.insert(EasyMock.anyObject(File.class),
EasyMock.eq("application/zip"), EasyMock.anyString());
+ EasyMock.expectLastCall();
+
+ replayAll();
+
+ DataSegment segment = pusher.push(tempFolder, segmentToPush, false);
+
+ Assertions.assertFalse(segment.getLoadSpec().containsKey("rangeable"));
+
+ verifyAll();
+ }
+
@Test
public void testPushNoZipRemovesObjectsLeftByPreviousPush() throws Exception
{
diff --git
a/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleLoadSpecTest.java
b/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleLoadSpecTest.java
new file mode 100644
index 00000000000..2bd2747cc73
--- /dev/null
+++
b/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleLoadSpecTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.druid.storage.google;
+
+import org.apache.druid.segment.loading.SegmentRangeReader;
+import org.easymock.EasyMockSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class GoogleLoadSpecTest extends EasyMockSupport
+{
+ private static final String BUCKET = "test-bucket";
+ private static final String RAW_PATH = "path/to/segment/";
+ private static final String ZIP_PATH = "path/to/index.zip";
+
+ private GoogleStorage storage;
+
+ @BeforeEach
+ public void setUp()
+ {
+ storage = createMock(GoogleStorage.class);
+ // opening a range reader must not touch storage, so any call fails these
tests
+ replayAll();
+ }
+
+ @Test
+ public void testOpenRangeReaderReturnsReaderWhenRangeableTrue()
+ {
+ final SegmentRangeReader reader = newLoadSpec(RAW_PATH,
true).openRangeReader();
+ assertNotNull(reader);
+ assertInstanceOf(GoogleSegmentRangeReader.class, reader);
+ verifyAll();
+ }
+
+ @Test
+ public void testOpenRangeReaderReturnsNullForZipPathEvenWhenRangeableTrue()
+ {
+ // Defensive: a zip object can't be range-read; the zip check wins over
the flag even if hand-crafted input claims
+ // the layout is rangeable.
+ assertNull(newLoadSpec(ZIP_PATH, true).openRangeReader());
+ verifyAll();
+ }
+
+ @Test
+ public void testOpenRangeReaderReturnsNullWhenRangeableFalse()
+ {
+ assertNull(newLoadSpec(RAW_PATH, false).openRangeReader());
+ verifyAll();
+ }
+
+ @Test
+ public void testOpenRangeReaderReturnsNullForLegacySegmentWithoutFlag()
+ {
+ // Legacy segment (pushed before this field existed) → null flag →
full-download path.
+ assertNull(newLoadSpec(RAW_PATH, null).openRangeReader());
+ verifyAll();
+ }
+
+ private GoogleLoadSpec newLoadSpec(String path, @Nullable Boolean rangeable)
+ {
+ return new GoogleLoadSpec(
+ BUCKET,
+ path,
+ rangeable,
+ new GoogleDataSegmentPuller(storage, new GoogleInputDataConfig())
+ );
+ }
+}
diff --git
a/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleSegmentRangeReaderTest.java
b/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleSegmentRangeReaderTest.java
new file mode 100644
index 00000000000..e35078cef5a
--- /dev/null
+++
b/extensions-core/google-extensions/src/test/java/org/apache/druid/storage/google/GoogleSegmentRangeReaderTest.java
@@ -0,0 +1,223 @@
+/*
+ * 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.druid.storage.google;
+
+import com.google.cloud.storage.StorageException;
+import com.google.common.io.ByteStreams;
+import org.apache.druid.segment.loading.SegmentRangeReader;
+import org.easymock.Capture;
+import org.easymock.CaptureType;
+import org.easymock.EasyMock;
+import org.easymock.EasyMockSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class GoogleSegmentRangeReaderTest extends EasyMockSupport
+{
+ private static final String BUCKET = "test-bucket";
+ private static final String PATH_PREFIX =
"ds/2024-01-01T00:00:00.000Z_2024-01-02T00:00:00.000Z/0/0/";
+
+ private GoogleStorage storage;
+ private GoogleSegmentRangeReader reader;
+
+ @BeforeEach
+ public void setUp()
+ {
+ storage = createMock(GoogleStorage.class);
+ reader = new GoogleSegmentRangeReader(storage, BUCKET, PATH_PREFIX);
+ }
+
+ @Test
+ public void testReadRangeOpensPathPrefixPlusFilenameAtOffsetAndLength()
throws IOException
+ {
+ EasyMock.expect(storage.getInputStream(BUCKET, PATH_PREFIX +
"druid.segment", 100L, 250L))
+ .andReturn(new ByteArrayInputStream(new byte[0]));
+
+ replayAll();
+
+ try (InputStream ignored = reader.readRange("druid.segment", 100, 250)) {
+ // open is performed in the RetryingInputStream constructor; the read
channel should have already been opened.
+ }
+
+ verifyAll();
+ }
+
+ @Test
+ public void testReadRangeBuildsDifferentPathsForDifferentFilenames() throws
IOException
+ {
+ final Capture<String> pathCapture = Capture.newInstance(CaptureType.ALL);
+ EasyMock.expect(storage.getInputStream(EasyMock.eq(BUCKET),
EasyMock.capture(pathCapture), EasyMock.eq(0L), EasyMock.eq(16L)))
+ .andReturn(new ByteArrayInputStream(new byte[0]))
+ .times(2);
+
+ replayAll();
+
+ reader.readRange("file-a", 0, 16).close();
+ reader.readRange("file-b", 0, 16).close();
+
+ verifyAll();
+
+ assertEquals(PATH_PREFIX + "file-a", pathCapture.getValues().get(0));
+ assertEquals(PATH_PREFIX + "file-b", pathCapture.getValues().get(1));
+ }
+
+ @Test
+ public void testReadRangeFailsFastOnNonRetryableStorageException() throws
IOException
+ {
+ // 403 isn't retryable per StorageException#isRetryable, so open fails
once and RetryingInputStream surfaces it as
+ // IOException(StorageException). The exception must reach the predicate
unwrapped for that to hold:
+ // GoogleUtils.isRetryable inspects only the throwable it is handed and
answers `instanceof IOException` for
+ // anything it doesn't recognize, so an IOException wrapper here would
report a permission error as retryable and
+ // spend ten backing-off attempts on it.
+ EasyMock.expect(storage.getInputStream(BUCKET, PATH_PREFIX + "f", 0L, 1L))
+ .andThrow(new StorageException(403, "denied"));
+
+ replayAll();
+
+ final IOException thrown = assertThrows(IOException.class, () ->
reader.readRange("f", 0, 1));
+ assertSame(StorageException.class, thrown.getCause().getClass());
+
+ verifyAll();
+ }
+
+ @Test
+ public void testReadRangeRetriesMidStreamFromBytesAlreadyConsumed() throws
IOException
+ {
+ // First range read delivers the first 4 bytes then errors mid-stream with
a retryable IOException.
+ // RetryingInputStream should reopen for the bytes still owed, exercising
the offset math in
+ // RangeOpenFunction.open(request, start). NOTE: this test sleeps ~1s
because RetryingInputStream's first retry
+ // uses RetryUtils.BASE_SLEEP_MILLIS exponential backoff (no
@VisibleForTesting hook is accessible from here).
+ final byte[] firstChunk = {0x01, 0x02, 0x03, 0x04};
+ final byte[] secondChunk = {0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
+ final byte[] all = new byte[firstChunk.length + secondChunk.length];
+ System.arraycopy(firstChunk, 0, all, 0, firstChunk.length);
+ System.arraycopy(secondChunk, 0, all, firstChunk.length,
secondChunk.length);
+
+ // First: the full requested range
+ EasyMock.expect(storage.getInputStream(BUCKET, PATH_PREFIX + "f", 100L,
10L))
+ .andReturn(failingAfter(firstChunk));
+ // Retry: resume at (offset + bytes already consumed), asking only for
what is still owed
+ EasyMock.expect(storage.getInputStream(BUCKET, PATH_PREFIX + "f", 104L,
6L))
+ .andReturn(new ByteArrayInputStream(secondChunk));
+
+ replayAll();
+
+ final byte[] read;
+ try (InputStream stream = reader.readRange("f", 100, 10)) {
+ read = ByteStreams.toByteArray(stream);
+ }
+ assertArrayEquals(all, read);
+
+ verifyAll();
+ }
+
+ @Test
+ public void
testReadRangeReturnsEmptyStreamForZeroLengthWithoutContactingStorage() throws
IOException
+ {
+ // SegmentFileBuilderV10 allows zero-length internal-file entries;
readRange must accept length=0 and return an
+ // empty stream without opening a read channel. Replaying with no
expectations fails the test on any call.
+ replayAll();
+
+ try (InputStream stream = reader.readRange("f", 100, 0)) {
+ assertEquals(-1, stream.read());
+ }
+
+ verifyAll();
+ }
+
+ @Test
+ public void testReadRangeRejectsNegativeOffset()
+ {
+ replayAll();
+ assertThrows(IllegalArgumentException.class, () -> reader.readRange("f",
-1, 16));
+ verifyAll();
+ }
+
+ @Test
+ public void testReadRangeRejectsNegativeLength()
+ {
+ replayAll();
+ assertThrows(IllegalArgumentException.class, () -> reader.readRange("f",
0, -1));
+ verifyAll();
+ }
+
+ @Test
+ public void testRetryPredicateCannotSeeThroughAnIOExceptionWrapper()
+ {
+ // Guards the reason RangeOpenFunction hands StorageException to
RetryingInputStream unwrapped. If isRetryable ever
+ // starts walking the cause chain, wrapping becomes safe and the comment
at that throw site should be revisited.
+ final StorageException nonRetryable = new StorageException(403, "denied");
+ assertFalse(GoogleUtils.isRetryable(nonRetryable));
+ assertTrue(GoogleUtils.isRetryable(new IOException(nonRetryable)));
+ }
+
+ @Test
+ public void testImplementsSegmentRangeReader()
+ {
+ // Compile-time and runtime guard: ensure the implements relationship
survives refactors so callers can rely on
+ // returning GoogleSegmentRangeReader from openRangeReader().
+ final SegmentRangeReader downcast = reader;
+ assertSame(reader, downcast);
+ }
+
+ /**
+ * Returns a stream that delivers the given {@code head} bytes successfully,
then raises a bare {@link IOException}
+ * (no cause) on the next read — which {@link GoogleUtils#isRetryable}
treats as retryable, so
+ * {@link org.apache.druid.data.input.impl.RetryingInputStream} will close
this delegate and call the open function
+ * again with {@code start = head.length}.
+ */
+ private static InputStream failingAfter(byte[] head)
+ {
+ return new FilterInputStream(new ByteArrayInputStream(head))
+ {
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException
+ {
+ final int read = super.read(b, off, len);
+ if (read < 0) {
+ throw new IOException("mid-stream failure");
+ }
+ return read;
+ }
+
+ @Override
+ public int read() throws IOException
+ {
+ final int read = super.read();
+ if (read < 0) {
+ throw new IOException("mid-stream failure");
+ }
+ return read;
+ }
+ };
+ }
+}
diff --git
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3DataSegmentPusherTest.java
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3DataSegmentPusherTest.java
index 5e89f5a3897..c92f28c40f0 100644
---
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3DataSegmentPusherTest.java
+++
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/S3DataSegmentPusherTest.java
@@ -19,11 +19,17 @@
package org.apache.druid.storage.s3;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.Files;
+import org.apache.druid.data.input.MapBasedInputRow;
import org.apache.druid.error.DruidException;
+import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.segment.IndexBuilder;
+import org.apache.druid.segment.IndexIO;
import org.apache.druid.segment.loading.DeepStorageSegmentConfig;
import org.apache.druid.timeline.DataSegment;
import org.apache.druid.timeline.partition.NoneShardSpec;
@@ -156,16 +162,41 @@ public class S3DataSegmentPusherTest
config.setBucket("bucket");
config.setBaseKey("key");
- // version.bin = [0, 0, 0, 0x0A] → IndexIO.V10_VERSION
+ // A real V10 segment rather than a hand-made file, so the layout under
test is whatever IndexMergerV10 actually
+ // writes: one druid.segment whose leading byte is the version
getVersionFromDir reads.
+ final File segmentDir = IndexBuilder.create()
+ .useV10()
+ .tmpDir(new File(tempFolder, "v10"))
+ .rows(ImmutableList.of(
+ new MapBasedInputRow(
+ DateTimes.of("2015-01-01"),
+ ImmutableList.of("dim"),
+ ImmutableMap.of("dim", "a")
+ )
+ ))
+ .buildMMappedIndexFile();
+
+ final File[] segmentFiles = segmentDir.listFiles();
+ Assertions.assertNotNull(segmentFiles);
+ long segmentSize = 0;
+ for (final File file : segmentFiles) {
+ segmentSize += file.length();
+ }
+
DataSegment segment = validate(
false,
"key/foo/2015-01-01T00:00:00\\.000Z_2016-01-01T00:00:00\\.000Z/0/0/",
s3Client,
config,
false,
- new byte[]{0x0, 0x0, 0x0, 0x0A}
+ segmentDir,
+ segmentSize
);
- Assertions.assertEquals(10, (int) segment.getBinaryVersion());
+
+ // a V10 segment is a single file, and that is what makes it range-readable
+ Assertions.assertEquals(1, segmentFiles.length);
+ Assertions.assertEquals(IndexIO.V10_FILE_NAME, segmentFiles[0].getName());
+ Assertions.assertEquals(IndexIO.V10_VERSION, (int)
segment.getBinaryVersion());
Assertions.assertEquals(Boolean.TRUE,
segment.getLoadSpec().get("rangeable"));
}
@@ -378,13 +409,24 @@ public class S3DataSegmentPusherTest
byte[] versionBytes
) throws IOException
{
- S3DataSegmentPusher pusher = new S3DataSegmentPusher(s3Client, config, new
DeepStorageSegmentConfig(zip));
-
// Create a mock segment on disk
File tmp = new File(tempFolder, "version.bin");
-
Files.write(versionBytes, tmp);
- final long size = versionBytes.length;
+
+ return validate(useUniquePath, matcher, s3Client, config, zip, tempFolder,
versionBytes.length);
+ }
+
+ private DataSegment validate(
+ boolean useUniquePath,
+ String matcher,
+ ServerSideEncryptingAmazonS3 s3Client,
+ S3DataSegmentPusherConfig config,
+ boolean zip,
+ File segmentDir,
+ long size
+ ) throws IOException
+ {
+ S3DataSegmentPusher pusher = new S3DataSegmentPusher(s3Client, config, new
DeepStorageSegmentConfig(zip));
DataSegment segmentToPush = new DataSegment(
"foo",
@@ -398,7 +440,7 @@ public class S3DataSegmentPusherTest
size
);
- DataSegment segment = pusher.push(tempFolder, segmentToPush,
useUniquePath);
+ DataSegment segment = pusher.push(segmentDir, segmentToPush,
useUniquePath);
Assertions.assertEquals(segmentToPush.getSize(), segment.getSize());
Assertions.assertEquals("bucket", segment.getLoadSpec().get("bucket"));
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]