Skip to content
Closed
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
24 changes: 16 additions & 8 deletions native/src/main/java/org/humemai/nativelib/ArcadeNative.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* Phase 2: the C entry points behind the Python native backend (arcadedb/_native). Every function returns a
* Phase 2: the C entry points behind the Python native backend (arcadedb/_native). Most functions return a
* malloc'd UTF-8 JSON text that the caller releases with arcadedb_free: {"ok":true,...} or
* {"ok":false,"error":msg,"cls":java class,"chain":[[class,msg],..],"retry":"need_retry"|"duplicated_key"|null,"retry_ms":n}.
* arcadedb_n_cmd and arcadedb_n_tx return a long (-1 for an error, whose text in that form arcadedb_n_last_error returns).
* Databases, result sets and servers are long handles. Any thread attached to the isolate may call any function; a
* transaction belongs to the (Java) thread that began it, as in the embedded API.
*/
Expand Down Expand Up @@ -184,9 +185,15 @@ public static CCharPointer info(final IsolateThread thread, final long h) {
}
}

/** op 0 begin, 1 commit, 2 rollback, 3 mark. Every reply carries {"active":b,"depth":n,"commits":n}. */
/**
* op 0 begin, 1 commit, 2 rollback, 3 mark. Returns the transaction state after the call as one non-negative long: bit 0 is
* isTransactionActive, bits 1 to 31 the nesting depth, bits 32 to 62 the low 31 bits of the commit count of the thread's
* transaction context (callers compare two counts for equality). -1 on error: the error text is then read with
* arcadedb_n_last_error on the same thread, as for arcadedb_n_cmd. No reply text, so no malloc, no arcadedb_free and no JSON
* decode in the caller.
*/
@CEntryPoint(name = "arcadedb_n_tx")
public static CCharPointer tx(final IsolateThread thread, final long h, final int op) {
public static long tx(final IsolateThread thread, final long h, final int op) {
try {
final Database db = db(h);
switch (op) {
Expand All @@ -196,14 +203,15 @@ public static CCharPointer tx(final IsolateThread thread, final long h, final in
default -> {
}
}
return out(txState(db));
return txState(db);
} catch (final Throwable t) {
return err(t);
LAST_ERROR.set(errorJson(t));
return -1;
}
}

private static String txState(final Database db) {
int depth = 0;
private static long txState(final Database db) {
long depth = 0;
long commits = 0;
if (db instanceof LocalDatabase local)
depth = local.getNestedTransactions();
Expand All @@ -212,7 +220,7 @@ private static String txState(final Database db) {
if (ctx != null)
commits = ctx.getCommitCount();
}
return "{\"ok\":true,\"active\":" + db.isTransactionActive() + ",\"depth\":" + depth + ",\"commits\":" + commits + "}";
return (db.isTransactionActive() ? 1L : 0L) | ((depth & 0x7FFFFFFFL) << 1) | ((commits & 0x7FFFFFFFL) << 32);
}

// ---- statements
Expand Down
80 changes: 34 additions & 46 deletions src/arcadedb/_native/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,10 @@ def _call(prefix: str, name: str, *args) -> dict:
return reply


# Every successful ``arcadedb_n_tx`` reply is ``{"ok":true,"active":<bool>,"depth":<n>,"commits":<n>}`` (``ArcadeNative.txState``). A caller
# that reads none of the state (begin, commit, rollback), or only ``active``, tells success and that bool from the text it already holds
# instead of decoding a JSON object. An error reply is ``{"ok":false,...}``: it fails the prefix test and is decoded, as before.
_TX_OK = '{"ok":true,'
_TX_ACTIVE = '{"ok":true,"active":true,'
_TX_IDLE = '{"ok":true,"active":false,'
# ``arcadedb_n_tx`` answers with the transaction state after the call as one int (``ArcadeNative.tx``, no reply text to copy, free or
# decode): bit 0 is ``active``, bits 1 to 31 the nesting ``depth``, bits 32 to 62 the ``commits`` of the transaction context (its low 31
# bits: callers compare two of them for equality). Callers read the fields inline: ``state & 1``, ``(state >> 1) & 0x7FFFFFFF``,
# ``state >> 32``. -1 is an error: its text, the ``{"ok":false,...}`` reply of every other entry point, is ``lib.last_error()``.


def _first_row(database, reply: dict):
Expand Down Expand Up @@ -877,73 +875,63 @@ def _command_first_row(self, language: str, command: str, *args) -> NativeResult

# -- transactions

def _tx_text(self, op: int, prefix: str) -> str:
"""One ``arcadedb_n_tx`` crossing: the reply text of a success (not decoded), an ArcadeDBError for a failure."""
def _tx_state(self, op: int, prefix: str) -> int:
"""One ``arcadedb_n_tx`` crossing, one foreign call: the transaction state after the operation as an int (its layout is
described at the top of this module), an ArcadeDBError for a failure.
"""
if self._closed:
raise ArcadeDBError("Database is closed")
if (op == 1 or op == 2) and lib.CLOSE_BATCH:
lib.flush_closes()
text = lib.call("arcadedb_n_tx", self._h, op)
if text.startswith(_TX_OK):
return text
reply = json.loads(text)
if not reply.get("ok"):
raise _error(prefix, reply)
return text

def _tx(self, op: int, prefix: str) -> dict:
"""The decoded state reply: for a caller that reads ``depth`` or ``commits``."""
return json.loads(self._tx_text(op, prefix))
state = lib.tx(self._h, op)
if state < 0:
raise _error(prefix, json.loads(lib.last_error()))
return state

def begin(self):
self._tx_text(0, "Failed to begin transaction")
self._tx_state(0, "Failed to begin transaction")

def _begin_state(self) -> dict:
"""begin(), and the state right after it: the reply of ``arcadedb_n_tx`` carries ``active``, ``depth`` and ``commits``."""
return self._tx(0, "Failed to begin transaction")
def _begin_state(self) -> int:
"""begin(), and the state right after it: the int of ``arcadedb_n_tx`` carries ``active``, ``depth`` and ``commits``."""
return self._tx_state(0, "Failed to begin transaction")

def commit(self):
self._tx_text(1, "Failed to commit transaction")
self._tx_state(1, "Failed to commit transaction")

def rollback(self):
self._tx_text(2, "Failed to rollback transaction")
self._tx_state(2, "Failed to rollback transaction")

def _rollback_state(self) -> dict:
"""rollback(), and the state right after it (the reply carries it, as begin's does): the unwind reads it instead of asking."""
return self._tx(2, "Failed to rollback transaction")
def _rollback_state(self) -> int:
"""rollback(), and the state right after it (the int carries it, as begin's does): the unwind reads it instead of asking."""
return self._tx_state(2, "Failed to rollback transaction")

def transaction(self) -> TransactionContext:
return TransactionContext(self)

def is_transaction_active(self) -> bool:
text = self._tx_text(3, "Failed to check transaction status")
if text.startswith(_TX_ACTIVE):
return True
if text.startswith(_TX_IDLE):
return False
return json.loads(text)["active"]
return bool(self._tx_state(3, "Failed to check transaction status") & 1)

def _transaction_stack_mark(self):
state = self._tx(3, "Failed to read the transaction state")
return state["active"], state["depth"]
state = self._tx_state(3, "Failed to read the transaction state")
return bool(state & 1), (state >> 1) & 0x7FFFFFFF

def _transaction_commit_mark(self):
"""The commit count of the current transaction (the witness that a BATCH boundary published part of its work)."""
return self._tx(3, "Failed to read the transaction state")["commits"]
return self._tx_state(3, "Failed to read the transaction state") >> 32

def _unwind_opened_since(self, mark):
"""Roll back every level opened since ``mark``, and no other; returns ``(state, first_error)``.

``state`` is the transaction state after the last crossing (every rollback reply carries it, so no read sits between two
``state`` is the transaction state int after the last crossing (every rollback carries it, so no read sits between two
steps), or None when that crossing failed. A level the caller held at the mark is theirs, as in the JPype ``Database``.
"""
was_active, depth_before = mark
first_error = None
state = self._tx(3, "Failed to read the transaction state")
for _ in range(state["depth"] - depth_before):
state = self._tx_state(3, "Failed to read the transaction state")
for _ in range(((state >> 1) & 0x7FFFFFFF) - depth_before):
if state is None:
state = self._tx(3, "Failed to check transaction status")
if not state["active"]:
state = self._tx_state(3, "Failed to check transaction status")
if not state & 1:
break
try:
state = self._rollback_state()
Expand All @@ -952,8 +940,8 @@ def _unwind_opened_since(self, mark):
first_error = first_error or e
if not was_active:
if state is None:
state = self._tx(3, "Failed to check transaction status")
if state["active"]:
state = self._tx_state(3, "Failed to check transaction status")
if state & 1:
try:
state = self._rollback_state()
except Exception as e:
Expand All @@ -977,7 +965,7 @@ def run_in_transaction(self, fn, retries: int = 12, backoff_s: float = 0.005):
mark = self._transaction_stack_mark()
commits_at_start = None
try:
commits_at_start = self._begin_state()["commits"]
commits_at_start = self._begin_state() >> 32
result = fn()
self.commit()
return result
Expand All @@ -1003,7 +991,7 @@ def run_in_transaction(self, fn, retries: int = 12, backoff_s: float = 0.005):
# retry, as before); the raise sits outside that try, or its own except would swallow it.
try:
commits_now = (
state["commits"]
state >> 32
if state is not None
else self._transaction_commit_mark()
)
Expand Down
17 changes: 16 additions & 1 deletion src/arcadedb/_native/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ class _Params(ctypes.Structure):
"arcadedb_n_set_upcall": [P, P],
"arcadedb_free": [P, P],
}
# the entry points that answer with a number, not with the address of a reply text: a cursor id (arcadedb_n_cmd) and the
# transaction state (arcadedb_n_tx); -1 is an error for both, its text is read with arcadedb_n_last_error
returns_long = ("arcadedb_n_cmd", "arcadedb_n_tx")
for name, argtypes in sigs.items():
fn = getattr(lib, name, None)
if fn is None: # a library built before this entry point existed
Expand All @@ -275,7 +278,7 @@ class _Params(ctypes.Structure):
"arcadedb_n_rs_close_many",
"arcadedb_n_set_upcall",
)
else ctypes.c_longlong if name == "arcadedb_n_cmd" else ctypes.c_void_p
else ctypes.c_longlong if name in returns_long else ctypes.c_void_p
)
global BINARY_PARAMS, CMD_PATH, CLOSE_BATCH, INSERT_ROWS, EXEC_MANY, NEXT_BIN, GB_BIN, TS_BIN
if not hasattr(
Expand Down Expand Up @@ -491,7 +494,19 @@ def cmd(handle, flags, language, command, params, buf, count, first) -> int:
)


def tx(handle, op) -> int:
"""``arcadedb_n_tx``: op 0 begin, 1 commit, 2 rollback, 3 mark. Returns the transaction state after the call as one int (bit 0
``active``, bits 1 to 31 ``depth``, bits 32 to 62 ``commits``, as ``ArcadeNative.tx`` writes it), or -1 for an error (read it
with :func:`last_error`). One foreign call: no reply text to copy, free or decode.
"""
lib = _lib or _load()
t = _local.__dict__.get("thread")
th = t.handle if t is not None else _thread_handle()
return lib.arcadedb_n_tx(th, handle, op)


def last_error() -> str:
"""The error of the last failed :func:`cmd` or :func:`tx` on this thread, as the JSON text of an error reply."""
return call("arcadedb_n_last_error")


Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
collect_ignore.append("test_native_vector_provider.py")
# the copy of a record's values as read that save() compares with the stored row (arcadedb._native.records), counted through its seams
collect_ignore.append("test_native_save_baseline.py")
# crossing counts of the transaction entry points (lib.call is the native library's ctypes door)
# crossing and foreign-call counts of the transaction entry points (lib.tx and lib.call are the native library's ctypes doors)
collect_ignore.append("test_native_tx_crossings.py")
# counts the statement texts the native backend hands to the engine (the JPype backend has no such layer)
collect_ignore.append("test_native_statement_texts.py")
Expand Down
8 changes: 5 additions & 3 deletions tests/test_native_one_row_crossings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
reads the stored record first: the engine's reply to an UPDATE comes after the write, and the check must refuse before writing
anything inside the caller's transaction, so it cannot be read from the update's own result.

The counts are crossings (the ctypes doors ``cmd``, ``call``, ``call_bin``, ``exec_query`` and the batched cursor close), not seconds:
The counts are crossings (the ctypes doors ``cmd``, ``call``, ``call_bin``, ``exec_query``, ``tx`` and the batched cursor close), not seconds:
deterministic. The oracle for the values and the errors is the lazy form itself (``db.command(...).first()``), kept for every other caller.
"""

Expand All @@ -27,7 +27,7 @@ class _Crossings:

def __init__(self, monkeypatch):
self.names = []
for attr in ("cmd", "call", "call_bin", "exec_query"):
for attr in ("cmd", "call", "call_bin", "exec_query", "tx"):
monkeypatch.setattr(lib, attr, self._spy(attr, getattr(lib, attr)))
real_close = lib._lib.arcadedb_n_rs_close_many

Expand All @@ -39,7 +39,9 @@ def close_many(*args):

def _spy(self, attr, real):
def spy(*args, **kwargs):
self.names.append(args[0] if attr in ("call", "call_bin") else attr)
# the transaction door is named by its entry point, as it was when it went through lib.call
name = "arcadedb_n_tx" if attr == "tx" else attr
self.names.append(args[0] if attr in ("call", "call_bin") else name)
return real(*args, **kwargs)

return spy
Expand Down
Loading
Loading