B&W print-friendly diagrams + tinkerpop-0001 + wave-3 proof sections. Squash of 94 local commits onto remote master.
5.7 KiB
| id | repo | file | line | severity | status | created | updated |
|---|---|---|---|---|---|---|---|
| erlang-0002 | erlang/otp | lib/stdlib/src/digraph_utils.erl | 495 | MEDIUM | FIXABLE-UPSTREAM | 2026-03-23 | 2026-03-24 |
Defect
File: lib/stdlib/src/digraph_utils.erl:495
Pattern: lists:member(V, digraph:out_neighbours(G, V)) in is_reflexive_vertex/2
Complexity: O(degree(V)) per vertex → O(V²) aggregate over full graph
Language: Erlang
Description
is_reflexive_vertex/2 checks whether a vertex has a self-loop:
is_reflexive_vertex(V, G) ->
lists:member(V, digraph:out_neighbours(G, V)).
digraph:out_neighbours/2 builds a complete neighbor list by joining two ETS tables:
out_neighbours(G, V) ->
ET = G#digraph.etab,
NT = G#digraph.ntab,
[V2 || {{out, _}, E} <- ets:lookup(NT, {out, V}),
{E, _, V2, _} <- ets:lookup(ET, E)].
The ntab key is {out, V} — not {out, V, Neighbor}. There is no O(1) path to check
"does V have a self-loop" from outside digraph.erl. You must build the full neighbor
list and scan it.
is_reflexive_vertex is called from:
digraph_utils:loop_vertices/1(line 294) — iterates all V, calls once per vertexdigraph_utils:is_simple/1(line 485) — same pattern
Both call is_reflexive_vertex for every vertex in the graph → O(V²) total.
Why the originally proposed fix is wrong
The ticket previously proposed:
sets:is_element(V, NeighbourSet)
This does not help. You must first call digraph:out_neighbours(G, V) (which builds
the list), then convert it with sets:from_list/1 (O(degree(V))), then do the O(1)
lookup. Net cost: still O(degree(V)) per vertex, O(V²) total. No improvement.
Actual fix — add sltab to digraph.erl internals
digraph.erl uses three private ETS tables:
| Table | Key | Value | Purpose |
|---|---|---|---|
vtab |
V |
{V, Label} |
vertex storage |
etab |
E |
{E, V1, V2, Label} |
edge storage |
ntab |
{out|in, V} |
{{out|in, V}, E} |
neighbor index |
Add a fourth table: sltab (self-loop table)
sltab is a set storing V for every vertex that has at least one self-loop edge.
Maintained entirely inside digraph.erl. No public API change required.
Changes to digraph.erl:
1. Record:
-record(digraph, {vtab = notable :: ets:table(),
etab = notable :: ets:table(),
ntab = notable :: ets:table(),
sltab = notable :: ets:table(), %% NEW: self-loop index
cyclic = true :: boolean()}).
2. new/1 — initialize sltab:
SL = ets:new(digraph_sltab, [set, public]),
set_type(Ts, #digraph{vtab=V, etab=E, ntab=N, sltab=SL});
3. delete/1 — destroy sltab:
ets:delete(G#digraph.sltab),
4. do_insert_edge/5 — record self-loops:
do_insert_edge(E, V1, V2, Label, #digraph{ntab=NT, etab=ET, sltab=SL}) ->
ets:insert(NT, [{{out, V1}, E}, {{in, V2}, E}]),
ets:insert(ET, {E, V1, V2, Label}),
case V1 =:= V2 of
true -> ets:insert(SL, {V1});
false -> ok
end,
E.
5. Edge deletion — remove self-loop record when last self-loop is deleted:
In do_del_edge/2, after deleting from ntab/etab, check if any self-loop remains:
case {V1 =:= V2,
ets:select(ET, [{{'_', V1, V1, '_'}, [], [true]}], 1)} of
{true, '$end_of_table'} -> ets:delete(SL, V1);
_ -> ok
end,
6. Export has_self_loop/2:
-spec has_self_loop(G, V) -> boolean() when G :: graph(), V :: vertex().
has_self_loop(G, V) ->
ets:member(G#digraph.sltab, V).
7. digraph_utils:is_reflexive_vertex/2 — call the new O(1) function:
%% Before — O(degree(V))
is_reflexive_vertex(V, G) ->
lists:member(V, digraph:out_neighbours(G, V)).
%% After — O(1)
is_reflexive_vertex(V, G) ->
digraph:has_self_loop(G, V).
Complexity after fix
| Operation | Before | After |
|---|---|---|
is_reflexive_vertex/2 |
O(degree(V)) | O(1) |
loop_vertices/1 |
O(V²) | O(V) |
is_simple/1 (reflexive check) |
O(V²) | O(V) |
add_edge |
O(1) | O(1) + 1 ETS insert |
del_edge |
O(1) | O(1) + 1 ETS select + conditional delete |
Why this is load-bearing
digraph and digraph_utils are OTP stdlib — used by:
- Erlang/OTP itself — release dependency resolution, application boot ordering
- Rebar3 — build tool for all Erlang projects; dependency graph resolution
- Mix / Hex — Elixir's build tool (calls into OTP digraph)
- ejabberd — XMPP server used in production at scale; routing graph
- RabbitMQ — message broker; exchange graph and routing topology
- Every Erlang application that calls
digraph_utils:loop_vertices/1oris_simple/1on a graph with self-loops
Any Erlang or Elixir application that checks graph reflexivity or simplicity on a non-trivial graph is running O(V²). For RabbitMQ or ejabberd routing graphs at scale, this is a live performance defect in production.
Status rationale
Previous status: NOT-FIXABLE (incorrect — the reason given was "OTP ABI constraint" but no specific constraint was identified).
Actual status: FIXABLE-UPSTREAM. The fix requires patching digraph.erl inside OTP
stdlib. This is not a local fix — it requires an upstream OTP PR. The public API of
digraph and digraph_utils is unchanged. has_self_loop/2 is a new addition.
Work items
- Patch
lib/stdlib/src/digraph.erl— add sltab,has_self_loop/2 - Patch
lib/stdlib/src/digraph_utils.erl:495— usedigraph:has_self_loop/2 - Unit test —
loop_verticesop count: V=100,200,400 on complete graph with self-loops - Integration test —
is_simple/1,loop_vertices/1correctness before/after - OTP upstream PR