Skip to content

Commit f7574df

Browse files
authored
Merge pull request #384 from OpenMS/claude/fix-vendor-queue-error-EwPar
fix(queue): show "Workflow was cancelled" on first Stop click
2 parents ce4092f + 465eaa2 commit f7574df

5 files changed

Lines changed: 268 additions & 6 deletions

File tree

src/workflow/StreamlitUI.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
tk_directory_dialog,
2121
tk_file_dialog,
2222
)
23+
from src.workflow._log_status import classify_log_outcome
2324

2425

2526
class StreamlitUI:
@@ -1268,9 +1269,11 @@ def execution_section(
12681269
with open(log_path, "r", encoding="utf-8") as f:
12691270
lines = f.readlines()
12701271
content = "".join(lines)
1271-
# Check if workflow finished successfully
1272-
if "WORKFLOW FINISHED" in content:
1272+
outcome = classify_log_outcome(content)
1273+
if outcome == "finished":
12731274
st.success("**Workflow completed successfully.**")
1275+
elif outcome == "cancelled":
1276+
st.warning("**Workflow was cancelled.**")
12741277
else:
12751278
st.error("**Errors occurred, check log file.**")
12761279
# Apply line limit to static display
@@ -1324,6 +1327,9 @@ def _show_queue_status(self, status: dict) -> None:
13241327
with st.expander("Error Details", expanded=True):
13251328
st.code(job_error)
13261329

1330+
elif job_status == "canceled":
1331+
st.warning(f"**Status: {label}** - Workflow was cancelled.")
1332+
13271333
# Expandable job details
13281334
with st.expander("Job Details", expanded=False):
13291335
st.code(f"""Job ID: {status.get('job_id', 'N/A')}

src/workflow/WorkflowManager.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,28 @@ def stop_workflow(self) -> bool:
178178
"""
179179
Stop a running workflow.
180180
181+
Writes a "WORKFLOW CANCELLED" marker to the log so the static
182+
run-page display can render a "Workflow was cancelled" message
183+
instead of "Errors occurred". Cleans up the worker-side pid_dir
184+
left behind when the RQ worker is force-stopped, so a subsequent
185+
get_workflow_status does not flip running back to True via the
186+
local-mode fallback.
187+
188+
.job_id is intentionally left in place: get_job_info will report
189+
the canceled status to the UI so _show_queue_status can render the
190+
Cancelled pill. Resubmission overwrites it; RQ's result_ttl
191+
eventually evicts the job and get_workflow_status self-heals.
192+
181193
Returns:
182-
True if successfully stopped
194+
True if a stop action was taken (queue cancel or local kill).
183195
"""
184196
# Try to cancel queue job first (online mode)
185197
if self._queue_manager and self._queue_manager.is_available:
186198
job_id = self._queue_manager.load_job_id(self.workflow_dir)
187199
if job_id:
188-
success = self._queue_manager.cancel_job(job_id)
189-
if success:
190-
self._queue_manager.clear_job_id(self.workflow_dir)
200+
if self._queue_manager.cancel_job(job_id):
201+
self.logger.log("WORKFLOW CANCELLED")
202+
shutil.rmtree(self.executor.pid_dir, ignore_errors=True)
191203
return True
192204

193205
# Fallback: stop local process
@@ -214,6 +226,8 @@ def _stop_local_workflow(self) -> bool:
214226

215227
# Clean up the pid directory
216228
shutil.rmtree(pid_dir, ignore_errors=True)
229+
if stopped:
230+
self.logger.log("WORKFLOW CANCELLED")
217231
return stopped
218232

219233
def show_file_upload_section(self) -> None:

src/workflow/_log_status.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Pure helper for classifying a workflow log file's terminal state.
3+
4+
Kept streamlit-free so the static-display dispatch in StreamlitUI can be
5+
unit-tested without a Streamlit runtime.
6+
"""
7+
8+
from typing import Literal
9+
10+
LogOutcome = Literal["finished", "cancelled", "error"]
11+
12+
CANCELLED_MARKER = "WORKFLOW CANCELLED"
13+
FINISHED_MARKER = "WORKFLOW FINISHED"
14+
15+
16+
def classify_log_outcome(content: str) -> LogOutcome:
17+
"""
18+
Classify a workflow log's terminal state from its full text.
19+
20+
Order matters: a TOPP subprocess often dies as the worker is being torn
21+
down, so a partial 'ERROR:' line followed by the cancellation marker is
22+
still a cancellation, not a crash. Cancellation therefore wins over
23+
finished (defensive — both shouldn't appear) and over the implicit error
24+
fallback.
25+
"""
26+
if CANCELLED_MARKER in content:
27+
return "cancelled"
28+
if FINISHED_MARKER in content:
29+
return "finished"
30+
return "error"

tests/test_log_status.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
Tests for the classify_log_outcome helper used by the run-page static display.
3+
4+
The UI must render three different messages depending on what's in the
5+
workflow log:
6+
finished -> "Workflow completed successfully" (success)
7+
cancelled -> "Workflow was cancelled" (warning)
8+
error -> "Errors occurred, check log file" (error)
9+
10+
This helper is split out so the dispatch is unit-testable without booting
11+
Streamlit and without pulling in pyopenms.
12+
"""
13+
14+
import os
15+
import sys
16+
17+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18+
19+
from src.workflow._log_status import classify_log_outcome
20+
21+
22+
def test_finished_marker_returns_finished():
23+
assert classify_log_outcome(
24+
"STARTING WORKFLOW\n\nstep 1\n\nWORKFLOW FINISHED\n\n"
25+
) == "finished"
26+
27+
28+
def test_cancelled_marker_returns_cancelled():
29+
assert classify_log_outcome(
30+
"STARTING WORKFLOW\n\nstep 1\n\nWORKFLOW CANCELLED\n\n"
31+
) == "cancelled"
32+
33+
34+
def test_cancelled_takes_precedence_over_partial_error():
35+
"""
36+
A TOPP subprocess often dies as the worker is being torn down, leaving
37+
an ERROR line followed by the cancellation marker. The user-meaningful
38+
state is 'cancelled', not 'error'.
39+
"""
40+
assert classify_log_outcome(
41+
"STARTING WORKFLOW\n\nERROR: subprocess died\n\nWORKFLOW CANCELLED\n\n"
42+
) == "cancelled"
43+
44+
45+
def test_truncated_log_returns_error():
46+
assert classify_log_outcome("STARTING WORKFLOW\n\nstep 1\n\n") == "error"
47+
48+
49+
def test_empty_log_returns_error():
50+
assert classify_log_outcome("") == "error"
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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

Comments
 (0)