Repository navigation
Description
SEVERITY: MEDIUM
Summary GraphEngine.getMostUpdatedVertex carries the rule, in its own javadoc: "Every read of an edge-list
head has to go through this: the head is a pointer inside the vertex record, so a handle obtained
before an edge was appended still points at the previous head and would hide the newest edges. The Vertex accessors call it before reaching the engine". ImmutableVertex keeps that rule, every one of
its eleven engine calls wraps the receiver in getMostUpdatedVertex(this). MutableVertex passes a
bare this on all of them. So within one transaction, the two handles of the same vertex answer
different neighbour sets, and the mutable one is wrong.
That is the visible half. The damaging half is the write: MutableVertex holds outEdges and inEdges as fields snapshotted when the copy was made, and save() writes them back. A handle taken
before an edge was appended therefore overwrites the vertex's head pointer with the pre-append one,
unlinking every edge appended in between. No exception is raised, at save or at commit. The edge record
survives and the far endpoint keeps its back-reference, so the graph becomes asymmetric: traversing
from the target backwards finds the source, traversing from the source forwards finds nothing. CHECK DATABASE sees it and calls it a warning, not an error, so an unattended database stays damaged.
This is the same consequence #9840 was filed and fixed for ("saving a projection truncates that record
and orphans its edges"). That fix was placed at the call site, include()/exclude() stopped handing
back a record. The authority, the vertex save path, still has no guard, so any other way of reaching a
vertex handle with stale head pointers reproduces the loss. modify() is one.
Where (HEAD e353330)
engine/src/main/java/com/arcadedb/graph/GraphEngine.java:2895-2913:getMostUpdatedVertexand the
rule it states.engine/src/main/java/com/arcadedb/graph/ImmutableVertex.java:186-227: every engine call wrapped.engine/src/main/java/com/arcadedb/graph/MutableVertex.java:184-225: the same calls, barethis.engine/src/main/java/com/arcadedb/graph/MutableVertex.java:43-44: theoutEdgesandinEdges
fields, the snapshot thatsave()writes back.engine/src/main/java/com/arcadedb/query/sql/method/AbstractSQLMethod.java:205-212: the include()/exclude()` return a dirty record stamped with the queried record's RID, so saving a projection truncates that record and orphans its edges; on an edge they throw instead #9840 fix,
placed at the call site.
Repro
One transaction, one thread. Recorded output of the read half:
case1 fresh MutableVertex: ma.countEdges(OUT)=1 iter=1 reload=1
case2 stale modify() handle: mo.countEdges(OUT)=0 iter=0 | immutable a.countEdges(OUT)=1 | reload=1
case2b mo.getVertices(OUT)=[] a.getVertices(OUT)=[b]
The case2 is: take a as an ImmutableVertex, call a.modify() into mo, append an edge through a
second handle of the same vertex, then ask both. The immutable handle answers 1 neighbour, the mutable
handle answers 0, same vertex, same transaction.
The write half, with the same shape and a property write on the stale handle, recorded:
after SQL create edge, before mo.save(): reload count=1
after mo.save(): reload count=0
after commit: reload count=0 in(b)=1 edgeRecords=1
CHECK: ... "edgesMissingOutReference":1 ... "totalErrors":0 ...
"warnings":["edge #4:0 is not referenced back by its outgoing vertex #1:1: it is missing from that
vertex's OUT list","edge #4:0 was not connected from the incoming vertex #1:1 to the vertex #1:0"]
The edge record is still there, b still counts it on its IN side, and a has lost it. The commit
raises nothing.
Refutation attempted
- Is the loss refused somewhere? Not in one transaction. Across transactions it is: a second connection
that commits the edge while the first holds themodify()handle makes the first commit fail with
ConcurrentModificationException: Slot rebase not possible on page ... Please retry the operation,
and the edge survives. Recorded. So the protection exists and only the same-transaction case is open. - Is a kept handle always unsafe? No, and that is what makes this a sibling rather than a general
caveat. A handle fromnewVertex().save()is the instance the transaction cache holds, so the engine
appends to it and nothing is lost. Recorded, through both SQL and the engine API:
E1 after kept-handle save: a.OUT=1 b.IN=1andE2 kept handle after engine-API edge: c.OUT=1 d.IN=1.
Only the detached copymodify()returns is stale. - Does any engine-internal path do this to itself? I tried four shapes and all four are safe, recorded:
openCypherCREATE (a)-[:ER]->(b) SET a.x = 1and its reverse order, and SQLCREATE EDGEfollowed by
UPDATEand its reverse. They all re-fetch. So the trigger is an application holding the handle,
which keeps this below the DROP TYPE finding in severity. - Is
CHECK DATABASE FIXa way out? It reports the anomaly only as a warning withtotalErrors=0. I
did not test whether FIX relinks it;relinkedOrphanEdgeRecordsexists in the report, so it may.
Suggested fix
Two halves, and the second is the one that matters. For the reads, MutableVertex's eleven accessors
should go through getMostUpdatedVertex exactly as ImmutableVertex's do, which is a one-line change
per method and makes the two handles agree. For the write, the vertex save path has to take the
edge-list head pointers from the authority rather than from the handle: on update, a vertex record's outEdges/inEdges should be read from the instance the transaction holds, not from the fields the
copy was made with, or the save should be refused when they differ.
The guarantee to ask for: no save() of a vertex can remove an edge from that vertex's edge list.
Saving a vertex is a property write; it must never be able to unlink a relationship, whatever handle it
was issued from. Stated that way the guard belongs in the save path and covers #9840's shape, this one,
and whatever the next way of holding a stale handle turns out to be.
Scope / confidence
Executed: all of the above, as standalone main() probes against engine/target/classes at
e353330. Read, not executed: the serialization path that writes the head pointers back on commit,
and whether CHECK DATABASE FIX relinks the orphaned edge. Module boundary swept for the getMostUpdatedVertex asymmetry: engine/src/main/java/com/arcadedb/graph only. I compared ImmutableVertex and MutableVertex call by call; I did not sweep SynchronizedVertex, GAVVertex or
the remote and server vertex implementations for the same asymmetry, and they are worth the same look.
Dedup
Checked the 1000 titles in alltitles.txt. #9840 is the same consequence on a different path
(include()/exclude() projections) and is CLOSED; I cite it as the sibling, not as the finding.
#9779 is about Iterator.remove() orphaning an edge, a different mechanism. #10304, #10263, #10302,
#10315 are concurrent-transaction edge-list anomalies and are all cross-transaction, which is the case
I showed is protected here. Nothing in the list is the modify() handle.
Activity
fix(#10929, #10927): a vertex save never unlinks edges appended throu…
lvca commentedon Oct 29, 2026
Fixed in #10952, will be part of 26.11.1.
Confirmed: inside one transaction, a vertex obtained with
modify()before an edge was added through another reference of the same vertex did not see the new edge, and saving it detached that edge from the vertex.Now:
If an existing database was hit by this,
CHECK DATABASEreports it as a warning ("edge ... is not referenced back by its outgoing vertex").❤️ Thanks for helping make ArcadeDB better! ArcadeDB is free and open source, sustained by its community. If it's useful to you or your company, please consider becoming a sponsor to keep development going.
ruispereira commentedon Oct 30, 2026
Re-validated at main 21d2a0e. COMPLETE: 6 of 6 at HEAD, and reverting the three production files fails 5 of 6. The guard is at the authority,
TransactionContext.addUpdatedRecord:1664-1670, not at the call site as #9840's was. That is the structural answer the report asked for.One correction to our own report, and it is the exact failure the brief warns about: we miscounted and over-claimed an enumeration. "Eleven engine calls" is thirteen, and our claim that "every one of
ImmutableVertex's eleven engine calls wraps the receiver" is false.moveToTypeandmoveToBucketpass a barethison both classes. It is harmless, sinceGraphEngine.moveToreads through the public accessor, but the enumeration was wrong and we stated it as a census.One residual, read and not executed. The read path (
getRecordFromCache: modified, then immutable, then updated) and the write path (adoptEdgeListHeadsOf: updated, then modified) consult the caches in different orders, so with read-your-writes off a read could see older heads than a save adopts. This is a pre-existingImmutableVertexshape rather than a regression from this fix. The authority to unify on looks likegetWrittenRecord, whose own javadoc at:629-631says it never returns a read-only cached record.fix(#10929, #10927): a vertex save never unlinks edges appended throu…