Files
ImageApi/src/enhance/service.rs
T
Cameron CordesandClaude Opus 5.5 e2cf18678f feat(enhance): pick LoRAs from ComfyUI per job (#220)
Templates with `imageapi:lora-slot:<n>:high|low` nodes take LoRAs picked
per job from a catalog built live from ComfyUI.

- Catalog: ComfyUI's LoraLoaderModelOnly `lora_name` choices (cached
  60 s, re-read when a submit names an unknown id), paired by High/Low
  name tokens (StyleHigh/StyleLow, x-HIGH/x-LOW, x_high_noise/...). Files
  the workflow already loads outside the slots (lightx2v, switches) and
  `[template.catalog] exclude` substrings never show; ids derive from the
  names so they stay stable.
- POST /enhance `slots: [{lora, scale?}]` fills the slots in order: each
  pick sets the slot nodes' lora_name and scales their exported strength;
  unused slots run at 0, so the graph is never rewired. More picks than
  slots, duplicates or unknown ids are 400s.
- GET /enhance/templates gains `slots` and `catalog: {loras, strength,
  min, max}`; jobs store and return the picks (new enhance_jobs.lora_slots
  JSON column).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:28:23 -05:00

916 lines
32 KiB
Rust

//! Enhance orchestration (#192): submit, poll, approve, idle `/free`.
//!
//! The job row (`enhance_jobs`) is the only state that matters. ComfyUI is
//! allowed to be down (a normal state) and to forget everything when it
//! restarts (unapproved results are cleared), so every transition tolerates
//! both.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use log::{info, warn};
use serde::{Deserialize, Serialize};
use super::comfy::{ApproveError, ComfyClient, ComfyError, HistoryEntry, Staged, SubmitError};
use super::config::{EnhanceConfig, Loaded, PromptMode};
use super::lora_catalog::{self, CatalogEntry};
use super::path_map::PathMap;
use super::template::{SlotPick, Template, random_seed};
use crate::database::enhance_dao::{
EnhanceJobDao, EnhanceJobRow, JobUpdate, RecentPrompt, STATUS_APPROVED, STATUS_EXPIRED,
STATUS_FAILED, STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE,
};
/// Short timeout for the `available` probe, so the templates call stays snappy
/// when ComfyUI's host is off.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// How often the idle check looks at ComfyUI.
const IDLE_CHECK_EVERY: Duration = Duration::from_secs(30);
/// Finished jobs are kept this long.
const JOB_RETENTION_SECS: i64 = 14 * 24 * 3600;
/// Jobs shown per photo.
pub const JOBS_PER_PHOTO: i64 = 20;
/// The jobs manager lists finished-but-unhandled jobs (ready, failed,
/// expired) for this long; active ones always.
pub const OPEN_JOBS_WINDOW_SECS: i64 = 3 * 24 * 3600;
/// Most jobs the jobs manager lists.
pub const OPEN_JOBS_LIMIT: i64 = 100;
pub const MSG_UNAVAILABLE: &str = "ComfyUI isn't running";
pub const MSG_EXPIRED: &str = "This result expired (ComfyUI was restarted). Run it again.";
pub const MSG_DISABLED: &str = "Enhance isn't configured";
/// A library file a request names.
#[derive(Debug, Clone)]
pub struct SourceFile {
pub library_id: i32,
pub rel_path: String,
pub abs: PathBuf,
pub hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnhanceError {
/// No `url` configured.
Disabled,
/// ComfyUI isn't reachable.
Unavailable,
UnknownTemplate(String),
BadRequest(String),
/// ComfyUI refused the workflow.
Rejected(String),
/// The result is gone (ComfyUI restarted).
Expired,
NotFound,
/// Approved, but ImageApi can't place the file in a library.
Unplaceable(String),
Upstream(String),
Internal(String),
}
impl EnhanceError {
pub fn message(&self) -> String {
match self {
EnhanceError::Disabled => MSG_DISABLED.into(),
EnhanceError::Unavailable => MSG_UNAVAILABLE.into(),
EnhanceError::UnknownTemplate(t) => format!("unknown template {t:?}"),
EnhanceError::BadRequest(m)
| EnhanceError::Rejected(m)
| EnhanceError::Unplaceable(m)
| EnhanceError::Upstream(m)
| EnhanceError::Internal(m) => m.clone(),
EnhanceError::Expired => MSG_EXPIRED.into(),
EnhanceError::NotFound => "not found".into(),
}
}
}
fn comfy_err(e: ComfyError) -> EnhanceError {
match e {
ComfyError::Unreachable(_) => EnhanceError::Unavailable,
other => EnhanceError::Upstream(other.to_string()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedEntry {
pub n: usize,
pub comfy_path: String,
pub library_id: Option<i32>,
pub rel_path: Option<String>,
}
/// What the idle check decided.
#[derive(Debug, Default)]
pub struct IdleState {
initialised: bool,
marker: Option<i64>,
seen_at_ms: i64,
freed: bool,
}
impl IdleState {
/// One observation of a reachable ComfyUI. `marker` identifies the
/// newest history entry (its last event time): when it changes, or the
/// queue isn't empty, or one of our jobs is active, ComfyUI is in use.
/// Returns true once per quiet spell when it has been idle for
/// `idle_ms` and `/free` should be called.
///
/// The marker's *value* is ComfyUI's clock; only changes in it matter,
/// and "how long ago" is measured on ImageApi's clock, so clock skew
/// between the hosts can't cause an early unload. After ImageApi
/// (re)starts the first observation counts as activity.
pub fn observe(
&mut self,
now_ms: i64,
queue_empty: bool,
active_jobs: usize,
marker: Option<i64>,
idle_ms: i64,
) -> bool {
if !self.initialised || marker != self.marker || !queue_empty || active_jobs > 0 {
self.initialised = true;
self.marker = marker;
self.seen_at_ms = now_ms;
self.freed = false;
return false;
}
if !self.freed && now_ms - self.seen_at_ms >= idle_ms {
self.freed = true;
return true;
}
false
}
/// ComfyUI went away: the next observation starts a fresh baseline.
pub fn reset(&mut self) {
*self = IdleState::default();
}
/// `/free` failed; try again on the next check.
pub fn free_failed(&mut self) {
self.freed = false;
}
}
pub struct EnhanceService {
pub config: EnhanceConfig,
templates: Vec<Template>,
client: Option<ComfyClient>,
pub path_map: PathMap,
dao: Arc<Mutex<Box<dyn EnhanceJobDao>>>,
probe: Mutex<Option<(Instant, bool)>>,
unreachable_since: Mutex<HashMap<String, Instant>>,
idle: Mutex<IdleState>,
/// Serialises approvals (rare; keeps "approve twice" simple).
approve_lock: tokio::sync::Mutex<()>,
/// ComfyUI's LoRA files and when they were read (#220).
lora_names: Mutex<Option<(Instant, Vec<String>)>>,
}
/// How long ComfyUI's LoRA list is reused; a new download shows up after it.
const LORA_LIST_TTL: Duration = Duration::from_secs(60);
/// One `POST /enhance { slots }` entry: a catalog id and an optional scale.
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
pub struct SlotRequest {
pub lora: String,
#[serde(default)]
pub scale: Option<f64>,
}
/// A resolved pick, as stored on the job and returned in its view.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SlotChoice {
pub lora: String,
pub label: String,
pub scale: f64,
}
impl EnhanceService {
/// Load the templates named in `loaded`, logging (not failing on) bad
/// ones: one broken template shouldn't take the rest down.
pub fn from_loaded(
loaded: Loaded,
dao: Arc<Mutex<Box<dyn EnhanceJobDao>>>,
) -> anyhow::Result<Self> {
let mut templates = Vec::new();
for cfg in &loaded.config.templates {
match Template::load(cfg, &loaded.base_dir) {
Ok(t) => {
for w in &t.warnings {
warn!("enhance: template '{}': {w}", cfg.name);
}
info!(
"enhance: template '{}' loaded (prompt: {}{}, reference: {})",
cfg.name,
if t.accepts_prompt() { "yes" } else { "no" },
if t.accepts_prompt() {
match cfg.prompt_mode {
PromptMode::Append => " append",
PromptMode::Replace => " replace",
}
} else {
""
},
if t.accepts_reference() { "yes" } else { "no" },
);
templates.push(t);
}
Err(e) => warn!("enhance: template '{}' rejected: {e:#}", cfg.name),
}
}
match &loaded.path {
Some(p) if loaded.config.url.is_some() => info!(
"enhance: config {} ({} of {} templates usable)",
p.display(),
templates.len(),
loaded.config.templates.len()
),
Some(p) => info!("enhance: config {} has no url; Enhance is off", p.display()),
None => info!("enhance: no enhance.toml; Enhance is off"),
}
Self::new(loaded.config, templates, dao)
}
pub fn new(
config: EnhanceConfig,
templates: Vec<Template>,
dao: Arc<Mutex<Box<dyn EnhanceJobDao>>>,
) -> anyhow::Result<Self> {
let client = config
.url
.as_deref()
.map(|u| ComfyClient::new(u, Duration::from_secs(config.timeout_secs)));
let path_map = PathMap::new(&config.path_map)?;
Ok(EnhanceService {
config,
templates,
client,
path_map,
dao,
probe: Mutex::new(None),
unreachable_since: Mutex::new(HashMap::new()),
idle: Mutex::new(IdleState::default()),
approve_lock: tokio::sync::Mutex::new(()),
lora_names: Mutex::new(None),
})
}
/// ComfyUI's LoRA files, cached for [`LORA_LIST_TTL`]. `fresh` re-reads
/// them (a submit naming an id the cache doesn't know). An unreachable
/// ComfyUI keeps the last list.
async fn lora_names(&self, fresh: bool) -> Vec<String> {
let Some(client) = &self.client else {
return Vec::new();
};
if !fresh
&& let Some((at, names)) = &*self.lora_names.lock().unwrap()
&& at.elapsed() < LORA_LIST_TTL
{
return names.clone();
}
match client.lora_names().await {
Ok(names) => {
*self.lora_names.lock().unwrap() = Some((Instant::now(), names.clone()));
names
}
Err(e) => {
warn!("enhance: couldn't list ComfyUI's LoRAs: {e}");
self.lora_names
.lock()
.unwrap()
.as_ref()
.map(|(_, n)| n.clone())
.unwrap_or_default()
}
}
}
/// The LoRAs a template's slots can take; empty without slots.
pub async fn catalog(&self, template: &Template) -> Vec<CatalogEntry> {
if template.slot_count() == 0 {
return Vec::new();
}
let names = self.lora_names(false).await;
lora_catalog::build(
&names,
template.fixed_loras(),
&template.cfg.catalog.exclude,
)
}
/// Turn `slots` into picks for the template's slots, checking the ids
/// against the catalog (re-read once if one is unknown).
async fn resolve_slots(
&self,
template: &Template,
slots: &[SlotRequest],
) -> Result<(Vec<SlotPick>, Vec<SlotChoice>), EnhanceError> {
if slots.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
if slots.len() > template.slot_count() {
return Err(EnhanceError::BadRequest(if template.slot_count() == 0 {
"this template doesn't take picked LoRAs".to_string()
} else {
format!(
"this template takes at most {} picked LoRAs",
template.slot_count()
)
}));
}
let mut seen = std::collections::HashSet::new();
if let Some(dup) = slots.iter().find(|s| !seen.insert(s.lora.as_str())) {
return Err(EnhanceError::BadRequest(format!(
"LoRA {:?} is picked twice",
dup.lora
)));
}
let mut catalog = self.catalog(template).await;
if slots
.iter()
.any(|s| !catalog.iter().any(|e| e.id == s.lora))
{
let names = self.lora_names(true).await;
catalog = lora_catalog::build(
&names,
template.fixed_loras(),
&template.cfg.catalog.exclude,
);
}
let mut picks = Vec::new();
let mut choices = Vec::new();
for s in slots {
let Some(entry) = catalog.iter().find(|e| e.id == s.lora) else {
return Err(EnhanceError::BadRequest(format!(
"no LoRA {:?} (it may have been removed from ComfyUI)",
s.lora
)));
};
let scale = template.cfg.catalog.resolve(s.scale);
picks.push(SlotPick {
high: entry.high.clone(),
low: entry.low.clone(),
scale,
});
choices.push(SlotChoice {
lora: entry.id.clone(),
label: entry.label.clone(),
scale,
});
}
Ok((picks, choices))
}
pub fn is_configured(&self) -> bool {
self.client.is_some()
}
pub fn templates(&self) -> &[Template] {
&self.templates
}
pub fn template(&self, name: &str) -> Option<&Template> {
self.templates.iter().find(|t| t.cfg.name == name)
}
pub fn default_template(&self) -> Option<&str> {
self.config
.default_template
.as_deref()
.and_then(|w| self.template(w))
.or_else(|| self.templates.first())
.map(|t| t.cfg.name.as_str())
}
/// Cached `GET /system_stats` probe.
pub async fn available(&self) -> bool {
let Some(client) = &self.client else {
return false;
};
let ttl = Duration::from_secs(self.config.probe_ttl_secs);
if let Some((at, up)) = *self.probe.lock().unwrap()
&& at.elapsed() < ttl
{
return up;
}
let up = client.probe(PROBE_TIMEOUT).await;
*self.probe.lock().unwrap() = Some((Instant::now(), up));
up
}
/// Fresh probe (ignores and refreshes the cache): a submit shouldn't be
/// refused because ComfyUI came up since the last probe.
async fn probe_now(&self) -> bool {
let Some(client) = &self.client else {
return false;
};
let up = client.probe(PROBE_TIMEOUT).await;
*self.probe.lock().unwrap() = Some((Instant::now(), up));
up
}
// ---- jobs -----------------------------------------------------------
pub async fn submit(
&self,
photo: &SourceFile,
template_name: &str,
prompt: Option<&str>,
reference: Option<&SourceFile>,
loras: &std::collections::BTreeMap<String, super::config::LoraRequest>,
slots: &[SlotRequest],
) -> Result<EnhanceJobRow, EnhanceError> {
let Some(client) = &self.client else {
return Err(EnhanceError::Disabled);
};
let template = self
.template(template_name)
.ok_or_else(|| EnhanceError::UnknownTemplate(template_name.to_string()))?;
let unmapped = |p: &Path| {
EnhanceError::Unplaceable(format!(
"{} isn't under any path_map entry, so ComfyUI can't open it",
p.display()
))
};
let source = self
.path_map
.to_comfy(&photo.abs)
.ok_or_else(|| unmapped(&photo.abs))?;
let reference_path = match reference {
Some(r) => Some(
self.path_map
.to_comfy(&r.abs)
.ok_or_else(|| unmapped(&r.abs))?,
),
None => None,
};
let (picks, choices) = self.resolve_slots(template, slots).await?;
let filled = template
.fill(
&source,
prompt,
reference_path.as_deref(),
loras,
&picks,
&mut random_seed,
)
.map_err(|e| EnhanceError::BadRequest(e.0))?;
if !self.probe_now().await {
return Err(EnhanceError::Unavailable);
}
let prompt_id = match client.submit(&filled.prompt).await {
Ok(id) => id,
Err(SubmitError::Comfy(e)) => return Err(comfy_err(e)),
Err(SubmitError::Rejected {
message,
node_errors,
}) => {
// A reference-less run of a template that wants one fails
// validation on the dropped node's consumers.
return Err(if filled.dropped_reference && node_errors {
EnhanceError::Rejected(format!(
"this template needs a reference photo ({message})"
))
} else {
EnhanceError::Rejected(message)
});
}
};
let now = chrono::Utc::now().timestamp();
let user_prompt = prompt.map(str::trim).filter(|p| !p.is_empty());
let row = EnhanceJobRow {
id: uuid::Uuid::new_v4().to_string(),
original_hash: photo.hash.clone(),
library_id: photo.library_id,
rel_path: photo.rel_path.clone(),
template: template.cfg.name.clone(),
prompt: user_prompt.map(str::to_string),
reference_hash: reference.map(|r| r.hash.clone()),
reference_path: reference.map(|r| r.rel_path.clone()),
comfy_prompt_id: prompt_id,
status: STATUS_QUEUED.to_string(),
error: None,
queue_position: None,
pending: "[]".to_string(),
saved: "[]".to_string(),
discarded: 0,
created_at: now,
updated_at: now,
run_ms: None,
loras: (!filled.loras.is_empty())
.then(|| serde_json::to_string(&filled.loras).unwrap_or_default()),
lora_slots: (!choices.is_empty())
.then(|| serde_json::to_string(&choices).unwrap_or_default()),
};
self.dao
.lock()
.unwrap()
.insert(&row)
.map_err(|e| EnhanceError::Internal(format!("saving the job failed: {e}")))?;
info!(
"enhance: job {} submitted ({} for {}, prompt {})",
row.id, row.template, row.rel_path, row.comfy_prompt_id
);
Ok(row)
}
pub fn get_job(&self, id: &str) -> Result<EnhanceJobRow, EnhanceError> {
self.dao
.lock()
.unwrap()
.get(id)
.map_err(|e| EnhanceError::Internal(e.to_string()))?
.ok_or(EnhanceError::NotFound)
}
pub fn jobs_for_hash(&self, hash: &str) -> Result<Vec<EnhanceJobRow>, EnhanceError> {
self.dao
.lock()
.unwrap()
.list_for_hash(hash, JOBS_PER_PHOTO)
.map_err(|e| EnhanceError::Internal(e.to_string()))
}
/// Every photo's open jobs for the jobs manager, newest first. DB only:
/// the poller keeps the rows current, so this never calls ComfyUI.
pub fn open_jobs(&self) -> Result<Vec<EnhanceJobRow>, EnhanceError> {
let since = chrono::Utc::now().timestamp() - OPEN_JOBS_WINDOW_SECS;
self.dao
.lock()
.unwrap()
.list_open(since, OPEN_JOBS_LIMIT)
.map_err(|e| EnhanceError::Internal(e.to_string()))
}
/// Distinct recent prompts; DB only, so it works with ComfyUI down.
pub fn recent_prompts(
&self,
templates: Option<&[String]>,
limit: i64,
) -> Result<Vec<RecentPrompt>, EnhanceError> {
self.dao
.lock()
.unwrap()
.recent_prompts(templates, limit)
.map_err(|e| EnhanceError::Internal(e.to_string()))
}
/// Hide a job from the per-photo list. Nothing is deleted on ComfyUI:
/// staged results vanish on its restart anyway.
pub fn discard(&self, id: &str) -> Result<EnhanceJobRow, EnhanceError> {
self.get_job(id)?;
self.dao
.lock()
.unwrap()
.discard(id)
.map_err(|e| EnhanceError::Internal(e.to_string()))?;
self.get_job(id)
}
fn update_job(&self, id: &str, u: &JobUpdate) {
if let Err(e) = self.dao.lock().unwrap().update(id, u) {
warn!("enhance: updating job {id} failed: {e}");
}
}
/// The staged result `n` of a job, for the preview relay.
pub fn pending_of(job: &EnhanceJobRow) -> Vec<Staged> {
serde_json::from_str(&job.pending).unwrap_or_default()
}
pub fn saved_of(job: &EnhanceJobRow) -> Vec<SavedEntry> {
serde_json::from_str(&job.saved).unwrap_or_default()
}
/// Relay the staged PNG `n` of a job. A 404 from ComfyUI means it was
/// restarted: the job becomes `expired`.
pub async fn preview(
&self,
job: &EnhanceJobRow,
n: usize,
) -> Result<(String, bytes::Bytes), EnhanceError> {
let client = self.client.as_ref().ok_or(EnhanceError::Disabled)?;
let staged = Self::pending_of(job);
let item = staged.get(n).ok_or(EnhanceError::NotFound)?;
match client.view(&item.view).await {
Ok(Some(found)) => Ok(found),
Ok(None) => {
self.expire(job);
Err(EnhanceError::Expired)
}
Err(e) => Err(comfy_err(e)),
}
}
/// Mark a job whose results are gone `expired` (unless it was approved:
/// the saved file is in the library regardless).
fn expire(&self, job: &EnhanceJobRow) {
if job.status == STATUS_READY {
let mut u = JobUpdate::status(STATUS_EXPIRED);
u.error = Some(MSG_EXPIRED.to_string());
self.update_job(&job.id, &u);
}
}
// ---- poller ---------------------------------------------------------
/// Advance every active job once. Public so tests (and the loop) drive it.
pub async fn poll_once(&self) {
let Some(client) = &self.client else { return };
let active = match self.dao.lock().unwrap().list_active() {
Ok(a) => a,
Err(e) => {
warn!("enhance: listing active jobs failed: {e}");
return;
}
};
if active.is_empty() {
self.unreachable_since.lock().unwrap().clear();
return;
}
// Queue first, history second: a job that finishes between the two
// calls is then found in history, never misread as forgotten.
let queue = match client.queue().await {
Ok(q) => q,
Err(ComfyError::Unreachable(_)) => {
self.note_unreachable(&active);
return;
}
Err(e) => {
warn!("enhance: /queue failed: {e}");
return;
}
};
for job in active {
self.unreachable_since.lock().unwrap().remove(&job.id);
if let Some((running, ahead)) = queue.position_of(&job.comfy_prompt_id) {
let status = if running {
STATUS_RUNNING
} else {
STATUS_QUEUED
};
let mut u = JobUpdate::status(status);
u.queue_position = Some(ahead as i32);
if job.status != status || job.queue_position != u.queue_position {
self.update_job(&job.id, &u);
}
continue;
}
match client.history(&job.comfy_prompt_id).await {
Ok(Some(entry)) if entry.completed || entry.status_str == "error" => {
self.finish(&job, entry)
}
// Known but not finished (a race with the queue): keep waiting.
Ok(Some(_)) => {}
Ok(None) => {
info!("enhance: job {} expired (ComfyUI forgot it)", job.id);
let mut u = JobUpdate::status(STATUS_EXPIRED);
u.error = Some(MSG_EXPIRED.to_string());
self.update_job(&job.id, &u);
}
Err(ComfyError::Unreachable(_)) => {
self.note_unreachable(std::slice::from_ref(&job))
}
Err(e) => warn!("enhance: /history for job {} failed: {e}", job.id),
}
}
}
fn finish(&self, job: &EnhanceJobRow, entry: HistoryEntry) {
if entry.status_str == "error" || (!entry.completed && entry.error.is_some()) {
let mut u = JobUpdate::status(STATUS_FAILED);
u.error = Some(
entry
.error
.unwrap_or_else(|| "ComfyUI reported an error".to_string()),
);
u.run_ms = entry.run_ms;
self.update_job(&job.id, &u);
return;
}
if entry.staged.is_empty() {
let mut u = JobUpdate::status(STATUS_FAILED);
u.error = Some("ComfyUI finished without producing a result".to_string());
self.update_job(&job.id, &u);
return;
}
let mut u = JobUpdate::status(STATUS_READY);
u.pending = serde_json::to_string(&entry.staged).ok();
u.run_ms = entry.run_ms;
self.update_job(&job.id, &u);
info!(
"enhance: job {} ready ({} result(s))",
job.id,
entry.staged.len()
);
}
fn note_unreachable(&self, jobs: &[EnhanceJobRow]) {
let grace = Duration::from_secs(self.config.unreachable_grace_secs);
let mut seen = self.unreachable_since.lock().unwrap();
for job in jobs {
let since = *seen.entry(job.id.clone()).or_insert_with(Instant::now);
if since.elapsed() >= grace {
seen.remove(&job.id);
let mut u = JobUpdate::status(STATUS_UNAVAILABLE);
u.error = Some(MSG_UNAVAILABLE.to_string());
self.update_job(&job.id, &u);
}
}
}
/// Release ComfyUI's VRAM once it has been idle. The idle test uses
/// ComfyUI's own history, so a session you're working in by hand keeps
/// it from unloading.
pub async fn idle_check(&self) {
let (Some(client), true) = (&self.client, self.config.idle_free_secs > 0) else {
return;
};
let reachable = async {
let queue = client.queue().await?;
let newest = client.newest_history().await?;
Ok::<_, ComfyError>((queue, newest))
}
.await;
let (queue, newest) = match reachable {
Ok(v) => v,
Err(_) => {
self.idle.lock().unwrap().reset();
return;
}
};
let active = self
.dao
.lock()
.unwrap()
.list_active()
.map(|a| a.len())
.unwrap_or(1);
let marker = newest.and_then(|h| h.timestamp_ms);
let should_free = self.idle.lock().unwrap().observe(
chrono::Utc::now().timestamp_millis(),
queue.is_empty(),
active,
marker,
self.config.idle_free_secs as i64 * 1000,
);
if should_free {
match client.free().await {
Ok(()) => info!(
"enhance: ComfyUI idle for {}s, released its models",
self.config.idle_free_secs
),
Err(e) => {
warn!("enhance: POST /free failed: {e}");
self.idle.lock().unwrap().free_failed();
}
}
}
}
/// The background loop: poll active jobs every `poll_secs`, run the idle
/// check every 30 s, prune old jobs once at start.
pub fn spawn(self: &Arc<Self>) {
if !self.is_configured() {
return;
}
let svc = Arc::clone(self);
tokio::spawn(async move {
let cutoff = chrono::Utc::now().timestamp() - JOB_RETENTION_SECS;
if let Err(e) = svc.dao.lock().unwrap().prune(cutoff) {
warn!("enhance: pruning old jobs failed: {e}");
}
let mut last_idle = Instant::now() - IDLE_CHECK_EVERY;
loop {
svc.poll_once().await;
if last_idle.elapsed() >= IDLE_CHECK_EVERY {
last_idle = Instant::now();
svc.idle_check().await;
}
tokio::time::sleep(Duration::from_secs(svc.config.poll_secs)).await;
}
});
}
// ---- approve --------------------------------------------------------
/// Ask ComfyUI to write result `n` into the library. Idempotent: a result
/// that is already saved returns the stored entry without calling ComfyUI.
/// The caller ingests the returned file.
pub async fn approve(
&self,
job_id: &str,
n: usize,
place: &dyn Fn(&Path) -> Option<(i32, String)>,
) -> Result<(EnhanceJobRow, SavedEntry), EnhanceError> {
let client = self.client.as_ref().ok_or(EnhanceError::Disabled)?;
let _guard = self.approve_lock.lock().await;
let job = self.get_job(job_id)?;
let mut saved = Self::saved_of(&job);
if let Some(done) = saved.iter().find(|s| s.n == n) {
return Ok((job.clone(), done.clone()));
}
match job.status.as_str() {
STATUS_READY | STATUS_APPROVED => {}
STATUS_EXPIRED => return Err(EnhanceError::Expired),
other => {
return Err(EnhanceError::BadRequest(format!(
"the job is {other}, there is nothing to approve"
)));
}
}
let staged = Self::pending_of(&job);
let item = staged
.get(n)
.ok_or_else(|| EnhanceError::BadRequest(format!("this job has no result {n}")))?;
let comfy_path = match client.approve(&item.id).await {
Ok(p) => p,
Err(ApproveError::Gone) => {
self.expire(&job);
return Err(EnhanceError::Expired);
}
Err(ApproveError::Refused(m)) => return Err(EnhanceError::Upstream(m)),
Err(ApproveError::Comfy(e)) => return Err(comfy_err(e)),
};
let located = self
.path_map
.from_comfy(&comfy_path)
.and_then(|abs| place(&abs));
let entry = SavedEntry {
n,
comfy_path: comfy_path.clone(),
library_id: located.as_ref().map(|(l, _)| *l),
rel_path: located.map(|(_, r)| r),
};
// Record it even when it can't be placed: the file exists now.
saved.push(entry.clone());
let mut u = JobUpdate::status(STATUS_APPROVED);
u.error = None;
u.saved = serde_json::to_string(&saved).ok();
self.update_job(&job.id, &u);
let job = self.get_job(job_id)?;
if entry.rel_path.is_none() {
return Err(EnhanceError::Unplaceable(format!(
"ComfyUI saved {comfy_path}, but it isn't under any library or path_map entry"
)));
}
Ok((job, entry))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_frees_once_after_the_quiet_period() {
let mut s = IdleState::default();
let idle = 300_000;
// First sight is activity.
assert!(!s.observe(0, true, 0, Some(5), idle));
assert!(!s.observe(299_000, true, 0, Some(5), idle));
assert!(s.observe(300_000, true, 0, Some(5), idle));
// Only once.
assert!(!s.observe(400_000, true, 0, Some(5), idle));
assert!(!s.observe(9_000_000, true, 0, Some(5), idle));
// A new history entry (e.g. an interactive run) restarts the clock
// and re-arms the free.
assert!(!s.observe(9_100_000, true, 0, Some(6), idle));
assert!(!s.observe(9_399_000, true, 0, Some(6), idle));
assert!(s.observe(9_400_000, true, 0, Some(6), idle));
}
#[test]
fn idle_never_frees_while_busy() {
let mut s = IdleState::default();
assert!(!s.observe(0, true, 0, Some(1), 1000));
// Queue not empty: activity, clock restarts each time.
assert!(!s.observe(5000, false, 0, Some(1), 1000));
assert!(!s.observe(5500, true, 0, Some(1), 1000));
// One of our jobs is active.
assert!(!s.observe(9000, true, 1, Some(1), 1000));
assert!(!s.observe(9500, true, 0, Some(1), 1000));
assert!(s.observe(10_000, true, 0, Some(1), 1000));
}
#[test]
fn idle_restarts_after_comfy_goes_away_and_retries_failures() {
let mut s = IdleState::default();
assert!(!s.observe(0, true, 0, None, 1000));
assert!(s.observe(1000, true, 0, None, 1000));
s.free_failed();
assert!(s.observe(1500, true, 0, None, 1000), "retried");
s.reset();
assert!(!s.observe(10_000, true, 0, None, 1000), "fresh baseline");
assert!(s.observe(11_000, true, 0, None, 1000));
}
}