diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 05f2ae3..2fba993 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -32,6 +32,8 @@ use stripstream_core::config::ApiConfig; use sqlx::postgres::PgPoolOptions; use tokio::sync::{Mutex, Semaphore}; use tracing::info; +use sqlx::{Pool, Postgres, Row}; +use serde_json::Value; #[derive(Clone)] struct AppState { @@ -66,6 +68,25 @@ impl Metrics { } } +async fn load_concurrent_renders(pool: &Pool) -> usize { + let default_concurrency = 8; + let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'limits'"#) + .fetch_optional(pool) + .await; + + match row { + Ok(Some(row)) => { + let value: Value = row.get("value"); + value + .get("concurrent_renders") + .and_then(|v: &Value| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(default_concurrency) + } + _ => default_concurrency, + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -80,13 +101,17 @@ async fn main() -> anyhow::Result<()> { .connect(&config.database_url) .await?; + // Load concurrent_renders from settings, default to 8 + let concurrent_renders = load_concurrent_renders(&pool).await; + info!("Using concurrent_renders limit: {}", concurrent_renders); + let state = AppState { pool, bootstrap_token: Arc::from(config.api_bootstrap_token), meili_url: Arc::from(config.meili_url), meili_master_key: Arc::from(config.meili_master_key), page_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(512).expect("non-zero")))), - page_render_limit: Arc::new(Semaphore::new(8)), + page_render_limit: Arc::new(Semaphore::new(concurrent_renders)), metrics: Arc::new(Metrics::new()), read_rate_limit: Arc::new(Mutex::new(ReadRateLimit { window_started_at: Instant::now(), diff --git a/apps/api/src/thumbnails.rs b/apps/api/src/thumbnails.rs index 8a8380e..983e451 100644 --- a/apps/api/src/thumbnails.rs +++ b/apps/api/src/thumbnails.rs @@ -1,4 +1,6 @@ use std::path::Path; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::Arc; use anyhow::Context; use axum::{ @@ -6,6 +8,7 @@ use axum::{ http::StatusCode, Json, }; +use futures::stream::{self, StreamExt}; use image::GenericImageView; use serde::Deserialize; use sqlx::Row; @@ -24,6 +27,25 @@ struct ThumbnailConfig { directory: String, } +async fn load_thumbnail_concurrency(pool: &sqlx::PgPool) -> usize { + let default_concurrency = 4; + let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'limits'"#) + .fetch_optional(pool) + .await; + + match row { + Ok(Some(row)) => { + let value: serde_json::Value = row.get("value"); + value + .get("concurrent_renders") + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(default_concurrency) + } + _ => default_concurrency, + } +} + async fn load_thumbnail_config(pool: &sqlx::PgPool) -> ThumbnailConfig { let fallback = ThumbnailConfig { enabled: true, @@ -156,38 +178,56 @@ async fn run_checkup(state: AppState, job_id: Uuid) { .execute(pool) .await; - for (i, &book_id) in book_ids.iter().enumerate() { - match pages::render_book_page_1(&state, book_id, config.width, config.quality).await { - Ok(page_bytes) => { - match generate_thumbnail(&page_bytes, &config) { - Ok(thumb_bytes) => { - if let Ok(path) = save_thumbnail(book_id, &thumb_bytes, &config) { - if sqlx::query("UPDATE books SET thumbnail_path = $1 WHERE id = $2") - .bind(&path) - .bind(book_id) - .execute(pool) - .await - .is_ok() - { - let processed = (i + 1) as i32; - let percent = ((i + 1) as f64 / total as f64 * 100.0) as i32; - let _ = sqlx::query( - "UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1", - ) - .bind(job_id) - .bind(processed) - .bind(percent) - .execute(pool) - .await; + let concurrency = load_thumbnail_concurrency(pool).await; + let processed_count = Arc::new(AtomicI32::new(0)); + let pool_clone = pool.clone(); + let job_id_clone = job_id; + let config_clone = config.clone(); + let state_clone = state.clone(); + + stream::iter(book_ids) + .for_each_concurrent(concurrency, |book_id| { + let processed_count = processed_count.clone(); + let pool = pool_clone.clone(); + let job_id = job_id_clone; + let config = config_clone.clone(); + let state = state_clone.clone(); + let total = total; + + async move { + match pages::render_book_page_1(&state, book_id, config.width, config.quality).await { + Ok(page_bytes) => { + match generate_thumbnail(&page_bytes, &config) { + Ok(thumb_bytes) => { + if let Ok(path) = save_thumbnail(book_id, &thumb_bytes, &config) { + if sqlx::query("UPDATE books SET thumbnail_path = $1 WHERE id = $2") + .bind(&path) + .bind(book_id) + .execute(&pool) + .await + .is_ok() + { + let processed = processed_count.fetch_add(1, Ordering::Relaxed) + 1; + let percent = (processed as f64 / total as f64 * 100.0) as i32; + let _ = sqlx::query( + "UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1", + ) + .bind(job_id) + .bind(processed) + .bind(percent) + .execute(&pool) + .await; + } + } } + Err(e) => warn!("thumbnail generate failed for book {}: {:?}", book_id, e), } } - Err(e) => warn!("thumbnail generate failed for book {}: {:?}", book_id, e), + Err(e) => warn!("render page 1 failed for book {}: {:?}", book_id, e), } } - Err(e) => warn!("render page 1 failed for book {}: {:?}", book_id, e), - } - } + }) + .await; let _ = sqlx::query( "UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1", diff --git a/apps/backoffice/app/settings/SettingsPage.tsx b/apps/backoffice/app/settings/SettingsPage.tsx index 212e37c..eb408cd 100644 --- a/apps/backoffice/app/settings/SettingsPage.tsx +++ b/apps/backoffice/app/settings/SettingsPage.tsx @@ -247,7 +247,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi Performance Limits - Configure API performance and rate limiting + Configure API performance, rate limiting, and thumbnail generation concurrency
@@ -266,6 +266,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi }} onBlur={() => handleUpdateSetting("limits", settings.limits)} /> +

+ Maximum number of page renders and thumbnail generations running in parallel +

@@ -299,7 +302,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi

- Note: Changes to limits require a server restart to take effect. + Note: Changes to limits require a server restart to take effect. The "Concurrent Renders" setting controls both page rendering and thumbnail generation parallelism.

@@ -424,7 +427,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi

- Note: Thumbnail settings are used during indexing. Existing thumbnails will not be regenerated automatically. + Note: Thumbnail settings are used during indexing. Existing thumbnails will not be regenerated automatically. The concurrency for thumbnail generation is controlled by the "Concurrent Renders" setting in Performance Limits above.