Repository navigation
Native: Python functions in SQL and openCypher, record events, Ctrl-C, and the async writer crash - #67
Merged
Conversation
…, 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>
tae898
force-pushed
the
native-callbacks
branch
from
October 25, 2026 12:22
552de83 to
fbd8664
Compare
…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
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
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>
This was referenced Oct 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 intests/test_async_executor.py. It covers 31 failures oftest_python_callbacks.py, 1 oftest_python_function_interrupt.pyand the crash, from the 68 failures of the native parity run (parity-native-two-dist). Nothing here raisesNotOnNativeYet: the features work on the nativearcadedb, with the behaviour ofarcadedb-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).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_schemaandtest_select_matrix: 303 passed, 26 skipped, 0 failed.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
CFUNCTYPEpointer witharcadedb_n_set_upcall(this existed for async callbacks). The image keeps it as aCFunctionPointerand calls it asfn(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.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=Trueis needed to replace, a revoked function says it was unregistered, a failing callback isPython function 'x' failed: ValueError: ...orPython 'before_create' callback failed: ..., abefore_*callback returningFalsevetoes (andsave()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 inarcadedb.callbacks, used by both backends.CFUNCTYPEcall (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.NativeCodec), results go back through the same encoder as parameters. A record argument or event record arrives as a dict with@rid,@typeand@catand is wrapped asDocument,VertexorEdge; theafter_deleteevent of a record that is not loaded carries its identity only. A result past a Java long is anOverflowError; numpy arrays,Decimal,datetime,listanddictwork as on JPype._dispatchcatchesBaseExceptionand replies{"error": "Type: text"}; the engine turns it into aCommandExecutionException(functions) orDatabaseOperationException(events), so the statement fails with the Python message and the surrounding transaction does not commit.outslot that Java zeroes before the call. The text is in memory fromarcadedb_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 itstry. With the slot, an early exit leaves NULL.KeyboardInterruptin a function or callback is remembered per thread; the place that turns the statement's failure into anArcadeDBErrorraises theKeyboardInterruptinstead, chained from that error, forquery,command, iteration andto_listalike. 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 aKeyboardInterrupttoo. The interrupt is cleared when a statement starts, so a stale one never turns a later error into aKeyboardInterrupt. This is the rule of the JPype backend (arcadedb.callbacks);sys.unraisablehookis 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_aftercallsdb.count_typefrom anon_okcallback, 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__calledgraal_detach_threadon a thread that was still running Java code, and the image aborted (Fatal Python error: Aborted). The thread handle now asksgraal_get_current_threadfirst: a thread that is attached already gets a handle that is never detached.Behaviour change
NativeDatabase.register_functionhad positionalmin_argsandmax_args; it has the signature of the JPype one now (keyword-onlymin_args,max_args,deterministic,replace), andunregister_functionandonexist.tests/test_native_udf.pyuses the keywords.test_registering_again_and_again_does_not_grow_the_python_heaponly forces a JVM gc on the JVM backend.Notes for the other native changes
native-not-yet-gaps) also changes the reply buffer inextras._dispatch(a dict keyed by thread id instead of a thread-local) and touchesNativeExtras.javaandlib.py. This PR removes the Python-owned reply buffer altogether (library memory, freed by Java), so the dict is not needed here and the two_dispatchhunks conflict: when both land, keep this PR's_dispatch. This PR does not depend on Native: get_stats, get_metadata, PQ search and AsyncExecutor.transaction work; the NotOnNativeYet docs say what is true #69.test_cypher.py::test_opencypher_ddl_auto_creates_vertex_and_edge_types('function' object has no attribute 'exists_type') was mine: replacingregister_functionindatabase.pydropped the@propertyline ofschema. It is restored (test_cypher.py: 30 passed, as on the base).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