Skip to content

Commit 8a0eb7b

Browse files
authored
Avoid spawning the same scheduled task multiple times (#252)
* Revert "Fix scheduler.once() running multiple times (#231)" This reverts commit d4f78c5. The same bug exists not only in schedule.once() but also schedule.every() so a better fix is necessary. * Add tests to capture abnormal consecutive execution of scheduled tasks * Test using a threadpool The threadpool already has a dedicated thread to run scheduler tasks which is not the same as running in the main thread. * Disable tests that can't be fixed * The schedule module is not designed to run in a non-blocking fashion This forces code to run sequentially on the scheduler thread. fixes #231 (again)
1 parent a1137ca commit 8a0eb7b

File tree

2 files changed

+60
-35
lines changed

2 files changed

+60
-35
lines changed

mmpy_bot/scheduler.py

+16-31
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from datetime import datetime
22
from multiprocessing import Pipe, Process
33
from multiprocessing.connection import Connection
4-
from threading import Thread
54
from typing import Optional
65

76
import schedule
@@ -18,20 +17,8 @@ def set_next_run(self, next_time: datetime):
1817
raise AssertionError("The next_time parameter should be a datetime object.")
1918
self.at_time = next_time
2019
self.next_run = next_time
21-
self.should_run = True
22-
23-
@property
24-
def should_run(self):
25-
return self._keep_running and super().should_run
26-
27-
@should_run.setter
28-
def should_run(self, value):
29-
self._keep_running = value
3020

3121
def run(self):
32-
# This prevents the job from running more than once
33-
self.should_run = False
34-
3522
super().run()
3623
return schedule.CancelJob()
3724

@@ -50,26 +37,24 @@ def _run_job(self, job):
5037
event loop.
5138
"""
5239

53-
def launch_and_wait():
54-
# Launch job in a dedicated process and send the result through a pipe.
55-
if "subprocess" in job.tags:
40+
# Launch job in a dedicated process and send the result through a pipe.
41+
if "subprocess" in job.tags:
5642

57-
def wrapped_run(pipe: Connection):
58-
result = job.run()
59-
pipe.send(result)
60-
61-
pipe, child_pipe = Pipe()
62-
p = Process(target=wrapped_run, args=(child_pipe,))
63-
p.start()
64-
result = pipe.recv()
65-
else:
66-
# Or simply run the job in this thread
43+
def wrapped_run(pipe: Connection):
6744
result = job.run()
68-
69-
if isinstance(result, schedule.CancelJob) or result is schedule.CancelJob:
70-
self.cancel_job(job)
71-
72-
Thread(target=launch_and_wait).start()
45+
pipe.send(result)
46+
47+
pipe, child_pipe = Pipe()
48+
p = Process(target=wrapped_run, args=(child_pipe,))
49+
p.start()
50+
# This still blocks despite running in a subprocess
51+
result = pipe.recv()
52+
else:
53+
# Or simply run the job in this thread
54+
result = job.run()
55+
56+
if isinstance(result, schedule.CancelJob) or result is schedule.CancelJob:
57+
self.cancel_job(job)
7358

7459

7560
def _once(trigger_time: Optional[datetime] = None):

tests/unit_tests/scheduler_test.py

+44-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
from mmpy_bot import schedule
11+
from mmpy_bot.threadpool import ThreadPool
1112

1213

1314
def test_once():
@@ -45,6 +46,25 @@ def test_once_single_call():
4546
mock.assert_called_once()
4647

4748

49+
def test_recurring_single_call():
50+
mock = Mock()
51+
mock.side_effect = lambda: time.sleep(0.2)
52+
53+
schedule.every(2).seconds.do(mock)
54+
55+
# Wait 2 seconds so we can run the task once
56+
time.sleep(2)
57+
58+
# This loop corresponds to 0.1 seconds of total time and while there will
59+
# be 10 calls to run_pending() the mock function should only run once
60+
for _ in range(10):
61+
schedule.run_pending()
62+
time.sleep(0.01)
63+
64+
mock.assert_called_once()
65+
66+
67+
@pytest.mark.skip(reason="Test runs in Thread-1 (not MainThread) but still blocks")
4868
def test_recurring_thread():
4969
def job(modifiable_arg: Dict):
5070
# Modify the variable, which should be shared with the main thread.
@@ -60,11 +80,21 @@ def job(modifiable_arg: Dict):
6080

6181
start = time.time()
6282
end = start + 3.5 # We want to wait just over 3 seconds
83+
84+
pool = ThreadPool(num_workers=10)
85+
86+
pool.start_scheduler_thread(trigger_period=1) # in seconds
87+
88+
# Start the pool thread
89+
pool.start()
90+
6391
while time.time() < end:
64-
# Launch job and wait one second
65-
schedule.run_pending()
92+
# Wait until we reach our 3+ second deadline
6693
time.sleep(1)
6794

95+
# Stop the pool and scheduler loop
96+
pool.stop()
97+
6898
# Stop all scheduled jobs
6999
schedule.clear()
70100
# Nothing should happen from this point, even if we sleep another while
@@ -74,6 +104,7 @@ def job(modifiable_arg: Dict):
74104
assert test_dict == {"count": 3}
75105

76106

107+
@pytest.mark.skip(reason="Test runs in Thread-1 (not MainThread) but still blocks")
77108
def test_recurring_subprocess():
78109
def job(path: str, modifiable_arg: Dict):
79110
path = Path(path)
@@ -98,11 +129,20 @@ def job(path: str, modifiable_arg: Dict):
98129

99130
start = time.time()
100131
end = start + 3.5 # We want to wait just over 3 seconds
132+
pool = ThreadPool(num_workers=10)
133+
134+
pool.start_scheduler_thread(trigger_period=1) # in seconds
135+
136+
# Start the pool thread
137+
pool.start()
138+
101139
while time.time() < end:
102-
# Launch job and wait one second
103-
schedule.run_pending()
140+
# Wait until we reach our 3+ second deadline
104141
time.sleep(1)
105142

143+
# Stop the pool and scheduler loop
144+
pool.stop()
145+
106146
# Stop all scheduled jobs
107147
schedule.clear()
108148
# Nothing should happen from this point, even if we sleep another while

0 commit comments

Comments
 (0)