diff --git a/flight/flight-core/pom.xml b/flight/flight-core/pom.xml index 905db61af7..ee1068c85d 100644 --- a/flight/flight-core/pom.xml +++ b/flight/flight-core/pom.xml @@ -61,6 +61,8 @@ under the License. io.grpc grpc-core + + runtime io.grpc diff --git a/flight/flight-core/src/main/java/module-info.java b/flight/flight-core/src/main/java/module-info.java index 669797ac93..9bafc5fddf 100644 --- a/flight/flight-core/src/main/java/module-info.java +++ b/flight/flight-core/src/main/java/module-info.java @@ -30,7 +30,6 @@ requires com.google.protobuf; requires com.google.protobuf.util; requires io.grpc; - requires io.grpc.internal; requires io.grpc.netty; requires io.grpc.protobuf; requires io.grpc.stub; diff --git a/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java b/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java index ab4eab3048..325671fc94 100644 --- a/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java +++ b/flight/flight-core/src/main/java/org/apache/arrow/flight/ArrowMessage.java @@ -23,7 +23,10 @@ import com.google.protobuf.CodedInputStream; import com.google.protobuf.CodedOutputStream; import com.google.protobuf.WireFormat; +import io.grpc.Detachable; import io.grpc.Drainable; +import io.grpc.HasByteBuffer; +import io.grpc.KnownLength; import io.grpc.MethodDescriptor.Marshaller; import io.grpc.protobuf.ProtoUtils; import io.netty.buffer.ByteBuf; @@ -46,6 +49,8 @@ import org.apache.arrow.flight.impl.Flight.FlightDescriptor; import org.apache.arrow.memory.ArrowBuf; import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.ForeignAllocation; +import org.apache.arrow.memory.util.MemoryUtil; import org.apache.arrow.util.AutoCloseables; import org.apache.arrow.util.Preconditions; import org.apache.arrow.vector.ipc.message.ArrowDictionaryBatch; @@ -280,12 +285,24 @@ public Iterable getBufs() { } private static ArrowMessage frame(BufferAllocator allocator, final InputStream stream) { + if (ENABLE_ZERO_COPY_READ) { + // If gRPC lets us take ownership of the whole message, parse it in place: the same loop + // below then runs over our own buffer and hands out slices instead of copies. + final OwnedFrame owned = OwnedFrame.tryTakeOwnership(allocator, stream); + if (owned != null) { + try { + return frame(allocator, owned); + } finally { + owned.close(); + } + } + } + ArrowBuf body = null; + ArrowBuf appMetadata = null; try { FlightDescriptor descriptor = null; MessageMetadataResult header = null; - ArrowBuf body = null; - ArrowBuf appMetadata = null; while (stream.available() > 0) { final int tagFirstByte = stream.read(); if (tagFirstByte == -1) { @@ -312,8 +329,7 @@ private static ArrowMessage frame(BufferAllocator allocator, final InputStream s case APP_METADATA_TAG: { int size = readRawVarint32(stream); - appMetadata = allocator.buffer(size); - GetReadableBuffer.readIntoBuffer(stream, appMetadata, size, ENABLE_ZERO_COPY_READ); + appMetadata = readBuffer(allocator, stream, size); break; } case BODY_TAG: @@ -323,8 +339,7 @@ private static ArrowMessage frame(BufferAllocator allocator, final InputStream s body = null; } int size = readRawVarint32(stream); - body = allocator.buffer(size); - GetReadableBuffer.readIntoBuffer(stream, body, size, ENABLE_ZERO_COPY_READ); + body = readBuffer(allocator, stream, size); break; default: @@ -362,9 +377,17 @@ private static ArrowMessage frame(BufferAllocator allocator, final InputStream s break; } } - return new ArrowMessage(descriptor, header, appMetadata, body); + final ArrowMessage message = new ArrowMessage(descriptor, header, appMetadata, body); + // Ownership of the buffers has moved into the message. + appMetadata = null; + body = null; + return message; } catch (Exception ioe) { throw new RuntimeException(ioe); + } finally { + // Only non-null if parsing failed part-way through. + AutoCloseables.closeNoChecked(appMetadata); + AutoCloseables.closeNoChecked(body); } } @@ -373,6 +396,154 @@ private static int readRawVarint32(InputStream is) throws IOException { return readRawVarint32(firstByte, is); } + /** Read {@code size} bytes of a length-delimited field into a buffer the caller must close. */ + private static ArrowBuf readBuffer(BufferAllocator allocator, InputStream stream, int size) + throws IOException { + if (stream instanceof OwnedFrame) { + return ((OwnedFrame) stream).slice(size); + } + final ArrowBuf buffer = allocator.buffer(size); + try { + GetReadableBuffer.readIntoBuffer(stream, buffer, size, ENABLE_ZERO_COPY_READ); + return buffer; + } catch (IOException | RuntimeException | Error e) { + buffer.close(); + throw e; + } + } + + /** + * A gRPC message we own outright, readable as a stream and sliceable without copying. + * + *

gRPC closes the stream it passes to a marshaller as soon as parsing returns, so a buffer + * merely borrowed through {@link HasByteBuffer} cannot outlive {@code parse()}. {@link + * Detachable#detach()} transfers ownership of the buffer to us; we wrap that memory as a foreign + * allocation whose release closes the detached stream, and hand out retained slices of it. The + * memory goes back to gRPC once the message and every slice are closed. + */ + private static final class OwnedFrame extends InputStream { + private final ArrowBuf frame; + private final int length; + private int position; + + private OwnedFrame(ArrowBuf frame, int length) { + this.frame = frame; + this.length = length; + } + + /** + * Take ownership of the stream's buffer if gRPC exposes it and it holds the entire message. + * + * @return the owned frame, or null to fall back to copying. + */ + static OwnedFrame tryTakeOwnership(BufferAllocator allocator, InputStream stream) { + if (!(stream instanceof Detachable) + || !(stream instanceof HasByteBuffer) + || !(stream instanceof KnownLength) + || !((HasByteBuffer) stream).byteBufferSupported()) { + return null; + } + final int size; + try { + size = stream.available(); + } catch (IOException e) { + throw new RuntimeException("Failed to query gRPC stream length", e); + } + final ByteBuffer peek = ((HasByteBuffer) stream).getByteBuffer(); + if (peek == null || !peek.isDirect() || size == 0 || peek.remaining() != size) { + // Empty, on-heap, or split across several buffers: only a single direct buffer holding + // the whole message can be wrapped. + return null; + } + + final InputStream owner = ((Detachable) stream).detach(); + final long address; + try { + if (!(owner instanceof HasByteBuffer) || !((HasByteBuffer) owner).byteBufferSupported()) { + throw new IllegalStateException("Detached gRPC stream does not expose its buffer"); + } + final ByteBuffer owned = ((HasByteBuffer) owner).getByteBuffer(); + if (owned == null || !owned.isDirect() || owned.remaining() != size) { + throw new IllegalStateException("Detached gRPC buffer differs from the one inspected"); + } + address = MemoryUtil.getByteBufferAddress(owned) + owned.position(); + } catch (RuntimeException | Error e) { + AutoCloseables.closeNoChecked(owner); + throw e; + } + + final ArrowBuf frame; + try { + frame = + allocator.wrapForeignAllocation( + new ForeignAllocation(size, address) { + @Override + protected void release0() { + AutoCloseables.closeNoChecked(owner); + } + }); + } catch (RuntimeException | Error e) { + // wrapForeignAllocation failed before adopting the allocation, so release0 will not run. + AutoCloseables.closeNoChecked(owner); + throw e; + } + return new OwnedFrame(frame, size); + } + + /** A zero-copy view of the next {@code size} bytes; the caller owns the returned buffer. */ + ArrowBuf slice(int size) throws IOException { + if (size < 0 || size > available()) { + throw new IOException("Unexpected end of gRPC message"); + } + final int offset = position; + position += size; + // A slice shares the frame's reference count, so give it its own reference first. + frame.getReferenceManager().retain(); + try { + return frame.slice(offset, size); + } catch (RuntimeException | Error e) { + frame.getReferenceManager().release(); + throw e; + } + } + + @Override + public int read() { + if (position >= length) { + return -1; + } + return frame.getByte(position++) & 0xFF; + } + + @Override + public int read(byte[] b, int off, int len) { + if (position >= length) { + return -1; + } + final int n = Math.min(len, available()); + frame.getBytes(position, b, off, n); + position += n; + return n; + } + + @Override + public long skip(long n) { + final int skipped = (int) Math.min(n, available()); + position += skipped; + return skipped; + } + + @Override + public int available() { + return length - position; + } + + @Override + public void close() { + frame.close(); + } + } + private static int readRawVarint32(int firstByte, InputStream is) throws IOException { return CodedInputStream.readRawVarint32(firstByte, is); } diff --git a/flight/flight-core/src/main/java/org/apache/arrow/flight/FlightStream.java b/flight/flight-core/src/main/java/org/apache/arrow/flight/FlightStream.java index 15cfd6ba85..9195eb6780 100644 --- a/flight/flight-core/src/main/java/org/apache/arrow/flight/FlightStream.java +++ b/flight/flight-core/src/main/java/org/apache/arrow/flight/FlightStream.java @@ -480,6 +480,7 @@ public void onNext(ArrowMessage msg) { ex = new UnsupportedOperationException( "Unable to handle message of type: " + msg.getMessageType()); + AutoCloseables.closeNoChecked(msg); enqueue(DONE_EX); } } diff --git a/flight/flight-core/src/main/java/org/apache/arrow/flight/grpc/GetReadableBuffer.java b/flight/flight-core/src/main/java/org/apache/arrow/flight/grpc/GetReadableBuffer.java index fcba88d212..341f10b16e 100644 --- a/flight/flight-core/src/main/java/org/apache/arrow/flight/grpc/GetReadableBuffer.java +++ b/flight/flight-core/src/main/java/org/apache/arrow/flight/grpc/GetReadableBuffer.java @@ -16,84 +16,81 @@ */ package org.apache.arrow.flight.grpc; -import com.google.common.base.Throwables; import com.google.common.io.ByteStreams; -import io.grpc.internal.ReadableBuffer; +import io.grpc.HasByteBuffer; import java.io.IOException; import java.io.InputStream; -import java.lang.reflect.Field; +import java.nio.ByteBuffer; import org.apache.arrow.memory.ArrowBuf; /** - * Enable access to ReadableBuffer directly to copy data from a BufferInputStream into a target - * ByteBuffer/ByteBuf. + * Reads a gRPC message body into an {@link ArrowBuf}. * - *

This could be solved by BufferInputStream exposing Drainable. + *

When the stream gRPC hands us exposes its backing buffers through {@link HasByteBuffer}, the + * bytes are copied straight from those buffers into the target, skipping the intermediate heap + * array. Otherwise the stream is read into a heap array first. */ public class GetReadableBuffer { - private static final Field READABLE_BUFFER; - private static final Class BUFFER_INPUT_STREAM; - - static { - Field tmpField = null; - Class tmpClazz = null; - try { - Class clazz = Class.forName("io.grpc.internal.ReadableBuffers$BufferInputStream"); - - Field f = clazz.getDeclaredField("buffer"); - f.setAccessible(true); - // don't set until we've gotten past all exception cases. - tmpField = f; - tmpClazz = clazz; - } catch (Exception e) { - new RuntimeException("Failed to initialize GetReadableBuffer, falling back to slow path", e) - .printStackTrace(); - } - READABLE_BUFFER = tmpField; - BUFFER_INPUT_STREAM = tmpClazz; - } - - /** - * Extracts the ReadableBuffer for the given input stream. - * - * @param is Must be an instance of io.grpc.internal.ReadableBuffers$BufferInputStream or null - * will be returned. - */ - public static ReadableBuffer getReadableBuffer(InputStream is) { - - if (BUFFER_INPUT_STREAM == null || !is.getClass().equals(BUFFER_INPUT_STREAM)) { - return null; - } - - try { - return (ReadableBuffer) READABLE_BUFFER.get(is); - } catch (Exception ex) { - throw Throwables.propagate(ex); - } - } + private GetReadableBuffer() {} /** - * Helper method to read a gRPC-provided InputStream into an ArrowBuf. + * Read exactly {@code size} bytes from {@code stream} into {@code buf}. * - * @param stream The stream to read from. Should be an instance of {@link #BUFFER_INPUT_STREAM}. - * @param buf The buffer to read into. + * @param stream The stream to read from. + * @param buf The buffer to read into. Its writer index is set to {@code size} on success. * @param size The number of bytes to read. - * @param fastPath Whether to enable the fast path (i.e. detect whether the stream is a {@link - * #BUFFER_INPUT_STREAM}). - * @throws IOException if there is an error reading form the stream + * @param fastPath Whether to copy directly from the stream's backing buffers when it exposes + * them. + * @throws IOException if the stream ends early or cannot be read. */ public static void readIntoBuffer( final InputStream stream, final ArrowBuf buf, final int size, final boolean fastPath) throws IOException { - ReadableBuffer readableBuffer = fastPath ? getReadableBuffer(stream) : null; - byte[] heapBytes = new byte[size]; - if (readableBuffer != null) { - readableBuffer.readBytes(heapBytes, 0, size); + if (fastPath + && stream instanceof HasByteBuffer + && ((HasByteBuffer) stream).byteBufferSupported()) { + readFromByteBuffers((HasByteBuffer) stream, stream, buf, size); } else { + final byte[] heapBytes = new byte[size]; ByteStreams.readFully(stream, heapBytes); + buf.setBytes(0, heapBytes); } - buf.writeBytes(heapBytes); buf.writerIndex(size); } + + private static void readFromByteBuffers( + final HasByteBuffer source, final InputStream stream, final ArrowBuf buf, final int size) + throws IOException { + int copied = 0; + while (copied < size) { + final ByteBuffer chunk = source.getByteBuffer(); + if (chunk == null || !chunk.hasRemaining()) { + throw new IOException( + "Unexpected end of gRPC stream: expected " + size + " bytes, got " + copied); + } + final int toRead = Math.min(size - copied, chunk.remaining()); + buf.setBytes(copied, chunk, chunk.position(), toRead); + // getByteBuffer() does not advance the stream; skip() consumes the bytes we just copied. + consume(stream, toRead); + copied += toRead; + } + } + + private static void consume(final InputStream stream, final int count) throws IOException { + int skipped = 0; + while (skipped < count) { + final long n = stream.skip(count - skipped); + if (n > 0) { + skipped += (int) n; + continue; + } + // InputStream.skip is allowed to return 0 before the end of the stream. Force progress with + // a single-byte read, which does distinguish end of stream. + if (stream.read() == -1) { + throw new IOException("Unexpected end of gRPC stream while skipping copied bytes"); + } + skipped++; + } + } } diff --git a/flight/flight-core/src/test/java/org/apache/arrow/flight/TestArrowMessageDetachable.java b/flight/flight-core/src/test/java/org/apache/arrow/flight/TestArrowMessageDetachable.java new file mode 100644 index 0000000000..0d4661723f --- /dev/null +++ b/flight/flight-core/src/test/java/org/apache/arrow/flight/TestArrowMessageDetachable.java @@ -0,0 +1,282 @@ +/* + * 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.arrow.flight; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.protobuf.ByteString; +import com.google.protobuf.CodedOutputStream; +import com.google.protobuf.WireFormat; +import io.grpc.Detachable; +import io.grpc.HasByteBuffer; +import io.grpc.KnownLength; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; +import org.apache.arrow.flight.impl.Flight.FlightData; +import org.apache.arrow.memory.ArrowBuf; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Tests for parsing FlightData without copying, by taking ownership of gRPC's buffer. */ +public class TestArrowMessageDetachable { + + private BufferAllocator allocator; + + @BeforeEach + public void setUp() { + allocator = new RootAllocator(Long.MAX_VALUE); + } + + @AfterEach + public void tearDown() { + assertEquals(0, allocator.getAllocatedMemory()); + allocator.close(); + } + + @Test + public void contiguousDirectBufferIsDetachedAndWrappedWithoutCopy() throws Exception { + final byte[] metadata = payload(16); + final byte[] body = payload(64); + final byte[] serialized = flightData(metadata, body); + final MockGrpcInputStream stream = MockGrpcInputStream.direct(serialized); + + try (ArrowMessage message = ArrowMessage.createMarshaller(allocator).parse(stream)) { + // gRPC closes the stream it handed us as soon as parse() returns. + stream.close(); + + assertEquals(1, stream.state.detachCount, "must take ownership through detach()"); + assertEquals( + serialized.length, + allocator.getAllocatedMemory(), + "the whole gRPC frame is wrapped, not a copy of the body"); + assertArrayEquals(metadata, contents(message.getApplicationMetadata())); + assertArrayEquals(body, contents(message.getBufs().iterator().next())); + assertEquals( + 0, stream.state.detachedCloseCount, "gRPC memory must stay alive with the message"); + } + assertEquals( + 1, stream.state.detachedCloseCount, "closing the message releases gRPC's buffer once"); + } + + @Test + public void heapBufferFallsBackToCopyingWithoutDetaching() throws Exception { + final byte[] metadata = payload(16); + final byte[] body = payload(64); + final MockGrpcInputStream stream = MockGrpcInputStream.heap(flightData(metadata, body)); + + try (ArrowMessage message = ArrowMessage.createMarshaller(allocator).parse(stream)) { + stream.close(); + + assertEquals(0, stream.state.detachCount, "on-heap buffers cannot be wrapped"); + assertEquals( + metadata.length + body.length, + allocator.getAllocatedMemory(), + "copying path allocates exactly the two fields"); + assertArrayEquals(metadata, contents(message.getApplicationMetadata())); + assertArrayEquals(body, contents(message.getBufs().iterator().next())); + } + assertEquals(0, stream.state.detachedCloseCount); + } + + @Test + public void messageSplitAcrossBuffersFallsBackToCopying() throws Exception { + final byte[] metadata = payload(16); + final byte[] body = payload(64); + final byte[] serialized = flightData(metadata, body); + final MockGrpcInputStream stream = MockGrpcInputStream.directFragmented(serialized, 20); + + try (ArrowMessage message = ArrowMessage.createMarshaller(allocator).parse(stream)) { + stream.close(); + + assertEquals(0, stream.state.detachCount, "only a single contiguous buffer is wrapped"); + assertEquals(metadata.length + body.length, allocator.getAllocatedMemory()); + assertArrayEquals(metadata, contents(message.getApplicationMetadata())); + assertArrayEquals(body, contents(message.getBufs().iterator().next())); + } + } + + @Test + public void parseFailureReleasesTheDetachedBuffer() throws Exception { + // A complete body, then a descriptor whose declared length runs past the end of the frame. + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final CodedOutputStream out = CodedOutputStream.newInstance(bytes); + out.writeBytes(FlightData.DATA_BODY_FIELD_NUMBER, ByteString.copyFrom(payload(8))); + out.writeTag(FlightData.FLIGHT_DESCRIPTOR_FIELD_NUMBER, WireFormat.WIRETYPE_LENGTH_DELIMITED); + out.writeUInt32NoTag(10); + out.writeRawByte(1); + out.flush(); + final MockGrpcInputStream stream = MockGrpcInputStream.direct(bytes.toByteArray()); + + assertThrows( + RuntimeException.class, () -> ArrowMessage.createMarshaller(allocator).parse(stream)); + stream.close(); + + assertEquals(1, stream.state.detachCount); + assertEquals(1, stream.state.detachedCloseCount, "the detached buffer must not leak"); + assertEquals(0, allocator.getAllocatedMemory()); + } + + @Test + public void lastOfDuplicateBodyFieldsWinsWithoutLeaking() throws Exception { + final byte[] first = payload(8); + final byte[] second = payload(24); + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final CodedOutputStream out = CodedOutputStream.newInstance(bytes); + out.writeBytes(FlightData.DATA_BODY_FIELD_NUMBER, ByteString.copyFrom(first)); + out.writeBytes(FlightData.DATA_BODY_FIELD_NUMBER, ByteString.copyFrom(second)); + out.flush(); + final byte[] serialized = bytes.toByteArray(); + final MockGrpcInputStream stream = MockGrpcInputStream.direct(serialized); + + try (ArrowMessage message = ArrowMessage.createMarshaller(allocator).parse(stream)) { + stream.close(); + assertEquals(1, stream.state.detachCount); + assertArrayEquals(second, contents(message.getBufs().iterator().next())); + assertEquals(serialized.length, allocator.getAllocatedMemory()); + } + assertEquals(1, stream.state.detachedCloseCount); + } + + private static byte[] flightData(byte[] metadata, byte[] body) { + return FlightData.newBuilder() + .setAppMetadata(ByteString.copyFrom(metadata)) + .setDataBody(ByteString.copyFrom(body)) + .build() + .toByteArray(); + } + + private static byte[] payload(int size) { + final byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = (byte) (i * 13 + 7); + } + return bytes; + } + + private static byte[] contents(ArrowBuf buf) { + final byte[] out = new byte[(int) buf.writerIndex()]; + buf.getBytes(0, out); + return out; + } + + /** + * A stand-in for the stream gRPC's Netty transport hands to a marshaller: one buffer, exposed + * through the three public capabilities, with counters for detach and close. + */ + static final class MockGrpcInputStream extends InputStream + implements Detachable, HasByteBuffer, KnownLength { + + static final class State { + int detachCount; + int detachedCloseCount; + } + + final State state; + private ByteBuffer buffer; + private final boolean detached; + + /** How many bytes getByteBuffer() exposes at once; gRPC may hold a message in pieces. */ + private final int exposeLimit; + + static MockGrpcInputStream direct(byte[] bytes) { + return directFragmented(bytes, Integer.MAX_VALUE); + } + + static MockGrpcInputStream directFragmented(byte[] bytes, int exposeLimit) { + final ByteBuffer buffer = ByteBuffer.allocateDirect(bytes.length); + buffer.put(bytes).flip(); + return new MockGrpcInputStream(buffer, new State(), false, exposeLimit); + } + + static MockGrpcInputStream heap(byte[] bytes) { + return new MockGrpcInputStream(ByteBuffer.wrap(bytes), new State(), false, Integer.MAX_VALUE); + } + + private MockGrpcInputStream(ByteBuffer buffer, State state, boolean detached, int exposeLimit) { + this.buffer = buffer; + this.state = state; + this.detached = detached; + this.exposeLimit = exposeLimit; + } + + @Override + public int read() { + return buffer.hasRemaining() ? buffer.get() & 0xFF : -1; + } + + @Override + public int read(byte[] b, int off, int len) { + if (!buffer.hasRemaining()) { + return -1; + } + final int n = Math.min(len, buffer.remaining()); + buffer.get(b, off, n); + return n; + } + + @Override + public long skip(long n) { + final int skipped = (int) Math.min(n, buffer.remaining()); + buffer.position(buffer.position() + skipped); + return skipped; + } + + @Override + public int available() { + return buffer.remaining(); + } + + @Override + public boolean byteBufferSupported() { + return true; + } + + @Override + public ByteBuffer getByteBuffer() { + if (!buffer.hasRemaining()) { + return null; + } + final ByteBuffer view = buffer.duplicate(); + if (exposeLimit < view.remaining()) { + view.limit(view.position() + exposeLimit); + } + return view; + } + + @Override + public InputStream detach() { + state.detachCount++; + final MockGrpcInputStream owner = new MockGrpcInputStream(buffer, state, true, exposeLimit); + buffer = ByteBuffer.allocate(0); + return owner; + } + + @Override + public void close() { + if (detached) { + state.detachedCloseCount++; + } + buffer = ByteBuffer.allocate(0); + } + } +} diff --git a/flight/flight-core/src/test/java/org/apache/arrow/flight/TestFlightZeroCopyRead.java b/flight/flight-core/src/test/java/org/apache/arrow/flight/TestFlightZeroCopyRead.java new file mode 100644 index 0000000000..4431f301df --- /dev/null +++ b/flight/flight-core/src/test/java/org/apache/arrow/flight/TestFlightZeroCopyRead.java @@ -0,0 +1,129 @@ +/* + * 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.arrow.flight; + +import static org.apache.arrow.flight.FlightTestUtil.LOCALHOST; +import static org.apache.arrow.flight.Location.forGrpcInsecure; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.io.ByteStreams; +import io.grpc.Drainable; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.VectorUnloader; +import org.apache.arrow.vector.ipc.message.IpcOption; +import org.junit.jupiter.api.Test; + +/** + * Proves the zero-copy read path is taken over a real gRPC transport, not only against a mock. + * + *

The oracle is allocator accounting. When the client takes ownership of gRPC's buffer, it is + * charged for the whole wire frame (header, tags and body). When it copies, it is charged for a + * buffer of the body's size only. The two are never equal, so the number distinguishes the paths. + */ +public class TestFlightZeroCopyRead { + + private static final int ROWS = 1000; + + @Test + public void recordBatchIsReadWithoutCopyingOverRealTransport() throws Exception { + assertTrue(ArrowMessage.ENABLE_ZERO_COPY_READ, "zero-copy reads must be enabled for this test"); + try (BufferAllocator serverAllocator = new RootAllocator(Long.MAX_VALUE); + BufferAllocator clientAllocator = new RootAllocator(Long.MAX_VALUE); + FlightServer server = + FlightServer.builder( + serverAllocator, + forGrpcInsecure(LOCALHOST, 0), + new OneBatchProducer(serverAllocator)) + .build() + .start(); + FlightClient client = FlightClient.builder(clientAllocator, server.getLocation()).build()) { + final long wireSize = wireSizeOfTheBatch(clientAllocator); + assertEquals(0, clientAllocator.getAllocatedMemory()); + + try (FlightStream stream = client.getStream(new Ticket(new byte[0]))) { + assertTrue(stream.next()); + final VectorSchemaRoot root = stream.getRoot(); + final IntVector values = (IntVector) root.getVector("c1"); + assertEquals(ROWS, root.getRowCount()); + for (int i = 0; i < ROWS; i++) { + assertEquals(i * 3, values.get(i)); + } + assertEquals( + wireSize, + clientAllocator.getAllocatedMemory(), + "the client should be holding gRPC's whole frame, not a copy of the body"); + assertFalse(stream.next()); + } + assertEquals(0, clientAllocator.getAllocatedMemory(), "gRPC's buffer must be released"); + } + } + + /** The exact number of bytes the server puts on the wire for the batch the producer sends. */ + private static long wireSizeOfTheBatch(BufferAllocator allocator) throws Exception { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + // Same ownership convention as the server: the batch's buffer references belong to the + // message, which is closed once the wire stream is done with them. + try (VectorSchemaRoot root = theBatch(allocator); + ArrowMessage message = + new ArrowMessage( + new VectorUnloader(root).getRecordBatch(), null, false, IpcOption.DEFAULT); + InputStream wire = ArrowMessage.createMarshaller(allocator).stream(message)) { + if (wire instanceof Drainable) { + ((Drainable) wire).drainTo(bytes); + } else { + ByteStreams.copy(wire, bytes); + } + } + return bytes.size(); + } + + private static VectorSchemaRoot theBatch(BufferAllocator allocator) { + final IntVector values = new IntVector("c1", allocator); + final VectorSchemaRoot root = VectorSchemaRoot.of(values); + root.allocateNew(); + for (int i = 0; i < ROWS; i++) { + values.set(i, i * 3); + } + values.setValueCount(ROWS); + root.setRowCount(ROWS); + return root; + } + + private static final class OneBatchProducer extends NoOpFlightProducer { + private final BufferAllocator allocator; + + OneBatchProducer(BufferAllocator allocator) { + this.allocator = allocator; + } + + @Override + public void getStream(CallContext context, Ticket ticket, ServerStreamListener listener) { + try (VectorSchemaRoot root = theBatch(allocator)) { + listener.start(root); + listener.putNext(); + listener.completed(); + } + } + } +} diff --git a/flight/flight-core/src/test/java/org/apache/arrow/flight/grpc/TestGetReadableBuffer.java b/flight/flight-core/src/test/java/org/apache/arrow/flight/grpc/TestGetReadableBuffer.java new file mode 100644 index 0000000000..589ce7df17 --- /dev/null +++ b/flight/flight-core/src/test/java/org/apache/arrow/flight/grpc/TestGetReadableBuffer.java @@ -0,0 +1,271 @@ +/* + * 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.arrow.flight.grpc; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.grpc.HasByteBuffer; +import io.grpc.KnownLength; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import org.apache.arrow.memory.ArrowBuf; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Tests for reading gRPC message bodies into an {@link ArrowBuf} through gRPC's public API. */ +public class TestGetReadableBuffer { + + private BufferAllocator allocator; + + @BeforeEach + public void setUp() { + allocator = new RootAllocator(Long.MAX_VALUE); + } + + @AfterEach + public void tearDown() { + assertEquals(0, allocator.getAllocatedMemory()); + allocator.close(); + } + + @Test + public void fastPathReadsThroughByteBuffersNotThroughHeapReads() throws IOException { + final byte[] payload = payload(64); + try (ChunkedStream stream = new ChunkedStream(payload); + ArrowBuf buf = allocator.buffer(payload.length)) { + GetReadableBuffer.readIntoBuffer(stream, buf, payload.length, true); + + assertArrayEquals(payload, contents(buf)); + assertEquals(0, stream.available()); + assertEquals(0, stream.heapReads, "fast path must not go through InputStream.read"); + assertTrue(stream.byteBufferPeeks > 0, "fast path must use HasByteBuffer.getByteBuffer"); + } + } + + @Test + public void fastPathToleratesSkipReturningZero() throws IOException { + final byte[] payload = payload(48); + try (ChunkedStream stream = new ChunkedStream(true, payload); + ArrowBuf buf = allocator.buffer(payload.length)) { + GetReadableBuffer.readIntoBuffer(stream, buf, payload.length, true); + + assertArrayEquals(payload, contents(buf)); + assertEquals(0, stream.available()); + } + } + + @Test + public void fastPathCopiesAcrossChunkBoundaries() throws IOException { + final byte[] payload = payload(70); + try (ChunkedStream stream = new ChunkedStream(split(payload, 10, 1, 32, 27)); + ArrowBuf buf = allocator.buffer(payload.length)) { + GetReadableBuffer.readIntoBuffer(stream, buf, payload.length, true); + + assertArrayEquals(payload, contents(buf)); + assertEquals(0, stream.available()); + assertEquals(0, stream.heapReads); + } + } + + @Test + public void fastPathReadsOnlyTheRequestedBytes() throws IOException { + final byte[] payload = payload(40); + try (ChunkedStream stream = new ChunkedStream(split(payload, 16, 24)); + ArrowBuf buf = allocator.buffer(20)) { + GetReadableBuffer.readIntoBuffer(stream, buf, 20, true); + + assertArrayEquals(Arrays.copyOf(payload, 20), contents(buf)); + assertEquals(20, stream.available()); + } + } + + @Test + public void fallsBackToHeapReadWhenByteBuffersAreNotSupported() throws IOException { + final byte[] payload = payload(24); + try (ChunkedStream stream = ChunkedStream.withoutByteBufferSupport(split(payload, 9, 15)); + ArrowBuf buf = allocator.buffer(payload.length)) { + GetReadableBuffer.readIntoBuffer(stream, buf, payload.length, true); + + assertArrayEquals(payload, contents(buf)); + assertEquals(0, stream.available()); + assertEquals(0, stream.byteBufferPeeks, "must not call getByteBuffer when unsupported"); + assertTrue(stream.heapReads > 0); + } + } + + @Test + public void fastPathDisabledUsesHeapRead() throws IOException { + final byte[] payload = payload(24); + try (ChunkedStream stream = new ChunkedStream(payload); + ArrowBuf buf = allocator.buffer(payload.length)) { + GetReadableBuffer.readIntoBuffer(stream, buf, payload.length, false); + + assertArrayEquals(payload, contents(buf)); + assertEquals(0, stream.byteBufferPeeks, "fastPath=false must not touch HasByteBuffer"); + assertTrue(stream.heapReads > 0); + } + } + + @Test + public void fastPathThrowsWhenStreamEndsEarly() throws IOException { + final byte[] payload = payload(10); + try (ChunkedStream stream = new ChunkedStream(payload); + ArrowBuf buf = allocator.buffer(16)) { + assertThrows( + IOException.class, () -> GetReadableBuffer.readIntoBuffer(stream, buf, 16, true)); + } + } + + private static byte[][] split(byte[] payload, int... sizes) { + final byte[][] pieces = new byte[sizes.length][]; + int offset = 0; + for (int i = 0; i < sizes.length; i++) { + pieces[i] = Arrays.copyOfRange(payload, offset, offset + sizes[i]); + offset += sizes[i]; + } + if (offset != payload.length) { + throw new IllegalArgumentException("sizes must sum to payload length"); + } + return pieces; + } + + private static byte[] payload(int size) { + final byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = (byte) (i * 7 + 3); + } + return bytes; + } + + private static byte[] contents(ArrowBuf buf) { + final byte[] out = new byte[(int) buf.writerIndex()]; + buf.getBytes(0, out); + return out; + } + + /** + * A stand-in for gRPC's message stream: a chain of direct buffers exposed through {@link + * HasByteBuffer} and {@link KnownLength}, with counters for how it was read. + */ + static final class ChunkedStream extends InputStream implements HasByteBuffer, KnownLength { + private final Deque chunks = new ArrayDeque<>(); + private final boolean skipReturnsZeroEveryOtherCall; + private final boolean byteBufferSupported; + private boolean returnZeroFromNextSkip; + int heapReads; + int byteBufferPeeks; + + ChunkedStream(byte[]... pieces) { + this(false, true, pieces); + } + + ChunkedStream(boolean skipReturnsZeroEveryOtherCall, byte[]... pieces) { + this(skipReturnsZeroEveryOtherCall, true, pieces); + } + + static ChunkedStream withoutByteBufferSupport(byte[]... pieces) { + return new ChunkedStream(false, false, pieces); + } + + private ChunkedStream( + boolean skipReturnsZeroEveryOtherCall, boolean byteBufferSupported, byte[]... pieces) { + this.skipReturnsZeroEveryOtherCall = skipReturnsZeroEveryOtherCall; + this.byteBufferSupported = byteBufferSupported; + this.returnZeroFromNextSkip = skipReturnsZeroEveryOtherCall; + for (byte[] piece : pieces) { + final ByteBuffer chunk = ByteBuffer.allocateDirect(piece.length); + chunk.put(piece).flip(); + chunks.add(chunk); + } + } + + private ByteBuffer current() { + while (!chunks.isEmpty() && !chunks.peek().hasRemaining()) { + chunks.poll(); + } + return chunks.peek(); + } + + @Override + public int read() { + final ByteBuffer chunk = current(); + return chunk == null ? -1 : chunk.get() & 0xFF; + } + + @Override + public int read(byte[] b, int off, int len) { + heapReads++; + final ByteBuffer chunk = current(); + if (chunk == null) { + return -1; + } + final int n = Math.min(len, chunk.remaining()); + chunk.get(b, off, n); + return n; + } + + @Override + public long skip(long n) { + if (skipReturnsZeroEveryOtherCall) { + returnZeroFromNextSkip = !returnZeroFromNextSkip; + if (!returnZeroFromNextSkip) { + // InputStream.skip may legitimately return 0 before end of stream. + return 0; + } + } + final ByteBuffer chunk = current(); + if (chunk == null) { + return 0; + } + // Like gRPC, skip at most one chunk per call. + final int skipped = (int) Math.min(n, chunk.remaining()); + chunk.position(chunk.position() + skipped); + return skipped; + } + + @Override + public int available() { + int total = 0; + for (ByteBuffer chunk : chunks) { + total += chunk.remaining(); + } + return total; + } + + @Override + public boolean byteBufferSupported() { + return byteBufferSupported; + } + + @Override + public ByteBuffer getByteBuffer() { + byteBufferPeeks++; + final ByteBuffer chunk = current(); + return chunk == null ? null : chunk.duplicate(); + } + } +}