Skip to content

Commit ce4092f

Browse files
authored
Merge pull request #383 from OpenMS/claude/fix-vendor-queue-error-EwPar
Fix workflow stop in online mode by sending stop command to RQ worker
2 parents b855b5e + 44c48d1 commit ce4092f

3 files changed

Lines changed: 204 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
run: |
2121
python -m pip install --upgrade pip
2222
pip install -r requirements.txt # test with requirements file so can easily bump with dependabot
23-
pip install pytest
23+
pip install pytest fakeredis
2424
- name: Test
2525
run: |
2626
python -m pytest test_gui.py tests/

src/workflow/QueueManager.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,16 @@ def get_job_info(self, job_id: str) -> Optional[JobInfo]:
178178

179179
job = Job.fetch(job_id, connection=self._redis)
180180

181-
# Map RQ status to our enum
181+
# 'stopped' is what RQ records after send_stop_job_command runs;
182+
# surface it as CANCELED so the UI doesn't show stopped jobs as queued.
182183
status_map = {
183184
"queued": JobStatus.QUEUED,
184185
"started": JobStatus.STARTED,
185186
"finished": JobStatus.FINISHED,
186187
"failed": JobStatus.FAILED,
187188
"deferred": JobStatus.DEFERRED,
188189
"canceled": JobStatus.CANCELED,
190+
"stopped": JobStatus.CANCELED,
189191
}
190192

191193
status = status_map.get(job.get_status(), JobStatus.QUEUED)
@@ -232,24 +234,61 @@ def cancel_job(self, job_id: str) -> bool:
232234
"""
233235
Cancel a queued or running job.
234236
237+
For queued jobs, this removes them from the queue. For jobs that are
238+
already executing in a worker, Job.cancel() alone is not enough — it
239+
only updates Redis registries while the worker keeps running the
240+
workflow. We send a stop-job command to the worker so the work-horse
241+
is actually interrupted.
242+
235243
Args:
236244
job_id: The job ID to cancel
237245
238246
Returns:
239-
True if successfully canceled
247+
True if the job is canceled (or already was), False otherwise.
240248
"""
241249
if not self.is_available:
242250
return False
243251

244252
try:
253+
from rq.command import send_stop_job_command
254+
from rq.exceptions import InvalidJobOperation, NoSuchJobError
245255
from rq.job import Job
256+
except ImportError:
257+
return False
246258

259+
try:
247260
job = Job.fetch(job_id, connection=self._redis)
248-
job.cancel()
261+
except NoSuchJobError:
262+
return False
263+
except Exception:
264+
return False
265+
266+
# Idempotent: a second Stop click (or rerun) should not surface an error.
267+
if job.is_canceled or job.is_stopped:
249268
return True
269+
270+
# Tell the worker to interrupt the work-horse before marking canceled.
271+
if job.is_started and job.worker_name:
272+
try:
273+
send_stop_job_command(self._redis, job_id)
274+
except InvalidJobOperation:
275+
# The worker just finished or the job moved out of 'started';
276+
# fall through to cancel() to settle registry state.
277+
pass
278+
except Exception:
279+
pass
280+
281+
try:
282+
job.cancel()
283+
except InvalidJobOperation:
284+
# Worker already transitioned the job (e.g. to 'stopped'); that
285+
# satisfies the user's intent to stop.
286+
pass
250287
except Exception:
251288
return False
252289

290+
return True
291+
253292
def get_queue_stats(self) -> dict:
254293
"""
255294
Get queue statistics.

tests/test_queue_manager_cancel.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""
2+
Tests for QueueManager.cancel_job - the "stop workflow" path used in online
3+
mode where workflows are executed by RQ workers (the vendor's queue).
4+
5+
Bug being fixed: when a workflow is mid-execution in an RQ worker and the
6+
user clicks "Stop Workflow", QueueManager.cancel_job calls Job.cancel() on
7+
the RQ Job. For a job in the "started" state this only marks the job as
8+
canceled in the Redis registries; the worker keeps executing the workflow
9+
and the user sees inconsistent / "weird" state (worker still appending to
10+
logs, status flipping around, etc.).
11+
12+
To actually stop a running RQ job, RQ exposes
13+
rq.command.send_stop_job_command(connection, job_id) which messages the
14+
worker over Redis pubsub to interrupt the work-horse.
15+
"""
16+
17+
import os
18+
import sys
19+
20+
import pytest
21+
22+
fakeredis = pytest.importorskip("fakeredis")
23+
rq = pytest.importorskip("rq")
24+
from rq import Queue
25+
from rq.job import Job, JobStatus
26+
27+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28+
29+
from src.workflow.QueueManager import QueueManager
30+
31+
32+
def _make_queue_manager() -> QueueManager:
33+
"""Build a QueueManager wired to fake Redis, bypassing __init__."""
34+
qm = QueueManager.__new__(QueueManager)
35+
qm._redis = fakeredis.FakeStrictRedis()
36+
qm._queue = Queue(QueueManager.QUEUE_NAME, connection=qm._redis)
37+
qm._is_online = True
38+
qm._init_attempted = True
39+
qm._default_timeout = 7200
40+
qm._default_result_ttl = 86400
41+
return qm
42+
43+
44+
def _force_started(job: Job, worker_name: str = "rq:worker:test-worker") -> None:
45+
"""Move a queued job into the 'started' state with a worker assigned."""
46+
job.set_status(JobStatus.STARTED)
47+
job.worker_name = worker_name
48+
job.save()
49+
50+
51+
def test_cancel_queued_job_marks_it_canceled():
52+
qm = _make_queue_manager()
53+
qm._queue.enqueue(os.getcwd, job_id="queued-job")
54+
55+
assert qm.cancel_job("queued-job") is True
56+
57+
refreshed = Job.fetch("queued-job", connection=qm._redis)
58+
assert refreshed.get_status() == JobStatus.CANCELED
59+
60+
61+
def test_cancel_started_job_sends_stop_command_to_worker(monkeypatch):
62+
"""
63+
Reproduces the vendor-queue stop bug.
64+
65+
A workflow that is actively running in a worker must be stopped by
66+
sending a stop-job command to the worker. The previous implementation
67+
only called Job.cancel(), which left the worker running.
68+
"""
69+
qm = _make_queue_manager()
70+
job = qm._queue.enqueue(os.getcwd, job_id="started-job")
71+
_force_started(job)
72+
73+
stop_calls: list[str] = []
74+
75+
def fake_send_stop_job_command(connection, job_id, *args, **kwargs):
76+
stop_calls.append(job_id)
77+
78+
import rq.command as rq_command
79+
monkeypatch.setattr(
80+
rq_command, "send_stop_job_command", fake_send_stop_job_command
81+
)
82+
83+
result = qm.cancel_job("started-job")
84+
85+
assert result is True, "cancel_job should report success for started jobs"
86+
assert stop_calls == ["started-job"], (
87+
"cancel_job must call send_stop_job_command for started jobs - "
88+
"otherwise the RQ worker keeps running the workflow."
89+
)
90+
91+
92+
def test_cancel_already_canceled_job_does_not_raise():
93+
"""
94+
User double-clicks 'Stop Workflow' or stop is invoked twice on rerun.
95+
The second call must not surface InvalidJobOperation as a 'weird error'.
96+
"""
97+
qm = _make_queue_manager()
98+
job = qm._queue.enqueue(os.getcwd, job_id="dup-cancel")
99+
job.cancel()
100+
assert Job.fetch("dup-cancel", connection=qm._redis).get_status() == JobStatus.CANCELED
101+
102+
# Must not raise; intent (job is canceled) is already satisfied.
103+
assert qm.cancel_job("dup-cancel") is True
104+
105+
106+
def test_cancel_missing_job_returns_false():
107+
qm = _make_queue_manager()
108+
assert qm.cancel_job("does-not-exist") is False
109+
110+
111+
def test_started_status_without_worker_is_handled_gracefully(monkeypatch):
112+
"""
113+
Edge case: job is marked started but has no worker_name yet (race between
114+
worker pickup and stop click). cancel_job must not raise; it should fall
115+
back to canceling the job in the registry.
116+
"""
117+
qm = _make_queue_manager()
118+
job = qm._queue.enqueue(os.getcwd, job_id="started-no-worker")
119+
job.set_status(JobStatus.STARTED)
120+
job.save()
121+
122+
stop_calls: list[str] = []
123+
124+
def fake_send_stop_job_command(connection, job_id, *args, **kwargs):
125+
stop_calls.append(job_id)
126+
127+
import rq.command as rq_command
128+
monkeypatch.setattr(
129+
rq_command, "send_stop_job_command", fake_send_stop_job_command
130+
)
131+
132+
result = qm.cancel_job("started-no-worker")
133+
134+
assert result is True
135+
# Without a worker_name there is nothing to send the stop command to.
136+
assert stop_calls == []
137+
assert (
138+
Job.fetch("started-no-worker", connection=qm._redis).get_status()
139+
== JobStatus.CANCELED
140+
)
141+
142+
143+
def test_stopped_status_is_mapped_in_get_job_info(monkeypatch):
144+
"""
145+
After send_stop_job_command runs, RQ marks the job 'stopped'. The status
146+
map in get_job_info must recognise it; otherwise the UI would show the
147+
job as still 'queued', which is the user-visible 'weird error'.
148+
"""
149+
qm = _make_queue_manager()
150+
job = qm._queue.enqueue(os.getcwd, job_id="stopped-job")
151+
job.set_status(JobStatus.STOPPED)
152+
job.save()
153+
154+
info = qm.get_job_info("stopped-job")
155+
assert info is not None
156+
assert info.status == __import__(
157+
"src.workflow.QueueManager", fromlist=["JobStatus"]
158+
).JobStatus.CANCELED, (
159+
"RQ 'stopped' status should be reported as CANCELED to the UI; "
160+
"otherwise stopped jobs appear stuck in 'queued'."
161+
)

0 commit comments

Comments
 (0)