Skip to content

Fix nil crash in paths_to_root at parentless ancestors - #313

Open
alexskr wants to merge 2 commits into
developfrom
fix/paths-to-root-nil-parent
Open

Fix nil crash in paths_to_root at parentless ancestors#313
alexskr wants to merge 2 commits into
developfrom
fix/paths-to-root-nil-parent

Conversation

@alexskr

@alexskr alexskr commented Aug 4, 2026

Copy link
Copy Markdown
Member

Problem

/ontologies/:acr/classes/:cls/paths_to_root returns 500 in production (reported for NDFRT, RCD, NCBITAXON, CL, etc):

NoMethodError - undefined method `id' for nil:NilClass
  models/class.rb:701:in `append_if_not_there_already'
  models/class.rb:725:in `traverse_path_to_root'
  ... recursion via 746 / 728 ...
  concerns/concepts/concept_tree.rb:64:in `paths_to_root'

traverse_path_to_root gated its empty-parents guard on the tree flag:

return if (tree && parents.length == 0)

so the terminal condition was only checked in single-spine mode. The paths_to_root endpoint calls with tree: false and no roots, which leaves owl:Thing as the only stop sentinel the traversal recognizes (append_if_not_there_already, tree_root?). When the climb reaches a class with no parents under the submission's tree property, goo loads parents as [] and marks it loaded (solutions_mapper.rb init_unloaded_attributes), so the loaded_attributes.include?(:parents) fail-safe does not catch it, the recursion is entered with an empty array, and parents[0] is nil.

Scope

The trigger is not ontology-specific: the ancestor chain reaches a class with zero parents under that submission's tree property, and that class is not owl:Thing.

  • SKOS (skos:broader) and OBO (metadata:treeView) have no owl:Thing equivalent under the tree property, so every class that has a parent is affected.
  • OWL mostly escapes it because owlapi asserts rdfs:subClassOf owl:Thing on top-level classes (12 such triples in a BRO_v3.2.owl parse, none present in the source file), and that is the sentinel the traversal already handles.
  • Root classes were never affected — paths_to_root returns [] before traversing.
  • /tree was never affected — path_to_root passes tree: true and real roots, so it stops both at the old guard and in tree_root?.
  • Provisional classes hit the same code via ProvisionalClass#traverse_path_to_root -> cls.paths_to_root, inside index_doc. There it is swallowed by a rescue Exception and the document is indexed with empty path_ids, so affected submissions have been indexing provisional classes without ancestor paths.

Who calls this endpoint

paths_to_root traffic is mostly BioMixer, which is why the failures show up in the API log and New Relic with no visible symptom in the portal.

The BioPortal UI has no server-side call path to it at all: ontologies_api_ruby_client has no paths_to_root wrapper, and the only two references in bioportal_web_ui both hand the work to BioMixer rather than calling the API themselves — app/views/concepts/_biomixer.html.erb:4 (the concept page's Visualization tab) and public/widgets/visualization/index.html:32 (the embeddable widget). Both build an iframe src of $BIOMIXER_URL/?mode=embed&embed_mode=paths_to_root&…&restURLPrefix=<api>, so the request is issued by BioMixer's JavaScript in the visitor's browser, straight against the API. Rails never sees the response: a 500 there produces no error page and no flash, just an empty visualization inside a third-party iframe on a secondary tab someone has to click. Everything the portal itself uses for hierarchy browsing goes through /tree, /children and /parents, none of which enter the broken branch.

The remainder is direct API clients; the reported URLs look like scripted traversal across NDFRT, RCD and NCBITAXON. Traffic attribution was not measured here — grouping the New Relic errors by referer and user agent would confirm the split.

Changes

  • Make the terminal guard unconditional. A parentless ancestor ends that path; the path built so far is already correct. This matches OntologyProperty#traverse_path_to_root, a near-copy of the same algorithm that has always used an unconditional return if parents.empty? — the tree && in Class was the outlier.
  • Nil-safe .dup on p.parents (goo nils @parents before re-querying in bring, and .dup raises before the entry guard can help).
  • The cannot-load-parents fail-safe used return, which aborted the remaining recursions.each_index iterations and silently truncated the sibling paths cloned for a multi-parent class. Now next, so only the offending path stops.

Tests

Two regression tests, both verified to raise the production NoMethodError against the pre-fix traversal:

  • test_skos_paths_to_root_terminates_at_parentless_concept — SKOS, efo_gwas.skos.owl fixture
  • test_obo_paths_to_root_terminates_at_parentless_class — OBO, hp.obo fixture, with an assertion that the tree property really is metadata:treeView so it cannot silently degrade into a duplicate of the existing OWL test

Both select their subject deterministically (sorted ids) rather than taking the first row, since row order differs per backend, skip owl:Thing when looking for a parentless parent, and assert both that no traversable parent is left at the terminal node and that at least one path terminates at a class with no parents at all. Green on 4store, AllegroGraph, GraphDB and Virtuoso.

Notes for the reviewer

  • ontology_property.rb:331 has the same return-instead-of-next truncation. It cannot crash there (guarded at 299 and 334), so it is deliberately left out of this PR.
  • The same unfixed guard exists upstream in agroportal/ontologies_linked_data (class.rb:572) and ontoportal/ontologies_linked_data (class.rb:546), with paths_to_root called without roots in both ontologies_api forks. Worth reporting upstream; AgroPortal is SKOS-heavy and therefore the most exposed.
  • Class and OntologyProperty carry two copies of this traversal with ~22 identical lines and four small points of variation (stop sentinel, skip rule, parent attributes to bring, fail-safe). The duplication is why the correct guard sat in the repo without helping. Consolidating behind a shared concern is a reasonable follow-up, gated on adding property-side path coverage first — today the only property tree assertion in the suite is test_ontology.rb:234.
  • Make the disabled state of OWLAPI reasoning explicit, and stop implying it in tests #312 was filed separately for the OWLAPI reasoning flag, found while investigating this (the reasoner turned out to be unrelated to this crash).

alexskr added 2 commits August 4, 2026 00:56
traverse_path_to_root gated its empty-parents guard on the `tree` flag, so it
only protected the tree/path_to_root caller. The /classes/:cls/paths_to_root
endpoint calls paths_to_root with tree=false and no roots, so the traversal
recognizes only owl:Thing as a stopping point (append_if_not_there_already,
tree_root?). When the ancestor chain reaches a class with no parents under the
submission's tree property, goo loads `parents` as [] and marks it loaded
(solutions_mapper init_unloaded_attributes), the recursion is entered with an
empty array, and parents[0] is nil:

  NoMethodError: undefined method `id' for nil:NilClass
    class.rb:701:in `append_if_not_there_already'
    class.rb:725:in `traverse_path_to_root'
    concept_tree.rb:64:in `paths_to_root'

OWL submissions mostly escape this because owlapi asserts
`rdfs:subClassOf owl:Thing` on top-level classes (12 such triples in a BRO_v3.2
parse, none present in the source file) and owl:Thing is the sentinel the
traversal already handles. SKOS (skos:broader) and OBO (metadata:treeView) have
no equivalent sentinel, so every class with a parent is exposed. Reported in
production for NDFRT, RCD and NCBITAXON.

Root classes were unaffected because paths_to_root returns [] before
traversing, and /tree was unaffected because path_to_root passes tree=true.

- make the terminal guard unconditional; a parentless ancestor ends the path
- nil-safe .dup on p.parents
- the cannot-load-parents fail-safe used `return`, aborting the remaining
  recursions.each_index iterations and silently truncating the sibling paths
  built for a multi-parent class; use `next` so only the failing path stops

Adds two regression tests, both verified to reproduce the production trace
before the change: SKOS via the efo_gwas.skos.owl fixture, and OBO via hp.obo
for the metadata:treeView tree property.
Both new tests picked their target class by iterating query results and taking
the first match, so the chosen class differed per backend -- row order is not
guaranteed and 4store, AllegroGraph, GraphDB and Virtuoso each returned a
different one. On AllegroGraph the OBO test then failed for two reasons:

- the discovery loop treated owl:Thing as a "parentless parent", so it selected
  a class whose only qualifying parent was the sentinel -- a path that never
  reaches the empty-parents branch the test is meant to cover
- the terminal assertion required path.first to have no parents at all, but a
  path may legitimately stop on the owl:Thing sentinel, which owlapi asserts on
  top-level classes

- sort candidate ids and pick the lowest, so every backend tests the same class
- skip owl:Thing when looking for a parentless parent
- assert the terminal node has no *traversable* parents (parents minus
  owl:Thing), plus a separate assertion that at least one path terminates at a
  class with no parents at all -- the case that used to raise

Verified on all four backends: test_class.rb, test_skos_submission.rb and
test_provisional_class.rb pass on 4store, AllegroGraph, GraphDB and Virtuoso,
and both tests still reproduce the NoMethodError against the pre-fix traversal.
@alexskr alexskr self-assigned this Aug 4, 2026
@alexskr
alexskr requested a review from mdorf August 4, 2026 17:22
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.05%. Comparing base (29e9fa7) to head (e8dd8c1).

Files with missing lines Patch % Lines
lib/ontologies_linked_data/models/class.rb 66.66% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #313      +/-   ##
===========================================
+ Coverage    82.02%   82.05%   +0.02%     
===========================================
  Files          101      101              
  Lines         6853     6853              
===========================================
+ Hits          5621     5623       +2     
+ Misses        1232     1230       -2     
Flag Coverage Δ
ag 81.90% <66.66%> (+0.02%) ⬆️
fs 82.02% <66.66%> (+0.01%) ⬆️
gd 81.99% <66.66%> (+0.10%) ⬆️
unittests 82.05% <66.66%> (+0.02%) ⬆️
vo 81.97% <66.66%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@alexskr
alexskr marked this pull request as ready for review August 4, 2026 21:26
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.

2 participants