Skip to content

The shared conformance corpus, read and run against this client - #19

Merged
tamnd merged 8 commits into
mainfrom
corpus
Aug 24, 2026
Merged

The shared conformance corpus, read and run against this client#19
tamnd merged 8 commits into
mainfrom
corpus

Conversation

@tamnd

@tamnd tamnd commented Aug 24, 2026

Copy link
Copy Markdown
Owner

The corpus is a directory of YAML cases versioned with the engine and answered by every client in the family. Until now this client was the one that did not run it, so it agreed with itself and nothing else. This adds zudb-corpus: a reader for the case files, the value and temporal encodings the files are written in, the case model, an Arrow read over the C Data Interface, the runner, and the command that runs a directory of cases.

Main sources rather than test sources, for the reason zudb-tck's are: it is both a command a person runs and a fixture a test can call, and a test source set is not something another module depends on without a test jar. Nothing here is published. It compiles to 25 rather than the 17 the API targets, because reading a result as Arrow without a dependency on arrow-java wants java.lang.foreign.

The report is the reference runner's word for word, so two clients disagreeing is a diff rather than a reading exercise. Run against the same corpus and the same engine build, this client and the Go client produce the same twenty unsupported lines, in the same order:

Java   1399 cases, 1377 passed, 2 failed, 20 unsupported
Go     1399 cases, 1373 passed, 6 failed, 20 unsupported

The four extra failures on the Go side are its pinned library being older, not a disagreement. The two shared failures were the same thing one layer down: both want 42001 for a pre-reserved word in a name slot, and is_reserved did not reach the pre-reserved list until 79b380d in the engine, which is newer than the build both runs used. The CI job here builds the engine from the checkout it reads the cases from, so it does not have that problem by construction.

That pairing is the point of the job. A corpus ahead of the library reports the engine catching up to its own cases as this client failing, and a library ahead of the corpus reports nothing at all. zu-go pins a revision because it ships an archive of the library; this one builds from source, so there is no revision to pin. It is a job of its own and not a step in the engine job because the run is fourteen hundred databases while that job runs its suite three times.

Under mvn test the corpus run is gated on ZU_CASES and skips without it, the way zu-go and zu-python gate theirs, so a checkout of this repository alone is still green. The rest of the module's tests are not gated and run everywhere.

The runner's own tests are the reference runner's, case for case. Three things the port needed that the Go tests get for free: a fresh directory per run, since a case that fails leaves its database behind on purpose and eight sub-cases all called one would collide where t.TempDir gives each its own; a handful of helpers made package private, because what the tests check is the status code, the unsupported rule, the column list and the case path; and a real ZuException from an empty statement rather than a made one, since the constructor is package private in dev.zudb.

The module takes zudb-ffm at test scope and runs surefire off the module path with --enable-native-access=ALL-UNNAMED, which is what zudb-arrow does and what the README now tells a person to pass. That puts it after zudb-ffm in the build order.

Verified on a Linux box against a release build of the engine:

Tests run: 67, Failures: 0, Errors: 0, Skipped: 2
BUILD SUCCESS

and with ZU_CASES set, the 1399 case run above.

tamnd added 8 commits August 24, 2026 23:36
The corpus is a directory of YAML files versioned with the engine and
shipped to every client in the family. Java is the one client with no
runner for it, which is the gap that keeps the differential suite from
being green across all five.

This is the first piece: the reader. It is a new module rather than
sources under an existing one, and it holds main sources for the same
reason zudb-tck does, because a test source set is not something another
module can depend on without a test jar and the trouble a test jar
brings on the module path. It ends up both a command a person runs
against a corpus directory and a fixture a test can call.

It compiles to 25 rather than to the 17 the API targets. The Arrow half
of the corpus wants a result read through the C Data Interface, and
doing that without taking a dependency on arrow-java wants
java.lang.foreign.

The reader is the fifth implementation of the same small subset of YAML,
after the ones in the engine, in the C client, in zu-python and in
zu-go. Writing a fifth rather than reaching for a YAML library is
deliberate and the tests are where it shows: a library would read these
files and would read a great deal more besides, and what the corpus
needs is a reader that refuses. A case using a flow sequence, an anchor,
a tag or a block scalar has to come back as a refusal with a line number
rather than as something the author did not write, because a construct
one implementation quietly accepts is a case that passes in one
repository and fails in four.

So the refusals are checked in full rather than by substring. They are
diffed against the reference runner's output and a wording that drifted
would be a difference in the report that is not a difference in the
answer. Twenty five refused constructs and eleven accepted shapes, in
twelve tests.

Two things Go spells and Java does not needed care. Java has no escape
for a vertical tab, so the one in the whitespace set is written as its
code point. And the quoting of a value into a message follows Rust's
{:?} rather than anything on the JVM, since Java and Rust disagree about
which characters are worth escaping and the messages are compared across
languages.
The four spellings a case writes a date, a time, a datetime and a
duration in, read by hand rather than handed to a formatter from the
library.

A corpus reader is a second opinion about the text, and a second
opinion that calls the same code the client calls is not one. A
formatter also takes a great deal this encoding does not: a pattern
with a fractional second matches text with none, a numeric zone
matches Z under some patterns and not others, and none of that is
visible at the call site. Writing the spellings out says exactly what
is accepted, which is the extended ISO 8601 form and nothing else.

The seven types come back as dev.zudb.Value.Temporal rather than as
seven java.time types, because that is the one shape the client hands
back and a comparison has to be against it. The two conventions that
shape carries are kept: a zoned datetime holds the instant in UTC with
the offset beside it, so two texts an hour apart in zones an hour
apart hold one count, and a zoned time holds the clock as written, so
12:00:00+07:00 and 05:00:00Z are two values rather than one.

A duration is months or it is nanoseconds and never both. That leaves
one text the fields decide and the numbers cannot, a duration of
nothing, and P0M and PT0S stay two values here where the Python runner
has to call them one.

Nine tests, ported case for case from the Go runner's, and most of
them are texts that look like temporal values and are not: a
basic-form date, a leap second, a fraction of ten digits, an offset
past the standard's own limit, a fraction of a year.
Every value a case can write, in both directions: the text a case wrote
becomes a value to bind or compare against, and a value the engine
handed back becomes the text a report prints.

The shape is a sealed Cell rather than dev.zudb.Value, for two reasons
that are the same reason twice. Value is sealed by the client, so a
corpus that needs a shape the client does not have could not add one.
And the two disagree about what a node is: the engine's node carries a
table id where a case writes a table name, and its edge carries a field
the corpus has no spelling for. Converting at the boundary means a
comparison is a comparison and not a walk through two shapes at once.

A type is written quoted or bare and never both, which is the rule that
lets a reader tell an integer a case meant from an integer YAML would
have handed it. Anything wider than what a double holds exactly is
written quoted, so INT64 and the two 64 bit unsigned widths are quoted
types and INT32 is not. DECIMAL is reserved rather than unknown, since
the encoding has a spelling for it and the engine has no value, and
saying so is more use than saying it is not a type.

The float printer is the one place the JVM had to be talked out of its
own answer. Double.toString has given the shortest run that reads back
since release 19, except where a single digit would do: its
specification then asks for the closest decimal of one digit or two,
and for the smallest subnormal the two digit one is closer. So Java
alone would print 4.9e-324 where the reference runner and every other
client print 5e-324. Two digits is the only length that can be one too
many, so one attempt at shortening closes it and the round trip is what
decides rather than a table of special cases.

Seventeen tests, ported case for case from the Go runner's, and the
float ones carry the values that catch a printer written the easy way:
the smallest subnormal, the largest finite, a half way case, and the
three that have no numeric spelling at all.
A case may say what the export of its result looks like, which is a
list of column names and Arrow format strings, or that the export is
refused. This is the half that reads one.

It goes through java.lang.foreign rather than arrow-java, and that is
the reason this module compiles to 25 while the API targets 17. The
alternative was a dependency on arrow-java for the sole purpose of
reading a schema the client already hands over the C Data Interface,
which would put a large library in the path of a test whose whole point
is that no copy happens. The three structs are laid out here as they
are in the interface, the three callbacks are called through downcall
handles, and nothing on the path allocates a Java object per value.

The schema is read off the stream rather than reconstructed from what
the client knows about its own columns. That is what makes the answer
worth comparing: the Java, Go and C runners all report the format
strings the engine wrote, so a case that says a duration column is tDn
is a case about the engine and not about three clients that each
decided what to call it.

A refusal is a type of its own. Arrow has no type for some of what the
engine can return, and a case saying `arrow: refused` wants the stream
to fail to open, which has to be told apart from a schema that opened
and did not match. The refusal messages are the reference runner's word
for word, including the one about a nested format that is not nested
and the one about a nesting that is.

The restricted method warnings are suppressed on the five methods that
call one, which is what zudb-ffm already does in three files: release
24 put the restricted lint inside -Xlint:all, and this module builds
with -Werror.
A suite is a header and a list of cases. A case is a statement, the
connection to run it on, the parameters to bind, and exactly one
account of what it produces: rows, or a GQLSTATUS, and never both and
never neither.

The reading is strict in the same way the YAML reader under it is
strict, and for the same reason. A key nobody recognises is a refusal
rather than a key that is ignored, because a case with a misspelled
`raises` would otherwise be a case that says nothing and passes. A name
is lower case words joined by dashes, checked against the file rather
than trusted, because the name is what a report prints and what two
runners are diffed by. Two cases in a suite may not share a name for
the same reason.

The schema version is checked before anything else, so that a corpus
written for a later shape says so rather than failing somewhere in the
middle with a message about a key.

Go's [2]int for an edge is a Pair record here, so that two edges
compare equal when they are equal. The rest of the model is records
nested inside the Suite record, which keeps the port one file the way
the original is one file while reading as Suite.read, Suite.Case and
Suite.Load at the call sites.

Fifteen tests, ported case for case from the Go runner's, and most of
them are files that are nearly right: a case with rows and a raises, a
row with the wrong number of values, a name with an underscore in it, a
load whose edge points past its own row count.
The last piece: the thing that opens a database per case, puts the
suite's load in, binds the parameters, runs the statement and says what
came back.

The report is the reference runner's line for line, down to the word
order of every failure, because the value of a shared corpus is that a
disagreement between two clients is a diff. An outcome is one of three
things and not two: unsupported is the third, and it is what a client
says about a statement the engine does not implement yet, which the
corpus allows on purpose since the cases are the contract and the
engine catches up to them.

A load goes in through the bulk loader rather than through statements,
which is the strongest form of the corpus question: the value crosses
the boundary twice and by two different mechanisms, once as a column of
a loader and once as a row of a result. A second connection is a
duplicate of the case's own rather than a second open of the file,
since the two share the write side and a case about a transaction means
the first and not the second.

Against the engine at d9f6b5d this reads 1399 cases and passes 1377,
with 20 unsupported and 2 failed. The Go runner on the same corpus
reports the same 20 unsupported, line for line and in the same order,
and the same 2 failures plus 4 more that are its own pinned library
being four months of reserved words behind. The two shared failures are
the engine accepting a pre-reserved word where a name belongs, which is
a gap in the engine and not in a client.

One thing the corpus can write and this client cannot bind is a BYTES
parameter, since Statement has no overload for a byte array. No case
binds one today, so the runner says so by name rather than putting the
value somewhere it does not belong, and the gap is filed rather than
worked around.
The runner's own tests are the reference runner's, case for case: a case
that says what it produces, a case that wants a condition, the eight ways
one can fail, a case ahead of the engine, a named connection, bound
parameters, a suite with a load, the database a failure leaves behind,
what a report comes to, where compare stops, and the two shapes the
report prints. Ported rather than written afresh, because a port that
tests something else is a port nobody can diff.

Three things the port needed that the Go tests get for free. A fresh
directory per run, since a case that fails leaves its database behind on
purpose and eight sub-cases all called `one` would collide where Go's
t.TempDir gives each one its own. A handful of the runner's helpers made
package private, because what the tests check is the status code, the
unsupported rule, the column list and the case path, and a test that
reaches them through the whole run checks the run instead. And a real
ZuException rather than a made one, since the constructor is package
private in dev.zudb and an empty statement is a better source of one
anyway.

The corpus test is gated on ZU_CASES and skips without it, the way
zu-go and zu-python gate theirs. The cases live in the engine's
repository and a client whose suite cannot run without a second
repository beside it is one nobody clones to fix a typo.

The module needs a provider and a grant to run at all, so it takes
zudb-ffm at test scope and runs surefire off the module path with
--enable-native-access=ALL-UNNAMED, which is what zudb-arrow does and
what the README tells a person to pass. That puts zudb-corpus after
zudb-ffm in the build order.
A job of its own rather than a step in the engine job, because the run
is fourteen hundred databases and that job runs its suite three times
over.

The job builds the engine and reads the cases out of the same checkout.
That pairing is the whole point: a corpus ahead of the library reports
the engine catching up to its own cases as this client failing, and a
library ahead of the corpus reports nothing at all. zu-go pins a
revision because it ships an archive of the library; this client builds
from source, so there is no revision to pin.

The README gets the command a person runs against a corpus directory,
the flags it takes, and the note that the same run happens under mvn
test when ZU_CASES points at the cases and skips when it does not.
@tamnd
tamnd merged commit 4b05b01 into main Aug 24, 2026
16 of 22 checks passed
@tamnd
tamnd deleted the corpus branch August 24, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant