diff --git a/README.md b/README.md index fa5af6f..a46e123 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,21 @@ A million rows, three columns, the same laptop: The first two lines are the whole point. Half a millisecond to make a million rows queryable against six hundred to write them down, and the three lines after that say the query is not paying for it afterwards. +## Watching a statement, and stopping it + +Three ways, and they are the same mechanism seen from different sides. `conn.interrupt()` is safe from another thread and stops whatever is running. `conn.rowsRead()` is safe from another thread too and says how far it has got. And a progress callback is both of those without the thread that would otherwise have to do the polling: + +```java +long deadline = 30_000; +conn.onProgress(Duration.ofMillis(250), (rows, millis) -> millis < deadline); +``` + +Answering false stops the statement exactly as `interrupt()` would, so a timeout is a one line watcher and a progress bar is the same watcher with a repaint in it. The arrangement belongs to the connection and covers every statement after it, so it is set once when the connection is opened rather than around each query. + +The callback runs on a thread of the library's, one per statement, never two at once and never after the statement it belongs to has returned. Two things follow. Whatever it touches has to be usable from another thread, so a counter a progress bar reads should be an `AtomicLong` rather than a field. And it must not call back into the library on the connection it is reporting on, because that connection is inside the executor. + +This is the other of the two places a pointer to Java code goes the other way. An exception crossing an upcall would take the JVM down, so a watcher that throws is logged and answered as though it had asked for the statement to stop, which is the reading that loses least: a callback that threw is a program that has stopped wanting the answer. + ## How it binds The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself. diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java b/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java index d2e7e39..b1469c9 100644 --- a/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java @@ -68,6 +68,7 @@ final class Abi { final MethodHandle connClose; final MethodHandle connInterrupt; final MethodHandle connRowsRead; + final MethodHandle connSetProgress; final MethodHandle connInTransaction; final MethodHandle begin; final MethodHandle commit; @@ -201,6 +202,8 @@ final class Abi { connClose = h("zu_conn_close", FunctionDescriptor.ofVoid(ADDRESS)); connInterrupt = h("zu_conn_interrupt", FunctionDescriptor.of(JAVA_INT, ADDRESS)); connRowsRead = h("zu_conn_rows_read", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); + connSetProgress = h("zu_conn_set_progress", FunctionDescriptor.of( + JAVA_INT, ADDRESS, ADDRESS, ADDRESS, JAVA_LONG)); connInTransaction = h("zu_conn_in_transaction", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); begin = h("zu_begin", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS)); commit = h("zu_commit", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS)); diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java index d4cb887..8ab8368 100644 --- a/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java @@ -46,10 +46,27 @@ final class FfmBinding implements ZuBinding { private final Abi abi; + /** + * The progress stub each connection is watching through, which is the one + * thing this binding has to remember about a connection. + * + *
It lives here rather than on the {@code Connection} because the stub is
+ * an FFM type and the API module never names one. Keyed by the handle, which
+ * is safe because the entry goes when the connection closes and not a moment
+ * later, so an address the allocator hands out again cannot find an old one.
+ */
+ private final java.util.Map A frame's release callback is how a host learns the engine has finished
- * with the buffers it lent: it runs once, on a thread of the library's, after
- * the last statement reading the frame ends. Reaching Java from there needs an
- * upcall stub, which is executable memory with a lifetime of its own.
- *
- * That lifetime is the awkward part. The stub has to outlive the frame,
- * because the callback is the last thing to happen and may happen after the
- * frame was freed, so it cannot hang off the frame. It cannot free itself
- * either: closing the arena a stub lives in from inside a call through that
- * same stub is closing the ground you are standing on. So a spent arena goes
- * on a queue and the next stub to be made closes it, which costs nothing, needs
- * no thread of ours, and bounds the outstanding stubs at the number of frames
- * whose callbacks have not run yet.
+ * It is how a host learns the engine has finished with the buffers it lent:
+ * it runs once, on a thread of the library's, after the last statement reading
+ * the frame ends, which is neither the unregister that preceded it nor the
+ * free.
*/
final class Release {
@@ -40,40 +26,23 @@ final class Release {
private static final MethodHandle RUN = run();
- /** Arenas whose callback has been and gone, waiting for somebody else to close them. */
- private static final Queue An upcall stub is executable memory with a lifetime of its own, and that
+ * lifetime is the awkward part. A stub has to outlive whatever holds it,
+ * because the callback is the last thing to happen and may happen after the
+ * thing that arranged it has gone. It cannot free itself either: closing the
+ * arena a stub lives in from inside a call through that same stub is closing
+ * the ground you are standing on.
+ *
+ * So a spent arena goes on a queue and the next stub to be made closes it.
+ * That costs nothing, needs no thread of ours, and bounds the outstanding
+ * stubs at the number of callbacks that have not been spent yet.
+ */
+final class Upcall {
+
+ /** Arenas nothing will call again, waiting for somebody else to close them. */
+ private static final Queue Nothing may be thrown out of an upcall, so a watcher that throws is
+ * logged and answered as though it had asked for the statement to stop. That
+ * is the reading that loses least: a progress callback that threw is a
+ * program that has stopped wanting the answer, and letting the statement run
+ * on would only mean throwing the answer away later.
+ *
+ * @param userData the pointer passed at creation, which this binding does
+ * not use because the watcher is already bound to this stub
+ * @param rows how many rows have been read
+ * @param millis how long the statement has been running
+ * @return 1 to let it go on, 0 to stop it
+ */
+ @SuppressWarnings("unused")
+ private int at(MemorySegment userData, long rows, long millis) {
+ try {
+ return body.at(rows, millis) ? 1 : 0;
+ } catch (Throwable t) {
+ LOG.log(Level.ERROR, "a zu progress callback threw, so the statement it watched is stopping", t);
+ return 0;
+ }
+ }
+
+ private static MethodHandle at() {
+ try {
+ return MethodHandles.lookup()
+ .findVirtual(
+ Watch.class,
+ "at",
+ MethodType.methodType(
+ int.class, MemorySegment.class, long.class, long.class));
+ } catch (ReflectiveOperationException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+}
diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/ProgressTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/ProgressTest.java
new file mode 100644
index 0000000..6f62d6c
--- /dev/null
+++ b/zudb-ffm/src/test/java/dev/zudb/ffm/ProgressTest.java
@@ -0,0 +1,225 @@
+package dev.zudb.ffm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import dev.zudb.Connection;
+import dev.zudb.Database;
+import dev.zudb.Frame;
+import dev.zudb.Progress;
+import dev.zudb.ZuException;
+import dev.zudb.ZuInterruptedException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.LongBuffer;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Being told how a statement is getting on, and telling it to stop.
+ *
+ * Every test here needs a statement that runs long enough to be reported
+ * on, which frames make harder rather than easier: a scan of ten million rows
+ * is ten milliseconds. So what is watched here is a nested loop over three
+ * thousand rows against themselves, which is a third of a second and is not
+ * something the planner can fold into a count.
+ */
+class ProgressTest {
+
+ private static final int ROWS = 3_000;
+
+ /**
+ * Nine million pairs, which is what there is to watch.
+ *
+ * A plain scan of a frame is too fast to be reported on at all, which is
+ * a nice problem to have and an awkward one to write a test against. A pair
+ * of patterns with a predicate over them is a nested loop the planner cannot
+ * fold into a count, and three thousand rows of it is a third of a second.
+ */
+ private static final String SLOW =
+ "MATCH (a:Person), (b:Person) WHERE a.id < b.id RETURN count(*)";
+
+ private static Database db;
+ private static Connection conn;
+ private static Frame frame;
+
+ @BeforeAll
+ static void engine() {
+ Libzu.require();
+ LongBuffer ids =
+ ByteBuffer.allocateDirect(ROWS * Long.BYTES).order(ByteOrder.nativeOrder()).asLongBuffer();
+ for (int i = 0; i < ROWS; i++) {
+ ids.put(i);
+ }
+ ids.flip();
+ db = Database.memory();
+ conn = db.connect();
+ frame = Frame.of("Person", ROWS);
+ frame.column("id", ids);
+ conn.register(frame);
+ }
+
+ @AfterAll
+ static void done() {
+ conn.close();
+ frame.close();
+ db.close();
+ }
+
+ @BeforeEach
+ void quiet() {
+ conn.clearProgress();
+ }
+
+ @Test
+ void theWatcherIsToldHowFarTheStatementHasGot() {
+ List This is {@link #rowsRead()} without the thread that would have to do
+ * the polling. The arrangement belongs to the connection and covers every
+ * statement after it, and a statement already running keeps the one it
+ * started with, so this is set once when the connection is opened rather
+ * than around each query.
+ *
+ * A watcher answering false stops the statement exactly as
+ * {@link #interrupt()} would, which is what a timeout is:
+ *
+ * The callback runs on a thread of the library's, so read
+ * {@link Progress} before writing one.
+ *
+ * @param every how often to be called, which the engine refuses at zero
+ * because a period of nothing is not a period
+ * @param watcher what to call
+ */
+ public void onProgress(Duration every, Progress watcher) {
+ zu.connSetProgress(open(), watcher, every.toMillis());
+ }
+
+ /**
+ * Takes the arrangement back, after which nothing is called and the next
+ * statement runs as it would have.
+ */
+ public void clearProgress() {
+ zu.connSetProgress(open(), null, 0);
+ }
+
/**
* Starts a transaction.
*
diff --git a/zudb/src/main/java/dev/zudb/Progress.java b/zudb/src/main/java/dev/zudb/Progress.java
new file mode 100644
index 0000000..086b87a
--- /dev/null
+++ b/zudb/src/main/java/dev/zudb/Progress.java
@@ -0,0 +1,37 @@
+package dev.zudb;
+
+/**
+ * Called while a statement runs, to say how far it has got and to be asked
+ * whether it should go on.
+ *
+ * This is {@link Connection#rowsRead()} the other way round: a poll wants a
+ * thread of its own to do the polling, and this one is called for you. It is
+ * what a progress bar, a query timeout and a server that has to answer within
+ * a deadline are all made of, and the deadline case is why the call has an
+ * answer at all.
+ *
+ * It runs on a thread of the library's, one per statement, never two at
+ * once and never after the statement it belongs to has returned. Two things
+ * follow from that. Whatever the callback touches has to be usable from
+ * another thread, so a counter a progress bar reads should be an
+ * {@code AtomicLong} rather than a field. And a callback must not call back
+ * into the library on the connection it is reporting on, because that
+ * connection is inside the executor and would answer
+ * {@link ZuConcurrentException} at best.
+ */
+@FunctionalInterface
+public interface Progress {
+
+ /**
+ * How far the running statement has got.
+ *
+ * @param rows how many rows it has read out of storage, which is rows read
+ * rather than rows answered because the statement a user is waiting on
+ * is exactly the one reading a hundred million rows to answer one
+ * @param millis how long it has been running
+ * @return true to let it go on, false to stop it, which ends it exactly as
+ * {@link Connection#interrupt()} would and raises
+ * {@link ZuInterruptedException} at the caller
+ */
+ boolean at(long rows, long millis);
+}
diff --git a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
index 7db49f3..2322a91 100644
--- a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
+++ b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
@@ -1,6 +1,7 @@
package dev.zudb.spi;
import dev.zudb.Diagnostic;
+import dev.zudb.Progress;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.DoubleBuffer;
@@ -159,6 +160,23 @@ public interface ZuBinding {
*/
long connRowsRead(long conn);
+ /**
+ * Asks to be called back every so often while a statement runs.
+ *
+ * The arrangement belongs to the connection and covers every statement
+ * after it, and a statement already running keeps the one it started with.
+ * A provider owns whatever it had to build to make the callback reachable
+ * from a thread of the library's, and frees it when the arrangement is
+ * replaced, taken back, or the connection closes.
+ *
+ * @param conn the connection
+ * @param watcher what to call, or null to take the arrangement back
+ * @param intervalMillis how often, which the engine refuses at zero because
+ * a period of nothing is not a period, and which is ignored for a null
+ * watcher
+ */
+ void connSetProgress(long conn, Progress watcher, long intervalMillis);
+
/**
* Whether a transaction is running, which no statement answers and every
* host offering a block needs.
{@code
+ * long deadline = 30_000;
+ * conn.onProgress(Duration.ofMillis(250), (rows, millis) -> millis < deadline);
+ * }
+ *
+ *