Repository navigation
Make loading configs and modules deterministic - #178
Conversation
Imports were parsed in parallel, and their quads were emitted as soon as they were parsed, so the order of quads depended on I/O timing. This made for example the order of list values that are spread over multiple files, and thereby the order of instantiations, differ between runs. Imports are still fetched and parsed in parallel, but their quads are now emitted after the quads of the importing file, in the order in which the imports occur. Furthermore, labels of blank nodes depended on the order in which files started being parsed. Each file now receives a blank node prefix that depends on its position in the registration order or the import tree. As a result, compiling the same config multiple times now produces the same output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeSuG8npLjD7EpaYC6LCWE
Coverage Report for CI Build 42386710967Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage remained the same at 100.0%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
| return new Promise((resolve, reject) => { | ||
| const quads: RDF.Quad[] = []; | ||
| data | ||
| .on('data', (quad: RDF.Quad) => quads.push(quad)) |
There was a problem hiding this comment.
Wait, but this converts a previously fully streaming pipeline into a buffered one.
Is it possible to make the fix without buffering?
There was a problem hiding this comment.
Yes. In 150d073, imports are no longer collected into arrays. Each import's stream is still started as soon as its rdfs:seeAlso quad is seen. Once the importing stream has ended, the includer reads the import streams one after another with for await. A stream that isn't being read stops once its buffer (16 quads in object mode) is full, so imports that are waiting stay paused with bounded memory, and the import that is being read streams through.
The resulting order and blank node labels are the same as with buffering. All of Comunica's compiled engines are byte-identical to the buffered version and still identical across runs and installs. Compiling Comunica's query-sparql config takes about as long as on master (5.2 to 5.9 s for both). I also added a test that checks that the first quad of an import is emitted while that import is still being parsed; it times out against the buffered version.
Two things stay as they are, since the old code worked the same way:
- The quads of the importing file itself all come before any imported quad. This is what makes the order independent of timing.
- Pushing imported quads doesn't wait for the consumer of the includer, as the old code also pushed on
data.
Generated by Claude Code
Imports are still started as soon as they are detected, but instead of buffering all their quads, they are read one after the other once the importing stream has ended. Imports that are not being read yet pause once their stream buffer is full, so quads keep flowing as they are parsed, and memory does not grow with the size of imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeSuG8npLjD7EpaYC6LCWE
Compiling the same config twice could produce different output. For Comunica's
query-sparqlconfig, 5 runs ofcompileConfiggave 5 differentengine-default.jsfiles. This showed up as noise when comparing Comunica's browser bundle sizes between commits (comunica/comunica#1954): a PR without engine changes reported changes of a few bytes in 9 of 10 bundles.Causes
RdfStreamIncluderfetched and parsed imports in parallel and pushed their quads as soon as they arrived. Values of a list spread over multiple files, such as theactorsof Comunica'sRunner, therefore came out in a different order on each run, and so did the order of instantiations.DataFactorywith the defaultdf_<global counter>_prefix, so labels depended on which file started parsing first. These labels end up as variable names in compiled configs.Changes
RdfStreamIncluderstill starts parsing each import as soon as it is detected. Once the importing stream has ended, it reads the import streams one after another, in the order in which the imports occur. Imports that are not being read yet pause once their stream buffer is full, so quads keep streaming and memory stays bounded. Errors are still emitted right away. This also removes a latent hang: if an import ended before the importing stream flushed, therunningImportersdecrement was skipped.RdfParserOptionshas a new optionalblankNodePrefix. If it is set and nodataFactoryis given, the parser uses aDataFactorywith that prefix. Imports derive their prefix as<parent prefix><import index>_. Labels are always a prefix followed by digits, so labels of different files can't clash.ConfigRegistry.registerandComponentRegistry.registerModuleassignconfig_<n>_andmodule_<n>_prefixes in call order.registerAvailableModulesregisters modules in sorted order, as module discovery order varies between runs.Behavior changes
df_418_1tomodule_71_0_1.query-sparqlconfig, compiling takes about as long as before (5.2 to 5.9 s on master and on this branch).Verification
query-sparqlnow give identical output, as do all its other engine configs, including the browser config. Two separate installs in different directories also give identical output. Three full cycles of regenerating all engine files and rebuilding all 10 browser bundles gave byte-identical results.dataFactorytaking precedence and errors not waiting for slow imports.yarn lint,yarn build,yarn test:jest(871 tests, 100% coverage) andyarn test:webpackpass.Not changed
These sources of nondeterminism remain. None of them changed Comunica's compiled output in my runs:
ModuleStateBuilderuses whichever copy it processes last, and that order varies._:x) with its own global counter, whichrdf-parsedoesn't expose.Generated by Claude Code