Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/modules/ROOT/pages/how-to/databases-and-queries.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 27 additions & 1 deletion src/arcadedb/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = []
Expand All @@ -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"]:
Expand Down
99 changes: 99 additions & 0 deletions tests/test_stale_vertex_new_edge.py
Original file line number Diff line number Diff line change
@@ -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
Loading