Recursive Traversals
See the Execution Options Reference for the full table of every
g.with() key, including repeat.order and repeat.max_loops below, and how a host can lock
either one so a query cannot override it.
repeat() loops a traversal: the output of each iteration is the input of the next. With it you
can write variable-length paths, walk hierarchies, compute a transitive closure, and search
breadth first or depth first. Graphersal follows the semantics of TinkerPop's
RepeatStep, including where the modulators times(), until() and emit() are written.
g.V("1").repeat(__.out()).times(2) // two hops away
g.V("1").repeat(__.out()).until(__.has("name", "ripple")).path() // the route to ripple
g.V("1").emit().repeat(__.out("knows")).times(2) // marko, josh, vadas
The script DSL and the Rust API use the same step names, with these Rust spellings for the overloads Rust cannot express:
| Script | Rust |
|---|---|
repeat(__.out()) | repeat(__::out(None)) |
repeat("a", __.out()) | repeat_named("a", __::out(None)) |
times(2) | times(2) |
until(__.has("name", "x")) | until(__::has("name", "x")) |
emit() | emit() |
emit(__.hasLabel("person")) | emit_with(__::has_label("person")) |
loops() | loops() |
loops("a") | loops_named("a") |
For tree-shaped walks, such as a directory tree matched against a pattern like
**/src/*.rs, the glob_path() step is shorter than a
repeat() loop: it matches the pattern level by level, prunes branches that cannot match, emits
each match once and stops on cycles. Where a loop would use times(n), glob_path("**", #{max_depth: n})
bounds the walk to n levels below the start, and prune: __... stops it below matching vertices
the way until() stops a loop (see Options). It is a
Gremlin extension, not part of TinkerPop.
Stopping and emitting
times(n)stops afterniterations. It isuntil(__.loops().is(n)).until(t)stops a traverser oncetyields a result for it. The traverser is output.emit()also outputs every traverser the loop visits, not only those it ends with.emit(t)outputs only the traversers for whichtyields a result.- Without
times()/until(), a traverser goes round until the body yields nothing for it. Withoutemit(), such a traverser is dropped, not output.
Placement: before or after repeat()
Where a modulator is written decides when it is checked, exactly as in TinkerPop:
- After
repeat()(do-while): the condition is checked after each iteration, so the body runs at least once.repeat(__.out()).times(0)runs the body once. - Before
repeat()(while-do): the condition is checked before each iteration, the first one included.times(0).repeat(__.out())returns its input unchanged, andemit().repeat(__.out())outputs the start vertex too.
| Written | Meaning |
|---|---|
repeat(__.out()).times(2) | two iterations |
times(2).repeat(__.out()) | two iterations, checked first |
emit().repeat(__.out()).times(2) | the start and every visited vertex, two iterations |
repeat(__.out()).emit().until(__.has(...)) | every visited vertex, until a match |
repeat(__.out()).times(2).emit().repeat(__.in()) | the emit() belongs to the first loop; the second repeat() is a new loop |
When a traverser meets both emit and until at the same check, it is output once. Setting a
modulator twice keeps the last one. A modulator with no repeat() before or after it, as in
g.V().times(2), fails with RepeatWithoutBody.
Loop counters: loops()
loops() is the number of iterations the enclosing loop has completed for the traverser:
0before the first iteration and inside its body;k + 1after the body of iterationk, which is when postfixuntil()/emit()run;0outside any loop, including after a traverser left its loop.
So repeat(__.out()).until(__.loops().is(2)) is repeat(__.out()).times(2), and
emit(__.loops().is(P.gt(0))).repeat(__.out()) emits everything except the start.
Nested and named loops
A repeat() can appear in the body, in until()/emit(), or in any child traversal of another
repeat(). loops() reads the innermost loop. To read an outer loop, name it:
g.V("1").repeat("a", __.out().repeat("b", __.in()).until(__.loops("b").is(1))).times(2)
g.V("1").repeat("a", __.out().repeat("b", __.in()).until(__.loops("a").is(0))).times(1)
loops(name) reads the nearest enclosing loop with that name. A name no enclosing loop has
fails with UnknownLoopName, which lists the active loop names.
Cycles and limits
On a graph with cycles, g.V().repeat(__.both()) never runs out of traversers. Three tools keep
loops finite:
simple_path()in the body drops every traverser that returns to an element it already visited:g.V("a").repeat(__.out().simplePath()).emit().path().times()/until()bound or stop the loop.repeat.max_loops(10 000 by default) stops a loop withouttimes()withRepeatLimitExceeded:g.with("repeat.max_loops", 100). A loop withtimes(n)is never limited.evaluationTimeoutbounds the wall-clock time of the whole traversal. See Query Limits.
Breadth first and depth first
By default a loop runs breadth first: the body runs once per iteration on all traversers of
that iteration, and everything iteration k outputs comes before what iteration k + 1 outputs.
Within an iteration, traversers output before the body (prefix emit/until) come before those
output after it.
g.with("repeat.order", "dfs") walks the loop depth first instead, in pre-order: a traverser
is followed to the end of its loop before its siblings. The walk uses an explicit stack, so deep
loops cannot overflow the call stack.
// a tree, children in the order out() returns them: r -> (b -> b1, a -> (a2, a1))
g.V("r").emit().repeat(__.out()) // r, b, a, b1, a2, a1
g.with("repeat.order", "dfs").V("r").emit().repeat(__.out()) // r, b, b1, a, a2, a1
Both orders return the same results for a body without barrier steps; only the order differs.
TinkerPop does not define the output order of repeat() in the same way, so a query that depends
on order should sort its results.
dedup() and other barriers in the body
A dedup() in the body keeps one seen-set for the whole loop, like in TinkerPop: an element met
again in a later iteration, or reached by another traverser, is dropped. V().repeat(dedup()).times(2)
passes every vertex in the first iteration and nothing in the second, and
g.V("s").repeat(__.out().dedup()).emit() is a visited set. Both orders (repeat.order) agree, and
merging the frontier stays invisible (survivors have bulk 1).
- Every
dedup()step of the body has its own set; a nestedrepeat()starts a fresh set each time it runs. - A
dedup()inside a per-traverser child of the body (where(...),not(...),local(...),by(traversal), theuntil()/emit()conditions) starts fresh for every traverser, like TinkerPop's reset children. Aunion()/choose()child of the body is not reset. simplePath()is still the way to stop cycles.
Other barriers in the body (limit(), range(), tail(), count()) run once per iteration in
breadth-first order, like TinkerPop 3.8.2: its feature files expect
g.V().repeat(both().limit(1)).times(2) to return one traverser and
repeat(union(constant('y').limit(1), identity())).times(2) to return y twice, which a loop-wide
counter would not give. In depth-first order (g.with("repeat.order", "dfs"), which TinkerPop
does not have) such a barrier sees one traverser at a time, so a limit() there limits each
traverser's walk, not the iteration. Only dedup() keeps loop-wide state.
Merging the frontier
In breadth-first order the loop merges equal traversers of the frontier (the same vertex reached by
different routes) into one traverser with a bulk, before until()/emit() are checked. The next
iteration then expands each distinct vertex once, so repeat(__.out()).times(8).count() on a dense
graph stays cheap. The results are the same multiset as without merging; only the order of
duplicates can change. Merging does not happen in depth-first order, when the plan records full
paths (path(), simplePath()), or when it mutates the graph. This is not a visited set:
unlike dedup(), every route still counts. A sack merge operator or withBulk(false) turns the
frontier merge off; write repeat(__.out().barrier()) to merge per iteration. See
Bulk and Barriers and
turn it off with g.with("bulk.merge", false).
Paths and labels
Every step of the body extends the path of its traversers, so path() after a loop shows the
whole route. as("x") in the body labels the position of every iteration, and select("x")
returns the last one. The path requirement analysis treats the body as loop-carried; see
Path Requirement Analysis.
Profiling
.profile() shows the loop as written, with the body and the until()/emit() traversals as
children. Their counts and times add up over all iterations. The loop summary line reports the
number of body executions, the deepest iteration reached, and the average loop count of the
output traversers:
repeat(__.out()).times(2) 1 1 2
[loops: 2, max_depth: 2, avg_loops: 2.0]
\> out() 2 4 5
repeat() and inject()
inject() cannot be a direct step of a repeat() body, because the injected values would
enter again in every iteration. Graphersal raises TraverserError::RepeatInject when the
traversal executes (not when it is built). An inject() nested deeper, for example inside
union(..), is allowed:
g.V("1").repeat(__.inject(1)).times(2).toList() // error
g.V().repeat(__.union(__.identity(), __.inject("y"))).times(2) // fine
A repeat() body that updates a sack (sack(Operator.sum).by(..)) keeps the sack of each traverser through every
iteration; see Sack and Operators.