diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/BufferingInputStreamExtension.java b/flink-core/src/main/java/org/apache/flink/core/fs/BufferingInputStreamExtension.java new file mode 100644 index 0000000000000..63a0979921d37 --- /dev/null +++ b/flink-core/src/main/java/org/apache/flink/core/fs/BufferingInputStreamExtension.java @@ -0,0 +1,53 @@ +/* + * 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.flink.core.fs; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.util.Preconditions; + +import javax.annotation.concurrent.Immutable; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Default {@link InputStreamExtension} that opens the raw stream via an {@link InputStreamOpener} + * and wraps it with a {@link BufferedInputStream}. + */ +@Internal +@Immutable +final class BufferingInputStreamExtension implements InputStreamExtension { + + private final InputStreamOpener opener; + private final int readBufferSize; + + BufferingInputStreamExtension(final InputStreamOpener opener, final int readBufferSize) { + this.opener = Preconditions.checkNotNull(opener, "opener"); + Preconditions.checkArgument(readBufferSize > 0, "readBufferSize must be positive"); + this.readBufferSize = readBufferSize; + } + + @Override + public RawAndWrappedInputStreams openStream(final InputStreamExtension.StreamContext ctx) + throws IOException { + final InputStream raw = opener.open(ReadContext.of(ctx.getPos())); + return new RawAndWrappedInputStreams(raw, new BufferedInputStream(raw, readBufferSize)); + } +} diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/InputStreamExtension.java b/flink-core/src/main/java/org/apache/flink/core/fs/InputStreamExtension.java new file mode 100644 index 0000000000000..4d44a28f4a16d --- /dev/null +++ b/flink-core/src/main/java/org/apache/flink/core/fs/InputStreamExtension.java @@ -0,0 +1,78 @@ +/* + * 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.flink.core.fs; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.annotation.Internal; + +import java.io.IOException; + +/** + * Extension point for {@link ObjectStorageInputStream}. + * + *
All methods are called with the stream's internal lock held. + * + * @see ObjectStorageInputStream + */ +@Internal +@Experimental +public interface InputStreamExtension { + + /** Read-only view of stream state passed to extension callbacks. */ + @Internal + @Experimental + interface StreamContext { + + /** Returns the current byte position. */ + long getPos(); + + /** Returns the total content length in bytes. */ + long getContentLength(); + } + + /** + * Opens streams at the current {@linkplain StreamContext#getPos() read position}. + * + *
Called during lazy initialization (first read) and stream recovery (seek beyond + * threshold). The previous streams are closed by the base class before this call. The base + * class manages the lifecycle of the returned streams (reads, closes, reopens on seek). + * + *
If the underlying source cannot do range reads (e.g., encrypted streams opened at offset + * 0), open at offset 0 and skip forward to {@link StreamContext#getPos()} after wrapping. + * + *
On failure, close any streams opened during this call before rethrowing. + * + * @param ctx read-only view of the stream state at the time of opening + * @return the raw and wrapped stream pair + * @throws IOException if opening fails + */ + RawAndWrappedInputStreams openStream(StreamContext ctx) throws IOException; + + /** + * Returns the default extension that opens the raw stream via {@code opener} and wraps it with + * a {@link java.io.BufferedInputStream}. + * + * @param opener opens a raw stream at a given byte position + * @param readBufferSize the buffer size for the {@link java.io.BufferedInputStream} + */ + static InputStreamExtension buffering( + final InputStreamOpener opener, final int readBufferSize) { + return new BufferingInputStreamExtension(opener, readBufferSize); + } +} diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/InputStreamOpener.java b/flink-core/src/main/java/org/apache/flink/core/fs/InputStreamOpener.java new file mode 100644 index 0000000000000..d8705b3d04f44 --- /dev/null +++ b/flink-core/src/main/java/org/apache/flink/core/fs/InputStreamOpener.java @@ -0,0 +1,52 @@ +/* + * 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.flink.core.fs; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.annotation.Internal; + +import javax.annotation.concurrent.NotThreadSafe; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Cloud-agnostic opener that returns an input stream starting at the position described by a {@link + * ReadContext}. + * + *
The implementation captures all cloud-specific state (client, path, bucket) in its closure, + * exposing only the read context to the caller. + * + * @see ReadContext + */ +@Internal +@Experimental +@NotThreadSafe +@FunctionalInterface +public interface InputStreamOpener { + + /** + * Opens an input stream starting at the position indicated by {@code ctx}. + * + * @param ctx context for this read, including the byte offset to start from + * @return an input stream positioned at {@code ctx.getPos()} + * @throws IOException if the stream cannot be opened + */ + InputStream open(ReadContext ctx) throws IOException; +} diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/ObjectStorageInputStream.java b/flink-core/src/main/java/org/apache/flink/core/fs/ObjectStorageInputStream.java new file mode 100644 index 0000000000000..88e8999834b75 --- /dev/null +++ b/flink-core/src/main/java/org/apache/flink/core/fs/ObjectStorageInputStream.java @@ -0,0 +1,436 @@ +/* + * 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.flink.core.fs; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.annotation.Internal; +import org.apache.flink.util.Preconditions; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Object storage input stream with configurable read-ahead buffer and range-based requests for seek + * operations. + * + *
Thread-safety: all public methods are guarded by a {@link ReentrantLock}. {@link #close()} is + * non-interruptible and safe to call from any thread (e.g., task cancellation). + * + *
Extension: use {@link InputStreamExtension#buffering(InputStreamOpener, int)} for the default + * behavior. Pass a custom {@link InputStreamExtension} to customize stream opening. + * + * @see InputStreamExtension + */ +@Internal +@Experimental +@ThreadSafe +public final class ObjectStorageInputStream extends FSDataInputStream { + + private final long contentLength; + private final long skipOnSeekThreshold; + + private final InputStreamExtension inputStreamExtension; + private final StreamContextImpl streamContext = new StreamContextImpl(); + + /** Lock guarding all mutable state. */ + private final ReentrantLock lock = new ReentrantLock(); + + @Nullable + @GuardedBy("lock") + private InputStream sdkStream; + + @Nullable + @GuardedBy("lock") + private InputStream wrappedStream; + + /** Current byte position. Written and read under {@code lock}. */ + @GuardedBy("lock") + private long position; + + @GuardedBy("lock") + private boolean closed; + + /** + * Creates a new object storage input stream. + * + *
Use {@link InputStreamExtension#buffering(InputStreamOpener, int)} for the default + * behavior (opens via the opener and wraps with {@link java.io.BufferedInputStream}). + * + * @param contentLength content length in bytes; must be ≥ 0 + * @param skipOnSeekThreshold maximum forward seek distance (bytes) handled by read-and-discard + * instead of close-and-reopen; must be non-negative (0 disables read-and-discard) + * @param inputStreamExtension extension for stream opening + */ + public ObjectStorageInputStream( + final long contentLength, + final long skipOnSeekThreshold, + final InputStreamExtension inputStreamExtension) { + Preconditions.checkArgument(contentLength >= 0, "contentLength must be non-negative"); + Preconditions.checkArgument( + skipOnSeekThreshold >= 0, "skipOnSeekThreshold must be non-negative"); + this.contentLength = contentLength; + this.skipOnSeekThreshold = skipOnSeekThreshold; + this.inputStreamExtension = Preconditions.checkNotNull(inputStreamExtension, "extension"); + this.position = 0; + } + + @GuardedBy("lock") + private void lazyInitialize() throws IOException { + checkLocked(); + if (sdkStream == null && !closed) { + openStream(); + } + } + + /** + * Closes the current streams and opens new ones at the current {@linkplain #getPos() position}. + * + *
If {@code openStream()} throws, the previous streams are already closed but no new streams + * are set — the next read will retry. + */ + @GuardedBy("lock") + private void openStream() throws IOException { + checkLocked(); + closeCurrentStream(); + final RawAndWrappedInputStreams pair = inputStreamExtension.openStream(streamContext); + this.sdkStream = pair.sdk(); + this.wrappedStream = pair.wrapped(); + } + + /** + * Closes the current underlying streams. If {@code wrappedStream} is present it is closed first + * (which also closes the wrapped {@code sdkStream}). If that close fails, a direct close of + * {@code sdkStream} is attempted so the remote connection is not leaked. + * + * @throws IOException if closing the stream(s) fails + */ + @GuardedBy("lock") + private void closeCurrentStream() throws IOException { + checkLocked(); + if (wrappedStream != null) { + try { + // Closing the wrapper typically closes the underlying stream as well. + wrappedStream.close(); + } catch (final IOException e) { + // wrappedStream.close() may have failed before closing the underlying stream. + // Try closing it directly so we don't leak the connection. + if (sdkStream != null) { + try { + sdkStream.close(); + } catch (final IOException suppressed) { + // Avoid self-suppression: the wrapper's close() delegates to + // sdkStream.close(), so both may throw the same exception instance. + if (suppressed != e) { + e.addSuppressed(suppressed); + } + } + } + throw e; + } finally { + wrappedStream = null; + sdkStream = null; + } + } else if (sdkStream != null) { + try { + sdkStream.close(); + } finally { + sdkStream = null; + } + } + } + + /** + * Size of the throwaway buffer used in read-and-discard seek. The exact size does not matter + * much: actual I/O granularity is governed by the SDK's internal chunk buffer, not this array. + */ + private static final int SKIP_BUFFER_SIZE = 8192; + + /** + * Seeks to the specified position in the stream. + * + *
For forward seeks within {@code skipThreshold}, bytes are read-and-discarded from the + * current stream. This uses {@code read()} rather than {@code skip()} because {@link + * java.io.BufferedInputStream#skip} can delegate to the underlying SDK stream's {@code skip()}, + * which discards its internal buffer even when the target falls within already-fetched data. + * Reading-and-discarding flows through the normal read path, consuming bytes from the SDK's + * existing buffer without additional HTTP requests. + * + *
For larger forward seeks or any backward seek, the stream is closed and reopened lazily + * with a range request at the new position — this costs one HTTP request regardless of + * distance. + * + * @param desired the position to seek to (byte offset from beginning) + * @throws IOException if the position is negative, beyond the file size, or the stream is + * closed + */ + @Override + public void seek(final long desired) throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + if (desired < 0 || desired > contentLength) { + throw new IOException( + "Cannot seek to position " + + desired + + " (file size: " + + contentLength + + ")"); + } + seekInternal(desired); + } finally { + lock.unlock(); + } + } + + @GuardedBy("lock") + private void seekInternal(final long desired) throws IOException { + checkLocked(); + if (desired == position) { + return; + } + final long delta = desired - position; + if (delta > 0 && delta <= skipOnSeekThreshold && wrappedStream != null) { + readAndDiscard(delta); + } else { + position = desired; + closeCurrentStream(); + } + } + + /** Advances the stream by reading-and-discarding {@code delta} bytes. */ + @GuardedBy("lock") + private void readAndDiscard(final long delta) throws IOException { + if (delta == 0) { + return; + } + checkLocked(); + Preconditions.checkState( + wrappedStream != null, "readAndDiscard requires an open wrapped stream"); + final byte[] skipBuf = new byte[(int) Math.min(delta, SKIP_BUFFER_SIZE)]; + long remaining = delta; + while (remaining > 0L) { + final int toRead = (int) Math.min(remaining, skipBuf.length); + final int bytesRead = wrappedStream.read(skipBuf, 0, toRead); + if (bytesRead <= 0) { + break; + } + remaining -= bytesRead; + position += bytesRead; + } + if (remaining > 0L) { + throw new IOException( + "Unexpected end of stream during read-and-discard seek: " + + remaining + + " bytes remaining"); + } + } + + /** + * Returns the current position in the stream. + * + * @return the byte offset from the beginning of the stream + */ + @Override + public long getPos() { + lock.lock(); + try { + return position; + } finally { + lock.unlock(); + } + } + + /** + * Reads a single byte from the stream. + * + * @return the byte read as an integer (0-255), or -1 if end of stream is reached + * @throws IOException if the stream is closed or an I/O error occurs + */ + @Override + public int read() throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + if (position >= contentLength) { + return -1; + } + lazyInitialize(); + final int data = wrappedStream.read(); + if (data != -1) { + position++; + } + return data; + } finally { + lock.unlock(); + } + } + + /** + * Reads bytes into a portion of a byte array. + * + * @param b the buffer into which the data is read + * @param off the start offset in array {@code b} at which the data is written + * @param len the maximum number of bytes to read + * @return the number of bytes read, or -1 if end of stream is reached + * @throws IOException if the stream is closed or an I/O error occurs + * @throws NullPointerException if {@code b} is null + * @throws IndexOutOfBoundsException if {@code off} or {@code len} is negative, or {@code len} + * is greater than {@code b.length - off} + */ + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + Preconditions.checkNotNull(b, "buffer"); + Objects.checkFromIndexSize(off, len, b.length); + if (len == 0) { + return 0; + } + if (position >= contentLength) { + return -1; + } + lazyInitialize(); + final long remaining = contentLength - position; + final int toRead = (int) Math.min(len, remaining); + final int bytesRead = wrappedStream.read(b, off, toRead); + if (bytesRead > 0) { + position += bytesRead; + } + return bytesRead; + } finally { + lock.unlock(); + } + } + + /** + * Idempotently closes this stream and releases the underlying connection. + * + *
Uses non-interruptible {@code lock()} instead of {@code lockInterruptibly()} so that close + * always completes — stream cleanup must run to avoid resource leaks. + * + * @throws IOException if closing the underlying stream fails + */ + @Override + public void close() throws IOException { + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + closeCurrentStream(); + } finally { + lock.unlock(); + } + } + + /** + * Returns an estimate of the number of bytes that can be read without blocking. + * + * @return the number of remaining bytes in the blob, capped at {@link Integer#MAX_VALUE} + * @throws IOException if the stream is closed + */ + @Override + public int available() throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + final long remaining = Math.max(0L, contentLength - position); + return (int) Math.min(remaining, Integer.MAX_VALUE); + } finally { + lock.unlock(); + } + } + + /** + * Skips over and discards {@code n} bytes of data from this input stream. + * + * @param n the number of bytes to skip + * @return the actual number of bytes skipped (may be less if end of stream is reached) + * @throws IOException if the stream is closed or an I/O error occurs + */ + @Override + public long skip(final long n) throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + if (n <= 0) { + return 0; + } + final long newPos = Math.min(position + n, contentLength); + final long skipped = newPos - position; + seekInternal(newPos); + return skipped; + } finally { + lock.unlock(); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private final class StreamContextImpl implements InputStreamExtension.StreamContext { + + private StreamContextImpl() {} + + @Override + public long getPos() { + checkLocked(); + return position; + } + + @Override + public long getContentLength() { + return contentLength; + } + } + + /** Asserts that the current thread holds the lock. */ + private void checkLocked() { + Preconditions.checkState(lock.isHeldByCurrentThread()); + } + + private void checkNotClosed() throws IOException { + checkLocked(); + if (closed) { + throw new IOException("Stream is closed"); + } + } + + /** + * Acquires the lock, wrapping {@link InterruptedException} as {@link IOException} while + * restoring the interrupt flag. + */ + private void acquireLockInterruptibly() throws IOException { + try { + lock.lockInterruptibly(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting to acquire stream lock", e); + } + } +} diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/ObjectStorageOutputStream.java b/flink-core/src/main/java/org/apache/flink/core/fs/ObjectStorageOutputStream.java new file mode 100644 index 0000000000000..72f95d324415a --- /dev/null +++ b/flink-core/src/main/java/org/apache/flink/core/fs/ObjectStorageOutputStream.java @@ -0,0 +1,249 @@ +/* + * 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.flink.core.fs; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.annotation.Internal; +import org.apache.flink.util.ExceptionUtils; +import org.apache.flink.util.Preconditions; + +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Object storage output stream that delegates writes to an underlying {@link OutputStream}. + * + *
The underlying stream may be a raw cloud stream or a wrapped stream (e.g., a compressing or + * encrypting stream). The delegate may buffer writes internally and upload blocks asynchronously. + * On {@link #close()} or {@link #sync()}, all buffered data is flushed and the stream is committed, + * making the file visible. + * + *
Thread-safety: all public methods are guarded by a {@link ReentrantLock}. {@link #close()} and + * {@link #sync()} use non-interruptible {@code lock()} so they always complete — stream cleanup + * must run to avoid resource leaks or silent data corruption. Other methods use {@code + * lockInterruptibly()} and throw {@link IOException} wrapping {@link InterruptedException} when + * interrupted. + * + * @see FSDataOutputStream + */ +@Internal +@Experimental +@ThreadSafe +public final class ObjectStorageOutputStream extends FSDataOutputStream { + + private final OutputStream delegate; + + /** Path to the file within the storage system. Used in diagnostic messages. */ + private final String filePath; + + /** Lock guarding all mutable state. */ + private final ReentrantLock lock = new ReentrantLock(); + + @GuardedBy("lock") + private long position; + + @GuardedBy("lock") + private boolean closed; + + /** + * Creates a new object storage output stream wrapping an already-opened delegate stream. + * + * @param delegate the underlying output stream (e.g., an SDK stream or a cipher-wrapped stream) + * @param filePath the path to the file within the storage system (used in diagnostic messages) + */ + public ObjectStorageOutputStream(final OutputStream delegate, final String filePath) { + this.delegate = Preconditions.checkNotNull(delegate, "delegate"); + this.filePath = Preconditions.checkNotNull(filePath, "filePath"); + this.position = 0; + } + + /** + * Returns the current position in the stream. + * + * @return the number of bytes written so far + * @throws IOException if interrupted while acquiring the lock, or if the stream is closed + */ + @Override + public long getPos() throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + return position; + } finally { + lock.unlock(); + } + } + + /** + * Writes a single byte to the stream. + * + * @param b the byte to write (only the low 8 bits are used) + * @throws IOException if interrupted while acquiring the lock, if the stream is closed, or if + * an I/O error occurs + */ + @Override + public void write(final int b) throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + delegate.write(b); + position++; + } finally { + lock.unlock(); + } + } + + /** + * Writes bytes from the specified byte array to this output stream. + * + * @param b the data to write + * @param off the start offset in the data + * @param len the number of bytes to write + * @throws IOException if interrupted while acquiring the lock, if the stream is closed, or if + * an I/O error occurs + * @throws NullPointerException if {@code b} is null + * @throws IndexOutOfBoundsException if {@code off} or {@code len} is out of bounds + */ + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + Preconditions.checkNotNull(b, "buffer"); + Objects.checkFromIndexSize(off, len, b.length); + delegate.write(b, off, len); + position += len; + } finally { + lock.unlock(); + } + } + + /** + * Flushes this output stream and forces any buffered data to be uploaded. + * + * @throws IOException if interrupted while acquiring the lock, if the stream is closed, or if + * an I/O error occurs + */ + @Override + public void flush() throws IOException { + acquireLockInterruptibly(); + try { + checkNotClosed(); + delegate.flush(); + } finally { + lock.unlock(); + } + } + + /** + * Flushes all buffered data and commits the file, making it visible to readers. + * + *
This closes the underlying stream, triggering the commit. After this method returns + * successfully, the file is durable and visible. No further writes are possible — subsequent + * write or flush calls will throw {@link IOException}. + * + *
Calling {@code sync()} on an already-synced or closed stream is a no-op. + * + *
Uses non-interruptible {@code lock()} so that the commit always completes even if the + * calling thread is interrupted. + * + * @throws IOException if the commit fails + */ + @Override + public void sync() throws IOException { + commitAndClose(); + } + + /** + * Closes this output stream and attempts to commit the file. + * + *
On the happy path, callers should use {@link #sync()} before close to guarantee data + * visibility. If {@code sync()} was already called, this method is a no-op. + * + *
This method is safe to call from any thread (e.g., task cancellation or safety-net + * cleanup). Uses non-interruptible {@code lock()} so that stream cleanup always completes. + * + * @throws IOException if closing the stream fails + */ + @Override + public void close() throws IOException { + commitAndClose(); + } + + private void commitAndClose() throws IOException { + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + flushAndCloseDelegate(); + } finally { + lock.unlock(); + } + } + + private void flushAndCloseDelegate() throws IOException { + Exception primaryException = null; + try { + delegate.flush(); + } catch (final Exception e) { + primaryException = e; + } + try { + delegate.close(); + } catch (final Exception closeEx) { + if (primaryException != null) { + primaryException.addSuppressed(closeEx); + } else { + primaryException = closeEx; + } + } + if (primaryException != null) { + ExceptionUtils.rethrowIOException(primaryException); + } + } + + @GuardedBy("lock") + private void checkNotClosed() throws IOException { + Preconditions.checkState(lock.isHeldByCurrentThread()); + if (closed) { + throw new IOException("Stream already closed: " + filePath); + } + } + + /** + * Acquires the lock, wrapping {@link InterruptedException} as {@link IOException} while + * restoring the interrupt flag. + */ + private void acquireLockInterruptibly() throws IOException { + try { + lock.lockInterruptibly(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting to acquire stream lock: " + filePath, e); + } + } +} diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/OutputStreamOpener.java b/flink-core/src/main/java/org/apache/flink/core/fs/OutputStreamOpener.java new file mode 100644 index 0000000000000..c15cf2e39ba3d --- /dev/null +++ b/flink-core/src/main/java/org/apache/flink/core/fs/OutputStreamOpener.java @@ -0,0 +1,50 @@ +/* + * 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.flink.core.fs; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.annotation.Internal; + +import javax.annotation.concurrent.NotThreadSafe; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Cloud-agnostic opener that creates an output stream for a write operation. + * + *
The implementation captures all cloud-specific state (client, path, bucket) in its closure.
+ * The {@link WriteContext} provides metadata to attach to the cloud object before the stream is
+ * opened.
+ */
+@Internal
+@Experimental
+@NotThreadSafe
+@FunctionalInterface
+public interface OutputStreamOpener {
+
+ /**
+ * Opens an output stream for the write operation described by {@code ctx}.
+ *
+ * @param ctx context for this write, including metadata to attach to the cloud object
+ * @return an output stream for writing the file content
+ * @throws IOException if the stream cannot be opened
+ */
+ OutputStream open(WriteContext ctx) throws IOException;
+}
diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/RawAndWrappedInputStreams.java b/flink-core/src/main/java/org/apache/flink/core/fs/RawAndWrappedInputStreams.java
new file mode 100644
index 0000000000000..bc0724c4a9f70
--- /dev/null
+++ b/flink-core/src/main/java/org/apache/flink/core/fs/RawAndWrappedInputStreams.java
@@ -0,0 +1,58 @@
+/*
+ * 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.flink.core.fs;
+
+import org.apache.flink.annotation.Experimental;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.util.Preconditions;
+
+import java.io.InputStream;
+
+/**
+ * A pair of streams returned by {@link
+ * InputStreamExtension#openStream(InputStreamExtension.StreamContext)}.
+ */
+@Internal
+@Experimental
+public final class RawAndWrappedInputStreams {
+
+ private final InputStream sdk;
+ private final InputStream wrapped;
+
+ /**
+ * Creates a new stream pair.
+ *
+ * @param sdk the raw SDK stream
+ * @param wrapped the wrapped stream used for reads
+ */
+ public RawAndWrappedInputStreams(final InputStream sdk, final InputStream wrapped) {
+ this.sdk = Preconditions.checkNotNull(sdk, "sdk");
+ this.wrapped = Preconditions.checkNotNull(wrapped, "wrapped");
+ }
+
+ /** Returns the raw SDK stream. */
+ public InputStream sdk() {
+ return sdk;
+ }
+
+ /** Returns the wrapped stream used for reads. */
+ public InputStream wrapped() {
+ return wrapped;
+ }
+}
diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/ReadContext.java b/flink-core/src/main/java/org/apache/flink/core/fs/ReadContext.java
new file mode 100644
index 0000000000000..3d38b950b83c3
--- /dev/null
+++ b/flink-core/src/main/java/org/apache/flink/core/fs/ReadContext.java
@@ -0,0 +1,55 @@
+/*
+ * 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.flink.core.fs;
+
+import org.apache.flink.annotation.Experimental;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.util.Preconditions;
+
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * Context passed to {@link InputStreamOpener} describing the desired read position.
+ *
+ * @see InputStreamOpener
+ */
+@Internal
+@Experimental
+@Immutable
+public interface ReadContext {
+
+ /**
+ * Returns the byte offset at which the stream should start.
+ *
+ * @return byte offset; must be ≥ 0
+ */
+ long getPos();
+
+ /**
+ * Creates a {@link ReadContext} for the given byte position.
+ *
+ * @param pos byte offset; must be ≥ 0
+ * @return a {@link ReadContext} that returns {@code pos}
+ * @throws IllegalArgumentException if {@code pos} is negative
+ */
+ static ReadContext of(final long pos) {
+ Preconditions.checkArgument(pos >= 0, "pos must be >= 0");
+ return () -> pos;
+ }
+}
diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/WriteContext.java b/flink-core/src/main/java/org/apache/flink/core/fs/WriteContext.java
new file mode 100644
index 0000000000000..262db64f71a71
--- /dev/null
+++ b/flink-core/src/main/java/org/apache/flink/core/fs/WriteContext.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.flink.core.fs;
+
+import org.apache.flink.annotation.Experimental;
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.util.Preconditions;
+
+import javax.annotation.concurrent.Immutable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Context passed to {@link OutputStreamOpener} describing the write operation, including metadata
+ * to be persisted with the cloud object.
+ *
+ * @see OutputStreamOpener
+ */
+@Internal
+@Experimental
+@Immutable
+public interface WriteContext {
+
+ /**
+ * Returns the metadata to attach to the cloud object (e.g., encryption headers).
+ *
+ * @return key-value metadata map; never {@code null}
+ */
+ Map A blocking write holds the lock; close() must wait until the write releases it.
+ */
+ @Test
+ void shouldBlockConcurrentWriteDuringClose() throws Exception {
+ final CountDownLatch writeStarted = new CountDownLatch(1);
+ final CountDownLatch writePermit = new CountDownLatch(1);
+ final RecordingOutputStream recordingOut = new RecordingOutputStream();
+ final OutputStream blockingDelegate =
+ new OutputStream() {
+ @Override
+ public void write(final int b) throws IOException {
+ writeStarted.countDown();
+ try {
+ writePermit.await();
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(e);
+ }
+ recordingOut.write(b);
+ }
+
+ @Override
+ public void flush() throws IOException {
+ recordingOut.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ recordingOut.close();
+ }
+ };
+
+ createStream(blockingDelegate);
+
+ final AtomicReference