dongjoon-hyun commented on code in PR #59934: URL: https://github.com/apache/spark/pull/59934#discussion_r4463375828
########## sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsArrowStreamRead.java: ########## @@ -0,0 +1,60 @@ +/* + * 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.spark.sql.connector.read; + +import org.apache.spark.annotation.Evolving; + +/** + * A mix-in interface for a {@link PartitionReaderFactory} that also reads a partition as an + * <a href="https://arrow.apache.org/docs/format/CStreamInterface.html">Arrow C stream</a>, for an + * engine that processes Arrow data in native code: the engine imports the arrays of the stream + * itself, instead of reading the {@link org.apache.spark.sql.vectorized.ColumnarBatch}es of + * {@link #createColumnarReader(InputPartition)}. The reader factories of the batch and + * micro-batch scans of native data sources implement it, with the stream that the library + * returns. + * <p> + * Each array of the stream is a struct array whose fields are the columns of the read schema of + * the scan. Spark only checks that the source initialized the callbacks of the stream, not its + * arrays, while it checks the arrays of the streams that it reads itself, so the engine checks + * what its import of the arrays requires, such as the schema of the stream against the read + * schema, and the offsets and the lengths of the arrays. The callbacks of the stream can be called + * from any thread, but never concurrently. They call into the JVM, so the engine releases the + * stream before the JVM exits. + * + * @since 4.4.0 + */ +@Evolving +public interface SupportsArrowStreamRead extends PartitionReaderFactory { + + /** + * Initializes an Arrow C stream with the data of a partition. It is called on the thread of the + * running task that reads the partition, and fails otherwise. The caller reads the stream in Review Comment: **5. [Nit] "and fails otherwise" is stricter than the check** `openEngineStream` checks that the thread has the `TaskContext` of a task that is still running (`NativeDataSource.scala` L1010-L1012), not that it is the thread of that task. A thread that got the context with `TaskContext.setTaskContext`, as the stdin writer of `PipedRDD` does, passes too. Could this say something like "It is called in the running task that reads the partition, on a thread that has its `TaskContext`, and fails otherwise"? ########## sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala: ########## @@ -351,17 +378,29 @@ class NativeDataSourceStreamReader( } override def partitions(start: String, end: String): Array[InputPartition] = { - val partitions = call(name, "plan a scan of") { - library.streamPartitions(handle.get, start, end) + if (plannedRange != (start, end)) { + val planned = call(name, "plan a scan of") { + library.streamPartitions(handle.get, start, end) + } + val partitions = NativeInputPartition.from(planned.partitions) + // The executors fetch the state once for the micro-batch, as a broadcast, rather than with + // each of its tasks. Spark only plans the next range once the micro-batch of the previous + // one finished, so the copies of its state on the executors are removed then. The driver + // keeps its own copy, so that the partitions and the reader factories that an engine keeps + // can still fetch it, until the ContextCleaner removes it once nothing refers to them. + plannedState.foreach(_.unpersist(blocking = false)) Review Comment: **2. [Low] `stop()` keeps the broadcast of the last micro-batch on the executors** Thank you for this `unpersist`, which answers [item 5 of round 6](https://github.com/apache/spark/pull/59934#discussion_r4424626687). It only runs when the next range is planned, though, and `stop()` (L410, outside of the diff) only closes the handle. So once the query stopped, the executors keep the state of its last micro-batch until the ContextCleaner removes it after a GC of the driver, which is the case of item 5 again, for one broadcast per query. Could `stop()` also call `plannedState.foreach(_.unpersist(blocking = false))`? ########## sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java: ########## @@ -903,6 +1158,234 @@ private static boolean isInterrupted(long task) { } } + /** The number of task contexts that are not closed yet, for tests. */ + static int openTaskContexts() { + return tasks.size(); + } + + // --------------------------------------------------------------------------------------------- + // Streams of engines + // --------------------------------------------------------------------------------------------- + + // The errno values that the callbacks of a stream return. + private static final int EINVAL = 22; + private static final int EIO = 5; + // The message of get_last_error for a stream that Spark does not know, which lives as long as + // the JVM. + private static final long UNKNOWN_STREAM_ERROR = + toCString("The stream was released, or is not a stream of Spark."); + + private static final AtomicLong nextEngineStreamId = new AtomicLong(1); + // The streams wrapped for engines that are not released yet, by their private_data. + /** + * The callbacks of the streams that Spark wraps for engines, see {@link EngineStream}, and the + * method handles that call the callbacks of the wrapped streams. They are created when the first + * stream is wrapped, so that a JVM that wraps no stream does not create them, and a failure to + * create them only fails the streams of engines. + */ + private static final class EngineStreamCallbacks { + // The addresses of the C functions that call Java, which live as long as the JVM. + static final long GET_SCHEMA; + static final long GET_NEXT; + static final long GET_LAST_ERROR; + static final long RELEASE; + // The callbacks of struct ArrowArrayStream, called with the address of the function first: + // get_schema and get_next, get_last_error, and release. + static final MethodHandle INT_CALLBACK; + static final MethodHandle POINTER_CALLBACK; + static final MethodHandle VOID_CALLBACK; + + static { + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + GET_SCHEMA = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamGetSchema", + MethodType.methodType(int.class, long.class, long.class)), + C.I32, C.PTR, C.PTR); + GET_NEXT = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamGetNext", + MethodType.methodType(int.class, long.class, long.class)), + C.I32, C.PTR, C.PTR); + GET_LAST_ERROR = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamGetLastError", + MethodType.methodType(long.class, long.class)), + C.PTR, C.PTR); + RELEASE = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamRelease", + MethodType.methodType(void.class, long.class)), + C.VOID, C.PTR); + INT_CALLBACK = Ffm.unboundDowncall(C.I32, C.PTR, C.PTR); + POINTER_CALLBACK = Ffm.unboundDowncall(C.PTR, C.PTR); + VOID_CALLBACK = Ffm.unboundDowncall(C.VOID, C.PTR); + } catch (Throwable e) { + // The message names the cause, because the JVM only keeps this message for the later + // uses of the class. + throw new IllegalStateException( + "Cannot create the callbacks of the streams of engines: " + rootCause(e), e); + } + } + } + + private static final ConcurrentHashMap<Long, EngineStream> engineStreams = + new ConcurrentHashMap<>(); + + /** + * The stream of a read that an engine got with {@code SupportsArrowStreamRead}, which ties the + * task context of the read to the stream: the context is closed once the library released the + * stream, whenever and on whichever thread the engine releases it, also after the task ended. + * The stream of the library is moved to memory of Spark, and the stream of the engine gets + * callbacks of Spark, which call the current callbacks of the library's stream, and a + * private_data that identifies the wrapped stream, so that the engine can move the stream too. + */ + private static final class EngineStream { + final long inner; + final Task task; + // The name of the data source, for the errors. + final String name; + // The message of the last error of Spark, rather than of the library, or 0 if the last call + // returned an error of the library, or none. The callbacks never run concurrently. + long sparkError; + + EngineStream(long inner, Task task, String name) { + this.inner = inner; + this.task = task; + this.name = name; + } + + long callback(long offset) { + return Platform.getLong(null, inner + offset); + } + + /** + * Records an error of Spark in the given callback, which get_last_error then returns, and logs + * it. The message is built here, since building it can throw too, as a StackOverflowError on + * the small stack of a thread of an engine can. + */ + int fail(String callback, Throwable e) { + try { + clearError(); + String message = + "The " + callback + " of the stream of the native data source " + name + " failed"; + sparkError = toCString(message + ": " + rootCause(e)); + LOGGER.error(message, e); + } catch (Throwable ignored) { + // An upcall must not throw. + } + return EIO; + } + + void clearError() { + if (sparkError != 0) { + Platform.freeMemory(sparkError); + sparkError = 0; + } + } + } + + /** + * Wraps the stream at the given address, which read initialized with non-NULL callbacks, so that + * releasing it closes the given task context (see {@link EngineStream}). If it fails, the stream + * is left as it is. + * + * @param name the name of the data source, for the errors of the stream + */ + static void wrapEngineStream(long stream, Task task, String name) { + long inner = Platform.allocateMemory(STREAM_SIZE); + long id = nextEngineStreamId.getAndIncrement(); + try { + Platform.copyMemory(null, stream, null, inner, STREAM_SIZE); + engineStreams.put(id, new EngineStream(inner, task, name)); + } catch (Throwable e) { + engineStreams.remove(id); + Platform.freeMemory(inner); + throw e; + } + Platform.putLong(null, stream + STREAM_GET_SCHEMA, EngineStreamCallbacks.GET_SCHEMA); Review Comment: **1. [Low] A failure to create the callbacks leaks the wrapped stream** The first use of `EngineStreamCallbacks` is here, after the `try`, when `engineStreams.put` (L1297) already ran. So if its static initializer fails, the `catch` that removes the entry and frees `inner` does not run, and `releaseOnFailure` (`NativeDataSource.scala` L1033) only releases the stream of the library and closes the task. The class then fails every later use too, with a `NoClassDefFoundError`, so each `openArrowStream` leaves one `EngineStream` in `engineStreams`, with its copy of the stream and a closed task, and `openEngineStreams()` keeps growing. Could this read the four addresses into locals before `allocateMemory`, or move these `putLong` calls into the `try`? ########## sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala: ########## @@ -886,10 +986,132 @@ class NativePartitionReader( } } -/** Writes the batches of a task to the data writer of a native library. */ +object NativePartitionReader { + import NativeCalls._ + + /** + * Initializes the stream at `streamAddress` with the data of a partition for an engine, see + * [[SupportsArrowStreamRead]]: the context of its task is closed once the stream is released. + * It first checks the arguments of `openArrowStream`, before it looks at the partition, which + * must be a `P`, the class of the partitions of the scan of the reader factory. It then reads + * the partition with the state and the partition of the library that `stateAndPartition` + * returns for it. + */ + private[ffi] def openEngineStream[P <: InputPartition: ClassTag]( + location: NativeLibraryLocation, + name: String, + kind: Int, + partition: InputPartition, + schema: StructType, + timeZoneId: String, + streamAddress: Long)( + stateAndPartition: P => (Array[Byte], NativeInputPartition)): Unit = { + // A task that failed or was killed is over too, also while its listeners run. + val context = TaskContext.get() + if (context == null || context.isInterrupted() || context.isFailed() || Review Comment: **4. [Nit] A killed task gets "must be called in the running task"** Thank you for these checks, which answer [item 3 of round 6](https://github.com/apache/spark/pull/59934#discussion_r4424626669). A task that was killed is still the running task, though, so for it this message reads as if the engine called `openArrowStream` from the wrong place. Spark reports a kill with a `TaskKilledException` and the reason of the kill, which `context.killTaskIfInterrupted()` throws. Could this call it after the check of `null`, and keep `isFailed()` and `isCompleted()` in this check? ########## sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java: ########## @@ -903,6 +1158,234 @@ private static boolean isInterrupted(long task) { } } + /** The number of task contexts that are not closed yet, for tests. */ + static int openTaskContexts() { + return tasks.size(); + } + + // --------------------------------------------------------------------------------------------- + // Streams of engines + // --------------------------------------------------------------------------------------------- + + // The errno values that the callbacks of a stream return. + private static final int EINVAL = 22; + private static final int EIO = 5; + // The message of get_last_error for a stream that Spark does not know, which lives as long as + // the JVM. + private static final long UNKNOWN_STREAM_ERROR = + toCString("The stream was released, or is not a stream of Spark."); + + private static final AtomicLong nextEngineStreamId = new AtomicLong(1); + // The streams wrapped for engines that are not released yet, by their private_data. + /** + * The callbacks of the streams that Spark wraps for engines, see {@link EngineStream}, and the + * method handles that call the callbacks of the wrapped streams. They are created when the first + * stream is wrapped, so that a JVM that wraps no stream does not create them, and a failure to + * create them only fails the streams of engines. + */ + private static final class EngineStreamCallbacks { + // The addresses of the C functions that call Java, which live as long as the JVM. + static final long GET_SCHEMA; + static final long GET_NEXT; + static final long GET_LAST_ERROR; + static final long RELEASE; + // The callbacks of struct ArrowArrayStream, called with the address of the function first: + // get_schema and get_next, get_last_error, and release. + static final MethodHandle INT_CALLBACK; + static final MethodHandle POINTER_CALLBACK; + static final MethodHandle VOID_CALLBACK; + + static { + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + GET_SCHEMA = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamGetSchema", + MethodType.methodType(int.class, long.class, long.class)), + C.I32, C.PTR, C.PTR); + GET_NEXT = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamGetNext", + MethodType.methodType(int.class, long.class, long.class)), + C.I32, C.PTR, C.PTR); + GET_LAST_ERROR = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamGetLastError", + MethodType.methodType(long.class, long.class)), + C.PTR, C.PTR); + RELEASE = Ffm.upcall( + lookup.findStatic(NativeLibrary.class, "engineStreamRelease", + MethodType.methodType(void.class, long.class)), + C.VOID, C.PTR); + INT_CALLBACK = Ffm.unboundDowncall(C.I32, C.PTR, C.PTR); + POINTER_CALLBACK = Ffm.unboundDowncall(C.PTR, C.PTR); + VOID_CALLBACK = Ffm.unboundDowncall(C.VOID, C.PTR); + } catch (Throwable e) { + // The message names the cause, because the JVM only keeps this message for the later + // uses of the class. + throw new IllegalStateException( + "Cannot create the callbacks of the streams of engines: " + rootCause(e), e); + } + } + } + + private static final ConcurrentHashMap<Long, EngineStream> engineStreams = + new ConcurrentHashMap<>(); + + /** + * The stream of a read that an engine got with {@code SupportsArrowStreamRead}, which ties the + * task context of the read to the stream: the context is closed once the library released the + * stream, whenever and on whichever thread the engine releases it, also after the task ended. + * The stream of the library is moved to memory of Spark, and the stream of the engine gets + * callbacks of Spark, which call the current callbacks of the library's stream, and a + * private_data that identifies the wrapped stream, so that the engine can move the stream too. + */ + private static final class EngineStream { + final long inner; + final Task task; + // The name of the data source, for the errors. + final String name; + // The message of the last error of Spark, rather than of the library, or 0 if the last call + // returned an error of the library, or none. The callbacks never run concurrently. + long sparkError; Review Comment: **3. [Nit] `sparkError` is a plain field, although the callbacks may run on different threads** The callbacks never run concurrently, as this comment says, but they may run on different threads one after the other, since `SupportsArrowStreamRead` lets the engine call them from any thread. `fail` writes this field on one thread, and `clearError` may free it and write 0 on the next one, so a thread that saw a stale value would free the message twice, or leak it. In practice, the synchronization with which the engine hands the stream over orders these accesses, but the Java memory model does not promise it for a plain field, and `volatile` costs little here. Could it be `volatile`? ########## sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java: ########## @@ -326,6 +433,9 @@ private static final class Ffm { DOWNCALL = lookup.findVirtual(linker, "downcallHandle", MethodType.methodType(MethodHandle.class, segment, descriptor, options)) .asFixedArity(); + UNBOUND_DOWNCALL = lookup.findVirtual(linker, "downcallHandle", Review Comment: **9. [Nit] The lookup of the unbound `downcallHandle` is still in `Ffm`** Thank you for `EngineStreamCallbacks`, which answers [item 12 of round 6](https://github.com/apache/spark/pull/59934#discussion_r4424626764). Only its initializer uses this handle, through `Ffm.unboundDowncall` (L1217-L1219), but the lookup still runs in the static initializer of `Ffm`, so a failure here still fails all of `Ffm`, unlike what the Javadoc of `EngineStreamCallbacks` says (L1183-L1184). Since `DOWNCALL` looks up the other overload of the same method just before, this can hardly fail alone, so it is mostly about where the lookup lives. Could it move to `EngineStreamCallbacks` too? ########## sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala: ########## @@ -1143,6 +1426,392 @@ class NativeDataSourceSuite extends QueryTest with SharedSparkSession { .mode("append") .save() check(writeTasks) + + // A read gets the index of its partition in the scan, also when a task reads several + // partitions, or the partitions of several scans: the task attempt that read each partition. + def readers(dir: File): Map[Int, Long] = dir.listFiles().map { file => + file.getName.toInt -> Files.readString(file.toPath).split(",")(5).toLong + }.toMap + val (first, second) = (Utils.createTempDir(), Utils.createTempDir()) + checkRows( + range(session, "end" -> "6", "task_path" -> first.getPath).select("id") + .union(range(session, "end" -> "6", "task_path" -> second.getPath).select("id")), + (0L until 6L).flatMap(i => Seq(Row(i), Row(i)))) + assert(readers(first).keySet == Set(0, 1) && readers(second).keySet == Set(0, 1)) + val coalesced = Utils.createTempDir() + checkRows(range(session, "end" -> "6", "task_path" -> coalesced.getPath).select("id") + .coalesce(1), (0L until 6L).map(Row(_))) + assert(readers(coalesced).keySet == Set(0, 1) && readers(coalesced).values.toSet.size == 1) + // So does a read of a micro-batch. + val (firstStream, secondStream) = (Utils.createTempDir(), Utils.createTempDir()) + def counter(dir: File): DataFrame = session.readStream.format("native_counter") + .option("max_offset", "4").option("task_path", dir.getPath).load() + val query = counter(firstStream).union(counter(secondStream)).writeStream.format("noop").start() + try query.processAllAvailable() finally query.stop() + assert(readers(firstStream).keySet == Set(0) && readers(secondStream).keySet == Set(0)) + } + + nativeTest("open the partitions of batch and micro-batch scans as Arrow C streams") { + val session = newSession(defaultPackage) + // The reader factory and the partitions of the batch scan of a query, which an engine gets + // from its plan. + def plannedScan(df: DataFrame): (SupportsArrowStreamRead, Seq[InputPartition]) = { + val scan = df.queryExecution.executedPlan.collectFirst { case s: BatchScanExec => s }.get + (scan.readerFactory.asInstanceOf[SupportsArrowStreamRead], scan.inputPartitions) + } + // Runs `f` in a task that only gets the files of the session. It is the task of the partition + // 1, so that the partition of the scan that a read gets is not the partition of its task. + def inTask[T: ClassTag](f: => T): T = session.artifactManager.withResources { + session.sparkContext.parallelize(Seq(0, 1), 2).flatMap(i => if (i == 1) Seq(f) else Nil) + .collect().head + } + import TestArrowEngine.{callbacks, readIds, readIdsInPlace} + // Reads the given partitions in a single task, as an engine does. + def readAll(factory: SupportsArrowStreamRead, partitions: Seq[InputPartition]): Seq[Long] = { + inTask { + partitions.flatMap { partition => + val allocator = ArrowUtils.rootAllocator.newChildAllocator("engine", 0, Long.MaxValue) + try { + val stream = ArrowArrayStream.allocateNew(allocator) + try factory.openArrowStream(partition, stream.memoryAddress()) catch { + case e: Throwable => + stream.close() + throw e + } + readIds(stream, allocator) + } finally { + allocator.close() + } + } + } + } + // Checks that `f` opens `delta` more task contexts than it closes. The count is global to the + // JVM, where the tasks of earlier tests may still close their contexts, so it is read right + // before `f`. + def checkContexts[T](delta: Int)(f: => T): T = { + val before = NativeLibrary.openTaskContexts() + val result = f + assert(NativeLibrary.openTaskContexts() == before + delta) + result + } + // The size of struct ArrowArrayStream, which the layout test compares with the header. + val streamSize = NativeLibrary.layout().get("ArrowArrayStream").longValue + + // An engine reads both partitions of the scan in a single task. + val taskDir = Utils.createTempDir() + val (factory, partitions) = + plannedScan(range(session, "end" -> "6", "task_path" -> taskDir.getPath).select("id")) + assert(partitions.length == 2) + assert(readAll(factory, partitions).sorted == (0L until 6L)) + // The library got the partition of the scan that it reads, not the one of the task. + val tasks = taskDir.listFiles().map { file => + file.getName.toInt -> Files.readString(file.toPath).split(",").map(_.toLong).toSeq + }.toMap + assert(tasks.keySet == Set(0, 1) && tasks.values.map(_(5)).toSet.size == 1, tasks) + assert(NativeLibrary.openEngineStreams() == 0) + + // The engine may release the stream after its task ended, and on another thread: Spark frees + // the context of the task once the library released the stream. + val released = new File(Utils.createTempDir(), "released") + val (laterFactory, laterPartitions) = + plannedScan(range(session, "end" -> "6", "released_path" -> released.getPath).select("id")) + // Opens a stream in a task, and returns it unreleased, with what `read` returned from it in + // the task. + def openInTask[T: ClassTag](factory: SupportsArrowStreamRead, partition: InputPartition)( + read: ArrowArrayStream => T): (Long, T) = inTask { + val stream = Platform.allocateMemory(streamSize) + Platform.setMemory(stream, 0.toByte, streamSize) + factory.openArrowStream(partition, stream) + (stream, read(ArrowArrayStream.wrap(stream))) + } + val (address, copy) = checkContexts(1) { + // The engine reads the stream to its end in the task, but releases it later. + val (address, ids) = openInTask(laterFactory, laterPartitions.head)(readIdsInPlace) + assert(ids == (0L until 3L)) + assert(NativeLibrary.openEngineStreams() == 1 && !released.exists()) + // A copy of the stream, as an engine that moves the stream may leave behind. + val copy = Platform.allocateMemory(streamSize) + Platform.copyMemory(null, address, null, copy, streamSize) + (address, copy) + } + checkContexts(-1) { + ArrowArrayStream.wrap(address).release() + assert(NativeLibrary.openEngineStreams() == 0 && released.exists()) + } + Platform.freeMemory(address) + // Once the stream is released, the callbacks of the copy fail with EINVAL, and its release + // only marks it released. + checkContexts(0) { + val array = ArrowArray.allocateNew(ArrowUtils.rootAllocator) + val stale = try intercept[Exception](ArrowArrayStream.wrap(copy).getNext(array)) finally { + array.close() + } + assert(Utils.exceptionString(stale) + .contains("The stream was released, or is not a stream of Spark.")) + ArrowArrayStream.wrap(copy).release() + assert(ArrowArrayStream.wrap(copy).snapshot().release == 0) + assert(NativeLibrary.openEngineStreams() == 0) + } + Platform.freeMemory(copy) + + // Once the task ended, its context tells the library that it was interrupted, so that the + // library stops waiting. + val marker = new File(Utils.createTempDir(), "interrupted") + val (waitingFactory, waitingPartitions) = plannedScan( + range(session, "end" -> "6", "wait_for_interrupt" -> marker.getPath).select("id")) + checkContexts(0) { + val (waiting, _) = openInTask(waitingFactory, waitingPartitions.head)(_ => ()) + val error = intercept[Exception] { + readIds(ArrowArrayStream.wrap(waiting), ArrowUtils.rootAllocator) + } + assert(Utils.exceptionString(error).contains("the task was killed")) + assert(Files.readString(marker.toPath) == "interrupted") + assert(NativeLibrary.openEngineStreams() == 0) + Platform.freeMemory(waiting) + } + + // So does the context of a stream that an engine reads in its task once the task is killed, + // although the task did not end. + val killedMarker = new File(Utils.createTempDir(), "interrupted") + val (killedFactory, killedPartitions) = plannedScan(range(session, "end" -> "6", + "partitions" -> "1", "wait_for_interrupt" -> killedMarker.getPath).select("id")) + val contextsBeforeKill = NativeLibrary.openTaskContexts() + val jobGroup = s"native-data-source-${UUID.randomUUID()}" + val killError = new AtomicReference[Throwable]() + val engineThread = new Thread(() => { + session.sparkContext.setJobGroup(jobGroup, "an engine that waits", interruptOnCancel = true) + try { + inTask { + // Reads the stream on the thread of the task, which waits in get_next. + val stream = ArrowArrayStream.allocateNew(ArrowUtils.rootAllocator) + try { + killedFactory.openArrowStream(killedPartitions.head, stream.memoryAddress()) + try readIdsInPlace(stream) finally stream.release() + } finally { + stream.close() + } + } + } catch { + case e: Throwable => killError.set(e) + } + }) + engineThread.start() + eventually(timeout(1.minute)) { + assert(new File(killedMarker.getPath + ".waiting").isFile) + } + session.sparkContext.cancelJobGroup(jobGroup) + engineThread.join(TimeUnit.MINUTES.toMillis(1)) + assert(!engineThread.isAlive && killError.get() != null) + eventually(timeout(1.minute)) { + assert(killedMarker.isFile && Files.readString(killedMarker.toPath) == "interrupted") + assert(NativeLibrary.openEngineStreams() == 0) + assert(NativeLibrary.openTaskContexts() == contextsBeforeKill) + } + + // So does the reader factory of a micro-batch scan, with the state of its micro-batch. + val streamTaskDir = Utils.createTempDir() + val query = session.readStream.format("native_counter").option("max_offset", "4") + .option("task_path", streamTaskDir.getPath).load() + .writeStream.format("noop").start() + val microBatchScan = try { + query.processAllAvailable() + query.asInstanceOf[StreamingQueryWrapper].streamingQuery.lastExecution.executedPlan + .collectFirst { case s: MicroBatchScanExec => s }.get + } finally { + query.stop() + } + // Only the reads of the engine record their tasks from now on. + streamTaskDir.listFiles().foreach(_.delete()) + val streamFactory = microBatchScan.readerFactory.asInstanceOf[SupportsArrowStreamRead] + val streamPartitions = microBatchScan.inputPartitions.toSeq + checkContexts(0) { + assert(readAll(streamFactory, streamPartitions).sorted == (0L until 4L)) + } + assert(NativeLibrary.openEngineStreams() == 0) + // The library got the partition of the scan, the only one of the micro-batch. + assert(streamTaskDir.listFiles().map(_.getName.toInt).toSet == Set(0)) + // The partitions of a micro-batch keep the state that the stream reader returned with them: + // a reader factory that an engine keeps reads them with it, also once Spark planned the next + // micro-batch, and so does the reader of Spark. + def microBatchStream(options: (String, String)*): MicroBatchStream = { + val relation = session.readStream.format("native_counter").options(options.toMap).load() + .queryExecution.analyzed.collectFirst { case relation: StreamingRelationV2 => relation }.get + relation.table.asInstanceOf[SupportsRead].newScanBuilder(relation.extraOptions) + .build().toMicroBatchStream(Utils.createTempDir().getPath) + } + def plan(stream: MicroBatchStream, start: Int, end: Int): Seq[InputPartition] = { + def offset(i: Int): Offset = stream.deserializeOffset(s"""{"offset":$i}""") + stream.planInputPartitions(offset(start), offset(end)).toSeq + } + val stream = microBatchStream("max_offset" -> "4") + try { + val first = plan(stream, 0, 2) + val keptFactory = stream.createReaderFactory() + val second = plan(stream, 2, 4) + // The executors get the state of a micro-batch once, as a broadcast that its partitions + // keep, rather than with the partition of each task. + Seq(first, second).foreach { partitions => + val partition = partitions.head.asInstanceOf[NativeMicroBatchPartition] + assert(partition.state.isDefined && partition.readerState.nonEmpty) + val serialized = new ByteArrayOutputStream() + Utils.tryWithResource(new ObjectOutputStream(serialized))(_.writeObject(partition)) + assert(!serialized.toByteArray.toSeq.containsSlice(partition.readerState.toSeq)) + } + checkContexts(0) { + assert(readAll(keptFactory.asInstanceOf[SupportsArrowStreamRead], first) == Seq(0L, 1L)) + assert(readAll(keptFactory.asInstanceOf[SupportsArrowStreamRead], second) == Seq(2L, 3L)) + val read = inTask { + first.flatMap { partition => + val reader = keptFactory.createColumnarReader(partition) + try { + val ids = ArrayBuffer.empty[Long] + while (reader.next()) { + val batch = reader.get() + (0 until batch.numRows()).foreach(i => ids += batch.column(0).getLong(i)) + } + ids.toSeq + } finally { + reader.close() + } + } + } + assert(read == Seq(0L, 1L)) + } + // A micro-batch without partitions broadcasts no state, although the stream reader + // returned one. + val nextBroadcast = session.sparkContext.broadcast(0).id + 1 Review Comment: **7. [Nit] Correction to my item 7 of round 6: the ID of the next broadcast counts every broadcast** The check that I suggested in [item 7 of round 6](https://github.com/apache/spark/pull/59934#discussion_r4424626708) assumes that nothing else in the `SparkContext` creates a broadcast while `plan(stream, 2, 2)` runs. That is likely in this suite, but a broadcast from another thread of the shared session would make it fail, and the two `broadcast(0)` probes stay until the ContextCleaner removes them. Since the micro-batch has no partition, the test cannot look at a partition either. Could it check the state that the reader planned instead, for example with a `private[ffi]` accessor for tests, or at least `destroy()` the probes? ########## sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSourceSuite.scala: ########## @@ -1143,6 +1426,392 @@ class NativeDataSourceSuite extends QueryTest with SharedSparkSession { .mode("append") .save() check(writeTasks) + + // A read gets the index of its partition in the scan, also when a task reads several + // partitions, or the partitions of several scans: the task attempt that read each partition. + def readers(dir: File): Map[Int, Long] = dir.listFiles().map { file => + file.getName.toInt -> Files.readString(file.toPath).split(",")(5).toLong + }.toMap + val (first, second) = (Utils.createTempDir(), Utils.createTempDir()) + checkRows( + range(session, "end" -> "6", "task_path" -> first.getPath).select("id") + .union(range(session, "end" -> "6", "task_path" -> second.getPath).select("id")), + (0L until 6L).flatMap(i => Seq(Row(i), Row(i)))) + assert(readers(first).keySet == Set(0, 1) && readers(second).keySet == Set(0, 1)) + val coalesced = Utils.createTempDir() + checkRows(range(session, "end" -> "6", "task_path" -> coalesced.getPath).select("id") + .coalesce(1), (0L until 6L).map(Row(_))) + assert(readers(coalesced).keySet == Set(0, 1) && readers(coalesced).values.toSet.size == 1) + // So does a read of a micro-batch. + val (firstStream, secondStream) = (Utils.createTempDir(), Utils.createTempDir()) + def counter(dir: File): DataFrame = session.readStream.format("native_counter") + .option("max_offset", "4").option("task_path", dir.getPath).load() + val query = counter(firstStream).union(counter(secondStream)).writeStream.format("noop").start() + try query.processAllAvailable() finally query.stop() + assert(readers(firstStream).keySet == Set(0) && readers(secondStream).keySet == Set(0)) + } + + nativeTest("open the partitions of batch and micro-batch scans as Arrow C streams") { + val session = newSession(defaultPackage) + // The reader factory and the partitions of the batch scan of a query, which an engine gets + // from its plan. + def plannedScan(df: DataFrame): (SupportsArrowStreamRead, Seq[InputPartition]) = { + val scan = df.queryExecution.executedPlan.collectFirst { case s: BatchScanExec => s }.get + (scan.readerFactory.asInstanceOf[SupportsArrowStreamRead], scan.inputPartitions) + } + // Runs `f` in a task that only gets the files of the session. It is the task of the partition + // 1, so that the partition of the scan that a read gets is not the partition of its task. + def inTask[T: ClassTag](f: => T): T = session.artifactManager.withResources { + session.sparkContext.parallelize(Seq(0, 1), 2).flatMap(i => if (i == 1) Seq(f) else Nil) + .collect().head + } + import TestArrowEngine.{callbacks, readIds, readIdsInPlace} + // Reads the given partitions in a single task, as an engine does. + def readAll(factory: SupportsArrowStreamRead, partitions: Seq[InputPartition]): Seq[Long] = { + inTask { + partitions.flatMap { partition => + val allocator = ArrowUtils.rootAllocator.newChildAllocator("engine", 0, Long.MaxValue) + try { + val stream = ArrowArrayStream.allocateNew(allocator) + try factory.openArrowStream(partition, stream.memoryAddress()) catch { + case e: Throwable => + stream.close() + throw e + } + readIds(stream, allocator) + } finally { + allocator.close() + } + } + } + } + // Checks that `f` opens `delta` more task contexts than it closes. The count is global to the + // JVM, where the tasks of earlier tests may still close their contexts, so it is read right + // before `f`. + def checkContexts[T](delta: Int)(f: => T): T = { + val before = NativeLibrary.openTaskContexts() + val result = f + assert(NativeLibrary.openTaskContexts() == before + delta) + result + } + // The size of struct ArrowArrayStream, which the layout test compares with the header. + val streamSize = NativeLibrary.layout().get("ArrowArrayStream").longValue + + // An engine reads both partitions of the scan in a single task. + val taskDir = Utils.createTempDir() + val (factory, partitions) = + plannedScan(range(session, "end" -> "6", "task_path" -> taskDir.getPath).select("id")) + assert(partitions.length == 2) + assert(readAll(factory, partitions).sorted == (0L until 6L)) + // The library got the partition of the scan that it reads, not the one of the task. + val tasks = taskDir.listFiles().map { file => + file.getName.toInt -> Files.readString(file.toPath).split(",").map(_.toLong).toSeq + }.toMap + assert(tasks.keySet == Set(0, 1) && tasks.values.map(_(5)).toSet.size == 1, tasks) + assert(NativeLibrary.openEngineStreams() == 0) + + // The engine may release the stream after its task ended, and on another thread: Spark frees + // the context of the task once the library released the stream. + val released = new File(Utils.createTempDir(), "released") + val (laterFactory, laterPartitions) = + plannedScan(range(session, "end" -> "6", "released_path" -> released.getPath).select("id")) + // Opens a stream in a task, and returns it unreleased, with what `read` returned from it in + // the task. + def openInTask[T: ClassTag](factory: SupportsArrowStreamRead, partition: InputPartition)( + read: ArrowArrayStream => T): (Long, T) = inTask { + val stream = Platform.allocateMemory(streamSize) + Platform.setMemory(stream, 0.toByte, streamSize) + factory.openArrowStream(partition, stream) + (stream, read(ArrowArrayStream.wrap(stream))) + } + val (address, copy) = checkContexts(1) { Review Comment: **8. [Nit] A failed assertion in this test leaks the stream and its copy** If an assertion from here to L1554 fails, the stream may never be released, and `address` and `copy` are never freed, so the stream of the library and the context of its task stay open for the rest of the run. The later tests count the contexts from their own start, so they still pass, and this only matters once this test already failed. Could the release and the `freeMemory` calls run in a `finally`, for example with `Utils.tryWithSafeFinally`, so that a failure there does not hide the first one? ########## sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java: ########## @@ -903,6 +1158,234 @@ private static boolean isInterrupted(long task) { } } + /** The number of task contexts that are not closed yet, for tests. */ + static int openTaskContexts() { + return tasks.size(); + } + + // --------------------------------------------------------------------------------------------- + // Streams of engines + // --------------------------------------------------------------------------------------------- + + // The errno values that the callbacks of a stream return. + private static final int EINVAL = 22; + private static final int EIO = 5; + // The message of get_last_error for a stream that Spark does not know, which lives as long as + // the JVM. + private static final long UNKNOWN_STREAM_ERROR = + toCString("The stream was released, or is not a stream of Spark."); + + private static final AtomicLong nextEngineStreamId = new AtomicLong(1); + // The streams wrapped for engines that are not released yet, by their private_data. Review Comment: **11. [Nit] The comment of `engineStreams` is now above `EngineStreamCallbacks`** The holder class went in between this comment and `engineStreams` (L1229), so the comment reads as if it described the callbacks, and `engineStreams` has none. Could this comment, and `nextEngineStreamId` with it, move down to `engineStreams`? ########## sql/core/src/main/java/org/apache/spark/sql/execution/datasources/v2/ffi/NativeLibrary.java: ########## @@ -903,6 +1158,234 @@ private static boolean isInterrupted(long task) { } } + /** The number of task contexts that are not closed yet, for tests. */ + static int openTaskContexts() { + return tasks.size(); + } + + // --------------------------------------------------------------------------------------------- + // Streams of engines + // --------------------------------------------------------------------------------------------- + + // The errno values that the callbacks of a stream return. + private static final int EINVAL = 22; + private static final int EIO = 5; + // The message of get_last_error for a stream that Spark does not know, which lives as long as + // the JVM. + private static final long UNKNOWN_STREAM_ERROR = Review Comment: **10. [Nit] Every JVM still allocates `UNKNOWN_STREAM_ERROR`** Only `engineStreamGetLastError` (L1349) returns it, but the static initializer of `NativeLibrary` allocates it, so the driver and each executor that uses a native data source allocate it without an engine. The memory is small, but `EngineStreamCallbacks` exists so that a JVM that wraps no stream does not create what the streams of engines need. Could it move into `EngineStreamCallbacks`, which is always initialized before a callback can run? ########## sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ffi/NativeDataSource.scala: ########## @@ -970,21 +1192,38 @@ class NativeDataWriter( /** Creates the `SparkDsTaskContext` of the running task. */ private[ffi] object NativeTask { /** - * @param partitionId the partition that the task reads or writes, by default the partition of - * the task + * @param partitionId the `partition_id` of the context: the index of the partition in the scan + * for a read, and by default the partition of the task * @param taskAttemptId the ID of the task attempt, by default the one of the task + * @param read whether the context is the one of a read, which the library may use after the + * task ended: an engine may read the stream of a partition then, and a thread that + * the task does not wait for may be in get_next once it completes, as with + * `PipedRDD`. `is_interrupted` then also reports that the task failed or completed, + * and the context does not keep the task alive. A data writer only runs within its + * task, whose kill `is_interrupted` reports. Outside of a task, it reports nothing. */ def create( partitionId: Option[Int] = None, - taskAttemptId: Option[Long] = None): NativeLibrary.Task = { + taskAttemptId: Option[Long] = None, + read: Boolean = false): NativeLibrary.Task = { val context = Option(TaskContext.get()) + val interrupted: BooleanSupplier = context match { + case Some(taskContext) if read => + val task = new WeakReference(taskContext) + () => Option(task.get()).forall(t => t.isInterrupted() || t.isFailed() || t.isCompleted()) Review Comment: **6. [Nit] Each `is_interrupted` of a read now takes the lock of the task twice** Since [item 2 of round 6](https://github.com/apache/spark/pull/59934#discussion_r4424626654), Spark's own reads get this context too. `isFailed()` and `isCompleted()` of `TaskContextImpl` are `synchronized`, so while the task runs, each call takes its monitor twice, besides the upcall and the lookup in `tasks`, where a read of Spark only did the volatile read of `isInterrupted()` before. The header asks a library that blocks to check it regularly, so a library that checks it for each batch or each row pays this each time. I did not measure it, and next to the upcall it may well be small. Could the context check something cheaper, such as a volatile flag that a completion and a failure listener of the task set, or is this cost fine? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
