Skip to content

fix(java): hold Dataset read lock when passing Dataset into native calls - #8575

Open
zhangyue19921010 wants to merge 2 commits into
lance-format:mainfrom
zhangyue19921010:fix/java-dataset-close-race
Open

fix(java): hold Dataset read lock when passing Dataset into native calls#8575
zhangyue19921010 wants to merge 2 commits into
lance-format:mainfrom
zhangyue19921010:fix/java-dataset-close-race

Conversation

@zhangyue19921010

Copy link
Copy Markdown
Contributor

Problem

Dataset guards its native handle with a ReentrantReadWriteLock: its own methods take the read lock, and close() takes the write lock. However, entry points outside the class pass the Dataset object into JNI without acquiring this lock:

  • Fragment: newScan / deleteRows / countRows / mergeColumns / updateColumns
  • LanceScanner.create / AsyncScanner.create
  • SqlQuery.intoBatchRecords
  • CommitBuilder.execute (dataset branch)
  • Compaction.planCompaction / commitCompaction, CompactionTask.execute
  • VectorTrainer.trainIvfCentroids / trainPqCodebook
  • DatasetDeltaBuilder.build
  • memwal: ShardWriter.create, LsmScanner.fromSnapshots, LsmPointLookupPlanner / LsmVectorSearchPlanner constructors

So Dataset.close() is not mutually exclusive with these calls. A concurrent close() hits a flaw in jni-rs take_rust_field: if another thread holds the handle mutex, try_lock fails and the boxed BlockingDataset is freed on the early-return path — a native use-after-free. Under contention (e.g. 16 threads Fragment.countRows() vs close()) this aborts the JVM (SIGABRT, malloc corruption); it also leaves both the Rust field and the Java handle in a corrupt state, crashing later unrelated calls. Any JVM sharing one Dataset across threads with a close/eviction path (e.g. Spark executors) is affected.

Fix

Follow the existing locking mechanism instead of introducing a new one:

  • Add Dataset.acquireReadLock(): acquires the shared read lock and rejects closed datasets, mirroring the readLock + handle != 0 check Dataset's own methods already use.
  • Wrap every external native call that borrows the Dataset in try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { ... }.

close() (write lock) now waits for in-flight native calls; callers racing with close() either complete safely or fail cleanly with IllegalArgumentException("Dataset is closed"). The read lock is reentrant, so nested paths like Dataset.newScanLanceScanner.create are safe.

Dataset guards its native handle with a ReentrantReadWriteLock: reads take
the read lock and close() takes the write lock. However, entry points that
pass the Dataset object into JNI from outside the class (Fragment scans and
row operations, LanceScanner/AsyncScanner creation, SqlQuery, CommitBuilder,
Compaction, CompactionTask, VectorTrainer, DatasetDeltaBuilder, and the
memwal ShardWriter/LsmScanner/planner constructors) invoked native methods
without acquiring that lock, so close() was not mutually exclusive with them.

A concurrent close() then hits the jni-rs take_rust_field flaw: when another
thread holds the handle mutex, take_rust_field frees the boxed
BlockingDataset on its try_lock early-return, causing a native use-after-free
(JVM SIGABRT under contention) and leaving both the Rust field and the Java
handle in a corrupt state that crashes later, unrelated calls.

Add Dataset.acquireReadLock(), which pins the native handle and rejects
closed datasets, and wrap every external native call that borrows the
Dataset in it. Concurrent callers now either complete before close() or
fail cleanly with "Dataset is closed".
@github-actions github-actions Bot added A-java Java bindings + JNI bug Something isn't working labels Aug 17, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 17, 2026
…ad lock

Compaction.nativeCommitCompaction was a public raw native method, so
callers could invoke it directly and bypass the read lock, leaving the
close/use race reachable. Preserve the public signature as a Java wrapper
that acquires the dataset read lock and delegates to a private renamed
native method (commitCompactionNative); commitCompaction now goes through
the wrapper instead of locking itself.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 17, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: approve.

The remaining public JNI bypass is fixed: commit compaction preserves its public signature behind the dataset read-lock boundary and keeps the raw native entry point private. The change now covers the reported close/use race at the Java/native boundary.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-java Java bindings + JNI bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant