Fix nil crash in paths_to_root at parentless ancestors - #313
Open
alexskr wants to merge 2 commits into
Open
Conversation
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.
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
alexskr
marked this pull request as ready for review
August 4, 2026 21:26
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.
Problem
/ontologies/:acr/classes/:cls/paths_to_rootreturns 500 in production (reported for NDFRT, RCD, NCBITAXON, CL, etc):traverse_path_to_rootgated its empty-parents guard on thetreeflag:so the terminal condition was only checked in single-spine mode. The
paths_to_rootendpoint calls withtree: falseand noroots, which leavesowl:Thingas 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 loadsparentsas[]and marks it loaded (solutions_mapper.rbinit_unloaded_attributes), so theloaded_attributes.include?(:parents)fail-safe does not catch it, the recursion is entered with an empty array, andparents[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:broader) and OBO (metadata:treeView) have noowl:Thingequivalent under the tree property, so every class that has a parent is affected.rdfs:subClassOf owl:Thingon top-level classes (12 such triples in aBRO_v3.2.owlparse, none present in the source file), and that is the sentinel the traversal already handles.paths_to_rootreturns[]before traversing./treewas never affected —path_to_rootpassestree: trueand realroots, so it stops both at the old guard and intree_root?.ProvisionalClass#traverse_path_to_root->cls.paths_to_root, insideindex_doc. There it is swallowed by arescue Exceptionand the document is indexed with emptypath_ids, so affected submissions have been indexing provisional classes without ancestor paths.Who calls this endpoint
paths_to_roottraffic 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_clienthas nopaths_to_rootwrapper, and the only two references inbioportal_web_uiboth hand the work to BioMixer rather than calling the API themselves —app/views/concepts/_biomixer.html.erb:4(the concept page's Visualization tab) andpublic/widgets/visualization/index.html:32(the embeddable widget). Both build an iframesrcof$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,/childrenand/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
OntologyProperty#traverse_path_to_root, a near-copy of the same algorithm that has always used an unconditionalreturn if parents.empty?— thetree &&inClasswas the outlier..duponp.parents(goo nils@parentsbefore re-querying inbring, and.dupraises before the entry guard can help).return, which aborted the remainingrecursions.each_indexiterations and silently truncated the sibling paths cloned for a multi-parent class. Nownext, so only the offending path stops.Tests
Two regression tests, both verified to raise the production
NoMethodErroragainst the pre-fix traversal:test_skos_paths_to_root_terminates_at_parentless_concept— SKOS,efo_gwas.skos.owlfixturetest_obo_paths_to_root_terminates_at_parentless_class— OBO,hp.obofixture, with an assertion that the tree property really ismetadata:treeViewso it cannot silently degrade into a duplicate of the existing OWL testBoth select their subject deterministically (sorted ids) rather than taking the first row, since row order differs per backend, skip
owl:Thingwhen 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:331has the samereturn-instead-of-nexttruncation. It cannot crash there (guarded at 299 and 334), so it is deliberately left out of this PR.agroportal/ontologies_linked_data(class.rb:572) andontoportal/ontologies_linked_data(class.rb:546), withpaths_to_rootcalled withoutrootsin bothontologies_apiforks. Worth reporting upstream; AgroPortal is SKOS-heavy and therefore the most exposed.ClassandOntologyPropertycarry 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 istest_ontology.rb:234.