This is an automated email from the ASF dual-hosted git repository.
SYaoJun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-graphar.git
The following commit(s) were added to refs/heads/main by this push:
new dd17c9b9 feat(java): add an optional S3 storage adapter (#966)
dd17c9b9 is described below
commit dd17c9b9f0fface85fedb02dcad9ba83cd6e9228
Author: alex <[email protected]>
AuthorDate: Tue Sep 8 02:36:01 2026 +0300
feat(java): add an optional S3 storage adapter (#966)
* feat(java): add an optional S3 storage adapter
S3 range reads and staged writes need to satisfy the storage API without
leaking AWS dependencies into metadata or core modules.
Relates to #965.
Rejected: a mandatory S3 or Hadoop dependency in the storage API.
Not-tested: a real MinIO endpoint; the follow-up opt-in transport test
remains separate.
* test(java): verify S3 storage against MinIO
Exercise staged conditional publication and range reads against an actual
S3-compatible endpoint, while keeping the test opt-in for normal SDK
verification.
Relates to #965.
* ci(java): build the SDK modules that the workflow already watches
The workflow triggers on storage-local, io-api and core but compiled only
storage-api, so every module merged after the storage API entered main
without being built or tested upstream. Add storage-s3 to the trigger paths
and list the shipped modules in SDK_MODULES.
* fix(java): reject an existing S3 object before staging bytes
create() deferred the existence check to the conditional publication, so a
caller could stream a whole object before learning about the conflict. Head
the object at open time to match the local adapter, and keep If-None-Match
as the atomic backstop when the head request cannot answer.
* style(java): use the canonical ASF header in the S3 input files
S3InputFile and S3SeekableInput carried a collapsed single-line header while
every other file in the SDK uses the multi-line form.
* ci(java): bind the javadoc step to a lifecycle phase
The step invoked javadoc:javadoc alone, so the reactor produced no jars and
a module resolved its siblings from the repository instead. With one module
in SDK_MODULES nothing was resolved across modules and the step passed; with
the shipped modules listed, storage-local could not find
graphar-storage-api.
Run package in the same invocation so the reactor supplies the dependency.
* fix(java): let S3 failures keep their identity and stop paying a round
trip per read
A head request that fails for any reason other than absence was read as
'object absent', so a permission or throttling error was deferred to close()
under a different message; only NoSuchKeyException and a 404 now mean
absent.
A conditional put rejected with 412 lost the race for an existing object but
surfaced as a plain IOException, so callers could not tell a conflict from a
transient failure; it now surfaces as FileAlreadyExistsException, matching
both the eager check and the OutputFile.create() contract.
Reads were served by one GetObject range request per call, so byte-level
readers of columnar headers and footers paid a round trip each. The input
now
keeps the last block in memory and serves subsequent reads from it, while a
read at least as large as the block bypasses the buffer.
---
.github/workflows/java-sdk.yml | 6 +-
maven-projects/pom.xml | 1 +
maven-projects/storage-s3/pom.xml | 75 ++++++++
.../org/apache/graphar/storage/s3/S3InputFile.java | 64 +++++++
.../apache/graphar/storage/s3/S3OutputFile.java | 179 +++++++++++++++++
.../apache/graphar/storage/s3/S3SeekableInput.java | 147 ++++++++++++++
.../org/apache/graphar/storage/s3/S3Storage.java | 110 +++++++++++
.../storage/s3/MinioS3StorageIntegrationIT.java | 135 +++++++++++++
.../apache/graphar/storage/s3/S3StorageTest.java | 212 +++++++++++++++++++++
9 files changed, 927 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/java-sdk.yml b/.github/workflows/java-sdk.yml
index 755845da..a8779ee0 100644
--- a/.github/workflows/java-sdk.yml
+++ b/.github/workflows/java-sdk.yml
@@ -25,6 +25,7 @@ on:
- 'maven-projects/pom.xml'
- 'maven-projects/storage-api/**'
- 'maven-projects/storage-local/**'
+ - 'maven-projects/storage-s3/**'
- 'maven-projects/io-api/**'
- 'maven-projects/io-parquet/**'
- 'maven-projects/core/**'
@@ -38,6 +39,7 @@ on:
- 'maven-projects/pom.xml'
- 'maven-projects/storage-api/**'
- 'maven-projects/storage-local/**'
+ - 'maven-projects/storage-s3/**'
- 'maven-projects/io-api/**'
- 'maven-projects/io-parquet/**'
- 'maven-projects/core/**'
@@ -53,7 +55,7 @@ jobs:
test:
runs-on: ubuntu-latest
env:
- SDK_MODULES: storage-api
+ SDK_MODULES: storage-api,storage-local,io-api,storage-s3
steps:
- uses: actions/checkout@v7
with:
@@ -83,4 +85,4 @@ jobs:
working-directory: maven-projects
run: |
export JAVA_HOME=${JAVA_HOME_11_X64}
- mvn --no-transfer-progress -pl ${SDK_MODULES} -am javadoc:javadoc
+ mvn --no-transfer-progress -pl ${SDK_MODULES} -am package
javadoc:javadoc -DskipTests -Dspotless.check.skip=true
diff --git a/maven-projects/pom.xml b/maven-projects/pom.xml
index 56b5cd87..5ea0b89c 100644
--- a/maven-projects/pom.xml
+++ b/maven-projects/pom.xml
@@ -81,6 +81,7 @@
<module>storage-api</module>
<module>storage-local</module>
<module>io-api</module>
+ <module>storage-s3</module>
</modules>
<build>
diff --git a/maven-projects/storage-s3/pom.xml
b/maven-projects/storage-s3/pom.xml
new file mode 100644
index 00000000..98ac2b2d
--- /dev/null
+++ b/maven-projects/storage-s3/pom.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.graphar</groupId>
+ <artifactId>graphar-root</artifactId>
+ <version>${graphar.version}</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+ <artifactId>graphar-storage-s3</artifactId>
+ <packaging>jar</packaging>
+ <version>${graphar.version}</version>
+ <name>graphar-storage-s3</name>
+ <properties>
+ <maven.compiler.source>11</maven.compiler.source>
+ <maven.compiler.target>11</maven.compiler.target>
+ <aws.sdk.version>2.31.54</aws.sdk.version>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ </properties>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.graphar</groupId>
+ <artifactId>graphar-storage-api</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>s3</artifactId>
+ <version>${aws.sdk.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>4.13.2</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.diffplug.spotless</groupId>
+ <artifactId>spotless-maven-plugin</artifactId>
+ <version>${spotless-maven-plugin.version}</version>
+
<configuration><java><googleJavaFormat><version>1.7</version><style>AOSP</style></googleJavaFormat></java></configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-javadoc-plugin</artifactId>
+
<executions><execution><id>attach-javadocs</id><goals><goal>jar</goal></goals></execution></executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git
a/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3InputFile.java
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3InputFile.java
new file mode 100644
index 00000000..069fa032
--- /dev/null
+++
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3InputFile.java
@@ -0,0 +1,64 @@
+/*
+ * 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.graphar.storage.s3;
+
+import java.io.IOException;
+import java.net.URI;
+import org.apache.graphar.storage.InputFile;
+import org.apache.graphar.storage.SeekableInput;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+
+final class S3InputFile implements InputFile {
+ private final S3Client client;
+ private final S3Storage.Location location;
+
+ S3InputFile(S3Client client, S3Storage.Location location) {
+ this.client = client;
+ this.location = location;
+ }
+
+ @Override
+ public URI uri() {
+ return location.uri;
+ }
+
+ @Override
+ public long size() throws IOException {
+ return head().contentLength();
+ }
+
+ @Override
+ public SeekableInput open() throws IOException {
+ HeadObjectResponse response = head();
+ return new S3SeekableInput(
+ client, location, response.contentLength(),
response.versionId(), response.eTag());
+ }
+
+ private HeadObjectResponse head() throws IOException {
+ try {
+ return client.headObject(
+
HeadObjectRequest.builder().bucket(location.bucket).key(location.key).build());
+ } catch (RuntimeException exception) {
+ throw new IOException("Cannot inspect S3 object " + location.uri,
exception);
+ }
+ }
+}
diff --git
a/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3OutputFile.java
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3OutputFile.java
new file mode 100644
index 00000000..8cf9da44
--- /dev/null
+++
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3OutputFile.java
@@ -0,0 +1,179 @@
+/*
+ * 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.graphar.storage.s3;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.graphar.storage.OutputFile;
+import org.apache.graphar.storage.PositionOutput;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+final class S3OutputFile implements OutputFile {
+ private final S3Client client;
+ private final S3Storage.Location location;
+ private final Path stagingDirectory;
+
+ S3OutputFile(S3Client client, S3Storage.Location location, Path
stagingDirectory) {
+ this.client = client;
+ this.location = location;
+ this.stagingDirectory = stagingDirectory;
+ }
+
+ @Override
+ public URI uri() {
+ return location.uri;
+ }
+
+ @Override
+ public PositionOutput create() throws IOException {
+ failIfPresent();
+ return open(true);
+ }
+
+ /**
+ * Rejects an existing object before any byte is staged, so that a caller
learns about the
+ * conflict at open time the way the local adapter does. Only an absent
object lets the caller
+ * proceed; a head request that fails for any other reason surfaces here
rather than after a
+ * full staging pass.
+ *
+ * @throws FileAlreadyExistsException when the object is already present
+ * @throws IOException when the object cannot be inspected
+ */
+ private void failIfPresent() throws IOException {
+ try {
+ client.headObject(
+
HeadObjectRequest.builder().bucket(location.bucket).key(location.key).build());
+ } catch (NoSuchKeyException absent) {
+ return;
+ } catch (S3Exception exception) {
+ if (exception.statusCode() == 404) {
+ return;
+ }
+ throw new IOException("Cannot inspect S3 object " + location.uri,
exception);
+ } catch (RuntimeException exception) {
+ throw new IOException("Cannot inspect S3 object " + location.uri,
exception);
+ }
+ throw new FileAlreadyExistsException(location.uri.toString());
+ }
+
+ @Override
+ public PositionOutput createOrOverwrite() throws IOException {
+ return open(false);
+ }
+
+ private PositionOutput open(boolean createOnly) throws IOException {
+ Path stage = Files.createTempFile(stagingDirectory, "graphar-s3-",
".stage");
+ try {
+ return new StagedOutput(client, location, stage, createOnly);
+ } catch (IOException | RuntimeException exception) {
+ Files.deleteIfExists(stage);
+ throw exception;
+ }
+ }
+
+ private static final class StagedOutput implements PositionOutput {
+ private final S3Client client;
+ private final S3Storage.Location location;
+ private final Path stage;
+ private final boolean createOnly;
+ private final OutputStream output;
+ private long position;
+ private boolean closed;
+
+ private StagedOutput(
+ S3Client client, S3Storage.Location location, Path stage,
boolean createOnly)
+ throws IOException {
+ this.client = client;
+ this.location = location;
+ this.stage = stage;
+ this.createOnly = createOnly;
+ this.output = Files.newOutputStream(stage);
+ }
+
+ @Override
+ public long position() throws IOException {
+ requireOpen();
+ return position;
+ }
+
+ @Override
+ public void write(ByteBuffer source) throws IOException {
+ requireOpen();
+ byte[] bytes = new byte[source.remaining()];
+ source.get(bytes);
+ write(bytes, 0, bytes.length);
+ }
+
+ @Override
+ public void write(byte[] source, int offset, int length) throws
IOException {
+ requireOpen();
+ output.write(source, offset, length);
+ position = Math.addExact(position, length);
+ }
+
+ @Override
+ public void flush() throws IOException {
+ requireOpen();
+ output.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ try {
+ output.close();
+ PutObjectRequest.Builder request =
+
PutObjectRequest.builder().bucket(location.bucket).key(location.key);
+ if (createOnly) {
+ request.ifNoneMatch("*");
+ }
+ client.putObject(request.build(), RequestBody.fromFile(stage));
+ } catch (S3Exception exception) {
+ if (createOnly && exception.statusCode() == 412) {
+ throw new
FileAlreadyExistsException(location.uri.toString());
+ }
+ throw new IOException("Cannot publish S3 object " +
location.uri, exception);
+ } catch (RuntimeException exception) {
+ throw new IOException("Cannot publish S3 object " +
location.uri, exception);
+ } finally {
+ Files.deleteIfExists(stage);
+ }
+ }
+
+ private void requireOpen() throws IOException {
+ if (closed) {
+ throw new IOException("S3 output is closed.");
+ }
+ }
+ }
+}
diff --git
a/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3SeekableInput.java
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3SeekableInput.java
new file mode 100644
index 00000000..4b9bef09
--- /dev/null
+++
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3SeekableInput.java
@@ -0,0 +1,147 @@
+/*
+ * 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.graphar.storage.s3;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.apache.graphar.storage.SeekableInput;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.sync.ResponseTransformer;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+
+/**
+ * Reads an object through range requests, keeping the last fetched block in
memory so that the
+ * small sequential reads typical of columnar footers do not cost one round
trip each. A read at
+ * least as large as the block size bypasses the buffer and is served by a
single request.
+ */
+final class S3SeekableInput implements SeekableInput {
+ private static final int BLOCK_SIZE = 1 << 20;
+ private static final byte[] NO_BLOCK = new byte[0];
+
+ private final S3Client client;
+ private final S3Storage.Location location;
+ private final long size;
+ private final String versionId;
+ private final String eTag;
+ private long position;
+ private byte[] block = NO_BLOCK;
+ private long blockStart;
+ private int blockLength;
+ private boolean closed;
+
+ S3SeekableInput(
+ S3Client client,
+ S3Storage.Location location,
+ long size,
+ String versionId,
+ String eTag) {
+ this.client = client;
+ this.location = location;
+ this.size = size;
+ this.versionId = versionId;
+ this.eTag = eTag;
+ }
+
+ @Override
+ public long position() throws IOException {
+ requireOpen();
+ return position;
+ }
+
+ @Override
+ public void seek(long newPosition) throws IOException {
+ requireOpen();
+ if (newPosition < 0) {
+ throw new IllegalArgumentException("S3 seek position must be
non-negative.");
+ }
+ position = newPosition;
+ }
+
+ @Override
+ public int read(ByteBuffer destination) throws IOException {
+ requireOpen();
+ if (!destination.hasRemaining()) {
+ return 0;
+ }
+ if (position >= size) {
+ return -1;
+ }
+ int wanted = (int) Math.min(destination.remaining(), size - position);
+ if (wanted >= BLOCK_SIZE) {
+ byte[] bytes = fetch(position, wanted);
+ destination.put(bytes);
+ position += bytes.length;
+ return bytes.length;
+ }
+ if (position < blockStart || position >= blockStart + blockLength) {
+ int length = (int) Math.min(BLOCK_SIZE, size - position);
+ block = fetch(position, length);
+ blockStart = position;
+ blockLength = block.length;
+ }
+ int offset = (int) (position - blockStart);
+ int count = Math.min(wanted, blockLength - offset);
+ destination.put(block, offset, count);
+ position += count;
+ return count;
+ }
+
+ private byte[] fetch(long start, int length) throws IOException {
+ GetObjectRequest.Builder request =
+ GetObjectRequest.builder()
+ .bucket(location.bucket)
+ .key(location.key)
+ .range("bytes=" + start + "-" + (start + length - 1));
+ if (versionId != null) {
+ request.versionId(versionId);
+ } else if (eTag != null) {
+ request.ifMatch(eTag);
+ }
+ try {
+ ResponseBytes<GetObjectResponse> response =
+ client.getObject(request.build(),
ResponseTransformer.toBytes());
+ byte[] bytes = response.asByteArray();
+ if (bytes.length != length) {
+ throw new IOException(
+ "S3 returned " + bytes.length + " bytes for requested
range of " + length);
+ }
+ return bytes;
+ } catch (IOException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ throw new IOException("Cannot range-read S3 object " +
location.uri, exception);
+ }
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ block = NO_BLOCK;
+ blockLength = 0;
+ }
+
+ private void requireOpen() throws IOException {
+ if (closed) {
+ throw new IOException("S3 input is closed.");
+ }
+ }
+}
diff --git
a/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3Storage.java
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3Storage.java
new file mode 100644
index 00000000..8fdbbbe0
--- /dev/null
+++
b/maven-projects/storage-s3/src/main/java/org/apache/graphar/storage/s3/S3Storage.java
@@ -0,0 +1,110 @@
+/*
+ * 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.graphar.storage.s3;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+import org.apache.graphar.storage.InputFile;
+import org.apache.graphar.storage.OutputFile;
+import org.apache.graphar.storage.Storage;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/** S3-backed GraphAr storage using an injected AWS SDK client. */
+public final class S3Storage implements Storage {
+ private final S3Client client;
+ private final Path stagingDirectory;
+
+ /** Creates S3 storage. The caller owns the client and is responsible for
closing it. */
+ public S3Storage(S3Client client, Path stagingDirectory) throws
IOException {
+ this.client = Objects.requireNonNull(client, "S3 client cannot be
null.");
+ this.stagingDirectory =
+ Objects.requireNonNull(stagingDirectory, "Staging directory
cannot be null.");
+ Files.createDirectories(stagingDirectory);
+ if (!Files.isDirectory(stagingDirectory)) {
+ throw new IllegalArgumentException(
+ "S3 staging path is not a directory: " + stagingDirectory);
+ }
+ }
+
+ @Override
+ public InputFile inputFile(URI uri) {
+ Location location = Location.from(uri);
+ return new S3InputFile(client, location);
+ }
+
+ @Override
+ public OutputFile outputFile(URI uri) {
+ Location location = Location.from(uri);
+ return new S3OutputFile(client, location, stagingDirectory);
+ }
+
+ @Override
+ public boolean exists(URI uri) throws IOException {
+ Location location = Location.from(uri);
+ try {
+ client.headObject(
+
HeadObjectRequest.builder().bucket(location.bucket).key(location.key).build());
+ return true;
+ } catch (NoSuchKeyException exception) {
+ return false;
+ } catch (S3Exception exception) {
+ if (exception.statusCode() == 404) {
+ return false;
+ }
+ throw new IOException("Cannot inspect S3 object " + uri,
exception);
+ } catch (RuntimeException exception) {
+ throw new IOException("Cannot inspect S3 object " + uri,
exception);
+ }
+ }
+
+ static final class Location {
+ final URI uri;
+ final String bucket;
+ final String key;
+
+ private Location(URI uri, String bucket, String key) {
+ this.uri = uri;
+ this.bucket = bucket;
+ this.key = key;
+ }
+
+ private static Location from(URI uri) {
+ if (uri == null
+ || !"s3".equalsIgnoreCase(uri.getScheme())
+ || uri.getHost() == null
+ || uri.getHost().isEmpty()
+ || uri.getPath() == null
+ || uri.getPath().length() <= 1
+ || uri.getUserInfo() != null
+ || uri.getQuery() != null
+ || uri.getFragment() != null) {
+ throw new IllegalArgumentException(
+ "Expected an absolute s3://bucket/key URI: " + uri);
+ }
+ return new Location(uri, uri.getHost(),
uri.getPath().substring(1));
+ }
+ }
+}
diff --git
a/maven-projects/storage-s3/src/test/java/org/apache/graphar/storage/s3/MinioS3StorageIntegrationIT.java
b/maven-projects/storage-s3/src/test/java/org/apache/graphar/storage/s3/MinioS3StorageIntegrationIT.java
new file mode 100644
index 00000000..d4b16fb4
--- /dev/null
+++
b/maven-projects/storage-s3/src/test/java/org/apache/graphar/storage/s3/MinioS3StorageIntegrationIT.java
@@ -0,0 +1,135 @@
+/*
+ * 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.graphar.storage.s3;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+import org.apache.graphar.storage.PositionOutput;
+import org.apache.graphar.storage.SeekableInput;
+import org.junit.Assume;
+import org.junit.Test;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+
+/**
+ * Opt-in MinIO transport test. Run with {@code
GRAPHAR_MINIO_ENDPOINT=http://localhost:19000}.
+ *
+ * <p>The default test lifecycle excludes {@code *IT}; this class
intentionally requires a
+ * separately started MinIO endpoint rather than adding Docker to normal SDK
verification.
+ */
+public class MinioS3StorageIntegrationIT {
+ private static final String ENDPOINT = "GRAPHAR_MINIO_ENDPOINT";
+ private static final String ACCESS_KEY = "GRAPHAR_MINIO_ACCESS_KEY";
+ private static final String SECRET_KEY = "GRAPHAR_MINIO_SECRET_KEY";
+
+ @Test
+ public void stagesConditionallyPublishesAndRangeReadsOverMinio() throws
Exception {
+ String endpoint = System.getenv(ENDPOINT);
+ Assume.assumeTrue(
+ "Set " + ENDPOINT + " to run the MinIO transport test.",
endpoint != null);
+
+ String bucket = "graphar-minio-" +
UUID.randomUUID().toString().replace("-", "");
+ Path staging = Files.createTempDirectory("graphar-minio-stage-");
+ try (S3Client client = client(endpoint)) {
+ client.createBucket(request -> request.bucket(bucket));
+ S3Storage storage = new S3Storage(client, staging);
+ URI object = URI.create("s3://" + bucket +
"/topology/chunk.parquet");
+
+ assertFalse(storage.exists(object));
+ write(storage.outputFile(object).create(), new byte[] {0, 1, 2, 3,
4, 5});
+ assertTrue(storage.exists(object));
+ assertEquals(6L, storage.inputFile(object).size());
+
+ ByteBuffer range = ByteBuffer.allocateDirect(3);
+ try (SeekableInput input = storage.inputFile(object).open()) {
+ input.seek(2);
+ assertEquals(3, input.read(range));
+ assertEquals(5L, input.position());
+ input.seek(6);
+ assertEquals(-1, input.read(ByteBuffer.allocate(1)));
+ }
+ range.flip();
+ byte[] actual = new byte[range.remaining()];
+ range.get(actual);
+ assertArrayEquals(new byte[] {2, 3, 4}, actual);
+
+ try {
+ write(storage.outputFile(object).create(), new byte[] {9});
+ fail("create() must not overwrite an existing S3 object.");
+ } catch (IOException expected) {
+ // MinIO returns the S3 conditional-publication failure
through the AWS SDK.
+ }
+ write(storage.outputFile(object).createOrOverwrite(), new byte[]
{9});
+ try (SeekableInput input = storage.inputFile(object).open()) {
+ ByteBuffer replacement = ByteBuffer.allocate(1);
+ assertEquals(1, input.read(replacement));
+ assertArrayEquals(new byte[] {9}, replacement.array());
+ }
+ } finally {
+ deleteTree(staging);
+ }
+ }
+
+ private static S3Client client(String endpoint) {
+ String accessKey = System.getenv().getOrDefault(ACCESS_KEY,
"minioadmin");
+ String secretKey = System.getenv().getOrDefault(SECRET_KEY,
"minioadmin");
+ return S3Client.builder()
+ .endpointOverride(URI.create(endpoint))
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey,
secretKey)))
+ .region(Region.US_EAST_1)
+ .serviceConfiguration(
+
S3Configuration.builder().pathStyleAccessEnabled(true).build())
+ .build();
+ }
+
+ private static void write(PositionOutput output, byte[] bytes) throws
IOException {
+ try (PositionOutput ignored = output) {
+ output.write(bytes);
+ }
+ }
+
+ private static void deleteTree(Path root) throws IOException {
+ if (!Files.exists(root)) {
+ return;
+ }
+ try (java.util.stream.Stream<Path> paths = Files.walk(root)) {
+ for (Path path :
+ paths.sorted(java.util.Comparator.reverseOrder())
+ .collect(java.util.stream.Collectors.toList())) {
+ Files.delete(path);
+ }
+ }
+ }
+}
diff --git
a/maven-projects/storage-s3/src/test/java/org/apache/graphar/storage/s3/S3StorageTest.java
b/maven-projects/storage-s3/src/test/java/org/apache/graphar/storage/s3/S3StorageTest.java
new file mode 100644
index 00000000..d60723d2
--- /dev/null
+++
b/maven-projects/storage-s3/src/test/java/org/apache/graphar/storage/s3/S3StorageTest.java
@@ -0,0 +1,212 @@
+/*
+ * 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.graphar.storage.s3;
+
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.util.Arrays;
+import org.apache.graphar.storage.PositionOutput;
+import org.apache.graphar.storage.SeekableInput;
+import org.junit.Assert;
+import org.junit.Test;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/** Credential-free contract tests using an injected in-memory S3 SDK
transport. */
+public class S3StorageTest {
+ @Test
+ public void stagesOutputAndUsesVersionPinnedByteRanges() throws Exception {
+ byte[] object = "abcdef".getBytes();
+ RequestLog log = new RequestLog(object);
+ S3Storage storage =
+ new S3Storage(log.client(),
Files.createTempDirectory("graphar-s3-stage"));
+ URI uri = URI.create("s3://bucket/graph/a.parquet");
+
+ Assert.assertTrue(storage.exists(uri));
+ try (SeekableInput input = storage.inputFile(uri).open()) {
+ input.seek(2);
+ ByteBuffer bytes = ByteBuffer.allocate(3);
+ Assert.assertEquals(3, input.read(bytes));
+ Assert.assertArrayEquals("cde".getBytes(), bytes.array());
+ }
+ Assert.assertEquals("bytes=2-5", log.range);
+ Assert.assertEquals("version-1", log.versionId);
+
+ URI target = URI.create("s3://bucket/graph/new.bin");
+ try (PositionOutput output = storage.outputFile(target).create()) {
+ output.write(new byte[] {9, 8, 7});
+ }
+ Assert.assertEquals("*", log.ifNoneMatch);
+ Assert.assertArrayEquals(new byte[] {9, 8, 7}, log.putBytes);
+ }
+
+ @Test
+ public void rejectsAnExistingObjectBeforeStagingAnyByte() throws Exception
{
+ RequestLog log = new RequestLog("abcdef".getBytes());
+ S3Storage storage =
+ new S3Storage(log.client(),
Files.createTempDirectory("graphar-s3-stage"));
+
+ try {
+
storage.outputFile(URI.create("s3://bucket/graph/a.parquet")).create();
+ Assert.fail("create() must reject an object that already exists.");
+ } catch (FileAlreadyExistsException expected) {
+ Assert.assertNull("No object may be published on a rejected
create().", log.putBytes);
+ }
+ }
+
+ @Test
+ public void servesSequentialReadsFromOneRangeRequest() throws Exception {
+ RequestLog log = new RequestLog("abcdef".getBytes());
+ S3Storage storage =
+ new S3Storage(log.client(),
Files.createTempDirectory("graphar-s3-stage"));
+
+ try (SeekableInput input =
+
storage.inputFile(URI.create("s3://bucket/graph/a.parquet")).open()) {
+ for (int index = 0; index < 6; index++) {
+ ByteBuffer single = ByteBuffer.allocate(1);
+ Assert.assertEquals(1, input.read(single));
+ Assert.assertEquals("abcdef".charAt(index), (char)
single.array()[0]);
+ }
+ Assert.assertEquals(-1, input.read(ByteBuffer.allocate(1)));
+ }
+ Assert.assertEquals(
+ "Byte-level reads must share one range request.", 1,
log.getObjectCalls);
+ }
+
+ @Test
+ public void reportsALostPublicationRaceAsAnExistingObject() throws
Exception {
+ RequestLog log = new RequestLog("abcdef".getBytes());
+ log.putStatusCode = 412;
+ S3Storage storage =
+ new S3Storage(log.client(),
Files.createTempDirectory("graphar-s3-stage"));
+
+ PositionOutput output =
+
storage.outputFile(URI.create("s3://bucket/graph/new.bin")).create();
+ output.write(new byte[] {1, 2, 3});
+ try {
+ output.close();
+ Assert.fail("A conditional put rejected with 412 must surface as a
conflict.");
+ } catch (FileAlreadyExistsException expected) {
+ Assert.assertEquals("s3://bucket/graph/new.bin",
expected.getMessage());
+ }
+ }
+
+ @Test
+ public void surfacesAFailedExistenceCheckInsteadOfStaging() throws
Exception {
+ RequestLog log = new RequestLog("abcdef".getBytes());
+ log.headStatusCode = 403;
+ S3Storage storage =
+ new S3Storage(log.client(),
Files.createTempDirectory("graphar-s3-stage"));
+
+ try {
+
storage.outputFile(URI.create("s3://bucket/graph/new.bin")).create();
+ Assert.fail("A head request denied with 403 must not be read as an
absent object.");
+ } catch (FileAlreadyExistsException unexpected) {
+ throw new AssertionError("403 must not be reported as a
conflict.", unexpected);
+ } catch (IOException expected) {
+ Assert.assertNull("No object may be published on a failed check.",
log.putBytes);
+ }
+ }
+
+ private static final class RequestLog {
+ private final byte[] object;
+ private String range;
+ private String versionId;
+ private String ifNoneMatch;
+ private byte[] putBytes;
+ private int getObjectCalls;
+ private int headStatusCode;
+ private int putStatusCode;
+
+ private RequestLog(byte[] object) {
+ this.object = object;
+ }
+
+ private S3Client client() {
+ return (S3Client)
+ Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class<?>[] {S3Client.class},
+ (proxy, method, arguments) -> {
+ if ("headObject".equals(method.getName())) {
+ HeadObjectRequest request =
(HeadObjectRequest) arguments[0];
+ Assert.assertEquals("bucket",
request.bucket());
+ if (headStatusCode != 0) {
+ throw S3Exception.builder()
+ .statusCode(headStatusCode)
+ .message("denied")
+ .build();
+ }
+ if
(!"graph/a.parquet".equals(request.key())) {
+ throw
NoSuchKeyException.builder().statusCode(404).build();
+ }
+ return HeadObjectResponse.builder()
+ .contentLength((long)
object.length)
+ .versionId("version-1")
+ .eTag("etag-1")
+ .build();
+ }
+ if ("getObject".equals(method.getName())) {
+ GetObjectRequest request =
(GetObjectRequest) arguments[0];
+ getObjectCalls++;
+ range = request.range();
+ versionId = request.versionId();
+ String[] bounds =
range.substring("bytes=".length()).split("-");
+ int start = Integer.parseInt(bounds[0]);
+ int end = Integer.parseInt(bounds[1]);
+ return ResponseBytes.fromByteArray(
+
GetObjectResponse.builder().build(),
+ Arrays.copyOfRange(object, start,
end + 1));
+ }
+ if ("putObject".equals(method.getName())) {
+ PutObjectRequest request =
(PutObjectRequest) arguments[0];
+ ifNoneMatch = request.ifNoneMatch();
+ if (putStatusCode != 0) {
+ throw S3Exception.builder()
+ .statusCode(putStatusCode)
+ .message("conflict")
+ .build();
+ }
+ RequestBody body = (RequestBody)
arguments[1];
+ putBytes =
+
body.contentStreamProvider().newStream().readAllBytes();
+ return PutObjectResponse.builder().build();
+ }
+ if ("serviceName".equals(method.getName())) {
+ return "s3";
+ }
+ throw new
UnsupportedOperationException(method.toString());
+ });
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]