Skip to content

GH-373: Implement Jelly-SPARQL - #392

Merged
Ostrzyciel merged 23 commits into
mainfrom
GH-373/jelly-sparql
Oct 27, 2026
Merged

Ostrzyciel merged 23 commits into
mainfrom
GH-373/jelly-sparql

Conversation

@Ostrzyciel

@Ostrzyciel Ostrzyciel commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Closes #373

This is also rather similar code to that in Jelly-JVM.

  • We pass all conformance tests, hooray 🥳
  • There is a nice simple API for result set streams.
  • It's decently fast. I don't see many ways to make this faster, we are severely limited by Python here.
  • This was also tested e2e with a Jena Fuseki 6 instance + Jelly-JVM 4.0.1 plugin. It does work.
  • Also fixed a bug in rdflib RDF parsing where parsing a QUADS stream into a Graph yielded empty results.

Performance

All done on the DGX Spark, CPython 3.15.

Write (thousands of cells per second)

TSV has no writer in rdflib 🤷 somehow.

Dataset rdflib JSON rdflib XML rdflib CSV Jelly (rdflib) Jelly (generic)
assist-iot-weather 1,417 325 1,546 7,183 11,724
citypulse-traffic-graphs 1,292 332 1,332 8,155 14,581
dbpedia-live 1,156 306 1,883 4,377 5,737
linked-spending 1,178 326 1,363 7,255 13,322
nanopubs 1,430 326 1,339 7,913 12,304
realistic-mixed 1,572 346 2,826 7,140 11,610
wide-20 1,488 356 2,497 6,112 6,365
sparse-random-50 2,258 434 2,063 8,723 13,402
lit-lang 953 208 2,064 6,191 11,745
iri-shuffled 1,368 271 1,546 2,411 3,111
Geometric mean 1,377 318 1,785 6,198 9,473

Read (thousands of cells per second)

Dataset rdflib JSON rdflib XML rdflib CSV rdflib TSV Jelly (rdflib) Jelly (generic)
assist-iot-weather 576 306 735 32.2 10,002 18,571
citypulse-traffic-graphs 564 323 742 33.9 8,749 17,971
dbpedia-live 442 277 741 29.7 2,455 6,559
linked-spending 543 337 696 - 5,815 12,782
nanopubs 581 342 735 33.3 10,291 17,264
realistic-mixed 558 313 864 34.7 8,984 14,317
wide-20 643 346 1,131 37.5 12,907 18,757
sparse-random-50 1,127 611 886 27.1 7,283 9,395
lit-lang 387 226 525 22.6 5,080 6,986
iri-shuffled 653 355 615 27.0 2,209 3,480
Geometric mean 584 333 752 30.5 (9/10) 6,435 11,147

Size (bytes per cell, geometric mean)

rdflib JSON rdflib XML rdflib CSV Jelly
Plain 79.8 91.7 40.1 5.85
gzip 3.37 3.58 2.71 1.69
zstd 2.95 3.15 2.58 1.63

@Ostrzyciel
Ostrzyciel marked this pull request as draft October 26, 2026 18:47
@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.71429% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pyjelly/integrations/generic/sparql.py 89.74% 2 Missing and 2 partials ⚠️
pyjelly/integrations/rdflib/encode.py 75.00% 1 Missing and 2 partials ⚠️
pyjelly/integrations/rdflib/sparql.py 97.91% 2 Missing and 1 partial ⚠️
pyjelly/integrations/rdflib/parse.py 80.00% 1 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
pyjelly/errors.py 100.00% <100.00%> (ø)
pyjelly/integrations/rdflib/serialize.py 95.68% <100.00%> (+0.15%) ⬆️
pyjelly/jelly/__init__.py 100.00% <100.00%> (ø)
pyjelly/jelly/sparql_pb2.py 100.00% <100.00%> (ø)
pyjelly/options.py 98.36% <100.00%> (+0.51%) ⬆️
pyjelly/integrations/rdflib/parse.py 87.74% <80.00%> (-0.93%) ⬇️
pyjelly/integrations/rdflib/encode.py 76.56% <75.00%> (+5.40%) ⬆️
pyjelly/integrations/rdflib/sparql.py 97.91% <97.91%> (ø)
pyjelly/integrations/generic/sparql.py 89.74% <89.74%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Ostrzyciel Ostrzyciel changed the title [WIP] GH-373: Implement Jelly-SPARQL GH-373: Implement Jelly-SPARQL Oct 26, 2026
@Ostrzyciel
Ostrzyciel marked this pull request as ready for review October 26, 2026 20:20
@Ostrzyciel
Ostrzyciel requested a review from nk2IsHere October 26, 2026 20:20
@Ostrzyciel
Ostrzyciel added this pull request to stack #394 October 26, 2026 20:49
Ostrzyciel and others added 23 commits October 27, 2026 09:31
sparql.proto imports rdf.proto, so the script points the generated import
at pyjelly.jelly. The messages are exported from pyjelly.jelly, like the
ones of Jelly-RDF.
Read and write SPARQL query results (SELECT and ASK) in Jelly-SPARQL 1.0,
following Jelly-JVM. The columns are those of Jelly-RDF 1.2, so the
encoder and decoder reuse them.

- pyjelly.serialize.sparql: SparqlStream writes the frames of result sets.
  Frames hold 4096 values by default and end early when the lookups are
  full. If getting the rows fails midway, the result set ends with a
  trailer that has the error.
- pyjelly.parse.sparql: SparqlDecoder checks and decodes the frames, and
  read_result_set(s) gives result sets whose rows are read as they are
  iterated. A trailer with an error raises JellyIncompleteResultsError
  after the rows; a stream that ends without a trailer is marked as
  truncated.
- rdflib: Result.parse() and Result.serialize() read and write the "jelly"
  format (and application/x-jelly-sparql, so rdflib's SPARQLStore can ask
  endpoints for it).
- The generic API reads and writes results with its own terms, including
  triple terms and literals with a base direction.
- PUNCTUATED streams (a sequence of result sets) are supported, with blank
  nodes scoped to each result set.

Shared code changes: TermEncoder.new_frame() budgets the datatypes of
several literals, the frame writer no longer needs write() to return a
count (rdflib's result streams don't), delimiting detection takes the tags
of the frame type, and out-of-range lookup references are reported as
JellyConformanceError instead of IndexError.
Run the tests of jelly-protobuf's test/sparql with the rdflib integration
and the generic API, like the Jelly-RDF ones. rdflib skips the RDF 1.2
categories, as it has no triple terms or base directions.

The parse tests compare with the expected SPARQL JSON results, the
serialize tests check the options, the trailer, and that the output reads
back as the input. The conformance report script writes separate reports
for Jelly-SPARQL.
parse_jelly_sparql(lazy=True), also through Result.parse(), reads the
rows as the result is iterated, like rdflib does with the results of its
own queries.
A page on SPARQL query results: reading and writing them with rdflib
(including from a SPARQL endpoint), lazy reading, incomplete results,
options, sequences of results, and the generic API. The examples run in
the tests, like the others.
A "sparql" suite of the benchmarks (--suite sparql), as in Jelly-JVM:
RiverBench datasets read as the results of SELECT * queries, and the
synthetic result sets of Jelly-JVM's generator (ported). Jelly-SPARQL,
through rdflib and the generic API, is compared with rdflib's JSON, XML and
CSV results. Throughput counts cells (rows times variables). The
measurement, processes and sweeps are those of the RDF benchmarks.
Bugs:
- Results are read and written as they come over a connection: a buffered
  input (e.g. from socket.makefile()) is no longer wrapped in a second
  buffer, which waited for 8 KiB, and each frame is flushed.
- Writing an rdflib result that was partly iterated wrote only the rest.
- Bindings keyed by plain strings were written as unbound.
- An incomplete boolean result read with the generic API looked complete.
- Lazy rdflib results had no links until iterated.
- Results read from an incomplete or truncated stream were written again as
  complete.

API changes:
- parse/serialize_jelly_sparql_results are now ..._sequence, and the
  generic API can write sequences too. In a sequence, an incomplete rdflib
  result sets its `error` instead of ending the sequence.
- A truncated stream issues JellyTruncatedResultsWarning, and a frame cut
  short or input that isn't Jelly-SPARQL raises a clear error.
- SparqlOptions.frame_size is now frame_values, as it counts values.
- Unknown keyword arguments of Result.parse/serialize raise TypeError.
- Rows can be iterated once, an iteration stopped early can go on, and
  reading from a closed input says so. Bytes are accepted as input.
- Trailers name the type of the error that stopped the results.
Reading: the decoder makes rdflib's bindings (dicts of the bound variables)
straight from a frame's columns, with a row factory compiled with mypyc,
instead of making tuples that a Python loop turns into dicts. An eager read
takes the rows a frame at a time (SparqlResultSet.collect()).

Writing: SparqlStream.binding() takes a binding and looks the variables up
in compiled code, instead of a Python loop making a list for each row.

Instructions per cell (geometric mean of 5 datasets, CPython 3.15): rdflib
writes -24%, rdflib reads -39%. The output is the same.
Two literals with the same lexical form, language tag and datatype are the
same term, so rdflib's __eq__ (Python code) is only called when these
differ. The variables of a dict binding are looked up with dict's own get.

Instructions per cell of rdflib writes, geometric mean of 5 datasets:
-4.8%. Jelly-RDF writes use the same comparison. The output is the same.
A few cells are cheaper to append than to slice or repeat as a new list,
which the layouts of columns with runs and unbound cells did for every
token. A non-delimited stream no longer looks up its options for each row.

Instructions per cell of reads, geometric mean of 5 datasets: generic
-2.5%, rdflib -1.7% (up to -6% with many runs). The output is the same.
mypyc indexes a value typed as Sequence through the general item lookup,
with the index as an int object, which took 12% of a generic write. Rows
that are tuples or lists, and lists of variables, are now indexed as such.

Instructions per cell of writes, geometric mean of 5 datasets: generic
-5.5%, rdflib -5.2%. The output is the same.
In code compiled with mypyc, iterating a list is cheaper than indexing it,
which checks the bounds each time. Done for the name ids of IRIs (also
in Jelly-RDF) and for rdflib's bindings.

Instructions per cell of reads, geometric mean of 5 datasets: generic
-1.6%, rdflib -2.8%. The output is the same.
Consecutive values of a column are compared to find runs. IRIs, blank
nodes and literals of the generic API are now compared by their strings,
without a call to their __eq__, and terms of different classes are never
equal. Jelly-RDF writes use the same comparison.

Instructions per cell of generic writes, geometric mean of 5 datasets:
-3.1%. The output is the same.
The decoder now also takes a row factory, which **kwargs: int doesn't fit.
A frame whose lookups had no room for another row was ended before the
next row was encoded. If that row failed, the full frame was never
returned, so its rows were lost, and if it was the first frame, the stream
had no options. Now the frame ends right after the row that fills it. The
frames are the same.
Plain, direct wording instead of roundabout phrases like "Return how many
cells write writes".
rdflib reads SPARQL TSV results but can't write them, so the TSV it reads
is written by a helper, and writing is skipped. Also rewords the comments of
the benchmark code.
Result.parse() and Result.serialize() take format="jellys" too, after the
file extension. "jelly" stays the documented name.
…ailer

When Fuseki cancels a query (e.g., on a timeout), Jelly-JVM ends the stream
with an error trailer, and then Fuseki appends a text message to the
response. The reader went on after the trailer, as concatenated streams may
follow, and raised a "stream is not valid" error instead. If the trailer
already said that the results are incomplete, the reader now stops there
and raises JellyIncompleteResultsError, with the rows read before.
Found when compat testing pyjelly with rdflib

@nk2IsHere nk2IsHere left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure looks ok

@Ostrzyciel
Ostrzyciel merged commit a54db24 into main Oct 27, 2026
23 checks passed
@Ostrzyciel
Ostrzyciel deleted the GH-373/jelly-sparql branch October 27, 2026 09:17
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.

Implement Jelly-SPARQL

2 participants