feat: make generation timeout configurable via GENERATION_TIMEOUT_MINUTES

The hardcoded 15-minute timeout was too short for some syntheses.
Now configurable via env var with a default of 30 minutes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 2 months ago
parent 1bac084d98
commit a9b60648ee

@ -45,3 +45,8 @@ EMAIL_FROM=AI Weekly Synth <noreply@synth.example.com>
# Sign up at https://dash.cloudflare.com/turnstile # Sign up at https://dash.cloudflare.com/turnstile
TURNSTILE_SECRET_KEY=0x4AAAAAAA_CHANGE_ME TURNSTILE_SECRET_KEY=0x4AAAAAAA_CHANGE_ME
TURNSTILE_SITE_KEY=0x4BBBBBB_CHANGE_ME TURNSTILE_SITE_KEY=0x4BBBBBB_CHANGE_ME
# --- Generation ---
# Maximum time (in minutes) allowed for a synthesis generation before timeout.
# Default: 30
# GENERATION_TIMEOUT_MINUTES=30

@ -27,6 +27,9 @@ pub struct AppConfig {
// Captcha (Cloudflare Turnstile) // Captcha (Cloudflare Turnstile)
pub turnstile_secret_key: String, pub turnstile_secret_key: String,
pub turnstile_site_key: String, pub turnstile_site_key: String,
// Generation
pub generation_timeout_secs: u64,
} }
impl AppConfig { impl AppConfig {
@ -51,6 +54,12 @@ impl AppConfig {
let static_dir = env::var("STATIC_DIR") let static_dir = env::var("STATIC_DIR")
.unwrap_or_else(|_| "../frontend/dist".to_string()); .unwrap_or_else(|_| "../frontend/dist".to_string());
let generation_timeout_secs = env::var("GENERATION_TIMEOUT_MINUTES")
.unwrap_or_else(|_| "30".to_string())
.parse::<u64>()
.map_err(|_| "GENERATION_TIMEOUT_MINUTES must be a valid positive integer".to_string())?
* 60;
Ok(Self { Ok(Self {
database_url, database_url,
master_encryption_key: Arc::new(master_encryption_key), master_encryption_key: Arc::new(master_encryption_key),
@ -61,6 +70,7 @@ impl AppConfig {
email_from, email_from,
turnstile_secret_key, turnstile_secret_key,
turnstile_site_key, turnstile_site_key,
generation_timeout_secs,
}) })
} }
@ -115,6 +125,7 @@ mod tests {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(), turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(), turnstile_site_key: "0x4BBB".into(),
generation_timeout_secs: 1800,
}; };
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
} }
@ -131,6 +142,7 @@ mod tests {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(), turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(), turnstile_site_key: "0x4BBB".into(),
generation_timeout_secs: 1800,
}; };
let err = config.validate().unwrap_err(); let err = config.validate().unwrap_err();
assert!(err.contains("MASTER_ENCRYPTION_KEY")); assert!(err.contains("MASTER_ENCRYPTION_KEY"));
@ -148,6 +160,7 @@ mod tests {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(), turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(), turnstile_site_key: "0x4BBB".into(),
generation_timeout_secs: 1800,
}; };
let err = config.validate().unwrap_err(); let err = config.validate().unwrap_err();
assert!(err.contains("MASTER_ENCRYPTION_KEY")); assert!(err.contains("MASTER_ENCRYPTION_KEY"));
@ -165,6 +178,7 @@ mod tests {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(), turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(), turnstile_site_key: "0x4BBB".into(),
generation_timeout_secs: 1800,
}; };
let err = config.validate().unwrap_err(); let err = config.validate().unwrap_err();
assert!(err.contains("APP_URL")); assert!(err.contains("APP_URL"));
@ -182,6 +196,7 @@ mod tests {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(), turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(), turnstile_site_key: "0x4BBB".into(),
generation_timeout_secs: 1800,
}; };
let err = config.validate().unwrap_err(); let err = config.validate().unwrap_err();
assert!(err.contains("trailing slash")); assert!(err.contains("trailing slash"));
@ -199,6 +214,7 @@ mod tests {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: "0x4AAA".into(), turnstile_secret_key: "0x4AAA".into(),
turnstile_site_key: "0x4BBB".into(), turnstile_site_key: "0x4BBB".into(),
generation_timeout_secs: 1800,
}; };
assert!(config.is_secure()); assert!(config.is_secure());

@ -86,13 +86,15 @@ pub async fn trigger_generate(
let state_for_panic = state.clone(); let state_for_panic = state.clone();
let join_handle = tokio::spawn(async move { let join_handle = tokio::spawn(async move {
let timeout_duration = std::time::Duration::from_secs(900); let timeout_secs = state_clone.config.generation_timeout_secs;
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
match tokio::time::timeout(timeout_duration, synthesis::run_generation(job_id, state_clone.clone(), user_id, theme_id, tx.clone(), None, cancelled)).await { match tokio::time::timeout(timeout_duration, synthesis::run_generation(job_id, state_clone.clone(), user_id, theme_id, tx.clone(), None, cancelled)).await {
Ok(()) => {} Ok(()) => {}
Err(_) => { Err(_) => {
tracing::error!(job_id = %job_id, user_id = %user_id, "Generation timed out after 15 minutes"); let timeout_mins = timeout_secs / 60;
tracing::error!(job_id = %job_id, user_id = %user_id, timeout_mins, "Generation timed out");
let _ = tx.send(ProgressEvent::Error { let _ = tx.send(ProgressEvent::Error {
message: "La generation a depasse le delai maximum de 15 minutes.".into(), message: format!("La generation a depasse le delai maximum de {} minutes.", timeout_mins),
}); });
} }
} }

@ -54,8 +54,9 @@ pub async fn run_scheduled_jobs(state: &AppState) {
let job_id = Uuid::new_v4(); let job_id = Uuid::new_v4();
let cancelled = AtomicBool::new(false); let cancelled = AtomicBool::new(false);
let timeout_secs = state.config.generation_timeout_secs;
let timeout_result = tokio::time::timeout( let timeout_result = tokio::time::timeout(
std::time::Duration::from_secs(900), std::time::Duration::from_secs(timeout_secs),
synthesis::run_generation_inner( synthesis::run_generation_inner(
job_id, state, schedule.user_id, schedule.theme_id, job_id, state, schedule.user_id, schedule.theme_id,
&tx, None, &cancelled, &tx, None, &cancelled,
@ -65,7 +66,7 @@ pub async fn run_scheduled_jobs(state: &AppState) {
let result = match timeout_result { let result = match timeout_result {
Ok(inner) => inner, Ok(inner) => inner,
Err(_) => { Err(_) => {
tracing::error!(schedule_id = %schedule.id, "Scheduled generation timed out after 15 minutes"); tracing::error!(schedule_id = %schedule.id, timeout_mins = timeout_secs / 60, "Scheduled generation timed out");
continue; continue;
} }
}; };

@ -115,6 +115,7 @@ impl TestApp {
email_from: "test@example.com".into(), email_from: "test@example.com".into(),
turnstile_secret_key: TURNSTILE_TEST_KEY.into(), turnstile_secret_key: TURNSTILE_TEST_KEY.into(),
turnstile_site_key: "test-site-key".into(), turnstile_site_key: "test-site-key".into(),
generation_timeout_secs: 1800,
}; };
let http_client = reqwest::Client::new(); let http_client = reqwest::Client::new();

Loading…
Cancel
Save