Gate the shape layer on the graph, and let Node's heap be raised - #29
Merged
Merged
Conversation
Two memory guards, each fixed after it failed on the same 170,935,101-triple graph. The shape-layer gate measured the wrong thing. pyshacl is in-memory, so the wrapper size-gates it -- but on the PACKAGED artifact's bytes, while what pyshacl pays for is the graph. One graph therefore landed on both sides of one 512 MiB gate purely by packaging: cottas 390,728,158 B under -> shapes attempted -> OOM nt.gz 756,594,166 B over -> skipped hdt 1,182,206,289 B over -> skipped The COTTAS run was SIGKILLed at 32.2 GB RSS on a 31 GB machine, after COTTAS decoding and rapper had both succeeded on every triple -- nothing was wrong with the artifact or the engine. The better a format compresses, the likelier it was to exhaust memory, so the guard inverted exactly where it was needed most. The authoritative gate now lives in the runner, which is the only place that knows what pyshacl will load, and counts triples, which no packaging can change. --shacl-max-triples defaults to 50M: above the largest graph the published campaign validated with shapes (17.1M) and below the one that exhausted 31 GB. A skip is recorded with its reason rather than being silent. Node chose a ceiling the machine did not. On the same graph the HDT endpoint aborted with "Reached heap limit Allocation failed - JavaScript heap out of memory" on the sample-level query while ~25 GB was free; the kernel OOM killer was never involved. Node does not size its old-space from the host, and nothing in the image, runner or wrapper set NODE_OPTIONS. --node-heap-mb now sets it for the three Comunica-backed engines. It is unset by default, so every published measurement keeps the conditions it was produced under. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI failed on five tests from the already-merged query-selection feature: 'Namespace' object has no attribute 'node_heap_mb' run_validation is driven by hand-built argparse.Namespace objects in several places -- that test suite and the mutation harness among them -- so reading a new option by plain attribute access breaks those callers at runtime rather than at import. The file already guarded progress_path and quiet with getattr for exactly this reason; --shacl-max-triples and --node-heap-mb now do the same, and the query-selection fixture carries both fields so it still exercises the real path. The regression test is behavioural rather than textual. The first version asserted on the source of run_validation and failed twice on formatting -- once on a wrapped getattr, once on the space in "getattr( args," -- which constrains how the guard is written rather than that it works. It now drives run_validation with a Namespace deliberately missing both fields and asserts the run never dies with "has no attribute". Verified by reintroducing the bug: the test fails, and passes again once restored. A regression test that has never been seen to fail has not been tested. Full suite: 958 tests, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…unt crash
The existing tests pinned the two pure helpers. Nothing exercised the code
that acts on them: the gate inside run_validation, the heap reaching the
spawned endpoint, or either option crossing the wrapper and the container
boundary. Every line and branch the PR adds is now covered, and each of 12
deliberate mutations (env dropped from Popen, a falsy 0 dropped by the
wrapper, an exclusive boundary, ...) fails at least one test.
The new tests found a crash. shacl_exceeds_limit treats an unknown triple
count as too large, and the skip reason then formatted it with f"{None:,}",
raising TypeError. With shapes requested, that turned two outcomes into
EXECUTION_FAILED: rapper passing without a parsable count line, and rapper
missing -- which should have been BLOCKED_BY_PREFLIGHT, because the gate runs
before the preflight check. The skip is now recorded with a reason that says
the count is unknown.
Also moves the test file's __main__ guard to the end, so running it directly
no longer skips the class defined below it.
Full unit suite: 994 tests, OK. Mutation suite: 116 tests, OK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two memory guards, each fixed after it failed on the same 170,935,101-triple graph during a scale experiment.
1. The shape-layer gate measured the wrong thing
pyshaclis in-memory, so the wrapper size-gates it — but on the packaged artifact's bytes, while whatpyshaclpays for is the graph. One graph therefore landed on both sides of one 512 MiB gate purely by packaging:cottasnt.gzhdtThe COTTAS run was SIGKILLed at 32.2 GB RSS on a 31 GB machine — after COTTAS decoding and
rapperhad both succeeded on all 170,935,101 triples. Nothing was wrong with the artifact or the engine.The perverse part: the better a format compresses, the likelier it was to exhaust memory. COTTAS's headline advantage (0.52× the gzipped N-Triples) is precisely what defeated the guard. The existing comment says the gate exists because "silently trying would turn a safety net into an OOM" — which is what happened.
Fix: the authoritative gate moves into the runner, the only place that knows what
pyshaclwill actually load, and counts triples — which no packaging can change.--shacl-max-triplesdefaults to 50M: above the largest graph the published campaign validated with shapes (17.1M, the 100,000-record HG005 slice) and below the one that exhausted 31 GB (170.9M). A skip is recorded with its reason inshacl.jsonrather than being silent.The wrapper's byte gate stays as a cheap pre-filter, now documented as non-authoritative.
2. Node chose a ceiling the machine did not
On the same graph the HDT endpoint died on the sample-level query:
q01–q04had passed (~36 min each);q05returnedIncompleteRead, andq06–q13then failed instantly withConnection refused. The kernel OOM killer was never involved — ~25 GB was free. Node does not size its old-space from the host, and nothing in the image, runner or wrapper setNODE_OPTIONS.Fix:
--node-heap-mbsets--max-old-space-sizefor the three Comunica-backed engines (comunica,hdt,cottas— all viaComunicaHttpEndpointMixin). It appends to any existingNODE_OPTIONSrather than replacing it.Compatibility
--node-heap-mbis unset by default, so every published measurement keeps the conditions it was produced under. The SHACL gate is the one behaviour change: a graph above 50M triples that previously attempted shapes now records a skip instead — which is the intended outcome, since attempting it is what caused the OOM.A crash the tests found
shacl_exceeds_limittreats an unknown triple count as too large, which is the right call. But the skip reason then formatted that count withf"{None:,}", which raisesTypeError. With shapes requested, two cases becameEXECUTION_FAILED:rappersucceeds but prints no parsable count line.rapperis missing. That should have beenBLOCKED_BY_PREFLIGHT, but the gate runs before the preflight check.The skip is now recorded with a reason stating the count is unknown. The decision itself is unchanged.
Testing
test/test_shacl_gate_and_node_heap_unit.py, 53 tests. Every line and branch this PR adds is covered by this file alone. Each of 12 deliberate mutations, such as droppingenv=fromPopen, the wrapper dropping a falsy0, or an exclusive boundary, fails at least one test.The two worth reading:
test_the_packaging_no_longer_decides: the property the bug violated. All three packagings of one graph must reach one verdict.test_the_old_byte_gate_did_disagree_across_packagings: not a test of current behaviour but a record of the defect, in the numbers that produced it, so nobody reinstates the byte gate.What else is covered:
0disables the gate, the boundary is inclusive, and the default sits between the graph that worked and the graph that died.run_validation: a graph over the limit never reaches pyshacl. The skip and its reason are written toshacl.json, announced on stderr, and matchsummary.json. A skip is not treated as a shape failure. A graph at or under the limit still gets its shapes checked. An unknown count is recorded as a skip, not a crash, and a missingrapperstill reachesBLOCKED_BY_PREFLIGHT.comunica,hdtandcottasall read the ceiling, and it reaches the spawned process'sNODE_OPTIONS, appended to the caller's own options. Unset leaves the environment unchanged.main():--shacl-max-triplesaccepts0and rejectslots,1.5and-1with exit 2.--node-heap-mbrejects0, negatives and non-integers.0, which a truthiness check would drop. The runner accepts the flag names and value types the wrapper sends.run_validationaccepts a hand-built namespace without either option, and falls back to the documented defaults.Full unit suite: 994 tests, OK.
test_validation_mutation_unit(116 tests) also passes.🤖 Generated with Claude Code