From e7ddf18bc07bc963571613a004e7bbd406923035 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Wed, 4 Nov 2026 00:07:20 +0900 Subject: [PATCH] Vertex.new_edge: resolve both ends by record id (JVM) A vertex object read before the first edge of that vertex was committed held the old record version, so newEdge from it raised ConcurrentModificationException on the JVM build; the native build goes by record id and succeeded. Look the source up by id inside the active transaction and pass the target as its id. Regression test runs on both builds. Co-Authored-By: Claude Opus 5.5 --- .../pages/how-to/databases-and-queries.adoc | 2 + src/arcadedb/graph.py | 28 +++++- tests/test_stale_vertex_new_edge.py | 99 +++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/test_stale_vertex_new_edge.py diff --git a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc index 3b372fd..6c34c38 100644 --- a/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc +++ b/docs/modules/ROOT/pages/how-to/databases-and-queries.adoc @@ -245,6 +245,8 @@ A record id inside a row value (`SELECT @rid AS r`, a `LINK` property, a list of On the native build the record wrappers send their record id to the engine as a parameter, not in the statement text. The engine keeps a parsed statement in a cache keyed by its text (`arcadedb.sqlStatementCache`, 300 entries by default) and leaves a record id written in a `FROM` target as part of that text, so each record would be a cache entry of its own. `get_out_edges()`, `get_in_edges()`, `get_both_edges()`, `new_edge()`, `Edge.get_in()`, `Edge.get_out()`, `save()` and `delete()` send the id as a parameter, so every record shares one text. `db.lookup_by_rid()` keeps the id in the statement text: measured, the parameter path was 2 % slower for a lookup, so it stays as it is. +`Vertex.new_edge()` works on a vertex object that you read in an earlier transaction, on both builds. The first edge of a vertex rewrites its record, so an object read before that commit is out of date. The native build goes by record id. The JVM build looks up the current source vertex by its record id inside the active transaction and passes the target as its record id, so the engine loads that one too. The vertex object you hold is not refreshed: call `db.lookup_by_rid()` again to see its current properties. + [source,python,subs="attributes+"] ---- row = db.query("sql", "SELECT @rid AS r, name FROM User LIMIT 1").first() diff --git a/src/arcadedb/graph.py b/src/arcadedb/graph.py index 4641788..5fe0a10 100644 --- a/src/arcadedb/graph.py +++ b/src/arcadedb/graph.py @@ -221,6 +221,11 @@ def new_edge(self, label: str, target: "Vertex", **kwargs) -> "Edge": definition, not by a per-call parameter. Use schema.create_edge_type() to control bidirectionality at the type level. + Both vertices are resolved by record id inside the active transaction, + so a vertex object read in an earlier transaction (or before an earlier + edge was committed) can still create edges; the object itself is not + refreshed. + Args: label: Edge label (type) target: Target vertex (Python Vertex or Java vertex object) @@ -237,6 +242,27 @@ def new_edge(self, label: str, target: "Vertex", **kwargs) -> "Edge": target_java = ( target.get_java_document() if isinstance(target, Vertex) else target ) + # A vertex object is the state it had when it was read. The first edge + # of a vertex rewrites its record (the head chunk of its edge list), so + # an object read before that commit would make the engine's update fail + # with ConcurrentModificationException. Resolve both ends by record id + # inside the active transaction, as the native build does: the source + # is looked up (one lookup), the target goes in as its id and the + # engine loads it. + source_java = self._java_document + source_rid = source_java.getIdentity() + if ( + source_rid is not None + and source_rid.isValid() + and self._database is not None + ): + source_java = self._database._java_db.lookupByRID( + source_rid, True + ).asVertex() + if hasattr(target_java, "getIdentity"): + target_rid = target_java.getIdentity() + if target_rid is not None and target_rid.isValid(): + target_java = target_rid # Convert kwargs to flat list of [key, value, key, value...] props = [] @@ -245,7 +271,7 @@ def new_edge(self, label: str, target: "Vertex", **kwargs) -> "Edge": props.append(convert_python_to_java(v)) # bidirectional is determined by the EdgeType schema - java_edge = self._java_document.newEdge(label, target_java, *props) + java_edge = source_java.newEdge(label, target_java, *props) return Edge(java_edge, self._database) def get_out_edges(self, *labels: str) -> List["Edge"]: diff --git a/tests/test_stale_vertex_new_edge.py b/tests/test_stale_vertex_new_edge.py new file mode 100644 index 0000000..9800237 --- /dev/null +++ b/tests/test_stale_vertex_new_edge.py @@ -0,0 +1,99 @@ +"""Vertex.new_edge on a vertex object that was read before other commits. + +The first edge of a vertex rewrites the vertex record (the head chunk of its edge list). A Java vertex object read before that +commit holds the old version, and the engine's update then failed with ConcurrentModificationException on the JVM build while +the native build, which goes by record id, succeeded (P-08 of the bindings discovery pass). Both builds now resolve both ends +by record id inside the active transaction, so a vertex object read once can create edges in any later transaction. +""" + +import arcadedb + +N_VERTICES = 6 + + +def _make_graph(db): + db.command("sql", "CREATE VERTEX TYPE SPerson") + db.command("sql", "CREATE EDGE TYPE SKnows") + with db.transaction(): + for i in range(N_VERTICES): + db.command("sql", "CREATE VERTEX SPerson SET id = ?", i) + rows = db.query("sql", "SELECT @rid AS r FROM SPerson ORDER BY id").to_list() + return [db.lookup_by_rid(str(row["r"])) for row in rows] + + +def _edge_count(db): + return db.query("sql", "SELECT count(*) AS c FROM SKnows").first().get("c") + + +def _degrees(db, i, direction): + sql = f"SELECT {direction}('SKnows').size() AS d FROM SPerson WHERE id = ?" # nosec B608 - direction is a fixed literal + return db.query("sql", sql, i).first().get("d") + + +def test_new_edge_from_vertices_read_once(temp_db_path): + """Source and target objects read once, one edge per transaction; the first + edge of a vertex must not make its later edges fail.""" + with arcadedb.create_database(temp_db_path) as db: + vs = _make_graph(db) + pairs = [(i, (i + 1) % N_VERTICES) for i in range(N_VERTICES)] + pairs += [(i, (i + 2) % N_VERTICES) for i in range(N_VERTICES)] + for src, dst in pairs: + with db.transaction(): + edge = vs[src].new_edge("SKnows", vs[dst], w=src) + assert edge.get("w") == src + + assert _edge_count(db) == len(pairs) + for i in range(N_VERTICES): + assert _degrees(db, i, "out") == 2 + assert _degrees(db, i, "in") == 2 + + +def test_new_edge_stale_target_only(temp_db_path): + """The source is read fresh, only the target object is stale.""" + with arcadedb.create_database(temp_db_path) as db: + vs = _make_graph(db) + with db.transaction(): + vs[0].new_edge("SKnows", vs[1]) # rewrites the record of vertex 1 + + fresh = db.lookup_by_rid(vs[2].get_rid()) + with db.transaction(): + fresh.new_edge("SKnows", vs[1]) # vs[1] is stale since the commit above + + assert _edge_count(db) == 2 + assert _degrees(db, 1, "in") == 2 + + +def test_new_edge_stale_after_edge_made_another_way(temp_db_path): + """An edge made by SQL in an earlier transaction also leaves the objects stale.""" + with arcadedb.create_database(temp_db_path) as db: + vs = _make_graph(db) + with db.transaction(): + db.command( + "sql", + "CREATE EDGE SKnows FROM (SELECT FROM SPerson WHERE id = 0) " + "TO (SELECT FROM SPerson WHERE id = 1)", + ) + with db.transaction(): + vs[0].new_edge("SKnows", vs[1]) + with db.transaction(): + vs[1].new_edge("SKnows", vs[0]) + + assert _edge_count(db) == 3 + assert _degrees(db, 0, "out") == 2 + assert _degrees(db, 0, "in") == 1 + + +def test_new_edge_two_edges_in_one_transaction_from_stale_objects(temp_db_path): + """Several edges from the same stale objects inside one transaction.""" + with arcadedb.create_database(temp_db_path) as db: + vs = _make_graph(db) + with db.transaction(): + vs[0].new_edge("SKnows", vs[1]) + with db.transaction(): + vs[0].new_edge("SKnows", vs[2]) + vs[0].new_edge("SKnows", vs[1]) + vs[3].new_edge("SKnows", vs[0]) + + assert _edge_count(db) == 4 + assert _degrees(db, 0, "out") == 3 + assert _degrees(db, 0, "in") == 1