|
| 1 | +""" |
| 2 | +Tests for WorkflowManager.stop_workflow / get_workflow_status interaction. |
| 3 | +
|
| 4 | +Bug being fixed (follow-up to PR #383): the RQ worker actually terminates when |
| 5 | +the user clicks "Stop Workflow", but the Streamlit UI still shows |
| 6 | +"workflow is running" and pressing Stop a second time produces an |
| 7 | +"error has occurred" message instead of "workflow has been cancelled". |
| 8 | +
|
| 9 | +Root causes: |
| 10 | + 1. stop_workflow clears .job_id on success, so the next get_workflow_status |
| 11 | + poll falls through to the local-mode pid_dir fallback. The killed worker |
| 12 | + left stale child PID files in pid_dir, so the fallback wrongly returns |
| 13 | + running=True. |
| 14 | + 2. The worker never wrote 'WORKFLOW FINISHED' to the log because it was |
| 15 | + killed mid-execution. The UI's static-display branch only knows two |
| 16 | + outcomes (FINISHED -> success, else -> error), so a cancelled run is |
| 17 | + misreported as an error. |
| 18 | +
|
| 19 | +These tests pin both behaviours. |
| 20 | +""" |
| 21 | + |
| 22 | +import os |
| 23 | +import sys |
| 24 | +import types |
| 25 | + |
| 26 | +import pytest |
| 27 | + |
| 28 | +fakeredis = pytest.importorskip("fakeredis") |
| 29 | +rq = pytest.importorskip("rq") |
| 30 | +streamlit = pytest.importorskip("streamlit") |
| 31 | +pyopenms = pytest.importorskip("pyopenms") |
| 32 | + |
| 33 | +from rq import Queue |
| 34 | +from rq.job import Job, JobStatus |
| 35 | + |
| 36 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 37 | + |
| 38 | +from src.workflow.Logger import Logger |
| 39 | +from src.workflow.QueueManager import QueueManager |
| 40 | +from src.workflow.WorkflowManager import WorkflowManager |
| 41 | + |
| 42 | + |
| 43 | +def _make_queue_manager() -> QueueManager: |
| 44 | + """Build a QueueManager wired to fake Redis, bypassing __init__.""" |
| 45 | + qm = QueueManager.__new__(QueueManager) |
| 46 | + qm._redis = fakeredis.FakeStrictRedis() |
| 47 | + qm._queue = Queue(QueueManager.QUEUE_NAME, connection=qm._redis) |
| 48 | + qm._is_online = True |
| 49 | + qm._init_attempted = True |
| 50 | + qm._default_timeout = 7200 |
| 51 | + qm._default_result_ttl = 86400 |
| 52 | + return qm |
| 53 | + |
| 54 | + |
| 55 | +def _force_started(job: Job, worker_name: str = "rq:worker:test-worker") -> None: |
| 56 | + job.set_status(JobStatus.STARTED) |
| 57 | + job.worker_name = worker_name |
| 58 | + job.save() |
| 59 | + |
| 60 | + |
| 61 | +def _make_workflow_manager(tmp_path, monkeypatch) -> WorkflowManager: |
| 62 | + """ |
| 63 | + Build a minimal WorkflowManager wired to a fakeredis-backed QueueManager. |
| 64 | +
|
| 65 | + Bypasses __init__ (which constructs a StreamlitUI and reads session |
| 66 | + state). Only the attributes used by stop_workflow / get_workflow_status |
| 67 | + are populated: |
| 68 | + - workflow_dir (real tmp dir) |
| 69 | + - logger (real Logger; streamlit-free) |
| 70 | + - executor (SimpleNamespace exposing pid_dir; CommandExecutor |
| 71 | + itself imports streamlit so we cannot instantiate it) |
| 72 | + - _queue_manager (fakeredis-backed QueueManager) |
| 73 | +
|
| 74 | + A stale child PID file is dropped in pid_dir to simulate the state the |
| 75 | + worker leaves behind when it is force-killed mid-execution. |
| 76 | + """ |
| 77 | + workflow_dir = tmp_path / "wf" |
| 78 | + workflow_dir.mkdir() |
| 79 | + |
| 80 | + pid_dir = workflow_dir / "pids" |
| 81 | + pid_dir.mkdir() |
| 82 | + (pid_dir / "12345").touch() |
| 83 | + |
| 84 | + qm = _make_queue_manager() |
| 85 | + job = qm._queue.enqueue(os.getcwd, job_id="wf-job") |
| 86 | + _force_started(job) |
| 87 | + qm.store_job_id(workflow_dir, "wf-job") |
| 88 | + |
| 89 | + monkeypatch.setattr( |
| 90 | + "rq.command.send_stop_job_command", |
| 91 | + lambda *a, **kw: None, |
| 92 | + ) |
| 93 | + |
| 94 | + wm = WorkflowManager.__new__(WorkflowManager) |
| 95 | + wm.workflow_dir = workflow_dir |
| 96 | + wm.logger = Logger(workflow_dir) |
| 97 | + wm.executor = types.SimpleNamespace(pid_dir=pid_dir) |
| 98 | + wm._queue_manager = qm |
| 99 | + return wm |
| 100 | + |
| 101 | + |
| 102 | +def test_stop_workflow_clears_running_state_in_queue_mode(tmp_path, monkeypatch): |
| 103 | + """ |
| 104 | + Bug #1: after a successful queue cancel, get_workflow_status must report |
| 105 | + running=False. Currently the stale pid_dir keeps the local-mode fallback |
| 106 | + returning running=True. |
| 107 | + """ |
| 108 | + wm = _make_workflow_manager(tmp_path, monkeypatch) |
| 109 | + |
| 110 | + assert wm.stop_workflow() is True |
| 111 | + |
| 112 | + status = wm.get_workflow_status() |
| 113 | + assert status["running"] is False, ( |
| 114 | + "After cancel, get_workflow_status must not report the workflow as " |
| 115 | + "still running." |
| 116 | + ) |
| 117 | + |
| 118 | + pid_dir = wm.executor.pid_dir |
| 119 | + assert not (pid_dir.exists() and any(pid_dir.iterdir())), ( |
| 120 | + "stop_workflow must clean up the stale pid_dir left behind by the " |
| 121 | + "killed worker; otherwise the local-mode fallback in " |
| 122 | + "get_workflow_status flips running back to True." |
| 123 | + ) |
| 124 | + |
| 125 | + |
| 126 | +def test_stop_workflow_writes_cancellation_marker_to_log(tmp_path, monkeypatch): |
| 127 | + """ |
| 128 | + Bug #2: the static log-display branch needs a way to tell 'cancelled' |
| 129 | + apart from 'crashed'. stop_workflow must drop a 'WORKFLOW CANCELLED' |
| 130 | + marker into the log so the UI can render the right message. |
| 131 | + """ |
| 132 | + wm = _make_workflow_manager(tmp_path, monkeypatch) |
| 133 | + wm.logger.log("STARTING WORKFLOW") # mimic a partial run |
| 134 | + |
| 135 | + assert wm.stop_workflow() is True |
| 136 | + |
| 137 | + logs_dir = wm.workflow_dir / "logs" |
| 138 | + for log_name in ("minimal.log", "commands-and-run-times.log", "all.log"): |
| 139 | + content = (logs_dir / log_name).read_text(encoding="utf-8") |
| 140 | + assert "WORKFLOW CANCELLED" in content, ( |
| 141 | + f"{log_name} should contain the WORKFLOW CANCELLED marker." |
| 142 | + ) |
| 143 | + assert "WORKFLOW FINISHED" not in content, ( |
| 144 | + f"{log_name} must not claim the workflow finished." |
| 145 | + ) |
| 146 | + |
| 147 | + |
| 148 | +def test_stop_workflow_is_idempotent(tmp_path, monkeypatch): |
| 149 | + """ |
| 150 | + Pressing Stop a second time (or stop firing twice on Streamlit rerun) |
| 151 | + must not raise and must keep running=False. The first call's user intent |
| 152 | + has already been satisfied; subsequent calls should be safe no-ops. |
| 153 | + """ |
| 154 | + wm = _make_workflow_manager(tmp_path, monkeypatch) |
| 155 | + |
| 156 | + assert wm.stop_workflow() is True |
| 157 | + |
| 158 | + # Second call: must not raise, get_workflow_status must remain not-running. |
| 159 | + wm.stop_workflow() |
| 160 | + assert wm.get_workflow_status()["running"] is False |
| 161 | + |
| 162 | + |
0 commit comments