🐛 Settle a cancelled export without noise or a leaked watchdog

A cancelled export is a normal ending, but the run treated it like a
failure: an error log with a full trace for something the user asked
for. Worse, the watchdog interval outlived the run — its stopper was
bound inside the scope the failure unwinds, so a cancelled or failed
job kept beating on a dead row forever, one HTTP call a second that
only ever answers skip. The stopper now travels in an atom the catch
reaches, the run stops beating on every ending, and a cancel logs a
single info line where a failure keeps its error with the cause.

AI-assisted-by: glm-5.3-flash
This commit is contained in:
Andrey Antukh 2026-10-07 13:54:15 +00:00
parent b22cce46f1
commit a63140d309
2 changed files with 50 additions and 6 deletions

View File

@ -322,6 +322,13 @@
:code (or (:code data) :export-failed)
:hint (or (:hint data) (ex-message cause) "export failed")}))
(defn cancelled?
"Whether `cause` ends a run the user asked to end: a cancelled
export is a normal ending, reported and logged as one, never as a
failure with a trace."
[cause]
(= :job-cancelled (:code (ex-data cause))))
(defn- settle-failure!
"Settles the job as failed, after releasing the temp files the run
owns. The settle never rejects: if the failure cannot reach the
@ -338,9 +345,14 @@
(defn run-export!
[{:keys [job-id]} params]
(let [session (atom nil)
cancelled (atom false)
_ (jobs/register! job-id)
(let [session (atom nil)
cancelled (atom false)
;; the watchdog outlives the scope that starts it, so its
;; stopper travels in this atom: a run that ends badly stops
;; beating too, or its interval would knock on a dead job
;; forever, one HTTP call a second that only ever answers skip
stop-watchdog! (atom nil)
_ (jobs/register! job-id)
stop-cmd! (fn []
(reset! cancelled true)
;; the hard-cancel of the render the run holds: the
@ -355,11 +367,11 @@
:job-id (str job-id))
beats (start-beats! job-id stop-cmd!)
beats! (:beat! beats)
stop! (with-watchdog! beats! (:last beats))
_ (reset! stop-watchdog! (with-watchdog! beats! (:last beats)))
_ (check-beat! (beats! :preparing {} :force? true))
plan (make-plan job-id token params)
resource (run-prepared! beats! cancelled plan)
_ (stop!)
_ (@stop-watchdog!)
artifact {:path (str (:path resource))
:filename (:filename resource)
:mtype (:mtype resource)}
@ -371,7 +383,10 @@
:outcome "completed")]
nil)
(fn [cause]
(l/error :hint "export job failed" :job-id (str job-id) :cause cause)
(when-let [stop @stop-watchdog!] (stop))
(if (cancelled? cause)
(l/info :hint "export job cancelled" :job-id (str job-id))
(l/error :hint "export job failed" :job-id (str job-id) :cause cause))
(settle-failure! job-id session cause)))))

View File

@ -13,6 +13,7 @@
["node:fs/promises" :as fsp]
["node:path" :as path]
["undici" :as http]
[app.common.exceptions :as ex]
[app.common.transit :as transit]
[app.common.uuid :as uuid]
[app.consumer.api :as api]
@ -272,6 +273,34 @@
(restore!)
(done)))))
(t/deftest cancelled-is-a-normal-ending-not-a-failure
(t/testing "the cooperative cancel classifies as cancelled"
(t/is (true? (exports/cancelled?
(ex/error :type :internal
:code :job-cancelled
:hint "export job was cancelled")))))
(t/testing "anything else is a failure"
(t/is (false? (exports/cancelled? (ex-info "render boom" {}))))))
(t/deftest a-failed-run-stops-beating-after-the-settle
;; the watchdog interval belongs to the run: if the failure path
;; does not clear it, it beats on a dead job forever — one HTTP call
;; a second that only ever answers skip
(t/async done
(let [job-id (uuid/next)
sid (uuid/next)
params (export-params)
stages (volatile! [])
respond! (fake-fetch! (answer-with params sid stages))
restore! (fake-render-fail!)]
(p/let [_ (worker/process! (run-claim job-id))
_ (p/delay 1400)]
(t/testing "no beat traveled after the fail settle"
(t/is (= "fail-job" (last (steps-of)))))
(respond!)
(restore!)
(done)))))
(t/deftest process-settles-a-failed-export-with-fail-job
(t/async done
(let [job-id (uuid/next)