Skip to content
Draft
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 @@ -793,73 +791,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 @@ -868,8 +856,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 @@ -893,7 +881,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 @@ -919,7 +907,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 @@ -259,6 +259,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 @@ -272,7 +275,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 @@ -488,7 +491,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
7 changes: 5 additions & 2 deletions src/arcadedb/_native/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,11 @@ def _save_update(self, db) -> None:

Like every write (and like the JVM build), it needs an active transaction while auto-transaction is off, the engine default:
outside one it raises ArcadeDBError ("Transaction not begun") before it reads or writes anything. This method opens no
transaction of its own."""
if not db.is_transaction_active() and not db._auto_transaction:
transaction of its own. With auto-transaction on the check cannot refuse, so the state is not asked for (one crossing less).
The engine's own refusal cannot stand in for the check with it off: the engine runs the before-update callbacks and the
validation before it looks for a transaction, and an UPDATE of a record that is gone raises nothing.
"""
if not db._auto_transaction and not db.is_transaction_active():
raise ArcadeDBError(
"Failed to save record: com.arcadedb.exception.TransactionException: Transaction not begun "
"(wrap the write in `with db.transaction():` or call begin() first)"
Expand Down
5 changes: 4 additions & 1 deletion src/arcadedb/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ def _column_to_java(name, values):
into a ``long[]``, ``double[]``, or ``boolean[]``; a string or object array
and any other sequence of mixed values convert per element into an ``Object[]`` (a ``str``
reuses one Java String per distinct value, ``None`` is a null); a column of nothing but ``str`` and ``None`` crosses instead as one
``StringColumn`` (``_string_column``). A pandas
``StringColumn`` (``_string_column``). A bridge ``TemporalColumn``, which ``insert_arrow`` and ``insert_dataframe`` build from a
timestamp or date column (``dataframe._temporal_column``), crosses as it is. A pandas
``Series`` is read through ``to_numpy``, with a nullable dtype's ``<NA>``
as ``None``. A 2-D ``float32`` array is a column of vectors: it crosses as
one ``float[]`` and each document gets one row of it. Kinds that do not cross natively raise ``TypeError`` rather
Expand Down Expand Up @@ -313,6 +314,8 @@ def _column_to_java(name, values):
# a string, bytes, or object array: its elements are Python objects (str, None, ...), converted one by one below
values = list(values.astype(object, copy=False))
elif not isinstance(values, (list, tuple)):
if isinstance(values, _java_class("com.arcadedb.python.TemporalColumn")):
return values, int(values.rows())
values = list(values)
column = _string_column(values)
if column is not None:
Expand Down
60 changes: 31 additions & 29 deletions src/arcadedb/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,43 +245,46 @@ def _check_declared(
)


def _temporal_to_java(chunk, pa):
"""A timestamp or date chunk as a list of ``LocalDateTime``/``LocalDate``, keeping the unit's precision.
def _temporal_column(chunk, pa):
"""A timestamp or date chunk as one ``TemporalColumn`` of the bridge, keeping the unit's precision.

The integer epoch values cross as ONE ``long[]`` (a buffer copy), the null rows as one ``boolean[]`` when there is a null, and the
bridge builds each ``LocalDateTime`` or ``LocalDate`` as it fills the document of that row: one call per column and batch, where a
``LocalDateTime.ofEpochSecond`` or ``LocalDate.ofEpochDay`` call per cell (and one conversion per element in ``insert_columns``) came
before. ``insert_columns`` takes the column as it is.

DATETIME is a UTC wall clock: a timestamp with a zone is already UTC epoch time, and a timestamp without
one is taken as the wall clock it shows (the same rule ``Document.set`` applies to a ``datetime``), so both
are read from the integer epoch value. Going through ``datetime`` would cut a nanosecond value to microseconds.
are read from the integer epoch value, in the unit of the column. Going through ``datetime`` would cut a nanosecond value to
microseconds. A date32 is days, a date64 is milliseconds (the bridge divides by 86,400,000, rounding toward negative infinity).
"""
import numpy as np

types = pa.types
mask = chunk.is_null().to_numpy(zero_copy_only=False)
if types.is_date(chunk.type):
local_date = jpype.JClass("java.time.LocalDate")
raw = chunk.cast(pa.int64() if types.is_date64(chunk.type) else pa.int32())
days = raw.fill_null(0).to_numpy(zero_copy_only=False).astype(np.int64)
if types.is_date64(chunk.type):
days = days // 86_400_000
return [
None if m else local_date.ofEpochDay(int(d)) for d, m in zip(days, mask)
]
per_second = {"s": 1, "ms": 1_000, "us": 1_000_000, "ns": 1_000_000_000}[
chunk.type.unit
]
values = chunk.cast(pa.int64()).fill_null(0).to_numpy(zero_copy_only=False)
seconds, rest = np.divmod(values.astype(np.int64), per_second)
nanos = rest * (1_000_000_000 // per_second)
local_datetime = jpype.JClass("java.time.LocalDateTime")
utc = jpype.JClass("java.time.ZoneOffset").UTC
return [
None if m else local_datetime.ofEpochSecond(int(s), int(n), utc)
for s, n, m in zip(seconds, nanos, mask)
]
date64 = types.is_date64(chunk.type)
raw = chunk.cast(pa.int64() if date64 else pa.int32())
ticks, per_unit, factory = raw, 86_400_000 if date64 else 1, "dates"
else:
per_second = {"s": 1, "ms": 1_000, "us": 1_000_000, "ns": 1_000_000_000}
ticks = chunk.cast(pa.int64())
per_unit, factory = per_second[chunk.type.unit], "dateTimes"
values = ticks.fill_null(0).to_numpy(zero_copy_only=False)
nulls = None
if chunk.null_count:
nulls = jpype.JArray(jpype.JBoolean)(
chunk.is_null().to_numpy(zero_copy_only=False)
)
return getattr(jpype.JClass("com.arcadedb.python.TemporalColumn"), factory)(
jpype.JArray(jpype.JLong)(np.ascontiguousarray(values, dtype=np.int64)),
per_unit,
nulls,
)


def _temporal_to_tagged(chunk, pa):
"""A timestamp or date chunk as tagged values (``T`` date-time, ``D`` date) for the native backend, with the same
reading rules as :func:`_temporal_to_java`: ISO text of the integer epoch value, so a nanosecond value keeps its
reading rules as :func:`_temporal_column`: ISO text of the integer epoch value, so a nanosecond value keeps its
nanoseconds (a Python ``datetime`` would cut them to microseconds).
"""
import numpy as np
Expand Down Expand Up @@ -381,7 +384,8 @@ def _column_values(
float_vectors: bool = False,
):
"""One Arrow array as what ``Database.insert_columns`` takes: a numpy array when the column crosses as one
buffer (integers, floats and booleans without nulls), else a list of values (``None`` is a null).
buffer (integers, floats and booleans without nulls), else a list of values (``None`` is a null). On the JVM backend a timestamp or
date column is one bridge ``TemporalColumn`` instead (``_temporal_column``: one ``long[]`` of epoch ticks, no value per cell).

``float_vectors`` (the JVM backend, a property declared ``ARRAY_OF_FLOATS``): a float32 vector column of one length and no null is
one 2-D float32 matrix, which ``insert_columns`` sends as one ``float[]``; ``to_pylist`` made one Python float per element and
Expand Down Expand Up @@ -434,9 +438,7 @@ def _column_values(
if matrix is not None:
return matrix
if kind in ("timestamp", "date"):
return (
_temporal_to_tagged(chunk, pa) if native else _temporal_to_java(chunk, pa)
)
return _temporal_to_tagged(chunk, pa) if native else _temporal_column(chunk, pa)
if native and kind in ("decimal", "binary"):
return _scalars_to_tagged(kind, chunk)
if kind == "null":
Expand Down
Loading
Loading