Skip to content

feat(sparql-anything): split a file into the chunks a conversion can hold - #799

Open
ddeboer wants to merge 2 commits into
mainfrom
feat/sparql-anything-chunk
Open

feat(sparql-anything): split a file into the chunks a conversion can hold#799
ddeboer wants to merge 2 commits into
mainfrom
feat/sparql-anything-chunk

Conversation

@ddeboer

@ddeboer ddeboer commented Sep 2, 2026

Copy link
Copy Markdown
Member

convert() takes chunks it does not create, so every consumer was left to produce them – the shell script this package replaces does it with split(1) and a cat loop per chunk. chunk() streams a line-oriented file into fixed-row pieces and returns their paths in order, which is exactly what a job's chunks takes.

const chunks = await chunk('data/allCountries.txt', {
  rows: 1_000_000,
  into: 'data/chunks',
  header: 'geonameid\tname\tlatitude\tlongitude',
  extension: '.csv',
});
await converter.convert([{ queryFile: 'places.rq', chunks, load }], 'places.nt');

Why here, and not a package of its own

I started out proposing @lde/line-chunker, on the grounds that chunking is how an input is made to fit a bounded unit of work (ADR 12). That was wrong: in LDE, bounded memory comes from streaming, and the pipeline has no use for a file in pieces. Chunking exists because SPARQL Anything materialises a chunk's whole result graph before writing it – a property of this tool, not of the data or the pipeline. So it belongs in the package whose job is working around this tool, beside the one-JVM-per-chunk decision it is the other half of.

Nothing in it touches the converter, so if a second, streaming-averse tool ever appears, promoting it is a file move.

Why by line, and not by parsing

The tempting alternative was csv-parse, so that a quoted field containing a newline could not be cut in two. Two things against it:

  • The input cannot produce that case. GeoNames is "tab-delimited text in utf8 encoding" with no quoting mechanism documented – without quoting there is no syntax for a newline inside a field. And the current pipeline does no parsing at chunk time either; split -l is pure line splitting, and the parsing happens downstream in SPARQL Anything. A parser here would be a new guarantee against an input this cannot receive.
  • It costs about 6×. On a 220 MB / 2 M-row TSV: line splitting 1.2 s, csv-parse with raw 7.5 s.

So the contract says what it does – splits on lines, every record must be one line – rather than parsing every field to defend against something that cannot arrive. Widening header?: string to also mean "the input's first line" is a later change that breaks no caller, if a headered-CSV consumer ever turns up.

Measurements

Same 220 MB file, two runs each, chunks verified identical:

time
cat copy (raw I/O floor, macOS) 0.18 s
GNU split -l (Linux, Docker) 0.30 s
this implementation 1.18 s
BSD split -l (macOS) 3.60 s
csv-parse with raw 7.50 s

GNU split is faster, and shelling out to it was the other candidate. It would have meant a POSIX dependency in the runner's environment, a dev machine 12× slower than CI (BSD split is pathological – 3.3 s of system time to move bytes a copy handles in 0.07 s), and a package that is five lines of shell behind an interface. Against a chunking step that is seconds inside a two-minute download.sh, that trade is not worth it.

extension

Defaults to the input's own, so chunking N-Triples for something that expects N-Triples needs nothing. It is there for tools that read the format from the file name – SPARQL Anything does, which is why the shell script renames its .txt chunks to .csv.

…hold

convert() takes chunks it does not create, so every consumer was left to
produce them – the shell script this package replaces does it with split(1)
and a cat loop per chunk.

chunk() streams a line-oriented file into fixed-row pieces and returns their
paths in order, which is what a job's `chunks` takes. It belongs here rather
than in a package of its own: LDE's pipeline streams and has no use for a file
in pieces. Chunking exists because SPARQL Anything materialises a chunk's whole
result graph before writing it, so it is part of working around this tool, and
this is the package that does that.

Splitting is by line, which is what the format in hand allows: a tab-separated
export has no quoting mechanism, so a record cannot span lines. A delimited
format that wraps a field in quotes to carry a newline would be cut in two, and
the contract says so rather than parsing every field to guard against an input
this cannot receive. Measured, line splitting also runs a single pass at about
6x a parser's throughput.

`extension` exists for tools that read the format from the file name: SPARQL
Anything does, so a .txt export of a CSV has to be chunked as .csv to be read
as one. It defaults to the input's own extension, so chunking N-Triples for
something that expects N-Triples needs nothing.
… on it

A review of chunk() found five things, four of them about what happens when
writing goes wrong:

- No 'error' listener was ever attached to the chunk file. once() covers only
  the instants an await is pending; a write that fails while the loop waits on
  the next line – ENOSPC part way through a multi-GB export – was an unhandled
  'error', which ends the process rather than this call.
- The open chunk was left open on any failure, keeping its handle and half a
  row, so a caller that caught and retried accumulated both.
- `extension` was concatenated unchecked, so 'csv' without its dot produced
  places-0000csv, which is exactly the naming the option exists to get right.
- Chunks of the same input from an earlier call were left in place, so a
  shorter re-run left a longer one's tail for a caller globbing the directory
  to pick up. Removed first, files only, and only those matching this input's
  own chunk names.

Closing a chunk now waits with finished() rather than once('close'): it
reports a stream that has already failed, and returns for one that has already
closed, where waiting for the event waits for one that will not come again.
That single reporting path replaced a stored error and a throw, which no test
could reach because the failure always arrived through an await first.

The fifth is documented rather than fixed: the input must hold data only, since
every line becomes a row. A file carrying its own header would repeat it inside
the first chunk, and no caller needs the alternative yet.
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