diff --git a/native/src/main/java/org/humemai/nativelib/ArcadeNative.java b/native/src/main/java/org/humemai/nativelib/ArcadeNative.java index 3d6eb6c..b1d3e7f 100644 --- a/native/src/main/java/org/humemai/nativelib/ArcadeNative.java +++ b/native/src/main/java/org/humemai/nativelib/ArcadeNative.java @@ -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. */ @@ -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) { @@ -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(); @@ -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 diff --git a/src/arcadedb/_native/database.py b/src/arcadedb/_native/database.py index e0c5c20..79f5079 100644 --- a/src/arcadedb/_native/database.py +++ b/src/arcadedb/_native/database.py @@ -74,12 +74,10 @@ def _call(prefix: str, name: str, *args) -> dict: return reply -# Every successful ``arcadedb_n_tx`` reply is ``{"ok":true,"active":,"depth":,"commits":}`` (``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): @@ -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() @@ -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: @@ -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 @@ -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() ) diff --git a/src/arcadedb/_native/lib.py b/src/arcadedb/_native/lib.py index aaceedc..9f7474d 100644 --- a/src/arcadedb/_native/lib.py +++ b/src/arcadedb/_native/lib.py @@ -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 @@ -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( @@ -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") diff --git a/tests/conftest.py b/tests/conftest.py index fd7402f..d6ef567 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_native_one_row_crossings.py b/tests/test_native_one_row_crossings.py index 73f32a9..e65e3e9 100644 --- a/tests/test_native_one_row_crossings.py +++ b/tests/test_native_one_row_crossings.py @@ -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. """ @@ -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 @@ -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 diff --git a/tests/test_native_tx_crossings.py b/tests/test_native_tx_crossings.py index 7f9f618..78a1635 100644 --- a/tests/test_native_tx_crossings.py +++ b/tests/test_native_tx_crossings.py @@ -1,16 +1,21 @@ -"""The native wheel spends the fewest library crossings a transaction needs. +"""The native wheel spends the fewest library crossings a transaction needs, and each one is a single foreign call. -Every ``arcadedb_n_tx`` call is one trip into the native library: a ctypes call, a malloc'd JSON reply, ``string_at``, a decode and a -``json.loads``. The reply of begin, commit and rollback already carries the whole transaction state (``active``, ``depth`` and -``commits``), so a caller that needs the state right after one of these calls must not ask for it again with a mark. +Every ``arcadedb_n_tx`` call is one trip into the native library. It used to return a malloc'd JSON text (about 60 bytes) that the +caller then read with ``strlen``, a copy into a ``str`` and ``arcadedb_free``: four foreign calls per crossing, twelve for +``with db.transaction()``. It now returns the transaction state as one integer (bit 0 ``active``, bits 1 to 31 ``depth``, bits 32 to +62 ``commits``, -1 for an error whose text ``lib.last_error`` holds), the way ``arcadedb_n_cmd`` returns a cursor id: one foreign +call, no reply text, no free, no JSON decode. The reply of begin, commit and rollback carries the whole state, so a caller that +needs the state right after one of these calls must not ask for it again with a mark. -The counts are crossings, not seconds: they are deterministic. The behaviour tests next to them pin what the marks are for (a caller's -transaction is never rolled back by a nested block, a retry rolls the failed attempt back), so a lower count can not buy a different result. +The counts are crossings and foreign calls, not seconds: they are deterministic. The behaviour tests next to them pin what the +marks are for (a caller's transaction is never rolled back by a nested block, a retry rolls the failed attempt back) and that an +error reaches Python as the same typed exception with the same message, so a lower count can not buy a different result. Needs the native library, so conftest.py does not collect this file on the JPype backend (a skip there would fail scripts/check_test_skips.py); it runs in the native parity run. """ +import contextlib import json import pytest @@ -20,20 +25,32 @@ from arcadedb._native import lib _OPS = {0: "begin", 1: "commit", 2: "rollback", 3: "mark"} +_DEPTH_MASK = 0x7FFFFFFF + + +def _fields(state): + """``(active, depth, commits)`` of the integer ``arcadedb_n_tx`` returns (the layout is pinned here, not read from the wheel).""" + return bool(state & 1), (state >> 1) & _DEPTH_MASK, state >> 32 class _Crossings: - """Counts every ``lib.call`` made while it is installed; ``tx`` is the sequence of ``arcadedb_n_tx`` operations.""" + """Counts every ``lib.tx`` and ``lib.call`` made while it is installed; ``tx`` is the sequence of transaction operations.""" def __init__(self, monkeypatch): self.calls = [] - real = lib.call + real_tx = lib.tx + real_call = lib.call - def counting(name, *args): - self.calls.append(_OPS[args[1]] if name == "arcadedb_n_tx" else name) - return real(name, *args) + def counting_tx(handle, op): + self.calls.append(_OPS[op]) + return real_tx(handle, op) - monkeypatch.setattr(lib, "call", counting) + def counting_call(name, *args): + self.calls.append(name) + return real_call(name, *args) + + monkeypatch.setattr(lib, "tx", counting_tx) + monkeypatch.setattr(lib, "call", counting_call) @property def tx(self): @@ -43,6 +60,51 @@ def reset(self): self.calls.clear() +class _ForeignCalls: + """Every foreign (ctypes) call the transaction path can make, by name, while it is installed. + + The entry points of the library are counted through a proxy of the loaded library object, and the two helpers that turn a reply + text into a ``str`` (``strlen`` and ``PyUnicode_FromStringAndSize``) by name. ``arcadedb_free`` is an entry point, so a reply + text that is read and released shows up as ``arcadedb_n_tx``, ``_strlen``, ``_str_from_utf8``, ``arcadedb_free``. + """ + + def __init__(self, monkeypatch): + self.names = [] + names = self.names + real_lib = lib._lib + + class _Proxy: + def __getattr__(self, attr): + function = getattr(real_lib, attr) + + def counted(*args): + names.append(attr) + return function(*args) + + return counted + + monkeypatch.setattr(lib, "_lib", _Proxy()) + for helper in ("_strlen", "_str_from_utf8"): + monkeypatch.setattr( + lib, helper, self._counted(helper, getattr(lib, helper)) + ) + + def _counted(self, name, function): + def counted(*args): + self.names.append(name) + return function(*args) + + return counted + + @property + def entries(self): + # a batched cursor close that an earlier statement left waiting runs before a commit: it belongs to that statement + return [n for n in self.names if n != "arcadedb_n_rs_close_many"] + + def reset(self): + del self.names[:] + + @pytest.fixture def db(temp_db_path): database = arcadedb.create_database(temp_db_path) @@ -51,8 +113,19 @@ def db(temp_db_path): database.transaction() ): # the thread's transaction context exists from here on, as it does in a running application pass - yield database - database.drop() + lib.flush_closes() # a cursor closed earlier is not part of a call measured below + try: + yield database + finally: + # a test that fails with a transaction open (a begin without its end, a level a direct lib.tx call pushed) must not turn into + # a teardown error as well: the engine refuses to drop a database that has a transaction open on the thread + try: + for _ in range(16): + if not database.is_transaction_active(): + break + database.rollback() + finally: + database.drop() @pytest.fixture @@ -60,6 +133,11 @@ def crossings(db, monkeypatch): return _Crossings(monkeypatch) +@pytest.fixture +def foreign(db, monkeypatch): + return _ForeignCalls(monkeypatch) + + def _count(db): return db.query("sql", "SELECT count(*) AS c FROM R").first().get("c") @@ -119,6 +197,51 @@ def fn(): ] +# -- foreign calls: a crossing is one call into the library, with no reply text to read and free + + +def test_with_transaction_is_three_foreign_calls(db, foreign): + # was twelve: each of the three crossings (mark, begin, commit) was the entry, strlen, the copy into a str and arcadedb_free + with db.transaction(): + pass + assert foreign.entries == ["arcadedb_n_tx"] * 3 + + +def test_each_transaction_call_is_one_foreign_call_and_frees_nothing(db, foreign): + for call in ( + db.is_transaction_active, + db.begin, + db.is_transaction_active, + db.rollback, + db.begin, + db.commit, + db._transaction_stack_mark, + ): + foreign.reset() + call() + assert foreign.entries == ["arcadedb_n_tx"], call.__name__ + + +def test_a_transaction_that_raises_is_three_foreign_calls(db, foreign): + with pytest.raises(ValueError): + with db.transaction(): + raise ValueError("boom") + assert foreign.entries == ["arcadedb_n_tx"] * 3 + + +def test_a_transaction_never_reads_a_reply_text(db, monkeypatch): + taken = [] + real = lib._take + monkeypatch.setattr(lib, "_take", lambda *a: taken.append(1) or real(*a)) + with db.transaction(): + pass + db.begin() + assert db.is_transaction_active() is True + db.rollback() + assert db.run_in_transaction(lambda: 1) == 1 + assert taken == [] + + # -- what the marks are for: the same results at the lower count @@ -143,6 +266,23 @@ def test_a_nested_block_that_succeeds_commits_with_the_caller(db): assert _count(db) == 2 +def test_nested_with_blocks_push_one_level_for_the_inner_block_only(db): + # The depth is the size of the thread's stack of transaction contexts (LocalDatabase.getNestedTransactions). The stack always holds + # the thread's base context once the thread has used the database (the fixture did), so it is 1 while idle. The outer begin + # reuses that base context (depth stays 1, active); only a begin while a transaction is active pushes a context (2), and the commit + # of that inner level pops it (1, still active). The outer commit ends the base context and leaves it on the stack (1, idle). + base = db._transaction_stack_mark()[1] + assert db._transaction_stack_mark() == (False, 1) + with db.transaction(): + assert db._transaction_stack_mark() == (True, base) + with db.transaction(): + assert db._transaction_stack_mark() == (True, base + 1) + db.command("sql", "INSERT INTO R SET k = 1") + assert db._transaction_stack_mark() == (True, base) + assert db._transaction_stack_mark() == (False, base) + assert _count(db) == 1 + + def test_a_refused_attempt_is_rolled_back_before_the_retry(db): attempts = [] @@ -193,11 +333,11 @@ def test_begin_commit_rollback_return_nothing(db): assert db.commit() is None -# -- decodes: a status the caller does not read is not a JSON decode +# -- decodes: a status the caller does not read is not a JSON decode, and a state the caller reads is not one either class _Decodes: - """Counts every ``json.loads`` while it is installed (the transaction replies are decoded with it).""" + """Counts every ``json.loads`` while it is installed (an error reply is decoded with it, nothing else is).""" def __init__(self, monkeypatch): self.n = 0 @@ -232,40 +372,101 @@ def test_is_transaction_active_decodes_nothing_and_reports_the_state(db, decodes assert decodes.n == 0 -def test_with_transaction_decodes_only_the_mark(db, decodes): - # the mark reads two fields (active, depth) and is decoded; begin and commit read none +def test_with_transaction_decodes_nothing(db, decodes): + # the mark reads two fields (active, depth) out of the integer, begin and commit read none: no JSON anywhere with db.transaction(): pass - assert decodes.n == 1 + assert decodes.n == 0 -def test_a_state_reply_still_carries_depth_and_commits(db): +def test_a_state_still_carries_active_depth_and_commits(db): db.begin() db.begin() + # the first begin reuses the thread's base context (depth 1), the second pushes one (2) assert db._transaction_stack_mark() == (True, 2) db.rollback() db.rollback() assert db._transaction_stack_mark()[0] is False - assert db._begin_state()["depth"] == 1 - assert db._rollback_state()["active"] is False + active, depth, _ = _fields(db._begin_state()) + assert (active, depth) == (True, 1) + active, _, _ = _fields(db._rollback_state()) + assert active is False + + +def test_the_state_comes_back_as_one_integer(db): + handle = db._h + idle = lib.tx(handle, 3) + assert type(idle) is int and idle >= 0 + active, base, _ = _fields(idle) + assert active is False + # the first begin reuses the thread's base context (the depth stays), a begin inside it pushes one more (see the nested with test) + one = lib.tx(handle, 0) + assert type(one) is int and one >= 0 + assert _fields(one)[:2] == (True, base) + two = lib.tx(handle, 0) + assert _fields(two)[:2] == (True, base + 1) + assert _fields(two)[2] == _fields(lib.tx(handle, 3))[2] # a mark reads the count + assert _fields(lib.tx(handle, 2))[:2] == (True, base) # the inner level is gone + assert _fields(lib.tx(handle, 2))[:2] == (False, base) # the outer one ended + + +# -- errors: the same typed exception with the same message + + +def test_a_failed_call_returns_minus_one_and_the_error_is_in_last_error(db): + assert lib.tx(2**62, 3) == -1 # a handle no database has + error = json.loads(lib.last_error()) + assert error["ok"] is False + assert error["cls"] == "java.lang.IllegalArgumentException" + assert "unknown database handle" in error["error"] + assert error["chain"][0][0] == error["cls"] + + +def test_a_commit_without_begin_raises_the_engines_error_unchanged(db): + with pytest.raises(ArcadeDBError) as caught: + db.commit() + native = caught.value.native + assert native["ok"] is False + assert native["cls"] and "Transaction not begun" in native["error"] + assert ( + str(caught.value) + == f"Failed to commit transaction: {native['cls']}: {native['error']}" + ) + assert caught.value.java_chain == native["chain"] + assert caught.value.java_chain[0] == [native["cls"], native["error"]] + assert db.is_transaction_active() is False + + +def test_a_rollback_with_nothing_to_roll_back_is_not_an_error(db): + db.rollback() + assert db.is_transaction_active() is False + + +def test_a_failed_statement_in_a_block_rolls_the_block_back_and_leaves_no_level(db): + db.command("sql", "CREATE PROPERTY R.k INTEGER") + db.command("sql", "CREATE INDEX ON R (k) UNIQUE") + base = db._transaction_stack_mark() + with pytest.raises(ArcadeDBError) as caught: + with db.transaction(): + db.command("sql", "INSERT INTO R (k) VALUES (1), (1)") + assert "DuplicatedKeyException" in str(caught.value) + assert db._transaction_stack_mark() == base + assert _count(db) == 0 -def test_an_error_reply_is_decoded_into_the_same_error(db, monkeypatch): +def test_a_failure_reply_is_read_from_last_error_into_the_same_error(db, monkeypatch): reply = ( '{"ok":false,"error":"boom","cls":"x.Y","chain":[["x.Y","boom"]],' '"retry":"need_retry","retry_ms":3}' ) - real = lib.call - monkeypatch.setattr( - lib, - "call", - lambda name, *a: reply if name == "arcadedb_n_tx" else real(name, *a), - ) + monkeypatch.setattr(lib, "tx", lambda handle, op: -1) + monkeypatch.setattr(lib, "last_error", lambda: reply) for op, prefix in ( (db.begin, "Failed to begin transaction"), (db.commit, "Failed to commit transaction"), (db.rollback, "Failed to rollback transaction"), (db.is_transaction_active, "Failed to check transaction status"), + (db._transaction_stack_mark, "Failed to read the transaction state"), ): with pytest.raises(ArcadeDBError) as caught: op() @@ -274,12 +475,36 @@ def test_an_error_reply_is_decoded_into_the_same_error(db, monkeypatch): assert caught.value.java_chain == [["x.Y", "boom"]] -def test_a_differently_spaced_ok_reply_is_still_read(db, monkeypatch): - monkeypatch.setattr( - lib, - "call", - lambda name, *a: '{"ok": true, "active": true, "depth": 1, "commits": 0}', - ) - assert db.is_transaction_active() is True - db.begin() - db.commit() +# -- a closed database + + +def test_a_transaction_left_open_is_gone_after_a_close_and_reopen(temp_db_path): + database = arcadedb.create_database(temp_db_path) + reopened = None + try: + database.command("sql", "CREATE DOCUMENT TYPE R") + database.begin() + database.command("sql", "INSERT INTO R SET k = 1") + assert database.is_transaction_active() is True + database.close() + for call in ( + database.begin, + database.commit, + database.rollback, + database.is_transaction_active, + database._transaction_stack_mark, + ): + with pytest.raises(ArcadeDBError, match="Database is closed"): + call() + reopened = arcadedb.open_database(temp_db_path) + assert reopened.is_transaction_active() is False + assert _count(reopened) == 0 + finally: + if reopened is not None: + reopened.drop() + else: + # a failure before the reopen: end what is open and drop, ignoring the refusals of a database already closed + with contextlib.suppress(ArcadeDBError): + database.rollback() + with contextlib.suppress(ArcadeDBError): + database.drop()