Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 129 additions & 4 deletions rust/lance/src/dataset/mem_wal/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,20 +536,39 @@ impl TaskExecutor {
Ok(())
}

/// Cancel and join every handler registered by [`Self::add_handler`].
///
/// Cancellation causes each handler's dispatcher to stop accepting messages and call
/// [`MessageHandler::cleanup`]. This method waits for every dispatcher to finish, even if
/// cleanup fails or a dispatcher panics, and then returns the first such failure. It returns
/// `Ok(())` only after every registered handler has been cleaned up successfully.
pub async fn shutdown_all(&self) -> Result<()> {
info!("Shutting down all tasks");
self.cancellation_token.cancel();

let tasks = std::mem::take(&mut *self.tasks.write().unwrap());
Comment on lines 547 to 549

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Prevent handlers from being registered after shutdown begins.

At Line 549, a concurrent add_handler can push a newly spawned task after shutdown_all takes tasks. That task is neither joined nor included in the returned error, despite already receiving the cancelled token. Serialize registration and shutdown with a lifecycle state: atomically mark the executor as shutting down while taking tasks, and reject later registrations with a descriptive error. Add a regression test for this interleaving.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/dataset/mem_wal/write.rs` around lines 547 - 549, Update the
lifecycle coordination between shutdown_all and add_handler so shutdown
atomically marks the executor as shutting down while taking the task list, and
add_handler rejects registrations after that state is set with a descriptive
error. Ensure rejected tasks are not spawned or lost, and add a regression test
covering registration racing with shutdown.

let mut first_error = None;
for (name, handle) in tasks {
match handle.await {
Ok(Ok(())) => debug!("Task '{}' completed successfully", name),
Ok(Err(e)) => warn!("Task '{}' completed with error: {}", name, e),
Err(e) => error!("Task '{}' panicked: {}", name, e),
Ok(Err(e)) => {
warn!("Task '{}' completed with error: {}", name, e);
if first_error.is_none() {
first_error = Some(e);
}
}
Err(e) => {
error!("Task '{}' panicked: {}", name, e);
if first_error.is_none() {
first_error = Some(Error::internal(format!(
"Task '{name}' panicked during shutdown: {e}"
)));
}
}
}
}

Ok(())
first_error.map_or(Ok(()), Err)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -4703,7 +4722,113 @@ mod tests {
call_count.load(Ordering::SeqCst)
);

executor.shutdown_all().await.ok();
executor
.shutdown_all()
.await
.expect("dispatcher should shut down successfully");
}

#[tokio::test]
async fn test_task_executor_shutdown_propagates_cleanup_error_and_joins_all_tasks() {
use std::sync::atomic::{AtomicUsize, Ordering};

struct CleanupHandler {
cleanup_count: Arc<AtomicUsize>,
error_message: Option<&'static str>,
}

#[async_trait]
impl MessageHandler<u32> for CleanupHandler {
async fn handle(&mut self, _message: u32) -> Result<()> {
Ok(())
}

async fn cleanup(&mut self, _shutdown_ok: bool) -> Result<()> {
self.cleanup_count.fetch_add(1, Ordering::SeqCst);
match self.error_message {
Some(message) => Err(Error::io(message)),
None => Ok(()),
}
}
}

let executor = TaskExecutor::new();
let cleanup_count = Arc::new(AtomicUsize::new(0));
let (_failing_tx, failing_rx) = mpsc::unbounded_channel::<u32>();
executor
.add_handler(
"failing-cleanup".to_string(),
Box::new(CleanupHandler {
cleanup_count: cleanup_count.clone(),
error_message: Some("intentional cleanup failure"),
}),
failing_rx,
)
.unwrap();
let (_successful_tx, successful_rx) = mpsc::unbounded_channel::<u32>();
executor
.add_handler(
"successful-cleanup".to_string(),
Box::new(CleanupHandler {
cleanup_count: cleanup_count.clone(),
error_message: None,
}),
successful_rx,
)
.unwrap();

let error = executor
.shutdown_all()
.await
.expect_err("shutdown must propagate the handler cleanup failure");
assert!(matches!(&error, Error::IO { .. }));
assert!(
error.to_string().contains("intentional cleanup failure"),
"unexpected error: {error}"
);
assert_eq!(
cleanup_count.load(Ordering::SeqCst),
2,
"shutdown must join and clean up every task after the first failure"
);
assert!(executor.tasks.read().unwrap().is_empty());
}

#[tokio::test]
async fn test_task_executor_shutdown_propagates_task_panic() {
struct PanickingCleanupHandler;

#[async_trait]
impl MessageHandler<u32> for PanickingCleanupHandler {
async fn handle(&mut self, _message: u32) -> Result<()> {
Ok(())
}

async fn cleanup(&mut self, _shutdown_ok: bool) -> Result<()> {
panic!("intentional cleanup panic");
}
}

let executor = TaskExecutor::new();
let (_tx, rx) = mpsc::unbounded_channel::<u32>();
executor
.add_handler(
"panicking-cleanup".to_string(),
Box::new(PanickingCleanupHandler),
rx,
)
.unwrap();

let error = executor
.shutdown_all()
.await
.expect_err("shutdown must propagate the task panic");
assert!(matches!(&error, Error::Internal { .. }));
assert!(
error.to_string().contains("panicking-cleanup")
&& error.to_string().contains("panicked during shutdown"),
"unexpected error: {error}"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Same as the local-fs test but against memory:// — closer to S3
Expand Down
Loading