Add job cancel support

This commit is contained in:
AppFactory Bot
2026-05-29 09:18:03 +02:00
parent d257ddf05f
commit 3da930f010
+76 -2
View File
@@ -1,5 +1,6 @@
import json import json
import os import os
import signal
import socket import socket
import sqlite3 import sqlite3
import subprocess import subprocess
@@ -15,6 +16,7 @@ TOOLS_DIR = Path("/tools")
WORKER_ID = os.getenv("APPFACTORY_WORKER_ID", socket.gethostname()) WORKER_ID = os.getenv("APPFACTORY_WORKER_ID", socket.gethostname())
POLL_SECONDS = int(os.getenv("APPFACTORY_WORKER_POLL_SECONDS", "3")) POLL_SECONDS = int(os.getenv("APPFACTORY_WORKER_POLL_SECONDS", "3"))
HEARTBEAT_SECONDS = int(os.getenv("APPFACTORY_WORKER_HEARTBEAT_SECONDS", "10")) HEARTBEAT_SECONDS = int(os.getenv("APPFACTORY_WORKER_HEARTBEAT_SECONDS", "10"))
CANCEL_CHECK_SECONDS = int(os.getenv("APPFACTORY_CANCEL_CHECK_SECONDS", "2"))
app = FastAPI(title="AppFactory Worker") app = FastAPI(title="AppFactory Worker")
@@ -42,6 +44,20 @@ def append_job_log(job_id: int, stream: str, message: str):
con.close() con.close()
def get_job_status(job_id: int) -> str | None:
con = get_connection()
row = con.execute(
"SELECT status FROM jobs WHERE id = ?",
(job_id,),
).fetchone()
con.close()
if not row:
return None
return row["status"]
def update_worker_status(current_job_id=None): def update_worker_status(current_job_id=None):
con = get_connection() con = get_connection()
@@ -77,6 +93,7 @@ def update_worker_status(current_job_id=None):
{ {
"poll_seconds": POLL_SECONDS, "poll_seconds": POLL_SECONDS,
"heartbeat_seconds": HEARTBEAT_SECONDS, "heartbeat_seconds": HEARTBEAT_SECONDS,
"cancel_check_seconds": CANCEL_CHECK_SECONDS,
}, },
ensure_ascii=False, ensure_ascii=False,
), ),
@@ -104,7 +121,7 @@ def recover_stale_jobs():
""" """
SELECT id SELECT id
FROM jobs FROM jobs
WHERE status = 'running' WHERE status IN ('running', 'cancelled_requested')
AND started_at < datetime('now', '-15 minutes') AND started_at < datetime('now', '-15 minutes')
""" """
).fetchall() ).fetchall()
@@ -197,7 +214,7 @@ def get_next_job():
""" """
SELECT id SELECT id
FROM jobs FROM jobs
WHERE status = 'running' WHERE status IN ('running', 'cancelled_requested')
AND target_type = ? AND target_type = ?
AND target_id = ? AND target_id = ?
AND id != ? AND id != ?
@@ -257,6 +274,22 @@ def command_for_job(job: dict):
raise ValueError(f"Unsupported job type: {job_type}") raise ValueError(f"Unsupported job type: {job_type}")
def terminate_process(process: subprocess.Popen, job_id: int):
append_job_log(job_id, "system", "Cancel requested. Terminating process...")
try:
process.terminate()
process.wait(timeout=10)
append_job_log(job_id, "system", "Process terminated.")
except subprocess.TimeoutExpired:
append_job_log(job_id, "system", "Process did not terminate in time. Killing process...")
process.kill()
process.wait(timeout=10)
append_job_log(job_id, "system", "Process killed.")
except Exception as exc:
append_job_log(job_id, "stderr", f"Error while terminating process: {exc}")
def execute_job(job: dict): def execute_job(job: dict):
job_id = int(job["id"]) job_id = int(job["id"])
target = job["target_id"] target = job["target_id"]
@@ -277,8 +310,38 @@ def execute_job(job: dict):
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
env=env, env=env,
start_new_session=True,
) )
while process.poll() is None:
status = get_job_status(job_id)
if status == "cancelled_requested":
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
terminate_process(process, job_id)
else:
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
process.kill()
process.wait(timeout=10)
update_job_status(
job_id,
"cancelled",
error_text="Job cancelled by user request",
result={"cancelled": True},
)
append_job_log(job_id, "system", "Job cancelled.")
return
time.sleep(CANCEL_CHECK_SECONDS)
stdout, stderr = process.communicate() stdout, stderr = process.communicate()
returncode = process.returncode returncode = process.returncode
@@ -288,6 +351,16 @@ def execute_job(job: dict):
if stderr: if stderr:
append_job_log(job_id, "stderr", stderr) append_job_log(job_id, "stderr", stderr)
if get_job_status(job_id) == "cancelled_requested":
update_job_status(
job_id,
"cancelled",
error_text="Job cancelled by user request",
result={"returncode": returncode, "cancelled": True},
)
append_job_log(job_id, "system", "Job cancelled after process finished.")
return
if returncode == 0: if returncode == 0:
update_job_status( update_job_status(
job_id, job_id,
@@ -361,4 +434,5 @@ def health():
"worker_id": WORKER_ID, "worker_id": WORKER_ID,
"poll_seconds": POLL_SECONDS, "poll_seconds": POLL_SECONDS,
"heartbeat_seconds": HEARTBEAT_SECONDS, "heartbeat_seconds": HEARTBEAT_SECONDS,
"cancel_check_seconds": CANCEL_CHECK_SECONDS,
} }