feat: enhance concurrency settings for rendering and thumbnail generation

- Introduce dynamic loading of concurrent render limits from the database for both page rendering and thumbnail generation.
- Update API to utilize the loaded concurrency settings, defaulting to 8 for page renders and 4 for thumbnails.
- Modify front-end settings page to reflect changes in concurrency limits and provide user guidance on their impact.
- Ensure that changes to limits require a server restart to take effect, with clear messaging in the UI.
This commit is contained in:
2026-03-08 21:03:04 +01:00
parent e64848a216
commit b1844a4f01
3 changed files with 99 additions and 31 deletions

View File

@@ -32,6 +32,8 @@ use stripstream_core::config::ApiConfig;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use tokio::sync::{Mutex, Semaphore}; use tokio::sync::{Mutex, Semaphore};
use tracing::info; use tracing::info;
use sqlx::{Pool, Postgres, Row};
use serde_json::Value;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
@@ -66,6 +68,25 @@ impl Metrics {
} }
} }
async fn load_concurrent_renders(pool: &Pool<Postgres>) -> 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@@ -80,13 +101,17 @@ async fn main() -> anyhow::Result<()> {
.connect(&config.database_url) .connect(&config.database_url)
.await?; .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 { let state = AppState {
pool, pool,
bootstrap_token: Arc::from(config.api_bootstrap_token), bootstrap_token: Arc::from(config.api_bootstrap_token),
meili_url: Arc::from(config.meili_url), meili_url: Arc::from(config.meili_url),
meili_master_key: Arc::from(config.meili_master_key), meili_master_key: Arc::from(config.meili_master_key),
page_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(512).expect("non-zero")))), 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()), metrics: Arc::new(Metrics::new()),
read_rate_limit: Arc::new(Mutex::new(ReadRateLimit { read_rate_limit: Arc::new(Mutex::new(ReadRateLimit {
window_started_at: Instant::now(), window_started_at: Instant::now(),

View File

@@ -1,4 +1,6 @@
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
@@ -6,6 +8,7 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use futures::stream::{self, StreamExt};
use image::GenericImageView; use image::GenericImageView;
use serde::Deserialize; use serde::Deserialize;
use sqlx::Row; use sqlx::Row;
@@ -24,6 +27,25 @@ struct ThumbnailConfig {
directory: String, 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 { async fn load_thumbnail_config(pool: &sqlx::PgPool) -> ThumbnailConfig {
let fallback = ThumbnailConfig { let fallback = ThumbnailConfig {
enabled: true, enabled: true,
@@ -156,7 +178,23 @@ async fn run_checkup(state: AppState, job_id: Uuid) {
.execute(pool) .execute(pool)
.await; .await;
for (i, &book_id) in book_ids.iter().enumerate() { 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 { match pages::render_book_page_1(&state, book_id, config.width, config.quality).await {
Ok(page_bytes) => { Ok(page_bytes) => {
match generate_thumbnail(&page_bytes, &config) { match generate_thumbnail(&page_bytes, &config) {
@@ -165,19 +203,19 @@ async fn run_checkup(state: AppState, job_id: Uuid) {
if sqlx::query("UPDATE books SET thumbnail_path = $1 WHERE id = $2") if sqlx::query("UPDATE books SET thumbnail_path = $1 WHERE id = $2")
.bind(&path) .bind(&path)
.bind(book_id) .bind(book_id)
.execute(pool) .execute(&pool)
.await .await
.is_ok() .is_ok()
{ {
let processed = (i + 1) as i32; let processed = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
let percent = ((i + 1) as f64 / total as f64 * 100.0) as i32; let percent = (processed as f64 / total as f64 * 100.0) as i32;
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1", "UPDATE index_jobs SET processed_files = $2, progress_percent = $3 WHERE id = $1",
) )
.bind(job_id) .bind(job_id)
.bind(processed) .bind(processed)
.bind(percent) .bind(percent)
.execute(pool) .execute(&pool)
.await; .await;
} }
} }
@@ -188,6 +226,8 @@ async fn run_checkup(state: AppState, job_id: Uuid) {
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( let _ = sqlx::query(
"UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1", "UPDATE index_jobs SET status = 'success', finished_at = NOW(), progress_percent = 100, current_file = NULL WHERE id = $1",

View File

@@ -247,7 +247,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
<Icon name="performance" size="md" /> <Icon name="performance" size="md" />
Performance Limits Performance Limits
</CardTitle> </CardTitle>
<CardDescription>Configure API performance and rate limiting</CardDescription> <CardDescription>Configure API performance, rate limiting, and thumbnail generation concurrency</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
@@ -266,6 +266,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
}} }}
onBlur={() => handleUpdateSetting("limits", settings.limits)} onBlur={() => handleUpdateSetting("limits", settings.limits)}
/> />
<p className="text-xs text-muted-foreground mt-1">
Maximum number of page renders and thumbnail generations running in parallel
</p>
</FormField> </FormField>
<FormField className="flex-1"> <FormField className="flex-1">
<label className="text-sm font-medium text-muted-foreground mb-1 block">Timeout (seconds)</label> <label className="text-sm font-medium text-muted-foreground mb-1 block">Timeout (seconds)</label>
@@ -299,7 +302,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</FormField> </FormField>
</FormRow> </FormRow>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
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.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -424,7 +427,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
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.
</p> </p>
</div> </div>
</CardContent> </CardContent>