Run C API work on the runtime's worker threads, not the caller's stack - #69
Run C API work on the runtime's worker threads, not the caller's stack#69aaltshuler wants to merge 1 commit into
Conversation
Every database-executing entry point used Runtime::block_on on the calling thread, so lance/datafusion planning ran on whatever stack the caller had. Hosts that call the C API from small fixed-size stacks (Ceph RGW's beast coroutines use 512 KiB) overflow; lancedb_run_on_stack() cannot reliably help there because stacker measures remaining stack against the OS thread's bounds, which do not describe a coroutine's separately mapped stack. Introduce runtime::run_blocking: spawn the future onto the runtime and park the caller on the JoinHandle (the pattern Lance's Python bindings use in BackgroundExecutor::spawn). Build the runtime explicitly with an 8 MiB worker stack (LANCEDB_C_WORKER_STACK_SIZE / LANCEDB_C_WORKER_THREADS override it). Panics inside the work, and the block_on misuse panic, are reported as LANCEDB_ERROR_RUNTIME instead of aborting the process. Convert all 37 call sites. Raw C pointers are read before the hop (table_create, merge_insert); the table-metadata entry points copy the entries out while holding the dataset read guard on the worker; futures without a Result output use run_blocking_infallible. Add tests/test_stack.cpp, which runs merge_insert, a nested SQL delete, vector search, scalar index creation and the metadata round trip from a 256 KiB pthread. Against the previous design the merge_insert, nested delete and scalar-index sections segfault (planning exceeds the caller's stack); with this change all five pass. Rust unit tests cover the helper itself. Document the threading model in lancedb.h; lancedb_run_on_stack() is kept for compatibility. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E63tgb7eXHwzmaQBSErFWu Signed-off-by: Andrew Altshuler <andrew@modernrelay.com>
082b4ff to
7fb3b1b
Compare
| add_test(NAME lancedb_vector_index_tests COMMAND ${TEST_ENV_PREFIX} $<TARGET_FILE:lancedb_vector_index_tests>) | ||
| # Run vector query tests WITHOUT valgrind (too slow under valgrind) | ||
| add_test(NAME lancedb_vector_query_tests COMMAND ${TEST_ENV_PREFIX} $<TARGET_FILE:lancedb_vector_query_tests>) | ||
| # Small-stack regression tests run on dedicated 256 KiB pthreads; not under valgrind |
| /// Upstream measured ~552 KB of stack for a merge_insert in | ||
| /// `examples/asio_coroutine.cpp`; tokio's default of 2 MiB leaves little margin | ||
| /// for debug builds, so we allocate 8 MiB. Stacks are mmap'd and committed | ||
| /// lazily, so the cost is virtual address space, not RSS. |
There was a problem hiding this comment.
can you measure how much more memory consumption this adds?
note that we may use high concurrency client count (e.g. 1K) - this is 8GB vs. 2GB.
| * lancedb_run_on_stack() below is no longer required for the above and is | ||
| * retained for compatibility with callers that already use it. | ||
| */ | ||
| /** |
There was a problem hiding this comment.
given the issues you mentione, it is probably better to remove the lancedb_run_on_stack() API.
this would be a breaking change, but probably ok at this early stage of the library
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core runtime/threading behavior across many FFI entry points and warrants careful human validation despite the added tests.
Pull request overview
This PR changes the C bindings’ execution model so synchronous C entry points no longer block_on futures on the caller’s stack, but instead spawn the work onto dedicated Tokio worker threads (with configurable worker count and stack size) and block the caller only on completion—preventing stack overflows in small-stack hosts (e.g., coroutines/fibers).
Changes:
- Introduces a dedicated Tokio runtime +
run_blocking/run_blocking_infalliblebridge that catches panics and converts them toError::Runtime. - Converts C API entry points from direct
runtime.block_on(...)torun_blocking(...), copying/owning raw-pointer inputs before hopping threads where needed. - Adds documentation of the threading model to the C header and adds a new small-stack regression test suite wired into CMake/CTest.
File summaries
| File | Description |
|---|---|
src/runtime.rs |
Adds explicit multi-thread Tokio runtime construction and the run_blocking bridge with panic-to-error handling plus unit tests. |
src/connection.rs |
Switches connection/session entry points to run_blocking and removes the old runtime singleton from this module. |
src/table.rs |
Converts table entry points to run_blocking, cloning/owning inputs to avoid crossing worker threads with raw pointers. |
src/index.rs |
Converts index-related entry points to run_blocking, owning config-derived values before thread hop. |
src/query.rs |
Converts query execution/result conversion to run_blocking. |
src/lib.rs |
Exposes the new internal runtime module. |
include/lancedb.h |
Documents the threading model, runtime-context restriction, and cross-thread Arrow release implications. |
tests/test_stack.cpp |
Adds regression tests that run operations on 256 KiB pthread stacks to validate no caller-stack polling. |
CMakeLists.txt |
Builds and registers the new lancedb_stack_tests executable in CTest. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| struct StackJob { | ||
| void (*fn)(StackJob&); | ||
| LanceDBTable* table; | ||
| LanceDBError result = LANCEDB_UNKNOWN; | ||
| char* error_message = nullptr; | ||
| uint64_t count = 0; | ||
| }; |
could you please add a test that covers that?
we should probably create a combined PR, that allows passing stack size, thread count (and maybe other parameters) from C into the runtime creation. using a env variable used when the process tarts gived very little flexibility. the thread count paramerter may be even more critical as we run reindexign and compaction in out background process, and we want to avoid it taking too much CPU. |
|
could you please prepare a ceph/s3vectors PR that point to this change as its lancedb-c submodule (temporarily)? |
Problem
Every database-executing entry point runs
runtime.block_on(async { … })on the calling thread, so lance/datafusion planning executes on whatever
stack the caller has. Hosts calling from small fixed-size stacks overflow —
Ceph RGW's beast coroutines use 512 KiB, and
examples/asio_coroutine.cppmeasures ~552 KB for a merge_insert.
lancedb_run_on_stack()(#48) cannotreliably protect such callers:
stackerdecides whether to grow fromremaining_stack(), computed against the OS thread's stack bounds(
pthread_getattr_np), which say nothing about a Boost coroutine'sseparately mapped stack. On one host we observed it grow (by luck of
mapping order); the same heuristic can decide "enough space" and let the
operation overflow. The unwrapped entry points have no protection at all.
Change
src/runtime.rs: build the runtime explicitly(
Builder::new_multi_thread,lancedb-workerthreads, 8 MiB workerstacks;
LANCEDB_C_WORKER_STACK_SIZE/LANCEDB_C_WORKER_THREADSoverride) and add
run_blocking: spawn the future onto the runtime andpark the caller on the
JoinHandle— the pattern Lance's Python bindingsuse (
BackgroundExecutor::spawn). Panics inside the work becomeError::Runtime(→LANCEDB_ERROR_RUNTIME) instead of unwinding acrossextern "C"; the "called from within a runtime" misuse panic is caughtand reported the same way.
block_oncall sites converted. Raw C pointers are read before thehop (
table_create,merge_insert); the table-metadata entry points copyentries out while holding the dataset read guard on the worker; futures
without a
Resultoutput userun_blocking_infallible.include/lancedb.h: "Threading model" section (workers, env knobs, theruntime-context restriction, and that Arrow objects may be released on
worker threads — producers must tolerate cross-thread release).
lancedb_run_on_stack()is retained for compatibility.Tests
tests/test_stack.cpp(lancedb_stack_tests): merge_insert, a 20-levelnested SQL delete, vector search, scalar index creation and the metadata
round trip, each on a pthread with a 256 KiB stack. Against the
previous design merge_insert, the nested delete and scalar index creation
segfault; with this change all five pass.
future returns
Error::Runtime; a 64 KiB-stack caller completes a deeprecursive future.
run_on_stacktest still passes).Notes for reviewers
workers for the top-level operation as well as for lance-io's spawned
reads. Implementations that block (Ceph's SAL-backed store does) should
use
block_in_place/spawn_blocking; the Ceph side does that in itsmatching change.
LanceDBRuntime):run_blockingcan resolve theruntime the same way.
lancedb-workerthreads in every case; the workload passed.🤖 Generated with Claude Code
https://claude.ai/code/session_01E63tgb7eXHwzmaQBSErFWu