From 83ba9d0ee73da09722640af8d5125be09aec1eb8 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:20:27 +0700 Subject: [PATCH] The engine hands over the buffers it filled The pin moves to the engine commit where the sink of a plain projection keeps its vectors instead of transposing them into rows. Nothing in this client's surface changes for it. What changes is what happens under columnar(): the buffers a caller gets are now the ones the pipeline filled, so a columnar read costs a move and not two walks over a million rows that were built to be walked twice. One behaviour did change, and it is the better one. A statement that matched nothing used to come back with columns typed null, because nothing had been seen to settle the type. It now comes back with the types the plan declared, and with the buffers to match: a string column of no rows carries the one starting offset every string column has, an integer column of no rows carries a buffer of no elements. So a table built from an empty answer has the schema the same statement would have had with rows in it, which is what a loop concatenating a page at a time wants and what it had to special case before. Two tests were measuring the machine rather than the code and are rewritten to measure the code. The one that says a million row read leaves the event loop free counted a fixed twenty timer ticks, which the faster read now finishes inside, so it asks for a tick every ten milliseconds of however long the read took: a blocked loop fires none of them whatever the speed. The one that says registering a frame does not follow its rows had a two millisecond budget, which a loaded machine misses without anything having walked a row, so it compares the five million row call against the ten row call instead, where walking would show up as five hundred thousand times and not twenty. --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 4 ++-- README.md | 2 ++ binding.d.cts | 1 + etc/zudb.api.md | 1 - test/columnar.test.mjs | 36 ++++++++++++++++++++++++++++-------- test/register.test.mjs | 11 ++++++++++- 7 files changed, 53 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75de74c..df0e730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1277,7 +1277,7 @@ dependencies = [ [[package]] name = "zu" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "zu-common", "zu-encoding", @@ -1293,7 +1293,7 @@ dependencies = [ [[package]] name = "zu-common" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "thiserror", ] @@ -1301,7 +1301,7 @@ dependencies = [ [[package]] name = "zu-encoding" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "ruzstd", "zu-common", @@ -1310,7 +1310,7 @@ dependencies = [ [[package]] name = "zu-exec" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "zu-common", "zu-query", @@ -1320,7 +1320,7 @@ dependencies = [ [[package]] name = "zu-query" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "crossbeam-deque", "zu-common", @@ -1331,7 +1331,7 @@ dependencies = [ [[package]] name = "zu-s3" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "crc32c", "object_store", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "zu-sqlite" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "rusqlite", "zu-common", @@ -1352,7 +1352,7 @@ dependencies = [ [[package]] name = "zu-storage" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "zu-common", "zu-encoding", @@ -1361,7 +1361,7 @@ dependencies = [ [[package]] name = "zu-vector" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "zu-common", ] @@ -1369,7 +1369,7 @@ dependencies = [ [[package]] name = "zu-zu1" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=130f67db924bcd0f766ee814b0da2edae32150d4#130f67db924bcd0f766ee814b0da2edae32150d4" +source = "git+https://github.com/tamnd/zu?rev=6753ded13a215bf5e8fe70ce41e69b35321e3e73#6753ded13a215bf5e8fe70ce41e69b35321e3e73" dependencies = [ "crc32c", "loom", diff --git a/Cargo.toml b/Cargo.toml index f8081f2..7699ce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,8 @@ crate-type = ["cdylib"] # with (ADR 0002), so a revision is the honest way to say which one. # A local checkout is used instead with a `paths` override in # `.cargo/config.toml`, which is untracked on purpose. -zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "130f67db924bcd0f766ee814b0da2edae32150d4" } -zu-common = { git = "https://github.com/tamnd/zu", rev = "130f67db924bcd0f766ee814b0da2edae32150d4" } +zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "6753ded13a215bf5e8fe70ce41e69b35321e3e73" } +zu-common = { git = "https://github.com/tamnd/zu", rev = "6753ded13a215bf5e8fe70ce41e69b35321e3e73" } # N-API by way of napi-rs (ADR 0002). `napi9` is the version of N-API # this addon declares it needs, which is what makes one binary work # across Node 24, Node 26, Electron and Bun without a rebuild: the diff --git a/README.md b/README.md index 7197cec..0f54de8 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,8 @@ The same memory is on both sides of that: `table.getChild("age").data[0].values` Two things are not buffers, and both are named by the type rather than found out by looking. A column of nodes, rels, paths, lists or records has no fixed width cell, so it arrives as `items`, holding the same JavaScript values `query` would have made. A column of nothing but nulls has a length and nothing else, because there is nothing to put in a buffer. A column that mixes two types is refused, naming the column and the row that did it, since a columnar result holds one type per column and a column that quietly became strings is worse than one that would not build. +A statement that matched nothing still comes back with its columns, each one the type the plan declared and each one empty: a string column of no rows has its one starting offset, an integer column of no rows has a buffer of no elements. So a table built from an empty answer has the schema the same statement would have had with rows in it, and a loop that concatenates a page at a time does not have to hold the first page that had anything in it as a special case. + `bigIntMode` says nothing here. A columnar read has one physical layout per type and an INT64 column is 64 bit cells however a caller would rather read one, which is the difference between a buffer and a value. The mode still decides what is inside `items`, where this client is making objects anyway. ## Preparing a statement diff --git a/binding.d.cts b/binding.d.cts index ed35b83..fd91a7d 100644 --- a/binding.d.cts +++ b/binding.d.cts @@ -593,6 +593,7 @@ export interface ZuProgressOptions { * already been left behind. */ export interface ZuProgress extends Disposable { + /** Stops the watch. The callback is not called again. */ stop(): void } diff --git a/etc/zudb.api.md b/etc/zudb.api.md index 89f320c..1f1ae18 100644 --- a/etc/zudb.api.md +++ b/etc/zudb.api.md @@ -373,7 +373,6 @@ export interface ZuProfile { // @public export interface ZuProgress extends Disposable { - // (undocumented) stop(): void } diff --git a/test/columnar.test.mjs b/test/columnar.test.mjs index bc4679e..2181961 100644 --- a/test/columnar.test.mjs +++ b/test/columnar.test.mjs @@ -240,14 +240,26 @@ test('what no buffer covers arrives as the values themselves', async (t) => { test('a statement that matched nothing is columns of no rows', async (t) => { const { conn } = await twoPeople(t) - const read = await conn.columnar("MATCH (p:person) WHERE p.name = 'nobody' RETURN p.id AS id") + const read = await conn.columnar( + "MATCH (p:person) WHERE p.name = 'nobody' RETURN p.id AS id, p.name AS name", + ) + const { id, name } = named(read) assert.equal(read.rows, 0) - assert.equal(read.columns.length, 1) - assert.equal(read.columns[0].length, 0) - // Nothing settled the type, so it is the type of nothing, which is - // the one every columnar format has for exactly this. - assert.equal(read.columns[0].type, 'null') + assert.equal(read.columns.length, 2) + // No row settled the type, so it is the type the plan declared and + // not the type of nothing: an empty answer has the schema the same + // statement would have had with rows in it, which is what makes a + // table built from one appendable to a table built from the other. + assert.equal(id.type, 'int') + assert.equal(id.length, 0) + assert.equal(id.values.length, 0) + // A string column of no rows still carries the offset every string + // column starts from, so the subarray arithmetic holds at zero rows + // rather than being a case a reader has to special case. + assert.equal(name.type, 'string') + assert.equal(name.data.length, 0) + assert.deepEqual([...name.offsets], [0]) }) test('a statement that projects nothing has no columns and says so', async (t) => { @@ -351,15 +363,23 @@ test('a million rows come back down one buffer and the loop stays free', async ( let ticks = 0 const timer = setInterval(() => (ticks += 1), 1) + const at = performance.now() const read = await conn.columnar('MATCH (n:number) RETURN n.at AS at') + const took = performance.now() - at clearInterval(timer) assert.equal(read.rows, rows) assert.equal(read.columns[0].values.length, rows) assert.equal(read.columns[0].values[rows - 1], BigInt(rows)) // The whole read is on the threadpool, so the timer kept firing - // throughout it rather than queueing behind it. - assert.ok(ticks > 20, `the event loop ticked ${ticks} times`) + // throughout it rather than queueing behind it. The bar is a tick + // every ten milliseconds of the read and not a fixed count, because + // a blocked loop fires none however long the read takes and a fixed + // count turns every speedup into a failure. + assert.ok( + ticks > took / 10, + `the event loop ticked ${ticks} times in ${took.toFixed(0)} ms`, + ) }) // The types every fixed-width column maps to, which is the whole of diff --git a/test/register.test.mjs b/test/register.test.mjs index 80528d6..2c54efb 100644 --- a/test/register.test.mjs +++ b/test/register.test.mjs @@ -520,5 +520,14 @@ test('registering costs the same whatever the frame holds', async (t) => { best[rows] = Math.min(best[rows], Number(process.hrtime.bigint() - started) / 1e6) } } - assert.ok(best[5_000_000] < 2, `registering 5m rows took ${best[5_000_000].toFixed(2)} ms`) + // Against the ten row call rather than against a millisecond count, + // because the claim is that the cost does not follow the rows and a + // fixed budget measures the machine's load as much as the code: a way + // in that walked the rows would be five hundred thousand times the + // small frame and not twenty. + assert.ok( + best[5_000_000] < best[10] * 20 + 2, + `registering 5m rows took ${best[5_000_000].toFixed(2)} ms ` + + `against ${best[10].toFixed(2)} ms for ten`, + ) })