Repository navigation
feat: add config to disable or restrict native UDF library loading, and load libraries without a global lock #6775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
andygrove
wants to merge
2
commits into
apache:main
Choose a base branch
from
andygrove:andygrove/rust-udf-support-improvement
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,58 +25,112 @@ | |
|
|
||
| use std::collections::HashMap; | ||
| use std::path::{Path, PathBuf}; | ||
| use std::sync::{Arc, OnceLock, RwLock}; | ||
| use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock}; | ||
|
|
||
| use super::loader::{load, LoadedLibrary, LoaderError}; | ||
|
|
||
| static CACHE: OnceLock<RwLock<HashMap<PathBuf, Arc<LoadedLibrary>>>> = OnceLock::new(); | ||
| /// One library's slot in the cache. The mutex is held while the library loads, so concurrent | ||
| /// requests for the same library wait for the one load, and a request for any other library does | ||
| /// not wait at all. | ||
| type Slot<T> = Arc<Mutex<Option<Arc<T>>>>; | ||
|
|
||
| fn cache() -> &'static RwLock<HashMap<PathBuf, Arc<LoadedLibrary>>> { | ||
| CACHE.get_or_init(|| RwLock::new(HashMap::new())) | ||
| /// Loaded libraries keyed by path. Both the path as the caller wrote it and its canonical form map | ||
| /// to the same slot, so a symlink and its target share one library. | ||
| /// | ||
| /// The map lock is only ever held for a lookup or an insert, never across a load. Neither it nor a | ||
| /// slot is treated as lost when a thread panicked while holding it: a map operation cannot leave | ||
| /// the map half-updated, and a slot is only filled once its load has succeeded, so a poisoned slot | ||
| /// still reads as "not loaded" and the next request retries. A library whose load panics therefore | ||
| /// fails its own queries and nothing else. | ||
| struct Cache<T> { | ||
| slots: RwLock<HashMap<PathBuf, Slot<T>>>, | ||
| } | ||
|
|
||
| /// Get an already-loaded library, or load and cache it. | ||
| pub fn get_or_load(path: impl AsRef<Path>) -> Result<Arc<LoadedLibrary>, LoaderError> { | ||
| let raw = path.as_ref().to_path_buf(); | ||
| impl<T> Cache<T> { | ||
| fn new() -> Self { | ||
| Self { | ||
| slots: RwLock::new(HashMap::new()), | ||
| } | ||
| } | ||
|
|
||
| if let Some(lib) = cache().read().unwrap().get(&raw).cloned() { | ||
| return Ok(lib); | ||
| fn existing(&self, path: &Path) -> Option<Slot<T>> { | ||
| self.slots | ||
| .read() | ||
| .unwrap_or_else(PoisonError::into_inner) | ||
| .get(path) | ||
| .cloned() | ||
| } | ||
|
|
||
| let canonical = raw.canonicalize().unwrap_or_else(|_| raw.clone()); | ||
| if canonical != raw { | ||
| // A statement of its own, so the read guard is dropped before the write lock is taken. In | ||
| // an `if let` scrutinee it would live until the end of the block, and this thread would | ||
| // wait on its own read lock forever. | ||
| let hit = cache().read().unwrap().get(&canonical).cloned(); | ||
| if let Some(lib) = hit { | ||
| cache().write().unwrap().insert(raw, Arc::clone(&lib)); | ||
| return Ok(lib); | ||
| fn slot(&self, path: &Path) -> Slot<T> { | ||
| if let Some(slot) = self.existing(path) { | ||
| return slot; | ||
| } | ||
| let mut slots = self.slots.write().unwrap_or_else(PoisonError::into_inner); | ||
| Arc::clone(slots.entry(path.to_path_buf()).or_default()) | ||
| } | ||
|
|
||
| // The write lock is held across `load`, which runs the cdylib's static initializers, so a slow | ||
| // load of one library blocks lookups of every other path. See | ||
| // <https://github.com/apache/datafusion-comet/issues/5297>. | ||
| let mut w = cache().write().unwrap(); | ||
| if let Some(lib) = w.get(&canonical).cloned() { | ||
| if canonical != raw { | ||
| w.insert(raw, Arc::clone(&lib)); | ||
| } | ||
| return Ok(lib); | ||
| fn alias(&self, path: PathBuf, slot: &Slot<T>) { | ||
| self.slots | ||
| .write() | ||
| .unwrap_or_else(PoisonError::into_inner) | ||
| .entry(path) | ||
| .or_insert_with(|| Arc::clone(slot)); | ||
| } | ||
| let loaded = Arc::new(load(&canonical)?); | ||
| w.insert(canonical.clone(), Arc::clone(&loaded)); | ||
| if canonical != raw { | ||
| w.insert(raw, Arc::clone(&loaded)); | ||
|
|
||
| fn get_or_load_with<E>( | ||
| &self, | ||
| path: &Path, | ||
| load: impl FnOnce(&Path) -> Result<T, E>, | ||
| ) -> Result<Arc<T>, E> { | ||
| if let Some(slot) = self.existing(path) { | ||
| if let Some(lib) = lock(&slot).clone() { | ||
| return Ok(lib); | ||
| } | ||
| } | ||
|
|
||
| let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); | ||
| let slot = self.slot(&canonical); | ||
| let lib = { | ||
| let mut guard = lock(&slot); | ||
| match guard.as_ref() { | ||
| Some(lib) => Arc::clone(lib), | ||
| None => { | ||
| // An error leaves the slot empty, so a later request tries again. | ||
| let lib = Arc::new(load(&canonical)?); | ||
|
Comment on lines
+94
to
+99
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I trust the test here; however, this reads a whole lot like holding a lock while loading a dynamic library. It may be worth a comment in this section explaining why this is not the case. |
||
| *guard = Some(Arc::clone(&lib)); | ||
| lib | ||
| } | ||
| } | ||
| }; | ||
| if canonical != path { | ||
| self.alias(path.to_path_buf(), &slot); | ||
| } | ||
| Ok(lib) | ||
| } | ||
| Ok(loaded) | ||
| } | ||
|
|
||
| fn lock<T>(slot: &Slot<T>) -> MutexGuard<'_, Option<Arc<T>>> { | ||
| slot.lock().unwrap_or_else(PoisonError::into_inner) | ||
| } | ||
|
|
||
| static CACHE: OnceLock<Cache<LoadedLibrary>> = OnceLock::new(); | ||
|
|
||
| /// Get an already-loaded library, or load and cache it. | ||
| /// | ||
| /// Loading one library does not block lookups of any other; see | ||
| /// <https://github.com/apache/datafusion-comet/issues/5297>. | ||
| pub fn get_or_load(path: impl AsRef<Path>) -> Result<Arc<LoadedLibrary>, LoaderError> { | ||
| CACHE | ||
| .get_or_init(Cache::new) | ||
| .get_or_load_with(path.as_ref(), |p| load(p)) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::execution::c_udf::test_support::{test_udfs_path, BUILD_HINT}; | ||
| use std::sync::atomic::{AtomicUsize, Ordering}; | ||
| use std::time::Duration; | ||
|
|
||
| #[test] | ||
| fn same_path_returns_same_arc() { | ||
|
|
@@ -92,6 +146,108 @@ mod tests { | |
| assert!(matches!(err, LoaderError::Open { .. })); | ||
| } | ||
|
|
||
| /// While one library is loading, a request for another library is answered. With a single | ||
| /// lock held across the load, the second request would wait for the first to finish, and this | ||
| /// test would time out. | ||
| #[test] | ||
| fn a_slow_load_does_not_block_another_library() { | ||
| let cache = Arc::new(Cache::<&'static str>::new()); | ||
| let (started_tx, started_rx) = std::sync::mpsc::channel(); | ||
| let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); | ||
|
|
||
| let slow = { | ||
| let cache = Arc::clone(&cache); | ||
| std::thread::spawn(move || { | ||
| cache.get_or_load_with::<()>(Path::new("/no/such/slow.so"), |_| { | ||
| started_tx.send(()).unwrap(); | ||
| release_rx.recv().unwrap(); | ||
| Ok("slow") | ||
| }) | ||
| }) | ||
| }; | ||
| started_rx.recv_timeout(Duration::from_secs(30)).unwrap(); | ||
|
|
||
| let (tx, rx) = std::sync::mpsc::channel(); | ||
| { | ||
| let cache = Arc::clone(&cache); | ||
| std::thread::spawn(move || { | ||
| let _ = tx.send( | ||
| cache.get_or_load_with::<()>(Path::new("/no/such/fast.so"), |_| Ok("fast")), | ||
| ); | ||
| }); | ||
| } | ||
| let fast = rx | ||
| .recv_timeout(Duration::from_secs(30)) | ||
| .expect("loading a second library waited for the first") | ||
| .unwrap(); | ||
| assert_eq!(*fast, "fast"); | ||
|
|
||
| release_tx.send(()).unwrap(); | ||
| assert_eq!(*slow.join().unwrap().unwrap(), "slow"); | ||
| } | ||
|
|
||
| /// Requests for one library that arrive while it loads wait for that load and share its result. | ||
| #[test] | ||
| fn concurrent_requests_for_one_library_load_it_once() { | ||
| let cache = Arc::new(Cache::<usize>::new()); | ||
| let loads = Arc::new(AtomicUsize::new(0)); | ||
| let threads: Vec<_> = (0..8) | ||
| .map(|_| { | ||
| let cache = Arc::clone(&cache); | ||
| let loads = Arc::clone(&loads); | ||
| std::thread::spawn(move || { | ||
| cache | ||
| .get_or_load_with::<()>(Path::new("/no/such/one.so"), |_| { | ||
| std::thread::sleep(Duration::from_millis(50)); | ||
| Ok(loads.fetch_add(1, Ordering::SeqCst)) | ||
| }) | ||
| .unwrap() | ||
| }) | ||
| }) | ||
| .collect(); | ||
| let results: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect(); | ||
| assert_eq!(loads.load(Ordering::SeqCst), 1); | ||
| assert!(results.iter().all(|r| Arc::ptr_eq(r, &results[0]))); | ||
| } | ||
|
|
||
| /// A load that panics fails only that library: the cache keeps serving every other path, and a | ||
| /// later request for the same path retries instead of panicking on a poisoned lock. | ||
| #[test] | ||
| fn a_load_that_panics_does_not_disable_the_cache() { | ||
| let cache = Arc::new(Cache::<&'static str>::new()); | ||
| let bad = Path::new("/no/such/bad.so"); | ||
|
|
||
| let panicked = { | ||
| let cache = Arc::clone(&cache); | ||
| std::thread::spawn(move || { | ||
| let _ = cache.get_or_load_with::<()>(bad, |_| panic!("static initializer failed")); | ||
| }) | ||
| .join() | ||
| }; | ||
| assert!(panicked.is_err()); | ||
|
|
||
| let other = cache | ||
| .get_or_load_with::<()>(Path::new("/no/such/other.so"), |_| Ok("other")) | ||
| .unwrap(); | ||
| assert_eq!(*other, "other"); | ||
| let retried = cache | ||
| .get_or_load_with::<()>(bad, |_| Ok("recovered")) | ||
| .unwrap(); | ||
| assert_eq!(*retried, "recovered"); | ||
| } | ||
|
|
||
| /// A failed load is not remembered. | ||
| #[test] | ||
| fn a_failed_load_is_retried() { | ||
| let cache = Cache::<&'static str>::new(); | ||
| let path = Path::new("/no/such/flaky.so"); | ||
| assert!(cache.get_or_load_with(path, |_| Err("boom")).is_err()); | ||
| assert_eq!( | ||
| *cache.get_or_load_with::<()>(path, |_| Ok("ok")).unwrap(), | ||
| "ok" | ||
| ); | ||
| } | ||
|
|
||
| /// A path seen for the first time that resolves to an already-loaded library, here a symlink, | ||
| /// takes the branch that records the new path under the write lock. That branch used to take | ||
| /// the lock while still holding its own read guard, and hung. The lookup runs on a thread of | ||
|
|
||
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious: if this is true, is there a point to sandboxing at all? It may be worth documenting why one might set this feature...is it common to have users that can register comet native UDFs that can't set Spark configs?