Skip to content
Merged
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
34 changes: 33 additions & 1 deletion docs/modules/ROOT/pages/how-to/concurrency.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<reads-fewer-calls,Reads with fewer calls into the JVM>>, 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"]
|===
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 30 additions & 7 deletions src/arcadedb/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -1445,17 +1455,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
Expand Down
5 changes: 3 additions & 2 deletions src/arcadedb/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
26 changes: 25 additions & 1 deletion src/arcadedb/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions src/java/com/arcadedb/python/DbCalls.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,38 @@ 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;
}

/**
* 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);
}
}
39 changes: 39 additions & 0 deletions src/java/com/arcadedb/python/RowAccess.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
"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)",
"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)
Expand Down
Loading
Loading