diff --git a/src/validation/validation_runner.py b/src/validation/validation_runner.py
index e830f84..3987504 100644
--- a/src/validation/validation_runner.py
+++ b/src/validation/validation_runner.py
@@ -2024,6 +2024,64 @@ def merge_shapes_graph(shapes: list[Path]):
return graph
+#: pyshacl loads the whole graph into memory, so the shape layer needs a size
+#: gate. This one is in triples rather than bytes, because what pyshacl pays
+#: for is the graph, not the packaging -- see the comment at its call site for
+#: the failure that motivated it.
+#:
+#: 50M is chosen to sit above the largest graph the published campaign actually
+#: validated with shapes (17.1M triples, the 100,000-record HG005 slice) and
+#: below the one that exhausted 31 GB (170.9M). It is deliberately not derived
+#: from measured bytes-per-triple: rdflib's footprint depends on term sharing
+#: and IRI length, so a constant here is a conservative guard rather than a
+#: prediction, and it is overridable for a machine that can afford more.
+DEFAULT_SHACL_MAX_TRIPLES = 50_000_000
+
+#: Node's V8 heap ceiling for the Comunica-backed endpoints (comunica, hdt,
+#: cottas -- all three go through ComunicaHttpEndpointMixin).
+#:
+#: Node sizes its old-space from a default that does not track the machine, so
+#: on a 31 GB host the endpoint still died with "Reached heap limit Allocation
+#: failed - JavaScript heap out of memory" on the sample-level query of a
+#: 170.9M-triple HDT graph, with 25 GB free. The kernel OOM killer was never
+#: involved: the process aborted itself inside a ceiling it chose.
+#:
+#: None keeps Node's default, which is the behaviour every published result was
+#: produced under. Set a value to raise it.
+DEFAULT_NODE_HEAP_MB: int | None = None
+
+
+def node_endpoint_env(heap_mb: int | None, base: dict[str, str] | None = None) -> dict[str, str]:
+ """Environment for a Comunica endpoint, with an optional heap ceiling.
+
+ Appends rather than replaces NODE_OPTIONS, so a caller's own setting is
+ kept and only the heap is added.
+ """
+ env = dict(base if base is not None else os.environ)
+ if not heap_mb:
+ return env
+ option = f"--max-old-space-size={heap_mb}"
+ existing = env.get("NODE_OPTIONS", "").strip()
+ env["NODE_OPTIONS"] = f"{existing} {option}".strip() if existing else option
+ return env
+
+
+
+def shacl_exceeds_limit(triple_count: int | None, limit: int | None) -> bool:
+ """Whether a decoded graph is too large to hand to pyshacl.
+
+ An unknown count is treated as too large, for the same reason the wrapper
+ treats an unreadable size that way: skipping a check is recoverable and is
+ recorded, while exhausting memory mid-run loses the whole validation.
+ A limit of 0 disables the gate.
+ """
+ if not limit:
+ return False
+ if triple_count is None:
+ return True
+ return triple_count > limit
+
+
def validate_shacl(
source: Path,
shapes: Path | list[Path],
@@ -2435,6 +2493,9 @@ def _init_endpoint(self, options: dict[str, Any]) -> None:
options.get("comunica_warmup_timeout")
or max(DEFAULT_COMUNICA_WARMUP_TIMEOUT, self.query_timeout)
)
+ # None leaves Node's own default, which is what every published result
+ # was produced under.
+ self.node_heap_mb = options.get("node_heap_mb") or DEFAULT_NODE_HEAP_MB
self.server: subprocess.Popen | None = None
self.executable: str | None = None
self.endpoint: str | None = None
@@ -2486,6 +2547,7 @@ def _start_endpoint(self) -> None:
stdout=server_log.open("wb"),
stderr=subprocess.STDOUT,
start_new_session=True,
+ env=node_endpoint_env(getattr(self, "node_heap_mb", None)),
)
self._await_bind(server_log)
self._await_warm(server_log)
@@ -3958,11 +4020,52 @@ def run_validation(args: argparse.Namespace) -> int:
)
rdf_validation = validate_ntriples(decoded, results_dir)
shacl_result = None
- if args.shacl_shapes is not None:
+ # The authoritative size gate for pyshacl, because this is the only
+ # point that knows what pyshacl will actually load.
+ #
+ # The wrapper also gates, on the PACKAGED artifact's bytes, and that
+ # measure is wrong in a way that bites hardest on the best format.
+ # On a 170,935,101-triple graph the same graph landed on both sides
+ # of the wrapper's 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 decoding and rapper had both succeeded on every triple. So
+ # the better a format compresses, the likelier it was to exhaust
+ # memory -- the guard inverted. Gating on the triple count fixes
+ # that, because a graph's cost to pyshacl does not depend on how it
+ # arrived.
+ decoded_triples = rdf_validation.get("tripleCount")
+ shacl_skipped: dict[str, Any] | None = None
+ shacl_limit = getattr(
+ args, "shacl_max_triples", DEFAULT_SHACL_MAX_TRIPLES
+ )
+ if args.shacl_shapes is not None and shacl_exceeds_limit(
+ decoded_triples, shacl_limit
+ ):
+ shacl_skipped = {
+ "status": "SKIPPED_TOO_LARGE",
+ "tripleCount": decoded_triples,
+ "limitTriples": shacl_limit,
+ "reason": (
+ f"the decoded graph holds {decoded_triples:,} triples, above the "
+ f"--shacl-max-triples limit of {shacl_limit:,}. "
+ f"pyshacl is in-memory, so attempting it risks exhausting memory "
+ f"mid-run; skipping is recoverable and is recorded here."
+ ),
+ }
+ eprint(f"[{args.dataset_id}] shapes skipped: {shacl_skipped['reason']}")
+ if args.shacl_shapes is not None and shacl_skipped is None:
progress.emit("progress", completed=0, detail="validating SHACL shapes")
shacl_result = validate_shacl(
decoded, args.shacl_shapes, results_dir, args.shacl_ontology
)
+ if shacl_skipped is not None:
+ shacl_result = shacl_skipped
+ write_json(results_dir / "shacl.json", shacl_skipped)
write_json(results_dir / "rdf-validation.json", rdf_validation)
materialization["decodedTripleCount"] = rdf_validation.get("tripleCount")
write_json(results_dir / "materialization.json", materialization)
@@ -3976,6 +4079,7 @@ def run_validation(args: argparse.Namespace) -> int:
"comunica_port": args.comunica_port,
"hdt_port": args.hdt_port,
"comunica_bind_timeout": args.comunica_bind_timeout,
+ "node_heap_mb": getattr(args, "node_heap_mb", DEFAULT_NODE_HEAP_MB),
"comunica_warmup_timeout": args.comunica_warmup_timeout,
}
engine_options["artifact_path"] = str(args.rdf)
@@ -4351,6 +4455,29 @@ def build_arg_parser() -> argparse.ArgumentParser:
"checked."
),
)
+ parser.add_argument(
+ "--node-heap-mb",
+ type=int,
+ default=DEFAULT_NODE_HEAP_MB,
+ help=(
+ "Raise the V8 old-space ceiling (MB) for the Comunica-backed "
+ "endpoints (comunica, hdt, cottas). Node does not size its heap "
+ "from the machine, so an endpoint can abort with a JavaScript "
+ "heap-out-of-memory while the host still has free memory. Unset "
+ "keeps Node's default"
+ ),
+ )
+ parser.add_argument(
+ "--shacl-max-triples",
+ type=int,
+ default=DEFAULT_SHACL_MAX_TRIPLES,
+ help=(
+ "Skip the shape layer when the decoded graph holds more triples "
+ "than this, recording the skip and its reason (0 disables the "
+ "gate). pyshacl is in-memory, and its cost tracks the graph rather "
+ f"than the artifact it arrived in (default: {DEFAULT_SHACL_MAX_TRIPLES:,})"
+ ),
+ )
parser.add_argument("--filter-oracle", choices=("auto", "bcftools", "cyvcf2"), default="auto")
parser.add_argument("--scratch-dir", type=Path, default=Path("/work"))
parser.add_argument(
diff --git a/test/test_query_selection_unit.py b/test/test_query_selection_unit.py
index 57d6aba..5544e6e 100644
--- a/test/test_query_selection_unit.py
+++ b/test/test_query_selection_unit.py
@@ -422,6 +422,11 @@ def _args(self, tmp_path, queries):
filter_oracle="cyvcf2",
dataset_id="sample",
queries=queries,
+ # Mirror the real argument surface. run_validation tolerates these
+ # being absent (see RunValidationToleratesAMinimalNamespaceTests),
+ # but a fixture that omits them stops exercising the real path.
+ shacl_max_triples=V.DEFAULT_SHACL_MAX_TRIPLES,
+ node_heap_mb=V.DEFAULT_NODE_HEAP_MB,
)
def _run(self, queries, *, corrupt=None):
diff --git a/test/test_shacl_gate_and_node_heap_unit.py b/test/test_shacl_gate_and_node_heap_unit.py
new file mode 100644
index 0000000..1aaa067
--- /dev/null
+++ b/test/test_shacl_gate_and_node_heap_unit.py
@@ -0,0 +1,225 @@
+"""Two memory guards, each fixed after it failed on a 170.9M-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 170,935,101-triple 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. The better a format
+compresses, the likelier it was to exhaust memory: the guard inverted. The fix
+gates on the decoded triple count, which no packaging can change.
+
+**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" while ~25 GB was free; the kernel OOM killer was never involved.
+Node does not size its old-space from the host, so the endpoints now accept an
+explicit ceiling.
+"""
+
+import importlib.util
+import os
+import unittest
+from pathlib import Path
+
+from test.helpers import VerboseTestCase
+
+RUNNER_PATH = Path(__file__).resolve().parents[1] / "src" / "validation" / "validation_runner.py"
+_spec = importlib.util.spec_from_file_location("validation_runner_guards", RUNNER_PATH)
+V = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(V)
+
+# The real numbers from the run that motivated the fix.
+GRAPH_TRIPLES = 170_935_101
+ARTIFACT_BYTES = {
+ "cottas": 390_728_158,
+ "nt.gz": 756_594_166,
+ "hdt": 1_182_206_289,
+}
+WRAPPER_BYTE_GATE = 512 * 1024 * 1024
+
+
+class ShaclGateTests(VerboseTestCase):
+ def test_the_packaging_no_longer_decides(self):
+ """The property the bug violated: same graph, same verdict.
+
+ Every packaging of one graph must get one answer. This is the
+ regression test for the inversion itself.
+ """
+ verdicts = {
+ kind: V.shacl_exceeds_limit(GRAPH_TRIPLES, V.DEFAULT_SHACL_MAX_TRIPLES)
+ for kind in ARTIFACT_BYTES
+ }
+ self.assertEqual(set(verdicts.values()), {True},
+ "the decoded graph is the same for every artifact, so "
+ "every artifact must reach the same decision")
+
+ def test_the_old_byte_gate_did_disagree_across_packagings(self):
+ """Pins why the fix was needed, so nobody reinstates the byte gate.
+
+ Not a test of current behaviour -- a record of the defect, expressed in
+ the numbers that produced it.
+ """
+ byte_verdicts = {k: b <= WRAPPER_BYTE_GATE for k, b in ARTIFACT_BYTES.items()}
+ self.assertTrue(byte_verdicts["cottas"], "cottas slipped under the gate")
+ self.assertFalse(byte_verdicts["nt.gz"])
+ self.assertFalse(byte_verdicts["hdt"])
+ self.assertEqual(len(set(byte_verdicts.values())), 2,
+ "the byte gate split one graph three ways; that was the bug")
+
+ def test_the_graph_that_oomed_is_refused(self):
+ self.assertTrue(V.shacl_exceeds_limit(GRAPH_TRIPLES, V.DEFAULT_SHACL_MAX_TRIPLES))
+
+ def test_the_campaigns_largest_validated_graph_still_passes(self):
+ """17.1M triples is the 100,000-record HG005 slice the campaign ran
+ shapes on. The gate must not retroactively disable published behaviour."""
+ self.assertFalse(V.shacl_exceeds_limit(17_098_746, V.DEFAULT_SHACL_MAX_TRIPLES))
+
+ def test_an_unknown_count_is_treated_as_too_large(self):
+ """Skipping is recoverable and recorded; exhausting memory is not."""
+ self.assertTrue(V.shacl_exceeds_limit(None, V.DEFAULT_SHACL_MAX_TRIPLES))
+
+ def test_zero_disables_the_gate(self):
+ for count in (0, 1, GRAPH_TRIPLES):
+ with self.subTest(count=count):
+ self.assertFalse(V.shacl_exceeds_limit(count, 0))
+ self.assertFalse(V.shacl_exceeds_limit(None, 0))
+
+ def test_the_boundary_is_inclusive(self):
+ limit = V.DEFAULT_SHACL_MAX_TRIPLES
+ self.assertFalse(V.shacl_exceeds_limit(limit, limit))
+ self.assertTrue(V.shacl_exceeds_limit(limit + 1, limit))
+
+ def test_the_default_sits_between_the_two_observations(self):
+ """It must admit what worked and refuse what died, or it is arbitrary."""
+ self.assertGreater(V.DEFAULT_SHACL_MAX_TRIPLES, 17_098_746)
+ self.assertLess(V.DEFAULT_SHACL_MAX_TRIPLES, GRAPH_TRIPLES)
+
+
+class NodeHeapEnvTests(VerboseTestCase):
+ def test_unset_leaves_the_environment_alone(self):
+ """Published results were produced under Node's default; keep it."""
+ env = V.node_endpoint_env(None, {"PATH": "/usr/bin"})
+ self.assertNotIn("NODE_OPTIONS", env)
+ self.assertEqual(env["PATH"], "/usr/bin")
+
+ def test_zero_is_also_unset(self):
+ self.assertNotIn("NODE_OPTIONS", V.node_endpoint_env(0, {}))
+
+ def test_a_ceiling_is_applied(self):
+ env = V.node_endpoint_env(16384, {})
+ self.assertEqual(env["NODE_OPTIONS"], "--max-old-space-size=16384")
+
+ def test_an_existing_setting_is_kept_not_replaced(self):
+ """A caller's own NODE_OPTIONS must survive; only the heap is added."""
+ env = V.node_endpoint_env(8192, {"NODE_OPTIONS": "--enable-source-maps"})
+ self.assertIn("--enable-source-maps", env["NODE_OPTIONS"])
+ self.assertIn("--max-old-space-size=8192", env["NODE_OPTIONS"])
+
+ def test_the_base_environment_is_not_mutated(self):
+ base = {"NODE_OPTIONS": "--enable-source-maps"}
+ V.node_endpoint_env(4096, base)
+ self.assertEqual(base["NODE_OPTIONS"], "--enable-source-maps")
+
+ def test_it_defaults_to_the_process_environment(self):
+ env = V.node_endpoint_env(None)
+ self.assertEqual(env.get("PATH"), os.environ.get("PATH"))
+
+ def test_the_default_is_node_s_own(self):
+ """Unset by default, so this change alters no existing measurement."""
+ self.assertIsNone(V.DEFAULT_NODE_HEAP_MB)
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class RunValidationToleratesAMinimalNamespaceTests(VerboseTestCase):
+ """A new option must not become a required attribute of run_validation.
+
+ run_validation is driven by hand-built namespaces in several places -- this
+ suite and the mutation harness among them -- so reading a new option with
+ plain attribute access breaks those callers at runtime rather than at
+ import. That is exactly how adding --shacl-max-triples and --node-heap-mb
+ broke CI: five tests failed with "'Namespace' object has no attribute
+ 'node_heap_mb'", and the failure surfaced in a merged feature's tests
+ rather than in the change that caused it.
+
+ The convention the file already used for progress_path and quiet is
+ getattr with the documented default. This pins it, so the next option
+ added cannot reintroduce the same break silently.
+ """
+
+ def test_run_validation_accepts_a_namespace_without_the_new_options(self):
+ """Behavioural, not textual: drive run_validation and look for the break.
+
+ An earlier version of this test asserted on the source text and failed
+ twice on formatting, which is the wrong instrument -- it constrains how
+ the guard is written rather than that it works.
+ """
+ import argparse
+ import json
+ import tempfile
+ from unittest import mock
+
+ with tempfile.TemporaryDirectory() as td:
+ tmp = Path(td)
+ (tmp / "scratch").mkdir()
+ (tmp / "s.vcf").write_text("##fileformat=VCFv4.2\n#CHROM\tPOS\n")
+ (tmp / "s.nt").write_text("