Visitar URL original
A MutableVertex taken by modify() keeps the edge-list head pointers it was copied with, so it reads a stale neighbour set and saving it silently unlinks every edge appended since, leaving an edge record CHECK DATABASE calls unreferenced · Issue #10929 · ArcadeData/arcadedb · GitHub
Skip to content

A MutableVertex taken by modify() keeps the edge-list head pointers it was copied with, so it reads a stale neighbour set and saving it silently unlinks every edge appended since, leaving an edge record CHECK DATABASE calls unreferenced #10929

Closed

Description

@ruispereira

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)

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 the modify() 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 from newVertex().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=1 and E2 kept handle after engine-API edge: c.OUT=1 d.IN=1.
    Only the detached copy modify() returns is stale.
  • Does any engine-internal path do this to itself? I tried four shapes and all four are safe, recorded:
    openCypher CREATE (a)-[:ER]->(b) SET a.x = 1 and its reverse order, and SQL CREATE EDGE followed by
    UPDATE and 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 FIX a way out? It reports the anomaly only as a warning with totalErrors=0. I
    did not test whether FIX relinks it; relinkedOrphanEdgeRecords exists 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

  1. added
    auditFindings from the 2026-07 engine deep audit
    engineCore engine: document/schema API
    severity:criticalData loss, corruption, crash/hang, security hole, or product unusable
    on Oct 29, 2026
  2. added this to the 26.11.1 milestone on Oct 29, 2026
  3. added
    in progressThe team is actively working on this issue
    on Oct 29, 2026
  4. self-assigned this
    on Oct 29, 2026
  5. added a commit that references this issue on Oct 29, 2026
  6. lvca commented on Oct 29, 2026

    @lvca
    Member

    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:

    • every reference to a vertex in a transaction sees the same edges;
    • saving a vertex only writes its properties, it can no longer remove edges, whichever reference you save it from.

    If an existing database was hit by this, CHECK DATABASE reports 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.

  7. removed
    in progressThe team is actively working on this issue
    on Oct 29, 2026
  8. ruispereira commented on Oct 30, 2026

    @ruispereira
    ContributorAuthor

    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. moveToType and moveToBucket pass a bare this on both classes. It is harmless, since GraphEngine.moveTo reads 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-existing ImmutableVertex shape rather than a regression from this fix. The authority to unify on looks like getWrittenRecord, whose own javadoc at :629-631 says it never returns a read-only cached record.

  9. added a commit that references this issue on Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

auditFindings from the 2026-07 engine deep auditbugengineCore engine: document/schema APIseverity:criticalData loss, corruption, crash/hang, security hole, or product unusable

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions