Skip to content

Return an ElementIterable from the column index get and release its lock - #362

Merged
mbastian merged 6 commits into
masterfrom
index-get-snapshot
Oct 25, 2026
Merged

mbastian merged 6 commits into
masterfrom
index-get-snapshot

Conversation

@mbastian

@mbastian mbastian commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Problem

ColumnStandardIndexImpl.get(value) took the lock of the column index when it was called and released it only when the returned iterator was exhausted, so the lock was held while the caller's code ran:

  • Iterating index.get(column, value) and calling a setter in the loop, while another thread sets the same column or adds a node, deadlocked: the other thread held the table lock or the view index monitor and waited for the index lock, and the setter waited for them. This happens for a view index, for the elements of the view.
  • Breaking out of the loop early, or calling get and never iterating, left the lock held for good, so every later setter or addNode on that column blocked.
  • Iterating the result twice failed with an IllegalMonitorStateException.

For the columns that are not indexed (ColumnNoIndexImpl), and the degree and edge type columns, get held the graph read lock the same way, and count, values, getMinValue and getMaxValue of a non-indexed column kept the graph read lock when getAttribute threw (a removed column), which blocked every later write to the graph.

Fix

Index.get and ColumnIndex.get return an ElementIterable<T> and follow the convention of the graph iterables: the lock is taken when iterator() is called and held until the iterator is exhausted or doBreak() is called by the iterating thread.

  • ColumnStandardIndexImpl.get(value) returns a ValueIterable. Iterators opened on one result are linked, so a single doBreak() releases all of them, and it does nothing after exhaustion or when called twice. toArray, toCollection and toSet copy under the lock and release it before returning. stream() iterates a copy and holds no lock, parallelStream() throws UnsupportedOperationException as in ElementIterableWrapper.
  • ColumnNoIndexImpl, DegreeNoIndexImpl and EdgeTypeNoIndexImpl return a FilteredElementIterable, a lazy filter over the graph iterable with the same contract. It releases the graph read lock if the filter or the source throws.
  • count, values, getMinValue and getMaxValue of a non-indexed column release the graph read lock when the loop does not finish.
  • IndexImpl.get returns an empty iterable typed with the element type for a column the index does not hold.
  • The Javadoc of Index.get and ColumnIndex.get states the rule and the limits below.

Compatibility

The return type of Index.get and ColumnIndex.get changes from Iterable to ElementIterable, which extends Iterable. Source code keeps compiling, but this is a binary incompatible change for code compiled against 0.8.10. Nothing in graphstore calls these methods, and Gephi uses values, count, the minimum and the maximum of the indexes but not get(column, value).

Limits (documented)

  • While the lock is held, threads that set the indexed column, or write to the graph for a non-indexed column, wait. A loop body that sets attributes, or that calls the graph, model or index APIs, can deadlock with them. Copy the result with toArray() first, it releases the lock before returning.
  • An exception thrown by the loop body leaves the lock held unless doBreak() is called.
  • A result is iterated and broken from one thread and is not shared between threads.
  • A doBreak() releases every open iteration of the same result, so it also ends an outer loop over that result.

Tests

In IndexStoreTest: a setter running while a view index is iterated, an unfinished get, a doBreak after a partial or complete iteration, nested iterations with one doBreak, parallelStream(), stream().findFirst(), the edge type column, toArray() on the empty result of the edge index, and get and the numeric index methods on a non-indexed column after removeColumn, on indexed and non-indexed columns (each fails on master with a timeout, an exception or a wrong result). I also compared get with a model on 200 to 300 random sequences of 500 to 600 operations, with nodes and edges, indexed and non-indexed columns, degree and edge type columns, null, -0.0 and NaN values and two views: no difference.

An indexed ColumnIndex, or a result obtained from it, used after its column was removed now throws the IllegalArgumentException of the non-indexed path where it threw a NullPointerException (count, values, countValues and the entry iterator included), and does not leave the lock held. getMinValue and getMaxValue still throw their UnsupportedOperationException.

🤖 Generated with Claude Code

Index.get locked the column index when called and unlocked it when the
iterator was exhausted, so a get that was never iterated or a loop that
broke early kept the lock, and setting an attribute inside the loop could
deadlock with a setter on a view index. Non-indexed, degree and edge type
columns held the graph read lock the same way. get and the entry iterator
now copy the elements under the lock and return the copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@mbastian
mbastian requested a review from a team as a code owner October 25, 2026 11:12
mbastian and others added 4 commits October 25, 2026 12:37
… when iterating

Index.get took the index lock when called and released it only when the
iterator was exhausted, so a result that was never iterated or a loop that
stopped early kept the lock with no way to release it. Non-indexed, degree
and edge type columns held the graph read lock the same way.

get now returns an ElementIterable, like Graph.getNodes: the lock is taken
when iterator() is called and released when the iterator is exhausted or on
doBreak(). Each iteration takes and releases its own hold, and doBreak after
exhaustion or twice does nothing. The snapshot of the previous commit is
reverted, the entry iterator is unchanged. Code compiled against the
Iterable return type must be recompiled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e graph lock

doBreak only released the latest iterator of a result, so nested loops over
the same result kept the outer lock. parallelStream unlocked the lock from a
worker thread. A non-indexed column removed during an iteration of get,
count, values, getMinValue or getMaxValue left the graph read lock held by
the graph iterator.

doBreak now releases every iteration of the result still holding the lock,
parallelStream throws UnsupportedOperationException, and an exception during
the iteration releases the graph iterator. The edge type column returns
Graph.getEdges(type) as before.

The documentation states that the lock is held from iterator() until the
iterator is exhausted or doBreak is called, and that a loop body setting
attributes or reading the graph can deadlock with threads writing to the
index or the graph, in which case the result should be copied with toArray.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…he same contract

stream() opened an iterator that kept the lock when the stream stopped early
or was never consumed; the result now streams over a copy. The edge type
column returned Graph.getEdges(type), whose doBreak released a read hold
even after the iteration ended; it is now wrapped like the other columns.
doBreak walks the open iterations once instead of looping on the last one,
and a column missing from the index gives an empty result with an array of
the index element type.

The documentation states that a result is used from a single thread, that a
loop body calling the graph, model or index APIs can deadlock with writers,
that doBreak is needed when the loop body throws, and that a value of the
wrong type fails when iterating.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… narrow who waits for the lock

The index of an indexed column removed after get() threw a
NullPointerException on its destroyed map. Iterating, copying, counting and
listing the values of that index now throw an IllegalArgumentException, like
a column that isn't indexed, and release the lock.

The documentation states that an open result of an indexed column only
blocks the threads that set the column's value or add or remove elements of
its table, and the graph writers otherwise.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@mbastian mbastian changed the title Return a snapshot from the column index get and entry iterator Return an ElementIterable from the column index get and release its lock Oct 25, 2026
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@mbastian mbastian added this to the 0.8.11 milestone Oct 25, 2026
@mbastian
mbastian merged commit 9cb346d into master Oct 25, 2026
1 check passed
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.

1 participant