From 4b6ae05661f2c11426d0602b32b159e03f8089d8 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Tue, 3 Nov 2026 22:44:30 +0900 Subject: [PATCH 1/6] Document.get_raw: one crossing and one scan of the record instead of has() then get() get() answers null for a property the record does not have, as it does for one set to null; Python reads None either way, so the has() call only repeated the scan. has_property() still tells the two apart. test_read_crossings.py counts the calls into the JVM on a proxy of the Java object and pins that the answers are the plain path's (absent, null-valued, mutable, vertex and edge). It is JVM-only (NATIVE_NOT_APPLICABLE); the native backend is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/arcadedb/graph.py | 5 +- tests/conftest.py | 1 + tests/test_read_crossings.py | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tests/test_read_crossings.py diff --git a/src/arcadedb/graph.py b/src/arcadedb/graph.py index 56f82a7c..85a07a7a 100644 --- a/src/arcadedb/graph.py +++ b/src/arcadedb/graph.py @@ -108,8 +108,9 @@ def get(self, name: str, convert_types: bool = True) -> Any: def get_raw(self, name: str) -> Any: """Get property value without Java-to-Python conversion.""" self._check_open() - if not self._java_document.has(name): - return None + # One crossing and one scan of the record: get() answers null for a property the record + # does not have, as it does for one set to null, and None is what Python reads either way. + # (has() then get() was two crossings and two scans; has() still tells the two apart.) return self._java_document.get(name) def set(self, name: str, value: Any) -> "Document": diff --git a/tests/conftest.py b/tests/conftest.py index 51677a9f..e7cb571c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,7 @@ "test_insert_many_gc_pause.py": "the cyclic collector pause is the JVM build's (the native insert_many allocates nothing the collector tracks)", "test_graph_batch_tagged_shapes.py": "the JVM wheel's graph batch bulk path (bridge VertexBatcher and EdgeBatcher, the property-matrix fallback)", "test_async_proxy_cache.py": "the JPype proxy classes of the JVM build's async executor", + "test_read_crossings.py": "crossing counts of the JVM build's read path (JPype proxies of the Java objects and the bridge classes)", } if BACKEND == "native" and not os.environ.get("NATIVE_PARITY_ALL"): collect_ignore.extend(NATIVE_NOT_APPLICABLE) diff --git a/tests/test_read_crossings.py b/tests/test_read_crossings.py new file mode 100644 index 00000000..3b57c3f9 --- /dev/null +++ b/tests/test_read_crossings.py @@ -0,0 +1,125 @@ +"""Crossings of the JVM read path (one JPype call is a few microseconds of dispatch). + +Each read the bindings make used to cross into the JVM several times: ``Document.get_raw`` asked +``has()`` and then ``get()``, ``ResultSet.one()`` and ``count()`` crossed per row, ``lookup_by_key`` +built two Java arrays per call, and the bookkeeping around ``with db.transaction()`` crossed twice +just to take the transaction-stack mark. Every test here pins two things: the number of calls into +the JVM (counted on a proxy of the Java object or bridge class, the technique of +``test_percall_calls.py``) and that the answer is the one the plain path gives (same value, same +errors, same result-set state), so a count cannot be bought with a change of behaviour. +""" + +import pytest + +from arcadedb import core, results + + +class Counting: + """Counts the method calls made on a Java object or a Java class. + + Arguments that are themselves a ``Counting`` are unwrapped before the call, so the proxy of the + database can be handed to a bridge class as it is. Return values are the real objects. + """ + + def __init__(self, target, log, label): + self._target = target + self._log = log + self._label = label + + def __getattr__(self, name): + attr = getattr(self._target, name) + if not callable(attr): + return attr + + def call(*args, **kwargs): + self._log.append(f"{self._label}.{name}") + args = [a._target if isinstance(a, Counting) else a for a in args] + return attr(*args, **kwargs) + + return call + + +@pytest.fixture +def db(temp_db): + temp_db.command("sql", "CREATE DOCUMENT TYPE T") + temp_db.command("sql", "CREATE PROPERTY T.k INTEGER") + temp_db.command("sql", "CREATE INDEX ON T (k) UNIQUE") + with temp_db.transaction(): + for key in range(5): + temp_db.command("sql", "INSERT INTO T SET k = ?, v = ?", key, key * 10) + return temp_db + + +@pytest.fixture +def log(db, monkeypatch): + """The list every counted call is appended to; the database and the bridge classes are counted.""" + calls = [] + real = {name: results._bridge_class(name) for name in ("RowAccess", "DbCalls")} + monkeypatch.setattr(db, "_java_db", Counting(db._java_db, calls, "db")) + for name, cls in real.items(): + monkeypatch.setitem(results._BRIDGE_CLASSES, name, Counting(cls, calls, name)) + monkeypatch.setattr(core, "_DB_CALLS", Counting(real["DbCalls"], calls, "DbCalls")) + return calls + + +def _no_bridge(monkeypatch, *names): + """Send ``names`` down the plain per-row path (the bridge class reported missing).""" + for name in names: + monkeypatch.setitem(results._BRIDGE_CLASSES, name, None) + monkeypatch.setattr(core, "_DB_CALLS", False) + + +# ---------------------------------------------------------------- Document.get_raw + + +def test_get_raw_is_one_crossing_and_one_buffer_scan(db): + doc = db.query("sql", "SELECT FROM T WHERE k = 3").first().get_element() + calls = [] + doc._java_document = Counting(doc._java_document, calls, "doc") + assert doc.get_raw("v") == 30 + assert calls == ["doc.get"] + + +def test_get_raw_absent_property_is_none_in_one_crossing(db): + doc = db.query("sql", "SELECT FROM T WHERE k = 3").first().get_element() + calls = [] + doc._java_document = Counting(doc._java_document, calls, "doc") + assert doc.get_raw("missing") is None + assert doc.get("missing") is None + assert calls == ["doc.get", "doc.get"] + + +def test_get_raw_null_valued_and_absent_properties_both_read_none(db): + """A property set to null and one never set read the same through get(); has() still tells them apart.""" + with db.transaction(): + doc = db.new_document("T") + doc.set("k", 99) + doc.set("nothing", None) + doc.save() + rid = doc.get_identity() + with db.transaction(): + mutable = db.new_document("T") + mutable.set("k", 100) + mutable.set("nothing", None) + assert mutable.get_raw("nothing") is None + assert mutable.get_raw("never") is None + assert mutable.has_property("nothing") is True + assert mutable.has_property("never") is False + mutable.save() + stored = db.lookup_by_rid(str(rid)) + assert stored.get_raw("k") == 99 + assert stored.get_raw("never") is None + + +def test_get_raw_on_vertex_and_edge(db): + db.command("sql", "CREATE VERTEX TYPE V") + db.command("sql", "CREATE EDGE TYPE E") + with db.transaction(): + a = db.command("sql", "CREATE VERTEX V SET name = 'a'").first().get_vertex() + b = db.command("sql", "CREATE VERTEX V SET name = 'b'").first().get_vertex() + edge = a.new_edge("E", b, w=7) + vertex = db.query("sql", "SELECT FROM V WHERE name = 'a'").first().get_vertex() + assert vertex.get_raw("name") == "a" + assert vertex.get_raw("nope") is None + assert edge.get_raw("w") == 7 + assert edge.get_raw("nope") is None From 97f774f9385329bf0ad72e09eb755fe2ffff0cd1 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Tue, 3 Nov 2026 22:45:02 +0900 Subject: [PATCH 2/6] ResultSet.one() and count(): one crossing through RowAccess, like first() one() read hasNext()/next() per row and closed afterwards; count() crossed twice per row. RowAccess.upToTwoAndClose and countAndClose do the same work Java-side with the same errors and the same result-set state; the plain path stays for a missing bridge class. Co-Authored-By: Claude Sonnet 5.5 --- src/arcadedb/results.py | 26 ++++++- src/java/com/arcadedb/python/RowAccess.java | 39 +++++++++++ tests/test_read_crossings.py | 78 +++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/arcadedb/results.py b/src/arcadedb/results.py index c2e11377..f148fdda 100644 --- a/src/arcadedb/results.py +++ b/src/arcadedb/results.py @@ -1155,6 +1155,15 @@ def count(self) -> int: >>> count = db.query("sql", "SELECT FROM User").count() >>> print(f"Found {count} users") """ + row_access = _bridge_class("RowAccess") + if row_access is not None and self._readable() and not self._closed: + # hasNext() and next() per row, and the close, in ONE crossing + try: + count = int(row_access.countAndClose(self._java_result_set)) + except JException as exc: + raise _read_error(exc) from exc + self._drained() + return count count = 0 for _ in self: count += 1 @@ -1208,8 +1217,23 @@ def one(self) -> "Result": ... "alice@example.com").one() >>> print(user.get("name")) """ - iterator = iter(self) + row_access = _bridge_class("RowAccess") try: + if row_access is not None and self._readable() and not self._closed: + # up to two rows and the close in ONE crossing instead of hasNext()/next() per row + try: + rows = row_access.upToTwoAndClose(self._java_result_set) + except JException as exc: + self._closed = True # upToTwoAndClose closes on every path + raise _read_error(exc) from exc + self._closed = True + self._exhausted = len(rows) < 2 + if len(rows) == 0: + raise ValueError("Query returned no results") + if len(rows) > 1: + raise ValueError("Query returned multiple results") + return Result(rows[0], self._database) + iterator = iter(self) try: result = next(iterator) except StopIteration as exc: diff --git a/src/java/com/arcadedb/python/RowAccess.java b/src/java/com/arcadedb/python/RowAccess.java index 4d3fef93..951347d0 100644 --- a/src/java/com/arcadedb/python/RowAccess.java +++ b/src/java/com/arcadedb/python/RowAccess.java @@ -79,6 +79,45 @@ public static Result firstAndClose(final ResultSet rs) { } } + /** + * The first two rows of a result set (fewer when it has fewer), and the result set is closed on every path: the + * {@code hasNext()}, {@code next()} calls of {@code ResultSet.one()} in one crossing instead of one per row. The array + * length tells the caller which case it has: 0 rows, exactly 1, or more than 1 (the second row is read, as before, so + * an error in it surfaces from here and is not mistaken for a result). An error in the statement surfaces from + * {@code hasNext()} or {@code next()}; a failing close is ignored, as the Python close() ignores it. + */ + public static Result[] upToTwoAndClose(final ResultSet rs) { + try { + if (!rs.hasNext()) + return new Result[0]; + final Result first = rs.next(); + if (!rs.hasNext()) + return new Result[] { first }; + return new Result[] { first, rs.next() }; + } finally { + try { + rs.close(); + } catch (final Exception ignored) { + // best-effort hygiene, like the Python close() + } + } + } + + /** + * The number of rows left in a result set, which is then drained and closed (see {@link RowBatcher#closeDrained}): + * the {@code hasNext()}, {@code next()} pair per row of {@code ResultSet.count()} in one crossing. An error in the + * statement surfaces as before and leaves the result set as it was, for the caller to close. + */ + public static long countAndClose(final ResultSet rs) { + long count = 0; + while (rs.hasNext()) { + rs.next(); + count++; + } + RowBatcher.closeDrained(rs); + return count; + } + /** * {@code row.getProperty(name)} when {@code row.hasProperty(name)}, else null: the two calls of * {@code Result.get()} in one crossing. diff --git a/tests/test_read_crossings.py b/tests/test_read_crossings.py index 3b57c3f9..58bebe43 100644 --- a/tests/test_read_crossings.py +++ b/tests/test_read_crossings.py @@ -12,6 +12,7 @@ import pytest from arcadedb import core, results +from arcadedb.exceptions import ArcadeDBError class Counting: @@ -123,3 +124,80 @@ def test_get_raw_on_vertex_and_edge(db): assert vertex.get_raw("nope") is None assert edge.get_raw("w") == 7 assert edge.get_raw("nope") is None + + +# ---------------------------------------------------------------- ResultSet.one() / count() + + +def _state(rs): + return rs._closed, rs._exhausted + + +def test_one_is_one_crossing(db, log): + rs = db.query("sql", "SELECT FROM T WHERE k = 3") + del log[:] + row = rs.one() + assert len(log) == 1 and log[0].startswith("RowAccess."), log + assert row.get("v") == 30 + assert _state(rs) == (True, True) + + +@pytest.mark.parametrize( + "statement, message, state", + [ + ("SELECT FROM T WHERE k = 99", "Query returned no results", (True, True)), + ("SELECT FROM T", "Query returned multiple results", (True, False)), + ], +) +def test_one_errors_and_state_match_the_plain_path( + db, monkeypatch, statement, message, state +): + seen = [] + for plain in (False, True): + if plain: + _no_bridge(monkeypatch, "RowAccess") + rs = db.query("sql", statement) + with pytest.raises(ValueError, match=message): + rs.one() + seen.append(_state(rs)) + with pytest.raises(ArcadeDBError if state == (True, False) else ValueError): + rs.one() + assert seen[0] == seen[1] == state + + +def test_one_on_a_closed_database_raises_like_the_plain_path(db, monkeypatch): + rs = db.query("sql", "SELECT FROM T WHERE k = 3") + db.close() + with pytest.raises(ArcadeDBError, match="closed"): + rs.one() + + +def test_count_is_one_crossing(db, log): + rs = db.query("sql", "SELECT FROM T") + del log[:] + assert rs.count() == 5 + assert len(log) == 1 and log[0].startswith("RowAccess."), log + assert _state(rs) == (True, True) + assert rs.count() == 0 # exhausted: nothing remains, as before + + +def test_count_counts_only_the_remaining_rows(db): + rs = db.query("sql", "SELECT FROM T") + next(rs) + next(rs) + assert rs.count() == 3 + assert _state(rs) == (True, True) + + +def test_count_of_an_empty_result_and_after_close(db): + assert db.query("sql", "SELECT FROM T WHERE k = 99").count() == 0 + rs = db.query("sql", "SELECT FROM T") + rs.close() + with pytest.raises(ArcadeDBError, match="closed before all its rows were read"): + rs.count() + + +def test_count_matches_the_plain_path(db, monkeypatch): + fast = db.query("sql", "SELECT FROM T WHERE k >= 2").count() + _no_bridge(monkeypatch, "RowAccess") + assert db.query("sql", "SELECT FROM T WHERE k >= 2").count() == fast == 3 From d80188ca354d932b59ea67edd13d2d660ac790b5 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Tue, 3 Nov 2026 22:45:12 +0900 Subject: [PATCH 3/6] lookup_by_key: the key names and values cross as scalars or varargs, no Java arrays DbCalls.lookupFirstKey takes one key as scalars and lookupFirstKeys takes any number as the varargs of one call, so neither side builds a String[] and an Object[] through JPype per lookup. The engine receives the same arrays, and a different number of names and values reaches it as before. lookupFirst, which takes the arrays, stays. Co-Authored-By: Claude Sonnet 5.5 --- src/arcadedb/core.py | 27 +++++-- src/java/com/arcadedb/python/DbCalls.java | 25 +++++++ tests/test_read_crossings.py | 89 +++++++++++++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/arcadedb/core.py b/src/arcadedb/core.py index 97966bf6..b2808631 100644 --- a/src/arcadedb/core.py +++ b/src/arcadedb/core.py @@ -1445,17 +1445,30 @@ def lookup_by_key(self, type_name: str, keys: List[str], values: List[Any]): try: # Converted like every other parameter: a datetime or a date key # matched no Java type at all, and a numpy bool was read as a number. - keys_array = _string_array(keys) - values_array = _object_array([convert_python_to_java(v) for v in values]) - calls = _db_calls() if calls is not None: - # lookup, hasNext(), next(), and getRecord() in ONE crossing - java_record = calls.lookupFirst( - self._java_db, type_name, keys_array, values_array - ) + # lookup, hasNext(), next(), and getRecord() in ONE crossing, and no + # Java array built on this side: the names and values cross as scalars + # or as the varargs of one call + if len(keys) == 1 and len(values) == 1: + java_record = calls.lookupFirstKey( + self._java_db, + type_name, + keys[0], + convert_python_to_java(values[0]), + ) + else: + java_record = calls.lookupFirstKeys( + self._java_db, + type_name, + len(keys), + *keys, + *[convert_python_to_java(v) for v in values], + ) return None if java_record is None else Document.wrap(java_record, self) + keys_array = _string_array(keys) + values_array = _object_array([convert_python_to_java(v) for v in values]) cursor = self._java_db.lookupByKey(type_name, keys_array, values_array) # Return first result wrapped, or None diff --git a/src/java/com/arcadedb/python/DbCalls.java b/src/java/com/arcadedb/python/DbCalls.java index f2e763ac..37cf9358 100644 --- a/src/java/com/arcadedb/python/DbCalls.java +++ b/src/java/com/arcadedb/python/DbCalls.java @@ -65,4 +65,29 @@ public static Record lookupFirst(final Database db, final String type, final Str final IndexCursor cursor = db.lookupByKey(type, keys, values); return cursor.hasNext() ? cursor.next().getRecord() : null; } + + /** + * {@link #lookupFirst} for one key, the scalars as they come from Python: the one-element arrays are built here and + * not through JPype, which costs a conversion call per array. + */ + public static Record lookupFirstKey(final Database db, final String type, final String key, final Object value) { + final IndexCursor cursor = db.lookupByKey(type, new String[] { key }, new Object[] { value }); + return cursor.hasNext() ? cursor.next().getRecord() : null; + } + + /** + * {@link #lookupFirst} for any number of keys with the key names and the values as the varargs of ONE call: + * {@code keyCount} names, then the values ({@code k1, ..., kn, v1, ..., vm}). The two lists are cut here and handed to + * the engine as they are, so a different number of names and values reaches it as before. + */ + public static Record lookupFirstKeys(final Database db, final String type, final int keyCount, + final Object... keysThenValues) { + final String[] keys = new String[keyCount]; + for (int i = 0; i < keyCount; i++) + keys[i] = (String) keysThenValues[i]; + final Object[] values = new Object[keysThenValues.length - keyCount]; + System.arraycopy(keysThenValues, keyCount, values, 0, values.length); + final IndexCursor cursor = db.lookupByKey(type, keys, values); + return cursor.hasNext() ? cursor.next().getRecord() : null; + } } diff --git a/tests/test_read_crossings.py b/tests/test_read_crossings.py index 58bebe43..bcce8288 100644 --- a/tests/test_read_crossings.py +++ b/tests/test_read_crossings.py @@ -9,6 +9,8 @@ errors, same result-set state), so a count cannot be bought with a change of behaviour. """ +import datetime + import pytest from arcadedb import core, results @@ -201,3 +203,90 @@ def test_count_matches_the_plain_path(db, monkeypatch): fast = db.query("sql", "SELECT FROM T WHERE k >= 2").count() _no_bridge(monkeypatch, "RowAccess") assert db.query("sql", "SELECT FROM T WHERE k >= 2").count() == fast == 3 + + +# ---------------------------------------------------------------- lookup_by_key + + +def _plain_lookup_rid(db, type_name, keys, values): + cursor = db.get_java_database().lookupByKey( + type_name, core._string_array(keys), core._object_array(values) + ) + return str(cursor.next().getIdentity()) if cursor.hasNext() else None + + +def test_lookup_by_key_is_one_crossing_without_java_arrays(db, log, monkeypatch): + def boom(values): + raise AssertionError("lookup_by_key built a Java array") + + monkeypatch.setattr(core, "_string_array", boom) + monkeypatch.setattr(core, "_object_array", boom) + del log[:] + doc = db.lookup_by_key("T", ["k"], [3]) + assert log == ["DbCalls.lookupFirstKey"] + assert doc.get("v") == 30 + del log[:] + assert db.lookup_by_key("T", ["k"], [99]) is None + assert log == ["DbCalls.lookupFirstKey"] + + +def test_lookup_by_key_composite_and_typed_keys_match_the_plain_path(db): + db.command("sql", "CREATE DOCUMENT TYPE C") + db.command("sql", "CREATE PROPERTY C.a STRING") + db.command("sql", "CREATE PROPERTY C.b LONG") + db.command("sql", "CREATE PROPERTY C.ts DATETIME") + db.command("sql", "CREATE PROPERTY C.f DOUBLE") + db.command("sql", "CREATE INDEX ON C (a, b) UNIQUE") + db.command("sql", "CREATE INDEX ON C (ts) UNIQUE") + db.command("sql", "CREATE INDEX ON C (f) UNIQUE") + stamp = datetime.datetime(2026, 10, 25, 12, 30, 45) + with db.transaction(): + db.command( + "sql", "INSERT INTO C SET a = ?, b = ?, ts = ?, f = ?", "x", 7, stamp, 2.5 + ) + db.command( + "sql", + "INSERT INTO C SET a = ?, b = ?, ts = ?, f = ?", + "y", + 8, + datetime.datetime(2026, 1, 2, 3, 4, 5), + 3.5, + ) + cases = [ + (["a", "b"], ["x", 7]), + (["a", "b"], ["y", 8]), + (["a", "b"], ["x", 8]), + (["ts"], [stamp]), + (["f"], [2.5]), + (["f"], [9.5]), + ] + for keys, values in cases: + found = db.lookup_by_key("C", keys, values) + converted = [core.convert_python_to_java(v) for v in values] + expected = _plain_lookup_rid(db, "C", keys, converted) + assert (None if found is None else str(found.get_identity())) == expected, ( + keys, + values, + ) + assert db.lookup_by_key("C", ["a", "b"], ["x", 7]).get("f") == 2.5 + + +def test_lookup_by_key_composite_is_one_crossing(db, log): + db.command("sql", "CREATE DOCUMENT TYPE C2") + db.command("sql", "CREATE PROPERTY C2.a STRING") + db.command("sql", "CREATE PROPERTY C2.b LONG") + db.command("sql", "CREATE INDEX ON C2 (a, b) UNIQUE") + with db.transaction(): + db.command("sql", "INSERT INTO C2 SET a = 'x', b = 7") + del log[:] + assert db.lookup_by_key("C2", ["a", "b"], ["x", 7]) is not None + assert log == ["DbCalls.lookupFirstKeys"] + + +def test_lookup_by_key_errors_stay_arcadedb_errors(db): + with pytest.raises(ArcadeDBError): + db.lookup_by_key("NoSuchType", ["k"], [1]) + with pytest.raises(ArcadeDBError): + db.lookup_by_key("T", ["k", "v"], [1]) # more keys than values + with pytest.raises(ArcadeDBError): + db.lookup_by_key("T", [3], [1]) # a key name that is not a string From 374ae549c7782d4e3640674b071a7129003396a3 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Tue, 3 Nov 2026 22:45:27 +0900 Subject: [PATCH 4/6] Transaction-stack mark in one crossing The mark taken before every begin() (with db.transaction(), run_in_transaction) asked isTransactionActive() and getNestedTransactions() separately. DbCalls.stackMark returns both as one number, so with db.transaction() goes from 4 crossings to 3 and run_in_transaction from 6 to 5. The mark is still taken before begin(), so the interrupt and refused-begin rules of #9970 and #9813 are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/arcadedb/core.py | 10 +++++ src/java/com/arcadedb/python/DbCalls.java | 9 ++++ tests/test_read_crossings.py | 52 +++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/src/arcadedb/core.py b/src/arcadedb/core.py index b2808631..d24a780f 100644 --- a/src/arcadedb/core.py +++ b/src/arcadedb/core.py @@ -563,6 +563,16 @@ def _transaction_stack_mark(self): The pair is what _end_transactions_opened_since needs to tell the levels a begin() pushed from the ones the caller already held. """ + calls = _db_calls() + if calls is not None: + # both numbers in ONE crossing: this runs before every begin() of + # ``with db.transaction()`` and of run_in_transaction + self._check_not_closed() + try: + packed = int(calls.stackMark(self._java_db)) + except Exception as e: + raise ArcadeDBError(f"Failed to check transaction status: {e}") from e + return bool(packed & 1), packed >> 1 return self.is_transaction_active(), self._java_db.getNestedTransactions() def _end_transactions_opened_since(self, mark): diff --git a/src/java/com/arcadedb/python/DbCalls.java b/src/java/com/arcadedb/python/DbCalls.java index 37cf9358..ec0c03e4 100644 --- a/src/java/com/arcadedb/python/DbCalls.java +++ b/src/java/com/arcadedb/python/DbCalls.java @@ -90,4 +90,13 @@ public static Record lookupFirstKeys(final Database db, final String type, final final IndexCursor cursor = db.lookupByKey(type, keys, values); return cursor.hasNext() ? cursor.next().getRecord() : null; } + + /** + * The transaction stack of {@code db} as one number, {@code nestedTransactions * 2 + (active ? 1 : 0)}: the + * {@code isTransactionActive()} and {@code getNestedTransactions()} of the mark {@code run_in_transaction} and + * {@code with db.transaction()} take before {@code begin()} (#9970, #9813), in one crossing instead of two. + */ + public static long stackMark(final Database db) { + return ((long) db.getNestedTransactions() << 1) | (db.isTransactionActive() ? 1L : 0L); + } } diff --git a/tests/test_read_crossings.py b/tests/test_read_crossings.py index bcce8288..2060173b 100644 --- a/tests/test_read_crossings.py +++ b/tests/test_read_crossings.py @@ -290,3 +290,55 @@ def test_lookup_by_key_errors_stay_arcadedb_errors(db): db.lookup_by_key("T", ["k", "v"], [1]) # more keys than values with pytest.raises(ArcadeDBError): db.lookup_by_key("T", [3], [1]) # a key name that is not a string + + +# ---------------------------------------------------------------- transaction bookkeeping + + +def test_transaction_stack_mark_is_one_crossing(db, log): + expected = (False, db.get_java_database().getNestedTransactions()) + del log[:] + assert db._transaction_stack_mark() == expected + assert log == ["DbCalls.stackMark"] + + +def test_transaction_stack_mark_matches_the_engine_stack(db): + java = db.get_java_database() + base = java.getNestedTransactions() + assert db._transaction_stack_mark() == (False, base) + db.begin() + inner = db._transaction_stack_mark() + assert inner == (True, java.getNestedTransactions()) + db.begin() # a nested level + nested = db._transaction_stack_mark() + assert nested == (True, java.getNestedTransactions()) + assert nested[1] == inner[1] + 1 + db.rollback() + db.rollback() + assert db._transaction_stack_mark() == (False, base) + + +def test_transaction_stack_mark_on_a_closed_database(db): + db.close() + with pytest.raises(ArcadeDBError, match="closed"): + db._transaction_stack_mark() + + +def test_with_transaction_is_three_crossings(db, log): + del log[:] + with db.transaction(): + pass + assert log == ["DbCalls.stackMark", "db.begin", "db.commit"], log + + +def test_run_in_transaction_crossings(db, log): + del log[:] + assert db.run_in_transaction(lambda: 41 + 1) == 42 + # The mark (1), begin, the commit witness (getTransactionIfExists, then getCommitCount on the + # transaction object, which is not counted here) and commit: five crossings, six before. + assert log == [ + "DbCalls.stackMark", + "db.begin", + "db.getTransactionIfExists", + "db.commit", + ], log From c0c633fb450b7eb10ee73a5da64a08ee6240ef23 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Thu, 5 Nov 2026 06:44:56 +0900 Subject: [PATCH 5/6] Docs: the concurrency figures of the JVM read-crossing cuts (mixed window at 2 Python threads) The study that adopts this PR is a mixed read/write window at 2 Python threads on the arcadedb-jvm package. The page gets the gating figure with its 95% interval, the 1-thread guard, the reader and writer shares and the Cypher control, all generated from the study's ab-score.md by script, and says what was not measured (4 or more threads, native, other hardware). The one-thread row is stated plainly: the gain is not specific to concurrency. Co-Authored-By: Claude Opus 5.5 --- .../ROOT/pages/how-to/concurrency.adoc | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/how-to/concurrency.adoc b/docs/modules/ROOT/pages/how-to/concurrency.adoc index 82a6afea..60282061 100644 --- a/docs/modules/ROOT/pages/how-to/concurrency.adoc +++ b/docs/modules/ROOT/pages/how-to/concurrency.adoc @@ -5,7 +5,7 @@ This page covers what is specific to the Python bindings: which calls work from == How to read the numbers -Every number on this page comes from probe scripts that were run on 2026-10-24 on one Linux x86_64 machine. The setup of each probe is next to its numbers. The scripts and their raw logs are not part of this repository yet. +Every number on this page, except the ratios in <>, comes from probe scripts that were run on 2026-10-24 on one Linux x86_64 machine. The setup of each probe is next to its numbers. The scripts and their raw logs are not part of this repository yet. [cols="1,3,2"] |=== @@ -50,6 +50,38 @@ The probe: one writer thread and 1, 4 or 16 reader threads in one process, all u All 7 runs ended with no error, no bad checksum and the same number of rows before and after the reopen. This probe has one writer. For several writers, read the next section. +[[reads-fewer-calls]] +=== Reads with fewer calls into the JVM, two threads + +Some read calls of the JVM build cross into the JVM fewer times than before: `lookup_by_key` without Java arrays, `ResultSet.one()` and `count()`, `Document.get`, and the transaction-stack mark. This was timed on the `{jvm-pkg-name}` package, built with the change and without it, in a mixed window of two Python threads on one `Database`: one reader thread and one writer thread. The reader rotates `lookup_by_key` with `get`, `.one()`, `.count()` and `.first()` on a graph of 20,000 vertices and 40,000 edges. The writer commits transactions of 10 vertex inserts, each with a `CREATE EDGE`. Each window ran 20 seconds after a 5 second warm-up of the same window. + +[cols="3,1,2"] +|=== +| Figure (time per operation) | Clean pairs | Time with the change, as a ratio of the time without it (median, 95% interval) + +| Mixed window, 2 Python threads (reads and writer transactions together) +| 10 +| 0.489 [0.475, 0.499] + +| The same reads on 1 thread, no writer +| 10 +| 0.510 [0.507, 0.514] + +| 2 threads, per reader operation +| 10 +| 0.476 [0.461, 0.487] + +| 2 threads, per writer transaction +| 10 +| 0.992 [0.945, 1.007] + +| Control: Cypher 2-hop count and `.first()` readers with the same writer, 2 threads +| 10 +| 1.016 [0.978, 1.037] +|=== + +A ratio below 1 means the build with the change needs less time per operation. The mixed window is the figure that was set before the run as the claim: its interval lies below 1. The one-thread row shows the single-thread reads got cheaper by a similar ratio, so these figures do not show a gain that is specific to concurrency. The gain is in the reader's share of the window, and the writer's share and the Cypher control did not move beyond their noise. The ratios come from one Linux x86_64 laptop with the two timed cores, Python 3.12, alternating order of the two builds, the first round dropped, and an A/A control (the same build twice) whose intervals contain 1. They are not measured at 4 or more threads, on the native build, or on other hardware. + == Several writers Two transactions that change the same record conflict at commit. The one that loses raises `ArcadeDBError`, and the name `ConcurrentModificationException` is in the message or in the `__cause__` chain. The engine treats it as retryable: see {arcadedb-docs-url}/concepts/transactions#isolation[Isolation] for when it is raised. `db.run_in_transaction(fn, retries=...)` runs `fn` in a transaction again after such a conflict. From 93df70612e8638b905b3fda3f6feac555c93d181 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Thu, 5 Nov 2026 11:56:29 +0900 Subject: [PATCH 6/6] tests: list the read- and reader-crossing tests together in the native skip list #214 and #233 each add one NATIVE_NOT_APPLICABLE entry at the same place, so merging one made the other conflict and cost a full CI run per rebase. Both branches now carry the same two entries in the same order; an entry for a test file that is not on this branch is only a collect_ignore name and changes nothing here. Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index e7cb571c..f14bafb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -115,6 +115,7 @@ "test_graph_batch_tagged_shapes.py": "the JVM wheel's graph batch bulk path (bridge VertexBatcher and EdgeBatcher, the property-matrix fallback)", "test_async_proxy_cache.py": "the JPype proxy classes of the JVM build's async executor", "test_read_crossings.py": "crossing counts of the JVM build's read path (JPype proxies of the Java objects and the bridge classes)", + "test_reader_crossings.py": "crossing counts of the JVM build's record and row readers (JPype proxies of the Java objects and the bridge classes)", } if BACKEND == "native" and not os.environ.get("NATIVE_PARITY_ALL"): collect_ignore.extend(NATIVE_NOT_APPLICABLE)