@ -8,6 +8,7 @@
//! consumed by SSE endpoints for real-time client updates.
//! consumed by SSE endpoints for real-time client updates.
use std ::collections ::HashMap ;
use std ::collections ::HashMap ;
use std ::sync ::atomic ::{ AtomicBool , Ordering } ;
use std ::sync ::Arc ;
use std ::sync ::Arc ;
use std ::time ::{ Duration , Instant } ;
use std ::time ::{ Duration , Instant } ;
@ -74,6 +75,8 @@ struct JobEntry {
user_id : Uuid ,
user_id : Uuid ,
/// When the job was created (for TTL cleanup).
/// When the job was created (for TTL cleanup).
created_at : Instant ,
created_at : Instant ,
/// Flag set to true when the user requests cancellation.
cancelled : Arc < AtomicBool > ,
}
}
/// In-memory store for active generation jobs.
/// In-memory store for active generation jobs.
@ -104,11 +107,11 @@ impl JobStore {
}
}
}
}
/// Create a new job for a user, returning the job ID and the watch Sender .
/// Create a new job for a user, returning the job ID , the watch Sender, and a cancellation flag .
///
///
/// Returns `None` if the user already has an active job.
/// Returns `None` if the user already has an active job.
/// Uses an atomic DashSet insert to prevent race conditions on double-click.
/// Uses an atomic DashSet insert to prevent race conditions on double-click.
pub fn create_job ( & self , user_id : Uuid ) -> Option < ( Uuid , Arc < watch ::Sender < ProgressEvent > > )> {
pub fn create_job ( & self , user_id : Uuid ) -> Option < ( Uuid , Arc < watch ::Sender < ProgressEvent > > , Arc < AtomicBool > )> {
if ! self . generating_users . insert ( user_id ) {
if ! self . generating_users . insert ( user_id ) {
return None ;
return None ;
}
}
@ -119,10 +122,12 @@ impl JobStore {
percent : 0 ,
percent : 0 ,
} ) ;
} ) ;
let tx = Arc ::new ( tx ) ;
let tx = Arc ::new ( tx ) ;
let cancelled = Arc ::new ( AtomicBool ::new ( false ) ) ;
self . inner . insert ( job_id , JobEntry {
self . inner . insert ( job_id , JobEntry {
tx : Arc ::clone ( & tx ) , _rx : rx , user_id , created_at : Instant ::now ( ) ,
tx : Arc ::clone ( & tx ) , _rx : rx , user_id , created_at : Instant ::now ( ) ,
cancelled : Arc ::clone ( & cancelled ) ,
} ) ;
} ) ;
Some ( ( job_id , tx ))
Some ( ( job_id , tx , cancelled ))
}
}
/// Get a watch receiver for a job, if it exists and belongs to the given user.
/// Get a watch receiver for a job, if it exists and belongs to the given user.
@ -145,6 +150,17 @@ impl JobStore {
None
None
}
}
/// Signal a job to stop. Returns true if the job was found and belongs to the user.
pub fn cancel_job ( & self , job_id : Uuid , user_id : Uuid ) -> bool {
if let Some ( entry ) = self . inner . get ( & job_id ) {
if entry . value ( ) . user_id = = user_id {
entry . value ( ) . cancelled . store ( true , Ordering ::Relaxed ) ;
return true ;
}
}
false
}
/// Release the generating lock for a user (called when job completes, errors, or times out).
/// Release the generating lock for a user (called when job completes, errors, or times out).
pub fn release_user ( & self , user_id : Uuid ) {
pub fn release_user ( & self , user_id : Uuid ) {
self . generating_users . remove ( & user_id ) ;
self . generating_users . remove ( & user_id ) ;
@ -196,8 +212,9 @@ pub async fn run_generation(
theme_id : Uuid ,
theme_id : Uuid ,
tx : Arc < watch ::Sender < ProgressEvent > > ,
tx : Arc < watch ::Sender < ProgressEvent > > ,
provider_override : Option < Arc < dyn crate ::services ::llm ::LlmProvider > > ,
provider_override : Option < Arc < dyn crate ::services ::llm ::LlmProvider > > ,
cancelled : Arc < AtomicBool > ,
) {
) {
let result = run_generation_inner ( job_id , & state , user_id , theme_id , & tx , provider_override ). await ;
let result = run_generation_inner ( job_id , & state , user_id , theme_id , & tx , provider_override , & cancelled ). await ;
match result {
match result {
Ok ( synthesis_id ) = > {
Ok ( synthesis_id ) = > {
@ -233,6 +250,7 @@ pub async fn run_generation_inner(
theme_id : Uuid ,
theme_id : Uuid ,
tx : & watch ::Sender < ProgressEvent > ,
tx : & watch ::Sender < ProgressEvent > ,
provider_override : Option < Arc < dyn crate ::services ::llm ::LlmProvider > > ,
provider_override : Option < Arc < dyn crate ::services ::llm ::LlmProvider > > ,
cancelled : & AtomicBool ,
) -> Result < Uuid , AppError > {
) -> Result < Uuid , AppError > {
// Batch buffer for article history traces (flushed at logical boundaries)
// Batch buffer for article history traces (flushed at logical boundaries)
let mut pending_traces : Vec < db ::article_history ::ArticleHistoryEntry > = Vec ::new ( ) ;
let mut pending_traces : Vec < db ::article_history ::ArticleHistoryEntry > = Vec ::new ( ) ;
@ -309,6 +327,13 @@ pub async fn run_generation_inner(
let total_waves = source_chunks . len ( ) ;
let total_waves = source_chunks . len ( ) ;
' wave_loop : for ( wave_idx , wave_sources ) in source_chunks . iter ( ) . enumerate ( ) {
' wave_loop : for ( wave_idx , wave_sources ) in source_chunks . iter ( ) . enumerate ( ) {
// Check cancellation before each wave
if cancelled . load ( Ordering ::Relaxed ) {
tracing ::info ! ( job_id = % job_id , "Generation cancelled by user (before wave)" ) ;
emit_progress ( tx , "saving" , "Generation arretee, sauvegarde..." , 90 ) ;
break 'wave_loop ;
}
let articles_so_far : usize = article_scraped . values ( ) . map ( | v | v . len ( ) ) . sum ( ) ;
let articles_so_far : usize = article_scraped . values ( ) . map ( | v | v . len ( ) ) . sum ( ) ;
let pct = 5 + ( ( articles_so_far as u32 * 60 ) / max_total . max ( 1 ) as u32 ) . min ( 60 ) ;
let pct = 5 + ( ( articles_so_far as u32 * 60 ) / max_total . max ( 1 ) as u32 ) . min ( 60 ) ;
emit_progress ( tx , "sources" ,
emit_progress ( tx , "sources" ,
@ -569,6 +594,13 @@ pub async fn run_generation_inner(
processed + = batch . len ( ) ;
processed + = batch . len ( ) ;
// Check cancellation after each batch
if cancelled . load ( Ordering ::Relaxed ) {
tracing ::info ! ( job_id = % job_id , "Generation cancelled by user (after batch)" ) ;
emit_progress ( tx , "saving" , "Generation arretee, sauvegarde..." , 90 ) ;
break ;
}
// Check if we've reached the maximum after this batch
// Check if we've reached the maximum after this batch
let total : usize = article_scraped . values ( ) . map ( | v | v . len ( ) ) . sum ( ) ;
let total : usize = article_scraped . values ( ) . map ( | v | v . len ( ) ) . sum ( ) ;
if total > = max_total {
if total > = max_total {
@ -593,13 +625,19 @@ pub async fn run_generation_inner(
}
}
// === PHASE 2: Web Search Fallback ===
// === PHASE 2: Web Search Fallback ===
// Skip Phase 2 if cancelled
let is_cancelled = cancelled . load ( Ordering ::Relaxed ) ;
if is_cancelled {
tracing ::info ! ( job_id = % job_id , "Skipping Phase 2 — generation cancelled by user" ) ;
}
let category_gaps : Vec < ( String , i32 ) > = user_categories . iter ( ) . filter_map ( | cat | {
let category_gaps : Vec < ( String , i32 ) > = user_categories . iter ( ) . filter_map ( | cat | {
let filled = filled_counts . get ( cat ) . copied ( ) . unwrap_or ( 0 ) ;
let filled = filled_counts . get ( cat ) . copied ( ) . unwrap_or ( 0 ) ;
let needed = ( theme . max_items_per_category as usize ) . saturating_sub ( filled ) ;
let needed = ( theme . max_items_per_category as usize ) . saturating_sub ( filled ) ;
if needed > 0 { Some ( ( cat . clone ( ) , needed as i32 ) ) } else { None }
if needed > 0 { Some ( ( cat . clone ( ) , needed as i32 ) ) } else { None }
} ) . collect ( ) ;
} ) . collect ( ) ;
if ! category_gaps . is_empty ( ) {
if ! category_gaps . is_empty ( ) & & ! is_cancelled {
if settings . use_brave_search {
if settings . use_brave_search {
// === BRAVE SEARCH PATH ===
// === BRAVE SEARCH PATH ===
emit_progress ( tx , "websearch" , "Recherche Brave Search..." , 70 ) ;
emit_progress ( tx , "websearch" , "Recherche Brave Search..." , 70 ) ;
@ -907,11 +945,21 @@ pub async fn run_generation_inner(
}
}
// === SAVE ===
// === SAVE ===
if article_scraped . values ( ) . all ( | items | items . is_empty ( ) ) {
let is_cancelled = cancelled . load ( Ordering ::Relaxed ) ;
let has_articles = article_scraped . values ( ) . any ( | items | ! items . is_empty ( ) ) ;
if ! has_articles {
if is_cancelled {
return Err ( AppError ::BadRequest ( "Generation arretee. Aucun article n'avait encore ete collecte." . into ( ) ) ) ;
}
return Err ( AppError ::BadRequest ( "Aucun article valide trouve. Verifiez vos sources et categories." . into ( ) ) ) ;
return Err ( AppError ::BadRequest ( "Aucun article valide trouve. Verifiez vos sources et categories." . into ( ) ) ) ;
}
}
emit_progress ( tx , "saving" , "Sauvegarde de la synthese..." , 90 ) ;
if is_cancelled {
emit_progress ( tx , "saving" , "Generation arretee, sauvegarde des articles collectes..." , 90 ) ;
} else {
emit_progress ( tx , "saving" , "Sauvegarde de la synthese..." , 90 ) ;
}
let mut final_sections : Vec < NewsSection > = Vec ::new ( ) ;
let mut final_sections : Vec < NewsSection > = Vec ::new ( ) ;
for ( i , cat_name ) in user_categories . iter ( ) . enumerate ( ) {
for ( i , cat_name ) in user_categories . iter ( ) . enumerate ( ) {
@ -1483,7 +1531,7 @@ mod tests {
let store = JobStore ::new ( ) ;
let store = JobStore ::new ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let ( job_id , tx ) = store . create_job ( user_id ) . unwrap ( ) ;
let ( job_id , tx , _cancelled ) = store . create_job ( user_id ) . unwrap ( ) ;
assert_eq! ( store . len ( ) , 1 ) ;
assert_eq! ( store . len ( ) , 1 ) ;
// Subscribe
// Subscribe
@ -1506,8 +1554,8 @@ mod tests {
let store = JobStore ::new ( ) ;
let store = JobStore ::new ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let result1 = store . create_job ( user_id ) ;
let _ result1 = store . create_job ( user_id ) ;
assert! ( result1. is_some ( ) ) ;
assert! ( _ result1. is_some ( ) ) ;
// Second job for same user should fail
// Second job for same user should fail
let result2 = store . create_job ( user_id ) ;
let result2 = store . create_job ( user_id ) ;
@ -1524,7 +1572,7 @@ mod tests {
let store = JobStore ::new ( ) ;
let store = JobStore ::new ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let ( _job_id , tx ) = store . create_job ( user_id ) . unwrap ( ) ;
let ( _job_id , tx , _cancelled ) = store . create_job ( user_id ) . unwrap ( ) ;
// Complete the job and release the user lock (as the pipeline does)
// Complete the job and release the user lock (as the pipeline does)
tx . send ( ProgressEvent ::Complete {
tx . send ( ProgressEvent ::Complete {
@ -1543,7 +1591,7 @@ mod tests {
let store = JobStore ::new ( ) ;
let store = JobStore ::new ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let ( _job_id , tx ) = store . create_job ( user_id ) . unwrap ( ) ;
let ( _job_id , tx , _cancelled ) = store . create_job ( user_id ) . unwrap ( ) ;
// Fail the job and release the user lock (as the pipeline does)
// Fail the job and release the user lock (as the pipeline does)
tx . send ( ProgressEvent ::Error {
tx . send ( ProgressEvent ::Error {
@ -1563,7 +1611,7 @@ mod tests {
let user_id = Uuid ::new_v4 ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
// Create a job and manually set its created_at to the past
// Create a job and manually set its created_at to the past
let ( _job_id , _tx ) = store . create_job ( user_id ) . unwrap ( ) ;
let ( _job_id , _tx , _cancelled ) = store . create_job ( user_id ) . unwrap ( ) ;
assert_eq! ( store . len ( ) , 1 ) ;
assert_eq! ( store . len ( ) , 1 ) ;
// Cleanup should not remove recent jobs
// Cleanup should not remove recent jobs
@ -1576,7 +1624,7 @@ mod tests {
let store = JobStore ::new ( ) ;
let store = JobStore ::new ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let user_id = Uuid ::new_v4 ( ) ;
let ( job_id , _tx ) = store . create_job ( user_id ) . unwrap ( ) ;
let ( job_id , _tx , _cancelled ) = store . create_job ( user_id ) . unwrap ( ) ;
assert_eq! ( store . len ( ) , 1 ) ;
assert_eq! ( store . len ( ) , 1 ) ;
store . remove ( & job_id ) ;
store . remove ( & job_id ) ;