Skip to content

Commit 5d6cc22

Browse files
committed
Add Agent, ProcessPool, and our own Supervisor
1 parent cd0b3a9 commit 5d6cc22

16 files changed

Lines changed: 692 additions & 150 deletions

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ RUN mkdir -p /data/db
5555

5656
RUN ln -s /app/mrq/bin/mrq_run.py /usr/bin/mrq-run
5757
RUN ln -s /app/mrq/bin/mrq_worker.py /usr/bin/mrq-worker
58+
RUN ln -s /app/mrq/bin/mrq_agent.py /usr/bin/mrq-agent
59+
RUN ln -s /app/mrq/dashboard/app.py /usr/bin/mrq-dashboard
60+
5861
ENV PYTHONPATH /app
5962

6063
VOLUME ["/data"]

mrq/agent.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
from future.builtins import object
2+
3+
from .context import get_current_config, connections, log
4+
import time
5+
import json
6+
import gevent
7+
from bson import ObjectId
8+
from collections import defaultdict
9+
from .processes import Process, ProcessPool
10+
11+
12+
class Agent(Process):
13+
""" MRQ Agent manages its local worker pool and takes turns in orchestrating the others in its group. """
14+
15+
def __init__(self, worker_group=None):
16+
self.greenlets = {}
17+
self.id = ObjectId()
18+
self.worker_group = worker_group or get_current_config()["worker_group"]
19+
self.pool = ProcessPool()
20+
21+
def work(self):
22+
23+
self.install_signal_handlers()
24+
25+
self.greenlets["orchestrate"] = gevent.spawn(self.greenlet_orchestrate)
26+
self.greenlets["orchestrate"].start()
27+
28+
self.greenlets["manage"] = gevent.spawn(self.greenlet_manage)
29+
self.greenlets["manage"].start()
30+
31+
self.pool.start()
32+
33+
self.pool.wait()
34+
35+
def shutdown_now(self):
36+
self.pool.terminate()
37+
38+
self.greenlets["orchestrate"].kill()
39+
self.greenlets["manage"].kill()
40+
41+
def shutdown_graceful(self):
42+
self.pool.stop(timeout=None)
43+
44+
def greenlet_manage(self):
45+
""" This greenlet always runs in background to update current status
46+
in MongoDB every N seconds.
47+
"""
48+
49+
while True:
50+
try:
51+
self.manage()
52+
except Exception as e: # pylint: disable=broad-except
53+
self.log.error("When reporting: %s" % e)
54+
finally:
55+
time.sleep(self.config["report_interval"])
56+
57+
def manage(self):
58+
59+
report = self.get_agent_report()
60+
61+
try:
62+
db = self.mongodb_jobs.mrq_agents.find_and_modify({
63+
"_id": ObjectId(self.id)
64+
}, {"$set": report}, upsert=True)
65+
except Exception as e: # pylint: disable=broad-except
66+
self.log.debug("Agent report failed: %s" % e)
67+
return
68+
69+
# If the desired_workers was changed by an orchestrator, apply the changes locally
70+
if sorted(db.get("desired_workers", [])) != sorted(self.pool.desired_commands):
71+
self.pool.set_commands(db.get("desired_workers", []))
72+
73+
def get_agent_report(self):
74+
report = {
75+
"current_workers": [p["command"] for p in self.pool.processes],
76+
"available_cpu": get_current_config()["available_cpu"],
77+
"available_memory": get_current_config()["available_memory"]
78+
}
79+
return report
80+
81+
def greenlet_orchestrate(self):
82+
83+
while True:
84+
with connections.redis.lock(self.redis_agent_orchestrator_key, timeout=60):
85+
self.orchestrate()
86+
time.sleep(30)
87+
88+
@property
89+
def redis_agent_orchestrator_key(self):
90+
""" Returns the global redis key used to ensure only one agent orchestrator runs at a time """
91+
return "%s:agentorchestrator:%s" % (get_current_config()["redis_prefix"], self.worker_group)
92+
93+
def orchestrate(self):
94+
""" Executed periodically on one of the agents, to manage the desired workers of *all* the agents in its group """
95+
96+
group = self.fetch_worker_group_definition()
97+
if not group:
98+
log.error("Worker group %s has no definition yet. Can't orchestrate!" % self.worker_group)
99+
return
100+
101+
agents = self.fetch_worker_group_agents()
102+
103+
desired_workers = self.get_desired_workers_for_group(group)
104+
105+
# Evaluate what workers are currently, rightfully there. They won't be touched.
106+
current_workers = defaultdict(int)
107+
for agent in agents:
108+
agent["free_memory"] = agent["available_memory"]
109+
agent["free_cpu"] = agent["available_cpu"]
110+
agent["new_desired_workers"] = []
111+
for worker in agent.get("desired_workers", []):
112+
if worker in desired_workers:
113+
cpu = desired_workers[worker]["cpu"]
114+
memory = desired_workers[worker]["memory"]
115+
116+
# If no more memory for currently existing workers: their requirements must have changed.
117+
# We need to schedule it somewhere else
118+
if cpu <= agent["free_cpu"] and memory <= agent["free_memory"]:
119+
current_workers[worker] += 1
120+
agent["free_cpu"] -= cpu
121+
agent["free_memory"] -= memory
122+
agent["new_desired_workers"].append(worker)
123+
124+
# What changes need to be made in worker count
125+
deltas = {
126+
worker: (worker_info["desired_count"] - current_workers[worker])
127+
for worker, worker_info in desired_workers.items()
128+
if worker_info["desired_count"] != current_workers[worker]
129+
}
130+
131+
# Remove workers from the most loaded machines (TODO improve)
132+
for worker, delta in deltas.items():
133+
if delta >= 0:
134+
continue
135+
136+
for _ in range(delta, 0):
137+
found = False
138+
for agent in sorted(agents, key=lambda a: float(a["free_cpu"]) / a["available_cpu"]):
139+
for i in range(len(agent["new_desired_workers"])):
140+
if agent["new_desired_workers"][i] == worker:
141+
agent["new_desired_workers"].pop(i)
142+
agent["free_cpu"] += desired_workers[worker]["cpu"]
143+
agent["free_memory"] += desired_workers[worker]["memory"]
144+
found = True
145+
break
146+
if found:
147+
break
148+
149+
assert found
150+
151+
# Add new workers to the least loaded machines
152+
for worker, delta in deltas.items():
153+
if delta <= 0:
154+
continue
155+
156+
cpu = desired_workers[worker]["cpu"]
157+
memory = desired_workers[worker]["memory"]
158+
159+
for _ in range(delta):
160+
found = False
161+
for agent in sorted(agents, key=lambda a: float(a["free_cpu"]) / a["available_cpu"], reverse=True):
162+
if cpu <= agent["free_cpu"] and memory <= agent["free_memory"]:
163+
agent["new_desired_workers"].append(worker)
164+
agent["free_cpu"] -= cpu
165+
agent["free_memory"] -= memory
166+
found = True
167+
break
168+
169+
if not found:
170+
log.debug("Worker orchestration: no agent had enough CPU & memory (%s & %s) to schedule a new worker" % (cpu, memory))
171+
# TODO: communicate the need for new resources
172+
break
173+
174+
for agent in agents:
175+
if sorted(agent["new_desired_workers"]) != sorted(agent.get("desired_workers", [])):
176+
# Commit the changes in DB
177+
connections.mongodb_jobs.mrq_agents.update_one({"_id": agent["_id"]}, {"$set": {
178+
"desired_workers": agent["new_desired_workers"],
179+
"free_cpu": agent["free_cpu"],
180+
"free_memory": agent["free_memory"]
181+
}})
182+
183+
def get_desired_workers_for_group(self, group):
184+
185+
workers = {}
186+
187+
for profile in group.get("profiles", []):
188+
workers[profile["command"]] = {
189+
"desired_count": profile["min_count"], # TODO!
190+
"memory": profile["memory"],
191+
"cpu": profile["cpu"]
192+
}
193+
194+
return workers
195+
196+
def fetch_worker_group_agents(self):
197+
return list(connections.mongodb_jobs.mrq_agents.find({"worker_group": self.worker_group}))
198+
199+
def fetch_worker_group_definition(self):
200+
return connections.mongodb_jobs.mrq_workergroups.find_one({"_id": self.worker_group})
201+

mrq/bin/mrq_agent.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env python
2+
import os
3+
4+
# Needed to make getaddrinfo() work in pymongo on Mac OS X
5+
# Docs mention it's a better choice for Linux as well.
6+
# This must be done asap in the worker
7+
if "GEVENT_RESOLVER" not in os.environ:
8+
os.environ["GEVENT_RESOLVER"] = "ares"
9+
10+
from gevent import monkey
11+
monkey.patch_all(subprocess=False)
12+
13+
import sys
14+
import argparse
15+
16+
from .config import get_config
17+
from .agent import Agent
18+
from .context import set_current_config
19+
20+
21+
def main():
22+
23+
parser = argparse.ArgumentParser(description='Start a MRQ agent')
24+
25+
cfg = get_config(parser=parser, config_type="agent", sources=("file", "env", "args"))
26+
27+
set_current_config(cfg)
28+
29+
agent = Agent()
30+
31+
agent.work()
32+
33+
sys.exit(agent.exitcode)
34+
35+
36+
if __name__ == "__main__":
37+
main()

mrq/bin/mrq_worker.py

Lines changed: 13 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import signal
1717
import psutil
1818
import argparse
19+
import pipes
1920

2021
try:
2122
import subprocess32 as subprocess
@@ -31,88 +32,29 @@
3132

3233
def main():
3334

34-
parser = argparse.ArgumentParser(description='Start a RQ worker')
35+
parser = argparse.ArgumentParser(description='Start a MRQ worker')
3536

3637
cfg = config.get_config(parser=parser, config_type="worker", sources=("file", "env", "args"))
3738

38-
# If we are launching with a --processes option and without the SUPERVISOR_ENABLED env
39-
# then we should just call supervisord.
40-
if cfg["processes"] > 0 and not os.environ.get("SUPERVISOR_ENABLED"):
39+
set_current_config(cfg)
4140

42-
# We wouldn't need to do all that if supervisord supported environment
43-
# variables in all its config fields!
44-
with open(cfg["supervisord_template"], "r") as f:
45-
conf = f.read()
41+
# If we are launching with a --processes option and without MRQ_IS_SUBPROCESS, we are a manager process
42+
if cfg["processes"] > 0 and not os.environ.get("MRQ_IS_SUBPROCESS"):
4643

47-
fh, path = tempfile.mkstemp(prefix="mrqsupervisordconfig")
48-
f = os.fdopen(fh, "w")
44+
from mrq.supervisor import Supervisor
4945

50-
# We basically relaunch ourselves, but the config will contain the
51-
# MRQ_SUPERVISORD_ISWORKER env.
52-
conf = conf.replace("{{ SUPERVISORD_COMMAND }}", " ".join(sys.argv))
53-
conf = conf.replace(
54-
"{{ SUPERVISORD_PROCESSES }}", str(cfg["processes"]))
46+
command = " ".join(map(pipes.quote, sys.argv))
47+
w = Supervisor(command, numprocs=cfg["processes"])
48+
w.work()
49+
sys.exit(w.exitcode)
5550

56-
f.write(conf)
57-
f.close()
58-
59-
try:
60-
61-
# start_new_session=True avoids sending the current process'
62-
# signals to the child.
63-
process = subprocess.Popen(
64-
["supervisord", "-c", path], start_new_session=True)
65-
66-
def sigint_handler(signum, frame): # pylint: disable=unused-argument
67-
68-
# At this point we need to send SIGINT to all workers. Unfortunately supervisord
69-
# doesn't support this, so we have to find all the children pids and send them the
70-
# signal ourselves :-/
71-
# https://github.com/Supervisor/supervisor/issues/179
72-
#
73-
psutil_process = psutil.Process(process.pid)
74-
worker_processes = psutil_process.children(recursive=False)
75-
76-
if len(worker_processes) == 0:
77-
return process.send_signal(signal.SIGTERM)
78-
79-
for child_process in worker_processes:
80-
child_process.send_signal(signal.SIGINT)
81-
82-
# Second time sigint is used, we should terminate supervisord itself which
83-
# will send SIGTERM to all the processes anyway.
84-
signal.signal(signal.SIGINT, sigterm_handler)
85-
86-
# Wait for all the childs to finish
87-
for child_process in worker_processes:
88-
child_process.wait()
89-
90-
# Then stop supervisord itself.
91-
process.send_signal(signal.SIGTERM)
92-
93-
def sigterm_handler(signum, frame): # pylint: disable=unused-argument
94-
process.send_signal(signal.SIGTERM)
95-
96-
signal.signal(signal.SIGINT, sigint_handler)
97-
signal.signal(signal.SIGTERM, sigterm_handler)
98-
99-
process.wait()
100-
101-
finally:
102-
os.remove(path)
103-
104-
# If not, start the actual worker
51+
# If not, start an actual worker
10552
else:
10653

10754
worker_class = load_class_by_path(cfg["worker_class"])
108-
109-
set_current_config(cfg)
110-
11155
w = worker_class()
112-
113-
exitcode = w.work()
114-
115-
sys.exit(exitcode)
56+
w.work()
57+
sys.exit(w.exitcode)
11658

11759
if __name__ == "__main__":
11860
main()

mrq/config.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import sys
66
import re
7+
import psutil
78
from .version import VERSION
89
from .utils import get_local_ip, DelimiterArgParser
910
import atexit
@@ -255,8 +256,31 @@ def add_parser_args(parser, config_type):
255256
type=str,
256257
help='Bind the dashboard to this IP. Default is "0.0.0.0", use "127.0.0.1" to restrict access.')
257258

258-
# Worker-specific args
259+
# Agent-specific args
260+
elif config_type == "agent":
261+
262+
parser.add_argument(
263+
'--worker_group',
264+
default="default",
265+
action="store",
266+
type=str,
267+
help='The name of the worker group to manage')
268+
269+
parser.add_argument(
270+
'--available_memory',
271+
default=int(psutil.virtual_memory().total * 0.8),
272+
action="store",
273+
type=int,
274+
help="How much memory MB this agent's workers can use. Used for scheduling, not a hard limit.")
259275

276+
parser.add_argument(
277+
'--available_cpu',
278+
default=psutil.cpu_count(logical=True) * 1024,
279+
action="store",
280+
type=int,
281+
help="How much CPU units this agent's workers can use. We recommend using 1024 per CPU.")
282+
283+
# Worker-specific args
260284
elif config_type == "worker":
261285

262286
parser.add_argument(

0 commit comments

Comments
 (0)