Skip to content

Commit 4b1cca8

Browse files
committed
Memory leak tools + rewrote & tested config merger
1 parent b0e0640 commit 4b1cca8

11 files changed

Lines changed: 131 additions & 56 deletions

File tree

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ test_jenkins: docker
1010
ssh:
1111
sh -c "docker run -rm -i -t -p 27017:27017 -p 6379:6379 -p 5555:5555 -p 20020:20020 -v `pwd`:/app:rw -w /app mrq/mrq_local bash"
1212

13+
ssh_noport:
14+
sh -c "docker run -rm -i -t -v `pwd`:/app:rw -w /app mrq/mrq_local bash"
15+
1316
lint:
1417
pylint --init-hook="import sys; sys.path.append('.')" --rcfile .pylintrc mrq
1518

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,27 @@ The main features of MRQ are:
2626
* **Thorough testing:** Edge-cases like worker interrupts, Redis failures, ... are tested inside a Docker container.
2727
* **Builtin scheduler:** Schedule tasks by interval or by time of the day
2828
* **Greenlet tracing:** See how much time was spent in each greenlet to debug CPU-intensive jobs.
29+
* **Integrated memory leak debugger:** Track down jobs leaking memory and find the leaks with objgraph.
2930

3031
Performance
3132
===========
3233

3334
On a MacbookPro, we see 1300 jobs/second in a single worker process with very simple jobs that store results, to measure the overhead of MRQ. However what we are really measuring there is MongoDB's write performance.
3435

36+
Hunting memory leaks
37+
====================
38+
39+
Memory leaks can be a big issue with gevent workers because several tasks share the same python process.
40+
41+
Thankfully, MRQ provides tools to track down such issues. Memory usage of each worker is graphed in the dashboard and makes it easy to see if memory leaks are happening.
42+
43+
When a worker has a steadily growing memory usage, here are the steps to find the leak:
44+
45+
* Check which jobs are running on this worker and try to isolate which of them is leaking and on which queue
46+
* Start a dedicated worker with ```--trace_memory --gevent 1``` on the same queue : This will start a worker doing one job at a time with memory profiling enabled. After each job you should see a report of leaked object types.
47+
* Find the most unique type in the list (usually not 'list' or 'dict') and restart the worker with ```--trace_memory --gevent 1 --trace_memory_type=XXX --trace_memory_output_dir=memdbg``` (after creating the directory memdbg).
48+
* There you will find a graph for each task generated by [objgraph](https://mg.pov.lt/objgraph/) which is incredibly helpful to track down the leak.
49+
3550
Tests
3651
=====
3752

mrq/basetasks/tests/general.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from time import sleep
22
from mrq.task import Task
3-
from mrq.context import log, retry_current_job, connections
3+
from mrq.context import log, retry_current_job, connections, get_current_config
44
import urllib2
55

66

@@ -37,6 +37,11 @@ def run(self, params):
3737
return len(t)
3838

3939

40+
class GetConfig(Task):
41+
def run(self, params):
42+
return get_current_config()
43+
44+
4045
LEAKS = []
4146

4247

@@ -92,5 +97,10 @@ def run(self, params):
9297

9398
connections.mongodb_logs.tests_inserts.insert(params)
9499

100+
if params.get("sleep", 0) > 0:
101+
sleep(params.get("sleep", 0))
102+
103+
return params
104+
95105

96106
MongoInsert2 = MongoInsert

mrq/config.py

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44

55

6-
def get_config(sources=("file", "env", "args"), env_prefix="MRQ_", defaults=None):
6+
def get_config(sources=("file", "env", "args"), env_prefix="MRQ_"):
77

88
parser = argparse.ArgumentParser(description='Starts an RQ worker')
99

@@ -89,46 +89,53 @@ def get_config(sources=("file", "env", "args"), env_prefix="MRQ_", defaults=None
8989
parser.add_argument('queues', nargs='*', default=["default"],
9090
help='The queues to listen on (default: \'default\')')
9191

92-
if "args" in sources:
93-
from_args = parser.parse_args()
94-
else:
95-
from_args = parser.parse_args([])
96-
97-
# Get defaults
98-
merged_config = from_args.__dict__
99-
if defaults is not None:
100-
merged_config.update(defaults)
101-
102-
# If a mrq-config.py file is in the current directory, use it!
103-
default_config_file = os.path.join(os.getcwd(), "mrq-config.py")
104-
105-
if merged_config["config"] is None and os.path.isfile(default_config_file):
106-
# print "Using config file at %s" % default_config_file
107-
merged_config["config"] = default_config_file
108-
109-
config_module = None
110-
if "file" in sources and merged_config["config"]:
111-
sys.path.append(os.path.dirname(merged_config["config"]))
112-
config_module = __import__(os.path.basename(merged_config["config"].replace(".py", "")))
113-
sys.path.pop(-1)
114-
merged_config.update({k.lower(): v for k, v in config_module.__dict__.iteritems() if k[0].isupper()})
92+
default_config = parser.parse_args([]).__dict__
11593

11694
# Keys that can't be passed from the command line
117-
merged_config["tasks"] = {}
118-
merged_config["scheduled_tasks"] = {}
95+
default_config["tasks"] = {}
96+
default_config["scheduled_tasks"] = {}
97+
98+
# Only keep values different from config, actually passed on the command line
99+
from_args = {}
100+
if "args" in sources:
101+
for k, v in parser.parse_args().__dict__.iteritems():
102+
if default_config[k] != v:
103+
from_args[k] = v
119104

105+
# If we were given another config file, use it
106+
if from_args.get("config"):
107+
config_file = from_args.get("config")
108+
# If a mrq-config.py file is in the current directory, use it!
109+
elif os.path.isfile(os.path.join(os.getcwd(), "mrq-config.py")):
110+
config_file = os.path.join(os.getcwd(), "mrq-config.py")
111+
else:
112+
config_file = None
113+
114+
from_file = {}
115+
if config_file and "file" in sources:
116+
sys.path.insert(0, os.path.dirname(config_file))
117+
config_module = __import__(os.path.basename(config_file.replace(".py", "")))
118+
sys.path.pop(0)
119+
for k, v in config_module.__dict__.iteritems():
120+
121+
# We only keep variables starting with an uppercase character.
122+
if k[0].isupper():
123+
default_config[k.lower()] = v
124+
if k.lower() not in default_config:
125+
default_config[k.lower()] = v
126+
127+
# Merge the config in the order given by the user
128+
merged_config = default_config
120129
for part in sources:
121130
for name, arg_value in merged_config.iteritems():
122131

123-
value = None
124132
if part == "env":
125133
value = os.environ.get(env_prefix + name.upper())
126-
elif part == "args":
127-
value = arg_value
128-
elif part == "file":
129-
value = getattr(config_module, name.upper(), None)
130-
131-
if value is not None:
132-
merged_config[name] = value
134+
if value:
135+
merged_config[name] = value
136+
elif part == "args" and name in from_args:
137+
merged_config[name] = from_args[name]
138+
elif part == "file" and name in from_file:
139+
merged_config[name] = from_file[name]
133140

134141
return merged_config

mrq/worker.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ def work_loop(self):
338338
# When debugging memory, intermediate psutils call like this one are
339339
# needed for some obscure reason. (tested in test_memoryleaks.py)
340340
self.get_memory()
341+
gevent.sleep(0.1)
341342

342343
free_pool_slots = self.gevent_pool.free_count()
343344

requirements-base.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ ujson==1.33
66
hiredis==0.1.2
77
psutil==1.2.1
88
supervisor==3.0
9-
subprocess32==3.2.5
9+
subprocess32==3.2.5
10+
objgraph==1.8.0

tests/fixtures/config2.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
NAME = "testworker"
2+
3+
# There configs should be added transparently.
4+
ADDITIONAL_UNEXPECTED_CONFIG = "1"

tests/test_cancel.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ def test_cancel_by_path(worker):
66
# Start the worker with only one greenlet so that tasks execute sequentially
77
worker.start(flags="--gevent 1")
88

9-
job_id1 = worker.send_task("mrq.basetasks.tests.general.Add", {"a": 41, "b": 1, "sleep": 2}, block=False)
9+
job_id1 = worker.send_task("mrq.basetasks.tests.general.MongoInsert", {"a": 41, "sleep": 2}, block=False)
1010

1111
worker.send_task("mrq.basetasks.utils.JobAction", {
1212
"path": "mrq.basetasks.tests.general.Add",
1313
"status": "queued",
1414
"action": "cancel"
1515
}, block=False)
1616

17-
job_id2 = worker.send_task("mrq.basetasks.tests.general.Add", {"a": 41, "b": 2}, block=False)
17+
job_id2 = worker.send_task("mrq.basetasks.tests.general.MongoInsert", {"a": 43}, block=False)
1818

1919
Job(job_id2).wait(poll_interval=0.01)
2020
worker.stop(deps=False)
@@ -23,7 +23,9 @@ def test_cancel_by_path(worker):
2323
job2 = Job(job_id2).fetch().data
2424

2525
assert job1["status"] == "success"
26-
assert job1["result"] == 42
26+
assert job1["result"] == {"a": 41, "sleep": 2}
2727

2828
assert job2["status"] == "cancel"
2929
assert job2.get("result") is None
30+
31+
assert worker.mongodb_logs.test_inserts.count() == 1

tests/test_config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
3+
def test_config(worker):
4+
""" Test different config passing options. """
5+
6+
worker.start()
7+
8+
cfg = worker.send_task("mrq.basetasks.tests.general.GetConfig", {}, block=True)
9+
10+
assert "mongodb_jobs" in cfg
11+
assert cfg.get("additional_unexpected_config") is None
12+
13+
worker.stop()
14+
15+
worker.start(flags="--config tests/fixtures/config2.py")
16+
17+
cfg = worker.send_task("mrq.basetasks.tests.general.GetConfig", {}, block=True)
18+
19+
assert cfg["name"] == "testworker"
20+
assert cfg.get("additional_unexpected_config") == "1"
21+
22+
worker.stop()
23+
24+
worker.start(flags="--config tests/fixtures/config2.py --name xxx")
25+
26+
cfg = worker.send_task("mrq.basetasks.tests.general.GetConfig", {}, block=True)
27+
28+
assert cfg["name"] == "xxx"
29+
assert cfg.get("additional_unexpected_config") == "1"
30+
31+
worker.stop()

tests/test_memoryleaks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_memoryleaks_1mleak(worker):
5858
worker.mongodb_jobs.mrq_jobs.remove()
5959

6060
# 1M leak!
61-
diff1m = get_diff_after_jobs(worker, 10, 100000, sleep=0.05) # sleep is needed so that psutil measurements are accurate :-/
61+
diff1m = get_diff_after_jobs(worker, 10, 100000, sleep=0.2) # sleep is needed so that psutil measurements are accurate :-/
6262

6363
assert diff1m > 900000
6464

0 commit comments

Comments
 (0)