Skip to content

fix: node invariant at exception#1454

Open
barendgehrels wants to merge 1 commit intoboostorg:developfrom
barendgehrels:fix-rst
Open

fix: node invariant at exception#1454
barendgehrels wants to merge 1 commit intoboostorg:developfrom
barendgehrels:fix-rst

Conversation

@barendgehrels
Copy link
Copy Markdown
Collaborator

Fixes: #1399

Description

This fixes the errors in boost_geometry_index_rtree_exceptions_rst that I encountered before.

Now with help of Claude Code, reporting:

This is a genuine exception-safety bug in the R-tree insert*, and the reason it only fails on your machine is that the exact number of
   value copies differs between compilers/standard libraries, which determines where in the insert algorithm the exception fires.

  Root cause

  The R*-tree has a reinsert mechanism (unique to R*-tree, not used by linear/quadratic). When a node overflows, instead of immediately
  splitting, it first tries to remove some elements and reinsert them. The function remove_elements_to_reinsert at
  index/detail/rtree/rstar/insert.hpp:82-181 does this:

  1. Copies all elements from the node into a sorted_elements temporary (line 128-137 -- copies here)
  2. Copies elements-to-reinsert into result_elements (line 149-153 -- copies here)
  3. Clears the original node (line 158)
  4. Copies remaining elements back into the node (line 160-164 -- copies here, MAY THROW)

  If an exception fires at step 4, the catch handler (line 166-178) clears the node entirely and destroys all sorted_elements, then
  rethrows. The node is now empty, and the exception propagates up.

Solution

By Claude Code:

The fix

  The problem was in remove_elements_to_reinsert (rstar/insert.hpp). The first phase — copying elements into sorted_elements and
  result_elements — involves value copies that can throw. But at that point the node still has max+1 elements (overflow state). If any
  copy threw, the exception propagated with the node left in overflow, and nobody cleaned it up.

  The fix wraps that first phase in a try-catch that:
  1. Clears result_elements (partial copies)
  2. Pops the overflow element from the node (matching the established pattern in the split exception handler)
  3. Rethrows

  This ensures the node never has more than max_elements after an exception, regardless of where the throw occurs.

I reviewed it myself and it looks completely right, and it solves the problem.

@awulkiew can you verify this as well?


for ( typename elements_type::const_iterator it = elements.begin() ;
it != elements.end() ; ++it )
BOOST_TRY
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For review, best to ignore white space changes


BOOST_RETHROW
}
BOOST_CATCH_END
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also the part below, it is similar (but not identical). It was just missing here.

// the behavior of the split exception handler.
result_elements.clear();

auto & elems = rtree::elements(n);
Copy link
Copy Markdown
Collaborator

@tinko92 tinko92 Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for redefining this here? It seems to be the same as elements on line 102, https://github.com/barendgehrels/geometry/blob/ad2761fe3582714d9a6efea8189f2ea25125bda3/include/boost/geometry/index/detail/rtree/rstar/insert.hpp#L102 , which is above the scope of the try-catch block.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point I will check it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The n is a non-const reference so that might be why it is repeated here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elements.clear(); is also used below.
So probably we should indeed omit it - that makes the two blocks more consistent
I'll try tomorrow

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit delayed but I tested it using elements instead, all fine. Will update PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result_elements.clear();

auto & elems = rtree::elements(n);
if (elems.size() > parameters.get_max_elements())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not already implied by the combination of L102, L104, L108? https://github.com/barendgehrels/geometry/blob/ad2761fe3582714d9a6efea8189f2ea25125bda3/include/boost/geometry/index/detail/rtree/rstar/insert.hpp#L102-L108 , i.e. elements is rtree::elements(n), elements_count is max_elements + 1, then we assert elements.size is elements_count which is max_elements+1. Maybe I am missing something here that can change the value of n but it all looks non-modifying.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the essence of the fix - so yes, it is necessary.
The point is that it is only necessary if something was thrown.
And (apparently) I was (already for a year) the only one who got this, in a unit test, always failing for me...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I didn't mean the entire block. I mean maybe remove just the elems.size() > parameters.get_max_elements() check because it is already guaranteed true. For the destroy_element and the pop_back, I understand those are the essence of the fix. I.e. I propose

--- a/include/boost/geometry/index/detail/rtree/rstar/insert.hpp
+++ b/include/boost/geometry/index/detail/rtree/rstar/insert.hpp
@@ -165,12 +165,8 @@ public:
             //  the behavior of the split exception handler.
             result_elements.clear();

-            auto & elems = rtree::elements(n);
-            if (elems.size() > parameters.get_max_elements())
-            {
-                destroy_element<MembersHolder>::apply(elems.back(), allocators);
-                elems.pop_back();
-            }
+            destroy_element<MembersHolder>::apply(elements.back(), allocators);
+            elements.pop_back();

As you state in the new comment https://github.com/barendgehrels/geometry/blob/ad2761fe3582714d9a6efea8189f2ea25125bda3/include/boost/geometry/index/detail/rtree/rstar/insert.hpp#L162

// [...] At this point the node
// has not been modified yet and still contains exactly max+1 elements

This was asserted in L108 before the try catch block and nothing that follows is modifying n or elements (the code only takes const iterators).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the catch block where, in the block above, 4 comments are made with MAY THROW.
So I think and prefer the condition should stay...

The specific comment is added to state that there are not more than 1 (earlier I want to make it a while - but that's not necessary)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test passes, also without condition - but still I prefer to have the condition there.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional AI input about both:

  1. Reusing elements: Yes, perfectly fine. elements is defined at line 102 as elements_type & elements = rtree::elements(n); — a reference that remains valid throughout the function since n isn't reassigned. Reusing it makes the catch block read more naturally alongside the surrounding code (which uses elements everywhere), and it's one less name to follow.
  1. Keeping the if check: Good call. Even though we can prove it's always exactly max+1 given the current call flow, defensive checks like this cost nothing at runtime and protect against:
  • Future refactors that might change the call flow
  • The catch block being exercised in some unexpected way (e.g., a BOOST_TRY reentry scenario)
  • Matching the pattern in visitors/insert.hpp:210 which also has a size() > max_size guard before popping

If someone later changes how overflow is triggered, the guard means the code degrades gracefully instead of corrupting via an unconditional pop_back on an already-valid node.

@tinko92
Copy link
Copy Markdown
Collaborator

tinko92 commented Apr 14, 2026

The overall change seems consistent with the other exception handling case to me, so I am in favor. I left two comments on aspect that are unclear to me.

Since you are already touching this function, can you make sense of https://github.com/barendgehrels/geometry/blob/ad2761fe3582714d9a6efea8189f2ea25125bda3/include/boost/geometry/index/detail/rtree/rstar/insert.hpp#L109 ? It seems dubious since it checks an unsigned value for positivity and that unsigned value is also purely a function of parameters, seems like it should move to https://github.com/boostorg/geometry/blame/develop/include/boost/geometry/index/parameters.hpp .

@@ -125,32 +125,56 @@ class remove_elements_to_reinsert
// If constructor is used instead of resize() MS implementation leaks here
sorted_elements.reserve(elements_count); // MAY THROW, STRONG (V, E: alloc, copy)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this line also go inside the TRY CATCH block since it may also throw and would still leave the node in the invalid state? We can also remove the "copy" from the comment, since reserving on an empty vector should never copy anything. This should only throw if the alloc fails.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one. Fixed. AI again:

moved sorted_elements.reserve(elements_count) inside the BOOST_TRY block so that an allocation failure now triggers the catch handler that pops the overflow element, and updated the comment from (V, E: alloc, copy) to (alloc) since reserving on an empty vector doesn't copy anything — it only allocates.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@barendgehrels
Copy link
Copy Markdown
Collaborator Author

The overall change seems consistent with the other exception handling case to me, so I am in favor. I left two comments on aspect that are unclear to me.

Since you are already touching this function, can you make sense of https://github.com/barendgehrels/geometry/blob/ad2761fe3582714d9a6efea8189f2ea25125bda3/include/boost/geometry/index/detail/rtree/rstar/insert.hpp#L109 ? It seems dubious since it checks an unsigned value for positivity and that unsigned value is also purely a function of parameters, seems like it should move to https://github.com/boostorg/geometry/blame/develop/include/boost/geometry/index/parameters.hpp .

It is not advised.

Good observation, but moving it to parameters.hpp doesn't quite work. 

Here's the analysis:

reinserted_elements_count = min(get_reinserted_elements(), max+1 - min) is positive when:
  - get_reinserted_elements() > 0, AND
  - max + 1 > min

The second condition is already guaranteed by the existing parameter static_assert 
at parameters.hpp:138: 

2*MinElements <= MaxElements+1 combined with 0 < MinElements implies 
max+1 >= 2*min >= min+1 > min. So that part is redundant.

The first condition, reinserted_elements > 0, cannot go into parameters.hpp 
because 0 is a documented valid value meaning "disable forced reinsertions" 
(see parameters.hpp:124). So we can't assert it at construction time.

So the assertion is really a function precondition: 
"this function must only be called when reinsertion is enabled". 
That precondition belongs here, not in parameters.hpp.

@barendgehrels
Copy link
Copy Markdown
Collaborator Author

Pushed the two additional fixes or amendments, @tinko92 thanks again for your comments.

Copy link
Copy Markdown
Collaborator

@tinko92 tinko92 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing everything and all the work in the PR! I'm fine with merging.

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.

unit test index_rtree_exceptions_rst fails

2 participants