This is an automated email from the ASF dual-hosted git repository.

fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 9bf1f4b8 chore: fix tighten test cluster cleanup matching (#627)
9bf1f4b8 is described below

commit 9bf1f4b821fb1c463f2531568dc049bf29ac6f5a
Author: Junbo Wang <[email protected]>
AuthorDate: Thu Jun 18 23:23:28 2026 +0800

    chore: fix tighten test cluster cleanup matching (#627)
---
 crates/fluss-test-cluster/src/lib.rs | 99 ++++++++++++++++++++++++------------
 1 file changed, 67 insertions(+), 32 deletions(-)

diff --git a/crates/fluss-test-cluster/src/lib.rs 
b/crates/fluss-test-cluster/src/lib.rs
index a6d68638..144d725e 100644
--- a/crates/fluss-test-cluster/src/lib.rs
+++ b/crates/fluss-test-cluster/src/lib.rs
@@ -17,7 +17,7 @@
 
 use fluss::client::FlussConnection;
 use fluss::config::Config;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::future::Future;
 use std::mem::ManuallyDrop;
 use std::sync::Arc;
@@ -45,6 +45,28 @@ async fn docker_client() -> Option<Docker> {
     }
 }
 
+fn zookeeper_container_name(cluster_name: &str) -> String {
+    format!("zookeeper-{cluster_name}")
+}
+
+fn coordinator_server_container_name(cluster_name: &str) -> String {
+    format!("coordinator-server-{cluster_name}")
+}
+
+fn tablet_server_container_name(cluster_name: &str, server_id: u16) -> String {
+    format!("tablet-server-{cluster_name}-{server_id}")
+}
+
+fn is_cluster_container_name(container_name: &str, cluster_name: &str) -> bool 
{
+    let container_name = container_name.trim_start_matches('/');
+    let tablet_server_prefix = format!("tablet-server-{cluster_name}-");
+    container_name == zookeeper_container_name(cluster_name)
+        || container_name == coordinator_server_container_name(cluster_name)
+        || container_name
+            .strip_prefix(&tablet_server_prefix)
+            .is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(|c| 
c.is_ascii_digit()))
+}
+
 /// Force-removes a container by name. Best-effort: a 404 (already gone) is 
success;
 /// other errors warn rather than panic.
 async fn force_remove_container(docker: &Docker, name: &str) {
@@ -160,15 +182,15 @@ impl FlussTestingClusterBuilder {
     }
 
     fn tablet_server_container_name(&self, server_id: u16) -> String {
-        format!("tablet-server-{}-{}", self.testing_name, server_id)
+        tablet_server_container_name(&self.testing_name, server_id)
     }
 
     fn coordinator_server_container_name(&self) -> String {
-        format!("coordinator-server-{}", self.testing_name)
+        coordinator_server_container_name(&self.testing_name)
     }
 
     fn zookeeper_container_name(&self) -> String {
-        format!("zookeeper-{}", self.testing_name)
+        zookeeper_container_name(&self.testing_name)
     }
 
     fn container_names(&self) -> Vec<String> {
@@ -222,21 +244,30 @@ impl FlussTestingClusterBuilder {
         let Some(docker) = docker_client().await else {
             return false;
         };
-        for name in self.container_names() {
-            // Anchored exact-name match; `all(false)` = running only, so a 
stopped
-            // leftover counts as absent and gets recreated.
-            let mut filters = HashMap::new();
-            filters.insert("name".to_string(), vec![format!("^{name}$")]);
-            let options = ListContainersOptionsBuilder::default()
-                .all(false)
-                .filters(&filters)
-                .build();
-            match docker.list_containers(Some(options)).await {
-                Ok(list) if !list.is_empty() => continue,
-                _ => return false,
-            }
-        }
-        true
+        let expected: HashSet<_> = 
self.container_names().into_iter().collect();
+
+        // Multiple exact-name filters are OR'd by the daemon, so we can query 
once
+        // while still ignoring unrelated containers. `all(false)` = running 
only, so a
+        // stopped leftover counts as absent and gets recreated.
+        let filters = HashMap::from([(
+            "name".to_string(),
+            expected.iter().map(|name| format!("^{name}$")).collect(),
+        )]);
+        let options = ListContainersOptionsBuilder::default()
+            .all(false)
+            .filters(&filters)
+            .build();
+        let running = match docker.list_containers(Some(options)).await {
+            Ok(containers) => containers,
+            Err(_) => return false,
+        };
+        let running_names: HashSet<_> = running
+            .into_iter()
+            .flat_map(|container| container.names.unwrap_or_default())
+            .map(|name| name.trim_start_matches('/').to_string())
+            .collect();
+
+        expected.iter().all(|name| running_names.contains(name))
     }
 
     async fn start_all_containers(&mut self) -> 
Vec<ContainerAsync<GenericImage>> {
@@ -564,24 +595,24 @@ pub fn stop_cluster(name: &str) {
     run_blocking(async move { stop_cluster_async(&name).await });
 }
 
-/// Force-removes every container of cluster `name` (matched by name prefix) 
on the
-/// testcontainers daemon — the same daemon `build_detached` started them on.
+/// Force-removes every container of cluster `name` on the testcontainers 
daemon —
+/// the same daemon `build_detached` started them on.
 async fn stop_cluster_async(name: &str) {
     let Some(docker) = docker_client().await else {
         return;
     };
 
-    // Multiple values for the `name` filter are OR'd by the daemon; these 
prefixes
-    // cover zookeeper, coordinator, and any number of tablet servers.
-    let mut filters = HashMap::new();
-    filters.insert(
+    // Multiple values for the `name` filter are OR'd by the daemon, so we use
+    // cluster-specific prefixes to narrow the list, then exact local checks 
before
+    // removing to avoid touching similarly-prefixed clusters such as `test` 
and `test2`.
+    let filters = HashMap::from([(
         "name".to_string(),
         vec![
-            format!("zookeeper-{name}"),
-            format!("coordinator-server-{name}"),
+            zookeeper_container_name(name),
+            coordinator_server_container_name(name),
             format!("tablet-server-{name}-"),
         ],
-    );
+    )]);
     let options = ListContainersOptionsBuilder::default()
         .all(true)
         .filters(&filters)
@@ -596,11 +627,15 @@ async fn stop_cluster_async(name: &str) {
     };
 
     for container in containers {
-        // Prefer the container name (daemon prefixes it with '/'); fall back 
to id.
+        // The daemon prefixes names with '/'. These testcontainers-managed 
containers
+        // are created with a single explicit container name here, so checking 
the
+        // first returned name is sufficient. Only remove containers whose 
exact name
+        // belongs to this cluster; if names are missing, skip rather than 
guessing by id.
         if let Some(cname) = container.names.and_then(|n| 
n.into_iter().next()) {
-            force_remove_container(&docker, 
cname.trim_start_matches('/')).await;
-        } else if let Some(id) = container.id {
-            force_remove_container(&docker, &id).await;
+            let cname = cname.trim_start_matches('/');
+            if is_cluster_container_name(cname, name) {
+                force_remove_container(&docker, cname).await;
+            }
         }
     }
 }

Reply via email to