Skip to content

Commit 3f587f4

Browse files
ggueretsylvinus
authored andcommitted
Locked jobs (#137)
* allow to lock the job for a specific task path, new expired status related * added test using concurrency * added lock_timeout parameter * worker.stop will stop mongodb too * from lock to job max concurrency * used redis pipeline, safer job run * added test on max concurrency interrupt * added expire on concurrency key * removed unused imports * no longer necessary * exception on interrupt * use of a simple lock for the current need * catch proper exception
1 parent ebd1176 commit 3f587f4

7 files changed

Lines changed: 94 additions & 3 deletions

File tree

mrq/basetasks/cleaning.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class RequeueInterruptedJobs(Task):
1010

1111
""" Requeue jobs that were marked as status=interrupt when a worker got a SIGTERM. """
1212

13+
max_concurrency = 1
14+
1315
def run(self, params):
1416
return run_task("mrq.basetasks.utils.JobAction", {
1517
"status": "interrupt",
@@ -21,6 +23,8 @@ class RequeueRetryJobs(Task):
2123

2224
""" Requeue jobs that were marked as retry. """
2325

26+
max_concurrency = 1
27+
2428
def run(self, params):
2529
return run_task("mrq.basetasks.utils.JobAction", {
2630
"status": "retry",
@@ -37,6 +41,8 @@ class RequeueStartedJobs(Task):
3741
The timeout parameter of this task is in addition to the task's own timeout.
3842
"""
3943

44+
max_concurrency = 1
45+
4046
def run(self, params):
4147

4248
additional_timeout = params.get("timeout", 300)
@@ -76,6 +82,8 @@ class RequeueRedisStartedJobs(Task):
7682
redis.lpop and mongodb.update
7783
"""
7884

85+
max_concurrency = 1
86+
7987
def run(self, params):
8088

8189
redis_key_started = Queue.redis_key_started()
@@ -124,6 +132,8 @@ class RequeueLostJobs(Task):
124132
They could have been lost by a Redis flush or another severe issue
125133
"""
126134

135+
max_concurrency = 1
136+
127137
def run(self, params):
128138

129139
# If there are more than this much items on the queue, we don't try to check if our mongodb
@@ -193,6 +203,9 @@ class MigrateKnownQueues(Task):
193203
"""
194204
Migrate known_queues from old set format to new zset
195205
"""
206+
207+
max_concurrency = 1
208+
196209
def run(self, params):
197210
key = "%s:known_queues" % get_current_config()["redis_prefix"]
198211
for queue in connections.redis.smembers(key):
@@ -209,6 +222,8 @@ class CleanKnownQueues(Task):
209222
- be empty
210223
"""
211224

225+
max_concurrency = 1
226+
212227
def run(self, params):
213228

214229
max_age = int(params.get("max_age") or (7 * 86400))

mrq/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ class StopRequested(GreenletExit):
5050
class JobInterrupt(GreenletExit):
5151
""" Interrupts that stop a job in its execution, e.g. when responding to a SIGTERM. """
5252
pass
53+
54+
55+
class MaxConcurrencyInterrupt(_MrqInterrupt):
56+
pass

mrq/job.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import datetime
22
from bson import ObjectId
3+
from redis.exceptions import LockError
34
import time
4-
from .exceptions import RetryInterrupt, MaxRetriesInterrupt, AbortInterrupt
5+
from .exceptions import RetryInterrupt, MaxRetriesInterrupt, AbortInterrupt, MaxConcurrencyInterrupt
56
from .utils import load_class_by_path, group_iter
67
import gevent
78
import objgraph
@@ -70,6 +71,12 @@ def __init__(self, job_id, queue=None, start=False, fetch=False):
7071
elif fetch:
7172
self.fetch(start=False, full_data=False)
7273

74+
@property
75+
def redis_max_concurrency_key(self):
76+
""" Returns the global redis key used to store started job ids """
77+
return "%s:c:%s" % (context.get_current_config()["redis_prefix"],
78+
self.data["path"])
79+
7380
def exists(self):
7481
""" Returns True if a job with the current _id exists in MongoDB. """
7582
return bool(self.collection.find_one({"_id": self.id}, projection={"_id": 1}))
@@ -274,7 +281,27 @@ def perform(self):
274281

275282
self.task.is_main_task = True
276283

277-
result = self.task.run_wrapped(self.data["params"])
284+
try:
285+
lock = None
286+
287+
if self.task.max_concurrency:
288+
289+
if self.task.max_concurrency > 1:
290+
raise NotImplementedError()
291+
292+
# TODO: implement a semaphore
293+
lock = context.connections.redis.lock(self.redis_max_concurrency_key, timeout=self.timeout + 5)
294+
if not lock.acquire(blocking=True, blocking_timeout=0):
295+
raise MaxConcurrencyInterrupt()
296+
297+
result = self.task.run_wrapped(self.data["params"])
298+
299+
finally:
300+
if lock:
301+
try:
302+
lock.release()
303+
except LockError:
304+
pass
278305

279306
self.save_success(result)
280307

mrq/task.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ class Task(object):
33

44
# Are we the first task that a Job called?
55
is_main_task = False
6+
max_concurrency = 0
67

78
# Default write concern values when setting status=success
89
# http://docs.mongodb.org/manual/reference/write-concern/

mrq/worker.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from .job import Job
1818
from .exceptions import (TimeoutInterrupt, StopRequested, JobInterrupt, AbortInterrupt,
19-
RetryInterrupt, MaxRetriesInterrupt)
19+
RetryInterrupt, MaxRetriesInterrupt, MaxConcurrencyInterrupt)
2020
from .context import (set_current_worker, set_current_job, get_current_job, get_current_config,
2121
connections, enable_greenlet_tracing)
2222
from .queue import Queue
@@ -617,6 +617,10 @@ def perform_job(self, job):
617617
try:
618618
job.perform()
619619

620+
except MaxConcurrencyInterrupt:
621+
self.log.error("Max concurrency reached")
622+
job._save_status("maxconcurrency", exception=True)
623+
620624
except RetryInterrupt:
621625
self.log.error("Caught retry")
622626
job.save_retry(sys.exc_info()[1])

tests/tasks/concurrency.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import time
2+
from mrq.task import Task
3+
from mrq.context import log
4+
from .general import Add
5+
6+
class LockedAdd(Add):
7+
8+
max_concurrency = 1
9+
10+
def run(self, params):
11+
log.info("adding", params)
12+
res = params.get("a", 0) + params.get("b", 0)
13+
14+
if params.get("sleep", 0):
15+
log.info("sleeping", params.get("sleep", 0))
16+
time.sleep(params.get("sleep", 0))
17+
18+
return res

tests/test_interrupts.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,25 @@ def test_interrupt_maxjobs(worker):
319319
time.sleep(2)
320320

321321
assert Queue("default").size() == 7
322+
323+
324+
def test_interrupt_maxconcurrency(worker):
325+
326+
# The worker will raise a maxconcurrency on the second job
327+
worker.start(flags="--greenlets=2")
328+
329+
job_ids = worker.send_tasks("tests.tasks.concurrency.LockedAdd", [
330+
{"a": i, "b": 1, "sleep": 2}
331+
for i in range(2)
332+
], block=False)
333+
334+
worker.wait_for_tasks_results(job_ids, accept_statuses=["success", "failed", "maxconcurrency"])
335+
job_statuses = [Job(job_id).fetch().data["status"] for job_id in job_ids]
336+
assert job_statuses == ["success", "maxconcurrency"]
337+
338+
# the job concurrency key must be equal to 0
339+
last_job_id = worker.send_task("tests.tasks.concurrency.LockedAdd",
340+
{"a": 1, "b": 1, "sleep": 2}, block=False
341+
)
342+
last_job = Job(last_job_id).wait(poll_interval=0.01)
343+
assert last_job.get("status") == "success"

0 commit comments

Comments
 (0)