feat(admin): runtime-editable crawler & analysis config in the dashboard
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.
- New `app_settings` table (migration 0026) holds one JSONB row per group;
env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
DB-overlay conversion and field-level validation; env-only/secret fields
(browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
env-managed view + prompt defaults; PUT validates (422 + per-field
details), persists + audits in one tx, then gracefully respawns the
affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
gate behind shared runtime cells so reloads take effect with no restart;
a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
(defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
reset-to-default, env-managed read-only panel, save-&-apply confirm, and
per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::RwLock as StdRwLock;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
@@ -17,7 +18,7 @@ use tower_http::trace::TraceLayer;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::auth::rate_limit::AuthRateLimiter;
|
||||
use crate::error::AppError;
|
||||
use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig};
|
||||
use crate::config::{AnalysisConfig, AuthConfig, Config, CrawlerConfig, UploadConfig};
|
||||
use crate::crawler::browser_manager::{self, BrowserManager};
|
||||
use crate::crawler::content::{self, SyncOutcome};
|
||||
use crate::crawler::daemon::{self, ChapterDispatcher, DaemonConfig, MetadataPass};
|
||||
@@ -40,33 +41,125 @@ pub struct AppState {
|
||||
/// One instance per AppState so tests stay isolated across the
|
||||
/// same process.
|
||||
pub auth_limiter: Arc<AuthRateLimiter>,
|
||||
/// Admin-triggered force resync. `None` when the crawler daemon
|
||||
/// is disabled (`CRAWLER_DAEMON=false`); admin handlers gate on
|
||||
/// `.is_some()` and return 503 otherwise. Set by [`build`] from the
|
||||
/// same wiring that builds the daemon's chapter dispatcher, so a
|
||||
/// force resync uses the daemon's BrowserManager + rate limiters.
|
||||
pub resync: Option<Arc<dyn ResyncService>>,
|
||||
/// Crawler observability + control handle (live status, coordinated
|
||||
/// browser restart, runtime session, manual run). `None` when the
|
||||
/// daemon is disabled; admin handlers gate on `.is_some()` → 503.
|
||||
pub crawler: Option<Arc<CrawlerControl>>,
|
||||
/// Runtime-swappable controls (crawler resync/control handles + the
|
||||
/// analysis enable gate). Shared with the [`Supervisors`] so a config
|
||||
/// reload that respawns a daemon updates what handlers see, live —
|
||||
/// without a restart. In tests this starts empty (no daemons).
|
||||
pub runtime: Arc<RuntimeControls>,
|
||||
/// Applies a persisted settings change to the running daemons
|
||||
/// (graceful stop + respawn). `Some` in production ([`build`]); `None`
|
||||
/// in the test harness, where settings still persist but no daemon is
|
||||
/// spawned. See [`DaemonReloader`].
|
||||
pub reloader: Option<Arc<dyn DaemonReloader>>,
|
||||
/// Env-derived crawler config — the **base** the stored settings DTO is
|
||||
/// overlaid onto (carries env-only fields: browser, proxy, TOR, session).
|
||||
pub crawler_base: CrawlerConfig,
|
||||
/// Env-derived analysis config base (carries the env-only vision API key).
|
||||
pub analysis_base: AnalysisConfig,
|
||||
/// Browser origins permitted to issue mutating requests to
|
||||
/// `/api/v1/admin/*`. See [`crate::config::Config::admin_allowed_origins`]
|
||||
/// for the policy. Cloned per-request into the CSRF middleware; the
|
||||
/// `Arc` keeps the clone cheap. Empty list → check is skipped
|
||||
/// (operator opt-out documented in `.env.example`).
|
||||
pub admin_allowed_origins: Arc<Vec<String>>,
|
||||
/// When `true`, page-create paths (upload + crawler) enqueue
|
||||
/// `analyze_page` jobs and the admin re-enqueue endpoints are active.
|
||||
/// Mirrors `config.analysis.enabled`; defaults to `false` so the
|
||||
/// feature is opt-in and the test harness inherits a no-op gate.
|
||||
pub analysis_enabled: bool,
|
||||
/// Live analysis-event broadcaster. Always present (the worker and the
|
||||
/// admin enqueue path publish here; the SSE endpoint subscribes). When
|
||||
/// the worker is disabled the channel simply stays quiet.
|
||||
pub analysis_events: Arc<crate::analysis::events::AnalysisEvents>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Current crawler control handle, or `None` when the daemon is stopped.
|
||||
pub fn crawler(&self) -> Option<Arc<CrawlerControl>> {
|
||||
self.runtime.crawler_control()
|
||||
}
|
||||
|
||||
/// Current force-resync service, or `None` when the daemon is stopped.
|
||||
pub fn resync(&self) -> Option<Arc<dyn ResyncService>> {
|
||||
self.runtime.resync()
|
||||
}
|
||||
|
||||
/// Whether AI page analysis is currently enabled (read live).
|
||||
pub fn analysis_enabled(&self) -> bool {
|
||||
self.runtime.analysis_enabled()
|
||||
}
|
||||
}
|
||||
|
||||
/// The crawler control handles, swapped atomically when the daemon is
|
||||
/// (re)spawned or stopped. Cloned cheaply on every read.
|
||||
#[derive(Clone, Default)]
|
||||
struct CrawlerControls {
|
||||
resync: Option<Arc<dyn ResyncService>>,
|
||||
control: Option<Arc<CrawlerControl>>,
|
||||
}
|
||||
|
||||
/// Shared, runtime-mutable surface read by handlers and mutated by the
|
||||
/// [`Supervisors`] on a config reload. The analysis gate is an `Arc<AtomicBool>`
|
||||
/// so the crawler's chapter dispatcher (which enqueues `analyze_page` jobs)
|
||||
/// sees toggles live without being respawned.
|
||||
pub struct RuntimeControls {
|
||||
crawler: StdRwLock<CrawlerControls>,
|
||||
analysis_enabled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl RuntimeControls {
|
||||
/// Empty controls (no crawler daemon) with the given initial analysis gate.
|
||||
pub fn new(analysis_enabled: bool) -> Self {
|
||||
Self {
|
||||
crawler: StdRwLock::new(CrawlerControls::default()),
|
||||
analysis_enabled: Arc::new(AtomicBool::new(analysis_enabled)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analysis_enabled(&self) -> bool {
|
||||
self.analysis_enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Flip the analysis gate. Used by the [`Supervisors`] on reload and by
|
||||
/// the test harness's stub reloader.
|
||||
pub fn set_analysis_enabled(&self, v: bool) {
|
||||
self.analysis_enabled.store(v, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The shared gate handle, passed to the crawler chapter dispatcher.
|
||||
fn analysis_gate(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.analysis_enabled)
|
||||
}
|
||||
|
||||
pub fn crawler_control(&self) -> Option<Arc<CrawlerControl>> {
|
||||
self.crawler.read().unwrap().control.clone()
|
||||
}
|
||||
|
||||
pub fn resync(&self) -> Option<Arc<dyn ResyncService>> {
|
||||
self.crawler.read().unwrap().resync.clone()
|
||||
}
|
||||
|
||||
/// Test helper: install a resync service without a running daemon.
|
||||
pub fn set_resync(&self, resync: Option<Arc<dyn ResyncService>>) {
|
||||
self.crawler.write().unwrap().resync = resync;
|
||||
}
|
||||
|
||||
fn set_crawler(
|
||||
&self,
|
||||
resync: Option<Arc<dyn ResyncService>>,
|
||||
control: Option<Arc<CrawlerControl>>,
|
||||
) {
|
||||
let mut g = self.crawler.write().unwrap();
|
||||
g.resync = resync;
|
||||
g.control = control;
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a validated settings change to the running daemons by gracefully
|
||||
/// stopping and respawning them with the new effective config (and updating
|
||||
/// the shared [`RuntimeControls`]). Implemented by [`Supervisors`] in
|
||||
/// production; the settings handlers call it after persisting.
|
||||
#[async_trait]
|
||||
pub trait DaemonReloader: Send + Sync {
|
||||
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()>;
|
||||
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
/// Shared handle the admin crawler endpoints use to observe and control
|
||||
/// the running daemon. Bundled so the handlers take one optional field on
|
||||
/// `AppState` rather than many.
|
||||
@@ -90,27 +183,96 @@ pub struct CrawlerControl {
|
||||
}
|
||||
|
||||
/// Bundle returned by [`build`]. The router is what `axum::serve` consumes;
|
||||
/// the daemon (when enabled) outlives the HTTP server and is awaited via
|
||||
/// [`AppHandle::shutdown`] after the listener has finished gracefully.
|
||||
/// the [`Supervisors`] own the background daemons (when enabled), outlive the
|
||||
/// HTTP server, and are awaited via [`AppHandle::shutdown`] after the listener
|
||||
/// has finished gracefully.
|
||||
pub struct AppHandle {
|
||||
pub router: Router,
|
||||
pub daemon: Option<daemon::DaemonHandle>,
|
||||
/// AI content-analysis worker daemon; `None` when `ANALYSIS_ENABLED`
|
||||
/// is off. Awaited alongside the crawler daemon on shutdown.
|
||||
pub analysis_daemon: Option<crate::analysis::daemon::AnalysisDaemonHandle>,
|
||||
pub supervisors: Arc<Supervisors>,
|
||||
}
|
||||
|
||||
impl AppHandle {
|
||||
pub async fn shutdown(self) {
|
||||
if let Some(d) = self.daemon {
|
||||
d.shutdown().await;
|
||||
self.supervisors.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the crawler + analysis daemon handles and respawns them on a config
|
||||
/// reload. Holds the build-time inputs (db, storage, env-base configs, the
|
||||
/// shared event bus and [`RuntimeControls`]) needed to spawn from scratch.
|
||||
pub struct Supervisors {
|
||||
db: PgPool,
|
||||
storage: Arc<dyn Storage>,
|
||||
runtime: Arc<RuntimeControls>,
|
||||
analysis_events: Arc<crate::analysis::events::AnalysisEvents>,
|
||||
/// Serializes reloads + owns the live crawler daemon handle.
|
||||
crawler_handle: tokio::sync::Mutex<Option<daemon::DaemonHandle>>,
|
||||
/// Serializes reloads + owns the live analysis daemon handle.
|
||||
analysis_handle: tokio::sync::Mutex<Option<crate::analysis::daemon::AnalysisDaemonHandle>>,
|
||||
}
|
||||
|
||||
impl Supervisors {
|
||||
pub async fn shutdown(&self) {
|
||||
if let Some(h) = self.crawler_handle.lock().await.take() {
|
||||
h.shutdown().await;
|
||||
}
|
||||
if let Some(a) = self.analysis_daemon {
|
||||
a.shutdown().await;
|
||||
if let Some(h) = self.analysis_handle.lock().await.take() {
|
||||
h.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DaemonReloader for Supervisors {
|
||||
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()> {
|
||||
// Serialize reloads; do the (possibly slow) graceful shutdown under
|
||||
// the handle lock but outside the read-path RwLock so status reads
|
||||
// never block on a draining browser.
|
||||
let mut guard = self.crawler_handle.lock().await;
|
||||
if let Some(h) = guard.take() {
|
||||
h.shutdown().await;
|
||||
}
|
||||
self.runtime.set_crawler(None, None);
|
||||
if cfg.daemon_enabled {
|
||||
let spawned = spawn_crawler_daemon(
|
||||
self.db.clone(),
|
||||
Arc::clone(&self.storage),
|
||||
&cfg,
|
||||
self.runtime.analysis_gate(),
|
||||
)
|
||||
.await?;
|
||||
self.runtime
|
||||
.set_crawler(Some(spawned.resync), Some(spawned.crawler));
|
||||
*guard = Some(spawned.handle);
|
||||
tracing::info!("crawler daemon (re)started from settings");
|
||||
} else {
|
||||
tracing::info!("crawler daemon stopped (disabled in settings)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()> {
|
||||
let mut guard = self.analysis_handle.lock().await;
|
||||
if let Some(h) = guard.take() {
|
||||
h.shutdown().await;
|
||||
}
|
||||
self.runtime.set_analysis_enabled(cfg.enabled);
|
||||
if cfg.enabled {
|
||||
let handle = spawn_analysis_daemon(
|
||||
self.db.clone(),
|
||||
Arc::clone(&self.storage),
|
||||
&cfg,
|
||||
Arc::clone(&self.analysis_events),
|
||||
)?;
|
||||
*guard = Some(handle);
|
||||
tracing::info!(model = %cfg.model, "analysis daemon (re)started from settings");
|
||||
} else {
|
||||
tracing::info!("analysis daemon stopped (disabled in settings)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
||||
let db = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
@@ -127,60 +289,48 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
||||
|
||||
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
|
||||
|
||||
let (daemon, resync, crawler) = if config.crawler.daemon_enabled {
|
||||
let spawned = spawn_crawler_daemon(
|
||||
db.clone(),
|
||||
Arc::clone(&storage),
|
||||
&config.crawler,
|
||||
config.analysis.enabled,
|
||||
)
|
||||
.await?;
|
||||
(Some(spawned.handle), Some(spawned.resync), Some(spawned.crawler))
|
||||
} else {
|
||||
tracing::info!("crawler daemon disabled (CRAWLER_DAEMON=false)");
|
||||
(None, None, None)
|
||||
};
|
||||
// Seed the settings rows from env on first boot, then load the effective
|
||||
// config (DB overlaid on the env base). The DB is the source of truth
|
||||
// after the first boot; env only fills a missing row.
|
||||
let crawler_cfg = load_effective_crawler(&db, &config.crawler).await?;
|
||||
let analysis_cfg = load_effective_analysis(&db, &config.analysis).await?;
|
||||
|
||||
// Live-event bus shared by the worker (progress) and the admin enqueue
|
||||
// path; the SSE endpoint subscribes. Created unconditionally so the
|
||||
// stream exists even with the worker disabled.
|
||||
let analysis_events = Arc::new(crate::analysis::events::AnalysisEvents::new());
|
||||
|
||||
// AI content-analysis worker. Independent of the crawler daemon (works
|
||||
// for uploads with the crawler off), gated on ANALYSIS_ENABLED. Uses a
|
||||
// plain reqwest client — no cookie jar / proxy, unlike the crawler's.
|
||||
let analysis_daemon = if config.analysis.enabled {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(config.analysis.request_timeout)
|
||||
.build()
|
||||
.context("build analysis http client")?;
|
||||
let vision = crate::analysis::vision::VisionClient::new(http, &config.analysis);
|
||||
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
|
||||
db: db.clone(),
|
||||
storage: Arc::clone(&storage),
|
||||
vision,
|
||||
model: config.analysis.model.clone(),
|
||||
max_image_bytes: config.analysis.max_image_bytes,
|
||||
});
|
||||
let handle = crate::analysis::daemon::spawn(
|
||||
db.clone(),
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
crate::analysis::daemon::AnalysisDaemonConfig {
|
||||
dispatcher,
|
||||
workers: config.analysis.workers,
|
||||
job_timeout: config.analysis.job_timeout,
|
||||
events: Arc::clone(&analysis_events),
|
||||
},
|
||||
);
|
||||
tracing::info!(
|
||||
workers = config.analysis.workers,
|
||||
model = %config.analysis.model,
|
||||
"analysis worker daemon started"
|
||||
);
|
||||
Some(handle)
|
||||
let runtime = Arc::new(RuntimeControls::new(analysis_cfg.enabled));
|
||||
let supervisors = Arc::new(Supervisors {
|
||||
db: db.clone(),
|
||||
storage: Arc::clone(&storage),
|
||||
runtime: Arc::clone(&runtime),
|
||||
analysis_events: Arc::clone(&analysis_events),
|
||||
crawler_handle: tokio::sync::Mutex::new(None),
|
||||
analysis_handle: tokio::sync::Mutex::new(None),
|
||||
});
|
||||
|
||||
// Initial spawn goes through the same reload path so there's one code
|
||||
// path for "bring the daemon up with this config". A spawn failure is
|
||||
// logged but does NOT abort startup: the persisted settings are the
|
||||
// source of truth and a bad value (e.g. a config that won't launch the
|
||||
// browser) must not wedge the whole server into a boot loop — the
|
||||
// server comes up with that daemon stopped so an admin can fix it via
|
||||
// the settings UI.
|
||||
if crawler_cfg.daemon_enabled {
|
||||
if let Err(e) = supervisors.reload_crawler(crawler_cfg).await {
|
||||
tracing::error!(?e, "crawler daemon failed to start; continuing with it stopped");
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing::info!("crawler daemon disabled");
|
||||
}
|
||||
if analysis_cfg.enabled {
|
||||
if let Err(e) = supervisors.reload_analysis(analysis_cfg).await {
|
||||
tracing::error!(?e, "analysis worker failed to start; continuing with it stopped");
|
||||
}
|
||||
} else {
|
||||
tracing::info!("analysis worker disabled");
|
||||
}
|
||||
|
||||
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
|
||||
let state = AppState {
|
||||
@@ -189,14 +339,99 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
|
||||
auth: config.auth.clone(),
|
||||
upload: config.upload.clone(),
|
||||
auth_limiter,
|
||||
resync,
|
||||
crawler,
|
||||
runtime,
|
||||
reloader: Some(Arc::clone(&supervisors) as Arc<dyn DaemonReloader>),
|
||||
crawler_base: config.crawler.clone(),
|
||||
analysis_base: config.analysis.clone(),
|
||||
admin_allowed_origins: Arc::new(config.admin_allowed_origins.clone()),
|
||||
analysis_enabled: config.analysis.enabled,
|
||||
analysis_events,
|
||||
};
|
||||
let router = router(state).layer(cors_layer(&config.cors_allowed_origins));
|
||||
Ok(AppHandle { router, daemon, analysis_daemon })
|
||||
Ok(AppHandle { router, supervisors })
|
||||
}
|
||||
|
||||
/// Seed the crawler settings row from env when absent, then return the
|
||||
/// effective [`CrawlerConfig`] (DB DTO overlaid on the env base). A stored
|
||||
/// DTO that fails to convert (corruption / drift) falls back to the env base
|
||||
/// rather than blocking startup.
|
||||
async fn load_effective_crawler(
|
||||
db: &PgPool,
|
||||
base: &CrawlerConfig,
|
||||
) -> anyhow::Result<CrawlerConfig> {
|
||||
use crate::settings::{CrawlerSettings, KEY_CRAWLER};
|
||||
let dto = match repo::app_settings::get(db, KEY_CRAWLER).await? {
|
||||
Some(v) => serde_json::from_value::<CrawlerSettings>(v)
|
||||
.unwrap_or_else(|_| CrawlerSettings::from_config(base)),
|
||||
None => {
|
||||
let dto = CrawlerSettings::from_config(base);
|
||||
repo::app_settings::seed_if_absent(db, KEY_CRAWLER, &serde_json::to_value(&dto)?)
|
||||
.await?;
|
||||
tracing::info!("seeded crawler settings from env");
|
||||
dto
|
||||
}
|
||||
};
|
||||
Ok(dto.to_config(base).unwrap_or_else(|e| {
|
||||
tracing::warn!(?e, "stored crawler settings invalid; using env base");
|
||||
base.clone()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Analysis counterpart of [`load_effective_crawler`].
|
||||
async fn load_effective_analysis(
|
||||
db: &PgPool,
|
||||
base: &AnalysisConfig,
|
||||
) -> anyhow::Result<AnalysisConfig> {
|
||||
use crate::settings::{AnalysisSettings, KEY_ANALYSIS};
|
||||
let dto = match repo::app_settings::get(db, KEY_ANALYSIS).await? {
|
||||
Some(v) => serde_json::from_value::<AnalysisSettings>(v)
|
||||
.unwrap_or_else(|_| AnalysisSettings::from_config(base)),
|
||||
None => {
|
||||
let dto = AnalysisSettings::from_config(base);
|
||||
repo::app_settings::seed_if_absent(db, KEY_ANALYSIS, &serde_json::to_value(&dto)?)
|
||||
.await?;
|
||||
tracing::info!("seeded analysis settings from env");
|
||||
dto
|
||||
}
|
||||
};
|
||||
Ok(dto.to_config(base).unwrap_or_else(|e| {
|
||||
tracing::warn!(?e, "stored analysis settings invalid; using env base");
|
||||
base.clone()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Spawn the AI content-analysis worker daemon with the given config. Returns
|
||||
/// its handle. Independent of the crawler daemon (works for uploads with the
|
||||
/// crawler off). Uses a plain reqwest client — no cookie jar / proxy.
|
||||
fn spawn_analysis_daemon(
|
||||
db: PgPool,
|
||||
storage: Arc<dyn Storage>,
|
||||
cfg: &AnalysisConfig,
|
||||
events: Arc<crate::analysis::events::AnalysisEvents>,
|
||||
) -> anyhow::Result<crate::analysis::daemon::AnalysisDaemonHandle> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(cfg.request_timeout)
|
||||
.build()
|
||||
.context("build analysis http client")?;
|
||||
let vision = crate::analysis::vision::VisionClient::new(http, cfg);
|
||||
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
|
||||
db: db.clone(),
|
||||
storage,
|
||||
vision,
|
||||
model: cfg.model.clone(),
|
||||
max_image_bytes: cfg.max_image_bytes,
|
||||
});
|
||||
let handle = crate::analysis::daemon::spawn(
|
||||
db,
|
||||
CancellationToken::new(),
|
||||
crate::analysis::daemon::AnalysisDaemonConfig {
|
||||
dispatcher,
|
||||
workers: cfg.workers,
|
||||
job_timeout: cfg.job_timeout,
|
||||
events,
|
||||
},
|
||||
);
|
||||
tracing::info!(workers = cfg.workers, model = %cfg.model, "analysis worker daemon started");
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Bundle returned by [`spawn_crawler_daemon`]. The handle owns the
|
||||
@@ -213,7 +448,7 @@ async fn spawn_crawler_daemon(
|
||||
db: PgPool,
|
||||
storage: Arc<dyn Storage>,
|
||||
cfg: &CrawlerConfig,
|
||||
analysis_enabled: bool,
|
||||
analysis_enabled: Arc<AtomicBool>,
|
||||
) -> anyhow::Result<SpawnedDaemon> {
|
||||
// Reqwest client with a shared cookie jar so CDN image fetches include
|
||||
// PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a
|
||||
@@ -512,9 +747,10 @@ struct RealChapterDispatcher {
|
||||
rate: Arc<HostRateLimiters>,
|
||||
download_allowlist: DownloadAllowlist,
|
||||
max_image_bytes: usize,
|
||||
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Mirrors
|
||||
/// `config.analysis.enabled`.
|
||||
analysis_enabled: bool,
|
||||
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Shared gate
|
||||
/// (read live) so toggling analysis at runtime takes effect without a
|
||||
/// crawler respawn. Mirrors the analysis enable setting.
|
||||
analysis_enabled: Arc<AtomicBool>,
|
||||
/// Consecutive transient chapter failures; resets on any success.
|
||||
/// Drives the automatic coordinated browser restart.
|
||||
transient_failures: Arc<std::sync::atomic::AtomicU32>,
|
||||
@@ -570,7 +806,7 @@ impl ChapterDispatcher for RealChapterDispatcher {
|
||||
self.max_image_bytes,
|
||||
self.tor.as_deref(),
|
||||
Some(&self.status),
|
||||
self.analysis_enabled,
|
||||
self.analysis_enabled.load(Ordering::Relaxed),
|
||||
)
|
||||
.await;
|
||||
drop(lease);
|
||||
|
||||
Reference in New Issue
Block a user