sterilize bulk: respect external cancel mid-loop

Job 3 surfaced the bug — when I set state=cancelled in the DB, the
daemon thread kept running and finalize() at the end overwrote it
with 'done'. User cancellations were getting silently undone.

Two changes:

1. Runners (run_bulk_preview, run_bulk_apply) now check the job's
   current state at the top of every iteration via the new lightweight
   db.get_sterilize_job_state. If the state has moved to a terminal
   value (cancelled, failed, done) externally, the loop returns
   without finalizing.

2. db.finalize_sterilize_job now refuses to overwrite a non-running
   state — added "AND state IN ('running','applying')" to the WHERE
   clause. Belt-and-suspenders for the same problem: even if a runner
   races past the state check and limps to its finalize call, the DB
   itself won't let the cancellation be replaced.

Net: hitting cancel via the UI button (or a DB update) now actually
stops the runner mid-flight. Polling roundtrip per recipe is one
SELECT — negligible vs the multi-second clawdforge call that
dominates each iteration.
This commit is contained in:
Kayos 2026-04-30 10:02:53 -07:00
parent 30332a0d58
commit 4707e6aacc
2 changed files with 34 additions and 1 deletions

View file

@ -89,6 +89,11 @@ def run_bulk_preview(
"""Walk all recipes; persist a proposal row per recipe that needs work.
Skip already-clean recipes. Move job state on completion."""
log.info("[bulk-sterilize:%s] starting walk", job_id)
def _cancelled() -> bool:
s = db.get_sterilize_job_state(job_id)
return s in ("cancelled", "failed", "done")
try:
# Resolve the user's household once. The walk skips any recipe in a
# different household — Mealie's group model lets all members read
@ -123,6 +128,9 @@ def run_bulk_preview(
)
for slug, name in slugs:
if _cancelled():
log.info("[bulk-sterilize:%s] walk aborted — job state changed externally", job_id)
return
try:
# Cheap pre-check: skip if every ingredient is already parsed
recipe = sterilizer.mealie.get_recipe(slug)
@ -208,10 +216,18 @@ def run_bulk_apply(
(caller transitions it). Each recipe runs through Sterilizer.apply_recipe;
any per-recipe failure is recorded but doesn't stop the loop."""
log.info("[bulk-sterilize:%s] starting apply", job_id)
def _cancelled() -> bool:
s = db.get_sterilize_job_state(job_id)
return s in ("cancelled", "failed", "done")
try:
user_household = _user_household_id(sterilizer.mealie)
approved = db.list_approved_unapplied_proposals(job_id)
for row in approved:
if _cancelled():
log.info("[bulk-sterilize:%s] apply aborted — job state changed externally", job_id)
return
slug = row["recipe_slug"]
try:
db.update_sterilize_job_progress(job_id, current_slug=slug)

View file

@ -927,6 +927,16 @@ class DB:
)
return cur.fetchone()
def get_sterilize_job_state(self, job_id: int) -> str | None:
"""Cheap state-only fetch used by the runner to detect external
cancels mid-loop without dragging the whole row over the wire."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT state FROM cauldron_sterilize_jobs WHERE id=%s", (job_id,)
)
row = cur.fetchone()
return row["state"] if row else None
def latest_sterilize_job_for_household(self, household_id: int) -> dict | None:
"""Most recent job (by started_at) for the household — used by the
UI to figure out what to render on /sterilize."""
@ -979,7 +989,13 @@ class DB:
)
def finalize_sterilize_job(self, job_id: int, *, state: str) -> None:
"""Move job to a terminal state (review/done/failed/cancelled)."""
"""Move job to a terminal state (review/done/failed/cancelled).
Will NOT overwrite a job that's already terminal — if a runner is
about to call finalize('done') but the row was set to 'cancelled'
externally, we leave the cancellation in place. This is the
anti-zombie guard that keeps user cancels from being silently
replaced when the daemon thread limps to the finish line."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
@ -990,6 +1006,7 @@ class DB:
last_progress_at = NOW(),
current_slug = NULL
WHERE id=%s
AND state IN ('running','applying')
""",
(state, state, job_id),
)