Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin

## What works today

`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Both module formats, typed separately.
`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. Both module formats, typed separately.

Build it with `npm run build`, and run the suite with `npm test`. Nothing is published yet, so `npm i zudb` is not a thing you can type at anybody's terminal, but everything it will do is built and installed on every run of the release workflow.

Expand Down Expand Up @@ -240,6 +240,66 @@ Two things are not buffers, and both are named by the type rather than found out

`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

`conn.prepare` compiles a statement now and hands back something that runs it later, as often as it is asked to, with different values bound each time:

```ts
await using find = await conn.prepare(`MATCH (p:person) WHERE p.name = $name RETURN p.id AS id`);
find.params; // ["name"], which the statement asked for and nobody had to read out of it
const ada = await find.query<{ id: bigint }>({ name: "ada" });
const zoe = await find.query<{ id: bigint }>({ name: "zoe" });
```

It answers the same three ways a connection does. `query` gives rows, `exec` gives nothing and is for a statement written to change something, and `columnar` gives the buffers. Each takes the bindings and the same options a statement takes, so a signal and a `bigIntMode` go on the run rather than on the prepare, since which run a caller wants to stop is a property of that run.

What this is not is a speedup, and it is worth saying so here rather than letting a reader assume the thing every other client's documentation says. A driver prepares to save a round trip to a server, and there is no server and no round trip here. The engine already caches the plan for a statement by its text, so the second `conn.query` of the same string is not compiled a second time either. On this machine, with `npm run bench:prepared` over 100 rows and 5000 runs:

```
prepared, bound per run 13756 ns each
the same text, bound per run 13812 ns each
a new text per run 21209 ns each
```

The first two are the same number, and that is the honest result. The third is the one to read: a statement whose text is different every run, which is what a program that pastes its values into the string is writing, pays the compile every time, and the roughly 7 microseconds between it and the other two is the size of that mistake. `prepare` and `close` together cost 10 microseconds, which is that same compile bought once.

So what preparing buys is two things, and neither of them is throughput. The compile happens at the line that asked for it, at startup, where a statement that does not compile fails on the way up rather than on the first request that needed it. And the names come back: `params` is what the statement wants bound, in the order the engine found them, which is how a layer that binds from a record knows what to look for. `statement` is the text it was given back, and `closed` says whether it still holds anything.

A prepared statement holds an id on the connection that made it, so closing it gives that back. `await using` is the intended scoping, `close()` is there for callers who cannot use the syntax, closing twice is not an error, and every run after the close is refused with the reason. One whose connection closed first is refused too, saying the connection is closed, because the session that was holding the id went with it. There is no `stream` on a prepared statement, deliberately: the engine's streaming path takes a text rather than a pinned id, so a streamed prepared statement would be this client quietly running the text again behind the caller, and a method that does not do what its name says is worse than one that is not there.

## Seeing what a statement will do

`explain` compiles a statement and answers the plan without running it:

```ts
const plan = await conn.explain(`MATCH (p:person) WHERE p.name = $name RETURN p.id AS id`);
plan.columns; // ["id"]
plan.params; // ["name"]
plan.root.op; // "Project"
plan.root.children[0].detail; // "p.name = $name"
console.log(plan.text);
// Project p.id AS id
// Filter p.name = $name
// ScanNodes p: person
```

It comes back twice on purpose. `root` is the tree, for a program: every operator carries `op`, the `detail` it works on, the `binds` it introduces, the `tables` it touches, and its `children`, so a test can assert that a scan became an index seek without matching on a string. `text` is the engine's own listing, for a person, and it is the engine's rather than this client's rendering of the tree so the two cannot drift apart from one release to the next. An operator inside a bracket says which one it is in, `Optional`, `Semi`, `Anti` or `Mark`, and `name` is what the listing calls it, which for an expand inside an optional match is `OptionalExpand` while `op` stays `Expand`.

`explain` takes no parameters, and that is not an oversight. A plan is chosen from the shape of the statement, and the values are bound when it runs, so a plan asked for with values would suggest that the values changed it. `scalars` is the other half of that: a query written where a value belongs gets a plan of its own, and `reads` says which variables of the query around it that plan reads, which is the whole difference between a subquery that runs once and one that runs once a row.

`profile` runs the statement and answers what the operators actually did:

```ts
const run = await conn.profile(`MATCH (p:person) RETURN p.name AS name`, { since: 1990 });
const scan = run.stages[0].ops.find((op) => op.op === "Scan");
scan.rows; // what it really produced
scan.estimate; // what the optimizer thought, or null if it had nothing to say
scan.qerror; // the two divided, the way the literature writes it
run.nanos; // every stage added up
```

A profile takes bindings, since it is a run. Every count is a `number` and not a `bigint`, which is the one place in this client an integer is spelled as a double on purpose: nothing a profile counts, not rows, not pulls, not nanoseconds of a statement anybody waited for, comes anywhere near 2^53, and a caller doing arithmetic on a measurement should not have to convert first. `pulls` is how many times the operator above asked, `rows` is how many it answered, `flat` is the same count with vectors unpacked, `bound` is the upper bound the optimizer had, and `qerror` is null where an estimate was. A statement that writes is refused rather than profiled, saying so, because a profile that also inserted two rows is a measurement that changed the thing measured.

## Asking for numbers instead of bigints

`bigIntMode` says how INT64 is spelled on the way out. It goes on one statement, or on a connection for all of them, and a statement on a connection that named one may still name the other:
Expand Down Expand Up @@ -332,7 +392,7 @@ typedoc rather than api-documenter, which would have been the obvious pick since

Anything outside that table has no binary and no source build to fall back on, so the install resolves nothing and the first `require` says so. The browser and the platforms nobody builds for are what the WASM target answers, later.

`npm run bench` measures what this package adds to the engine, which is a row object and one JavaScript value per column: the same scan with the rows dropped is the floor, and the difference between the two is what the boundary costs. Run it against a release build, since a debug build of the engine moves the floor by an order of magnitude and not the rest of it. `npm run bench:append` does the same for the appender, `npm run bench:load` for building a database out of columns, `npm run bench:register` for registered frames, where what is being watched is that the registration does not scale with the rows, and `npm run bench:columnar` for a result read down its columns against the same result read across its rows.
`npm run bench` measures what this package adds to the engine, which is a row object and one JavaScript value per column: the same scan with the rows dropped is the floor, and the difference between the two is what the boundary costs. Run it against a release build, since a debug build of the engine moves the floor by an order of magnitude and not the rest of it. `npm run bench:append` does the same for the appender, `npm run bench:load` for building a database out of columns, `npm run bench:register` for registered frames, where what is being watched is that the registration does not scale with the rows, `npm run bench:columnar` for a result read down its columns against the same result read across its rows, and `npm run bench:prepared` for a prepared statement against the same text run again and against a text that is new every time, which is the one of these whose interesting number is that the first two are equal.

## Still to come

Expand Down
132 changes: 132 additions & 0 deletions bench/prepared.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// What preparing a statement costs and what it saves.
//
// The answer is not the one a driver would give, and this exists to
// print that rather than to hide it. A driver prepares to save a round
// trip; there is no round trip here, and the engine caches the plan for
// a statement by its text, so the second `conn.query` of the same string
// is already not being compiled a second time. The two lines of the
// first pair should therefore land close together, and if `prepared` is
// a shade behind that is the id being looked up and the text cloned.
//
// The line worth reading is the third: a statement whose text is
// different every time, which is what a program that pastes its values
// into the string is doing. That one pays the whole compile per run, and
// the gap between it and the other two is the size of the mistake.
//
// The last block is the prepare itself, which is the compile a program
// pays once at startup so that no request pays it.
//
// Run it against a release build, for the reason bench/query.mjs gives.
//
// npm run build && npm run bench:prepared

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { connect } from 'zudb'

const ROWS = Number(process.env.ZU_BENCH_ROWS ?? 10_000)
const RUNS = Number(process.env.ZU_BENCH_RUNS ?? 2_000)
const REPEATS = Number(process.env.ZU_BENCH_REPEATS ?? 5)

const dir = await mkdtemp(join(tmpdir(), 'zu-bench-prepared-'))
const conn = await connect(join(dir, 'bench.zu1'))

await conn.exec("INSERT (p:person {uid: 1, name: 'n1'})")
{
const rows = await conn.appender('person')
for (let ix = 2; ix <= ROWS; ix++) rows.appendRow([BigInt(ix), `n${ix}`])
await rows.close()
}

/// The fastest of `REPEATS` runs, in milliseconds, after one warmup.
async function time(run) {
await run()
let best = Infinity
for (let round = 0; round < REPEATS; round++) {
const started = performance.now()
await run()
best = Math.min(best, performance.now() - started)
}
return best
}

function report(name, ms, each) {
console.log(
`${name.padEnd(34)} ${ms.toFixed(1).padStart(8)} ms ${Math.round((ms * 1e6) / each)
.toString()
.padStart(7)} ns each`,
)
}

const find = await conn.prepare('MATCH (p:person) WHERE p.name = $name RETURN p.uid AS uid')

/// A number that never repeats, for the case that wants a statement the
/// plan cache has never seen.
let stamp = 0

const cases = [
{
name: 'prepared, bound per run',
run: async () => {
for (let ix = 0; ix < RUNS; ix++) await find.query({ name: `n${(ix % ROWS) + 1}` })
},
},
{
name: 'the same text, bound per run',
run: async () => {
for (let ix = 0; ix < RUNS; ix++)
await conn.query('MATCH (p:person) WHERE p.name = $name RETURN p.uid AS uid', {
name: `n${(ix % ROWS) + 1}`,
})
},
},
{
name: 'a new text per run',
// The alias carries a counter that never repeats, so every one of
// these is a text the plan cache has not seen. A statement that
// pastes its values in rather than binding them is only this slow
// once the values stop repeating, which in a program serving
// requests is immediately.
run: async () => {
for (let ix = 0; ix < RUNS; ix++)
await conn.query(
`MATCH (p:person) WHERE p.name = 'n${(ix % ROWS) + 1}' RETURN p.uid AS uid${stamp++}`,
)
},
},
]

console.log(`${RUNS} runs over ${ROWS} rows, fastest of ${REPEATS}`)
for (const { name, run } of cases) report(name, await time(run), RUNS)

// The compile, which is what a program pays at startup so that no
// request pays it. Every prepare is closed again, since a bench that
// leaked two thousand of them would be measuring the map they went into.
console.log('')
console.log('preparing itself')
report(
'prepare and close',
await time(async () => {
for (let ix = 0; ix < 100; ix++) {
const statement = await conn.prepare(
'MATCH (p:person) WHERE p.name = $name RETURN p.uid AS uid',
)
await statement.close()
}
}),
100,
)
report(
'explain',
await time(async () => {
for (let ix = 0; ix < 100; ix++)
await conn.explain('MATCH (p:person) WHERE p.name = $name RETURN p.uid AS uid')
}),
100,
)

await find.close()
await conn.close()
await rm(dir, { recursive: true, force: true })
1 change: 1 addition & 0 deletions binding.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ if (!nativeBinding) {
module.exports = nativeBinding
module.exports.Appender = nativeBinding.Appender
module.exports.Connection = nativeBinding.Connection
module.exports.Prepared = nativeBinding.Prepared
module.exports.Transaction = nativeBinding.Transaction
module.exports.ZuCursor = nativeBinding.ZuCursor
module.exports.ZuDate = nativeBinding.ZuDate
Expand Down
Loading
Loading