Skip to content

Native: Python functions in SQL and openCypher, record events, Ctrl-C, and the async writer crash - #67

Merged
tae898 merged 7 commits into
native-two-distfrom
native-callbacks
Oct 25, 2026
Merged

tae898 merged 7 commits into
native-two-distfrom
native-callbacks

Conversation

@tae898

@tae898 tae898 commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #66 (native-two-dist, rebased on 56aab39). Closes the hardest native parity gap: Python functions called from SQL and openCypher, record event callbacks, the Ctrl-C behaviour of a Python function, and the crash in tests/test_async_executor.py. It covers 31 failures of test_python_callbacks.py, 1 of test_python_function_interrupt.py and the crash, from the 68 failures of the native parity run (parity-native-two-dist). Nothing here raises NotOnNativeYet: the features work on the native arcadedb, with the behaviour of arcadedb-jvm.

Test results

Source tree with ARCADEDB_BACKEND=native, on the image built from this branch (~/build-scratch/native-lib/callbacks2, GraalVM CE 25.4.4.1.1, -march=compatibility).

  • test_python_callbacks.py: 40 passed (9 passed, 31 failed before).
  • test_python_function_interrupt.py: 3 passed, 2 skipped (the two JPype-only cases). The in-process Ctrl-C test passes, and so does a new one that runs the same loop in a fresh process and fails on a nonzero exit.
  • test_async_executor.py: 21 passed (the file crashed before).
  • Together with test_native_udf, test_sigint, test_interrupt_hygiene, test_transaction_context_interrupt, test_results_after_close, test_core, test_cypher, test_resultset, test_concurrency, test_percall_calls, test_native_startup_runtime, test_schema and test_select_matrix: 303 passed, 26 skipped, 0 failed.
  • A Ctrl-C test run from a background job (nohup ... &) sees no interrupts at all: a non-interactive shell starts background jobs with SIGINT ignored, and Python then installs no handler. Run the signal tests in the foreground. The fresh-process test sets the default handler itself.

Design: how the native image calls back into Python

  • One function pointer. Python registers a single ctypes CFUNCTYPE pointer with arcadedb_n_set_upcall (this existed for async callbacks). The image keeps it as a CFunctionPointer and calls it as fn(callbackId, json, out). A Python-side table maps the id to the callable; the entry is removed when the function is unregistered or replaced, when the listener is removed, and when the database closes, so the table does not grow.
  • Registry entry point. A new arcadedb_n_cb(op, json) registers and removes functions and event listeners. Its checks, error wording, veto and failure rules are those of the JPype bridge (PythonFunctions, PythonRecordEvents): an engine function name is refused, replace=True is needed to replace, a revoked function says it was unregistered, a failing callback is Python function 'x' failed: ValueError: ... or Python 'before_create' callback failed: ..., a before_* callback returning False vetoes (and save() of the vetoed new record returns it unsaved, as on JPype). The Python-side checks (name, callable, keyword-only, argument counts, event names) are one shared helper in arcadedb.callbacks, used by both backends.
  • GIL. ctypes takes the GIL for a CFUNCTYPE call (PyGILState_Ensure), on the calling thread of the query or on a foreign Java thread (an async writer). The foreign-library call that is running the statement released the GIL, so there is no deadlock; a function may call back into the database.
  • Marshalling. Arguments are the tagged JSON of query parameters (NativeCodec), results go back through the same encoder as parameters. A record argument or event record arrives as a dict with @rid, @type and @cat and is wrapped as Document, Vertex or Edge; the after_delete event of a record that is not loaded carries its identity only. A result past a Java long is an OverflowError; numpy arrays, Decimal, datetime, list and dict work as on JPype.
  • Exceptions. A Python exception never crosses the C frame. _dispatch catches BaseException and replies {"error": "Type: text"}; the engine turns it into a CommandExecutionException (functions) or DatabaseOperationException (events), so the statement fails with the Python message and the surrounding transaction does not commit.
  • Reply memory, one owner. The reply does not travel as a return value but through an out slot that Java zeroes before the call. The text is in memory from arcadedb_n_alloc (the library's allocator); Java copies it and frees it, and Python writes nothing else into the slot. Two failures forced this. A thread-local Python buffer dies with the Python thread state of a callback on a Java thread (an async writer) before Java has read it. And a C callback that returns without setting its return value hands the engine whatever the return register held, here the pointer of an earlier reply that Java had freed: the second free aborted the process (free(): invalid pointer). That happens when a Ctrl-C is raised as the callback is entered, before its try. With the slot, an early exit leaves NULL.
  • Ctrl-C. A KeyboardInterrupt in a function or callback is remembered per thread; the place that turns the statement's failure into an ArcadeDBError raises the KeyboardInterrupt instead, chained from that error, for query, command, iteration and to_list alike. A callback that ended before it could reply (the Ctrl-C raised on entry) makes the engine fail the statement with a fixed text, and the same place turns that into a KeyboardInterrupt too. The interrupt is cleared when a statement starts, so a stale one never turns a later error into a KeyboardInterrupt. This is the rule of the JPype backend (arcadedb.callbacks); sys.unraisablehook is not touched (CPython prints the usual "Exception ignored on calling ctypes callback function" line for the entry case).

The async-executor crash

Not related to the callbacks themselves. test_per_record_callback_runs_before_the_batch_commits_and_on_ok_after calls db.count_type from an on_ok callback, which runs on an async writer thread. _thread_handle() saw a thread that was not a Python-created one and attached it to the isolate again as an owned handle. When the callback returned, CPython deleted the callback's thread state, _Thread.__del__ called graal_detach_thread on a thread that was still running Java code, and the image aborted (Fatal Python error: Aborted). The thread handle now asks graal_get_current_thread first: a thread that is attached already gets a handle that is never detached.

Behaviour change

NativeDatabase.register_function had positional min_args and max_args; it has the signature of the JPype one now (keyword-only min_args, max_args, deterministic, replace), and unregister_function and on exist. tests/test_native_udf.py uses the keywords. test_registering_again_and_again_does_not_grow_the_python_heap only forces a JVM gc on the JVM backend.

Notes for the other native changes

What is not here

The other parity failures (positional counts, record id values, bulk insert, log directory, PQ settings, docs examples, XML limits) are other changes.

🤖 Generated with Claude Code

tae898 and others added 4 commits October 25, 2026 21:21
…, and the async writer crash

Python functions (register_function with replace, deterministic and inferred argument counts, unregister_function) and record
events (Database.on) run on the native backend through the existing Java-to-Python upcall. The engine side is a new entry
point, arcadedb_n_cb, whose checks, wording, veto and failure rules are those of the JPype bridge. A record argument or event
record reaches Python as a Document, Vertex or Edge wrapper. A KeyboardInterrupt in a callback, or one that arrives as the
callback is entered, aborts the statement and reaches the caller as a KeyboardInterrupt chained from the ArcadeDBError.

A Java thread that runs a Python callback (an async writer) is attached to the isolate already; the thread handle for it is no
longer an owned one that detaches the thread when the callback's Python thread state ends. That detach aborted the process in
tests/test_async_executor.py (a callback that calls the database from a writer thread).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A callback on a Java thread (an async writer) ends its Python thread state when it returns, and the thread-local buffer that held the reply went with it: the engine read freed memory ("bad JSON value at 0" for a Python function called from an async command). The reply is now allocated by the library (arcadedb_n_alloc), and Java copies the text and frees it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…loaded record reports only its identity

save() of a new record whose before_create callback vetoed it returns the unsaved record, as on the JPype backend, instead of raising "the statement returned no record". The after_delete event of a record that was not loaded (a delete by RID) found no record to read the properties from and failed the delete; it sends the identity only now (Java change, in the next native image).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…urn value

A Ctrl-C raised as Python enters the callback (before its try) left the C return value unset, and the engine took whatever the return register held: the pointer of an earlier reply that Java had freed already. Java then freed it again and the process aborted in free(). The callback is void now and writes its reply to a slot that Java zeroes before the call, so a callback that ends early leaves NULL; the failed statement becomes a KeyboardInterrupt. Python writes only library memory into the slot (the fallback to a Python-owned buffer is gone), and the unraisablehook is not used.

A test runs the SIGINT loop in a fresh process and fails on a nonzero exit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lls the default handler

NativeDatabase.on() took the @Property line of schema with it when register_function was replaced; db.schema was a bound method again. The fresh-process SIGINT test sets the default Ctrl-C handler in the child: a parent started as a background job ignores SIGINT, and Python then installs no handler.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 marked this pull request as ready for review October 25, 2026 12:28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tae898 added a commit that referenced this pull request Oct 25, 2026
…nsaction runs its block; the module docstring says what is true

VectorIndex.get_stats reads the schema:index:<bucket> row (the counters, not the row's own columns). get_metadata and get_quantization ask the engine through one new glue entry point (arcadedb_n_vindex, op metadata), so an index this session did not create reports the same keys as on the JVM build, defaults included. find_nearest_approximate runs the engine's PQ search through the same entry point (op approximate): no statement runs it. warm_up runs the first search that loads a persisted graph.

AsyncExecutor.transaction is a new op of arcadedb_n_async: the engine begins the transaction on a writer thread, Python runs the block in the upcall, and a Python exception is thrown on the Java side so the writer reports it. The block runs in the upcall of #67, and a Python exception comes back through its out slot as {"error": ...}, which the Java side throws so the writer rolls back and reports it.

Tests first, shared by both builds: tests/test_vector_index_introspection.py and tests/test_async_transaction.py (18 tests on the JVM build, 17 of 18 on native, see the PR). The two get_java_* calls stay JVM-only and are documented as such. The module docstring of _native/__init__.py listed far more as missing than is true.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tae898 added a commit that referenced this pull request Oct 25, 2026
…cutor has finished its work

AsyncExecutor.transaction registers up to three callbacks per call (block, ok, error). They are kept in the executor's _cb_ids (declared, never used so far) and forgotten when wait_completion has returned or the executor is closed, because a retry calls the block again. The other async operations still register callbacks that are never released; that is unchanged.

The reply of a failing block needs nothing new here: with #67 it is written into the out slot as library memory, which outlives the Python thread state of an async writer, so the Java side reads {"error": ...} and the writer rolls back and reaches the error callback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…name, parity setuptools) into native-callbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898
tae898 merged commit 48c03df into native-two-dist Oct 25, 2026
22 of 23 checks passed
@tae898
tae898 deleted the native-callbacks branch October 25, 2026 13:28
tae898 added a commit that referenced this pull request Oct 25, 2026
…) into native-ci-python-versions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tae898 added a commit that referenced this pull request Oct 25, 2026
…umnar-port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant