Deadlock Prevention#
LanceDB (and the underlying Lance core library) has two distinct deadlock failure modes: a CPU blocking-pool deadlock during FTS index construction on resource-constrained hosts, and a fork/background-thread deadlock when PyTorch DataLoader workers are created with the fork start method. Both are silent hangs at 0% CPU with no error or timeout.
1. FTS Index Build β CPU Blocking-Pool Deadlock#
Root Cause#
lance_core::utils::tokio::spawn_cpu dispatches CPU-intensive work to a dedicated blocking thread pool. The pool is sized to get_num_compute_intensive_cpus(), which returns max(1, num_cpus β LANCE_IO_CORE_RESERVATION) . IO_CORE_RESERVATION defaults to 2, so any host with β€ 3 visible CPUs β 1-vCPU VMs, CPU-limited Kubernetes pods β has exactly one blocking worker .
The FTS posting-list pipeline in rust/lance-index/src/scalar/inverted/builder.rs uses a bounded channel (LANCE_FTS_WRITE_QUEUE_SIZE, default 1) to stream batches from a producer to a writer. When the pool has only one thread :
- Producer calls
send_blocking(batch)β channel is full β parks the sole blocking thread. - Writer receives the batch and calls
do_flushβspawn_cpuβ needs the same pool β no free worker. - Neither side can make progress. The process sits at 0% CPU indefinitely.
This surfaces with create_fts_index(..., base_tokenizer="ngram", prefix_only=False) once the input is large enough to produce more than one posting-list output batch .
Immediate Workaround#
Set LANCE_CPU_THREADS=2 (or higher) in the environment before running LanceDB. This forces the pool to have β₯ 2 workers and breaks the deadlock cycle .
LANCE_CPU_THREADS=2 python your_script.py
Proper Fix#
The spawn_cpu contract forbids any waiting inside the closure β no blocking channel sends, no I/O, no lock acquisition . The correct pattern is to keep the channel send in the async caller: build each batch with spawn_cpu, then dispatch it with tx.send(batch).await so the producer yields rather than parking a pool thread . The upstream issue also recommends ensuring get_num_compute_intensive_cpus() returns at least 2 and making CPU sizing cgroup-quota aware .
Relevant Environment Variables#
| Variable | Default | Effect |
|---|---|---|
LANCE_CPU_THREADS | num_cpus β 2 | Overrides the compute-pool size directly |
LANCE_IO_CORE_RESERVATION | 2 | CPUs reserved for I/O; compute pool = total β reservation |
LANCE_FTS_WRITE_QUEUE_SIZE | 1 | Bounded channel capacity between producer and writer |
2. PyTorch DataLoader β Fork/Background-Thread Deadlock#
Root Cause#
LanceDB drives async Rust work through a Tokio runtime in a background thread. On Linux, PyTorch DataLoader with num_workers > 0 uses fork by default to create worker processes. The background runtime thread does not survive fork(): the child process inherits the runtime's file descriptors and state, but the worker thread is gone, so any subsequent async call in the child blocks forever (see the PyO3 Async Runtime Bridge article for the full fork-safety story).
Test Guards and Subprocess Timeout Mitigation#
python/python/tests/test_torch.py contains two tests that exercise fork-based DataLoader workers:
test_permutation_dataloader_fork_workersβ native tabletest_remote_permutation_dataloader_fork_workersβ remote table over a mock HTTP server
Both are skipped on non-Linux platforms via :
@pytest.mark.skipif(
sys.platform != "linux",
reason=(
"fork() is unavailable on Windows and unsafe on macOS "
"(Apple frameworks/TLS are not fork-safe)"
),
)
To avoid a test suite hang if the deadlock regresses, both tests run the forking code inside a subprocess launched with mp.get_context("spawn") and joined with timeout=30 seconds. If the subprocess is still alive after the timeout, it is terminated and the test fails with an explicit message :
proc.join(timeout=30)
if proc.is_alive():
proc.terminate()
...
pytest.fail("Permutation hung when iterated in a fork-based DataLoader worker")
The subprocess itself uses multiprocessing_context="fork" for its DataLoader workers β this is what exercises the real deadlock path .
Using spawn Instead of fork#
The recommended fix is to always pass multiprocessing_context="spawn" to DataLoader when using LanceDB datasets. The spawn start method creates a fresh Python interpreter in each worker without inheriting the parent's Tokio runtime state, eliminating the deadlock. Multiprocessing tests that are not exercising the fork-safety path already use this .
Key Files & References#
| Resource | Description |
|---|---|
lance-core/src/utils/tokio.rs | spawn_cpu, get_num_compute_intensive_cpus, deadlock contract documented in spawn_cpu doc-comment |
lance-index/.../inverted/builder.rs | FTS posting-list pipeline: channel, LANCE_FTS_WRITE_QUEUE_SIZE, producer/writer interaction |
python/python/tests/test_torch.py | Fork-safe DataLoader tests with platform guards and subprocess timeouts |
| GitHub Issue #3568 | FTS deadlock bug report with reproduction script and root-cause analysis |
| PyO3 Async Runtime Bridge | Fork-safety mechanism (LanceRuntime, atfork_child) for the Python bindings |