From 2d7396a86bd08b8c7f20537a5458339df3419233 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 14 Jul 2026 19:31:57 +0200 Subject: [PATCH] =?UTF-8?q?perf(agent):=20parall=C3=A9liser=20les=20sondes?= =?UTF-8?q?=20de=20DetectProfiles::execute=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les sondes de détection de profils étaient exécutées séquentiellement, portant le coût total au pire à N×800 ms (N × timeout par candidat). Chaque candidat est désormais sondé dans une tâche Tokio dédiée (tokio::spawn), les JoinHandle étant attendus dans l'ordre de création : le coût total tombe à ~1×timeout tout en préservant un ordre de sortie déterministe. Aucune dépendance ajoutée, aucun changement de contrat. Test : crates/application/tests/profile_usecases.rs (concurrence multi-thread + ordre déterministe). profile_usecases 20/20, suite application verte. Co-Authored-By: Claude Opus 4.8 --- crates/application/src/agent/usecases.rs | 13 +++- crates/application/tests/profile_usecases.rs | 80 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 403d1b0..f5ba5ac 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -74,9 +74,18 @@ impl DetectProfiles { input: DetectProfilesInput, ) -> Result { let mut results = Vec::with_capacity(input.candidates.len()); + let mut probes = Vec::with_capacity(input.candidates.len()); for profile in input.candidates { - let available = self.runtime.detect(&profile).await.unwrap_or(false); - results.push(ProfileAvailability { profile, available }); + let runtime = Arc::clone(&self.runtime); + probes.push(tokio::spawn(async move { + let available = runtime.detect(&profile).await.unwrap_or(false); + ProfileAvailability { profile, available } + })); + } + for probe in probes { + results.push(probe.await.map_err(|err| { + AppError::Process(format!("profile detection task failed: {err}")) + })?); } Ok(DetectProfilesOutput { results }) } diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 4c957dd..27e84be 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -7,7 +7,9 @@ //! command → result (including an error case to prove graceful degradation). use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use async_trait::async_trait; @@ -90,6 +92,13 @@ struct StubRuntime { struct YieldingRuntime; +struct SlowRuntime { + delay: Duration, + started: Arc, + active: Arc, + max_active: Arc, +} + #[async_trait] impl AgentRuntime for StubRuntime { async fn detect(&self, profile: &AgentProfile) -> Result { @@ -129,6 +138,28 @@ impl AgentRuntime for YieldingRuntime { } } +#[async_trait] +impl AgentRuntime for SlowRuntime { + async fn detect(&self, _profile: &AgentProfile) -> Result { + self.started.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.max_active.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(self.delay).await; + self.active.fetch_sub(1, Ordering::SeqCst); + Ok(false) + } + + fn prepare_invocation( + &self, + _profile: &AgentProfile, + _ctx: &PreparedContext, + _cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + unreachable!("not used in these tests") + } +} + struct SeqIds(Mutex>); impl SeqIds { @@ -217,6 +248,55 @@ async fn detect_execute_awaits_async_runtime_without_nested_runtime_panic() { assert!(out.results[0].available); } +#[tokio::test(flavor = "multi_thread")] +async fn detect_profiles_runs_candidate_probes_concurrently_and_keeps_order() { + let started = Arc::new(AtomicUsize::new(0)); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let delay = Duration::from_millis(100); + let runtime: Arc = Arc::new(SlowRuntime { + delay, + started: Arc::clone(&started), + active, + max_active: Arc::clone(&max_active), + }); + let candidates = vec![ + profile(1, "One", "one"), + profile(2, "Two", "two"), + profile(3, "Three", "three"), + profile(4, "Four", "four"), + profile(5, "Five", "five"), + ]; + let detect = DetectProfiles::new(runtime); + + let started_at = Instant::now(); + let out = detect + .execute(DetectProfilesInput { candidates }) + .await + .unwrap(); + let elapsed = started_at.elapsed(); + + assert_eq!(started.load(Ordering::SeqCst), 5, "all candidates probed"); + assert_eq!( + max_active.load(Ordering::SeqCst), + 5, + "all probes should overlap instead of running sequentially" + ); + assert!( + elapsed < Duration::from_millis(300), + "parallel probes should take roughly one probe duration, got {elapsed:?}" + ); + assert_eq!( + out.results + .iter() + .map(|entry| entry.profile.command.as_str()) + .collect::>(), + vec!["one", "two", "three", "four", "five"], + "result order stays aligned with candidate order" + ); + assert!(out.results.iter().all(|entry| !entry.available)); +} + // --------------------------------------------------------------------------- // ConfigureProfiles // ---------------------------------------------------------------------------