Skip to content

Commit 9fe0579

Browse files
committed
Autoscaling!
1 parent a5f3d42 commit 9fe0579

10 files changed

Lines changed: 441 additions & 76 deletions

File tree

mrq/agent.py

Lines changed: 197 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
from future.builtins import object
2-
31
from .context import get_current_config, connections, log
42
import time
53
import datetime
64
import gevent
5+
import argparse
6+
import shlex
7+
import random
8+
import math
9+
import re
10+
from collections import defaultdict
711
from bson import ObjectId
812
from redis.lock import LuaLock
9-
from collections import defaultdict
1013
from .processes import Process, ProcessPool
14+
from .config import add_parser_args
15+
from .utils import MovingETA
16+
from .queue import Queue
1117

1218

1319
class Agent(Process):
@@ -17,7 +23,10 @@ def __init__(self, worker_group=None):
1723
self.greenlets = {}
1824
self.id = ObjectId()
1925
self.worker_group = worker_group or get_current_config()["worker_group"]
20-
self.pool = ProcessPool(extra_env={"MRQ_AGENT_ID": str(self.id)})
26+
self.pool = ProcessPool(extra_env={
27+
"MRQ_AGENT_ID": str(self.id),
28+
"MRQ_WORKER_GROUP": self.worker_group
29+
})
2130
self.config = get_current_config()
2231
self.status = "started"
2332

@@ -32,6 +41,9 @@ def work(self):
3241
self.greenlets["manage"] = gevent.spawn(self.greenlet_manage)
3342
self.greenlets["manage"].start()
3443

44+
self.greenlets["queuestats"] = gevent.spawn(self.greenlet_queuestats)
45+
self.greenlets["queuestats"].start()
46+
3547
self.pool.start()
3648

3749
try:
@@ -44,8 +56,8 @@ def work(self):
4456
def shutdown_now(self):
4557
self.pool.terminate()
4658

47-
self.greenlets["orchestrate"].kill()
48-
self.greenlets["manage"].kill()
59+
for g in self.greenlets.values():
60+
g.kill()
4961

5062
def shutdown_graceful(self):
5163
self.pool.stop(timeout=None)
@@ -97,15 +109,15 @@ def get_agent_report(self):
97109
def greenlet_orchestrate(self):
98110

99111
while True:
100-
with LuaLock(connections.redis, self.redis_agent_orchestrator_key,
112+
with LuaLock(connections.redis, self.redis_orchestrator_lock_key,
101113
timeout=self.config["orchestrate_interval"] + 10, thread_local=False, blocking=False):
102114
self.orchestrate()
103115
time.sleep(self.config["orchestrate_interval"])
104116

105117
@property
106-
def redis_agent_orchestrator_key(self):
118+
def redis_orchestrator_lock_key(self):
107119
""" Returns the global redis key used to ensure only one agent orchestrator runs at a time """
108-
return "%s:agentorchestrator:%s" % (get_current_config()["redis_prefix"], self.worker_group)
120+
return "%s:orchestratorlock:%s" % (get_current_config()["redis_prefix"], self.worker_group)
109121

110122
def orchestrate(self):
111123
""" Executed periodically on one of the agents, to manage the desired workers of *all* the agents in its group """
@@ -119,7 +131,7 @@ def orchestrate(self):
119131

120132
agents = self.fetch_worker_group_agents()
121133

122-
desired_workers = self.get_desired_workers_for_group(group)
134+
desired_workers = self.get_desired_workers_for_group(group, agents)
123135

124136
# Evaluate what workers are currently, rightfully there. They won't be touched.
125137
current_workers = defaultdict(int)
@@ -167,6 +179,8 @@ def orchestrate(self):
167179

168180
assert found
169181

182+
needs_new_agents = False
183+
170184
# Add new workers to the least loaded machines
171185
for worker, delta in deltas.items():
172186
if delta <= 0:
@@ -187,9 +201,13 @@ def orchestrate(self):
187201

188202
if not found:
189203
log.debug("Worker orchestration: no agent had enough CPU & memory (%s & %s) to schedule a new worker" % (cpu, memory))
190-
# TODO: communicate the need for new resources
204+
needs_new_agents = True
191205
break
192206

207+
connections.mongodb_jobs.worker_groups.update_one({"_id": self.worker_group}, {"$set": {
208+
"needs_new_agents": needs_new_agents
209+
}})
210+
193211
for agent in agents:
194212
if sorted(agent["new_desired_workers"]) != sorted(agent.get("desired_workers", [])):
195213
# Commit the changes in DB
@@ -201,21 +219,185 @@ def orchestrate(self):
201219

202220
log.debug("Orchestration finished.")
203221

204-
def get_desired_workers_for_group(self, group):
222+
def get_desired_workers_for_group(self, group, agents):
205223

206224
workers = {}
207225

208-
for profile in group.get("profiles", []):
226+
def unpack(v):
227+
s = v.split(" ")
228+
return (int(s[0]), None if s[1] == "N" else float(s[1]), int(s[2]))
229+
230+
# count_jobs, eta, last_time
231+
etas = {
232+
k: unpack(v)
233+
for k, v in connections.redis.hgetall(self.redis_queuestats_key).items()
234+
}
235+
236+
# Compute average usage of each worker profile
237+
worker_reports = self.fetch_worker_group_reports(projection=[
238+
"_id", "config.worker_profile", "usage_avg", "status", "datestarted" # , "process.cpu", "process.mem"
239+
])
240+
241+
worker_warmups_by_profile = defaultdict(int)
242+
worker_usages_by_profile = defaultdict(list)
243+
for rep in worker_reports:
244+
profileid = rep["config"].get("worker_profile")
245+
if not profileid or rep.get("status") not in ("wait", "spawn", "full"):
246+
continue
247+
# Don't take brand new workers into account yet.
248+
age = (datetime.datetime.utcnow() - rep["datestarted"]).total_seconds()
249+
if age < group.get("profiles", {}).get(profileid, {}).get("warmup", 60):
250+
worker_warmups_by_profile[profileid] += 1
251+
continue
252+
worker_usages_by_profile[profileid].append(rep["usage_avg"])
253+
254+
worker_count_by_profile = defaultdict(int)
255+
for agent in agents:
256+
for command in agent.get("desired_workers", []):
257+
profile = re.search(r"^MRQ_WORKER_PROFILE=([^\s]+)", command)
258+
if profile:
259+
profile = profile.group(1)
260+
worker_count_by_profile[profile] += 1
261+
262+
# Compute the desired count for each profile
263+
# This is the real "autoscaling" part.
264+
for profileid, profile in group.get("profiles", {}).items():
265+
266+
cfg = self.get_config_for_profile(profile)
267+
eta_queues = [q for q in cfg.queues if (q in etas and etas[q][1] is not None)]
268+
worker_usage = sum(worker_usages_by_profile.get(profileid, []))
269+
report_count = len(worker_usages_by_profile.get(profileid, []))
270+
current_desired_count = worker_count_by_profile[profileid]
271+
total_greenlets = (cfg.greenlets or 1) * (cfg.processes or 1)
272+
warmups = worker_warmups_by_profile[profileid]
273+
274+
# By default, try not to change count
275+
desired_count = current_desired_count
276+
277+
# If the queue is doing OK, are all instances necessary?
278+
if warmups == 0 and report_count > 0:
279+
desired_count = math.ceil(worker_usage * (float(current_desired_count) / report_count))
280+
281+
if len(eta_queues) > 0:
282+
283+
total_jobs = sum(etas[q][0] for q in eta_queues)
284+
max_eta = max(etas[q][1] for q in eta_queues)
285+
286+
max_allowed_eta = profile.get("max_eta", 3600)
287+
288+
# Queue is taking too long, try to add an instance.
289+
if max_eta > max_allowed_eta or any(etas[q][1] < 0 for q in eta_queues):
290+
291+
# Don't add a worker if the absolute number of remaining jobs is less than the number of
292+
# greenlets. (They may become available right now)
293+
# Also don't add a worker if the reported, warmed-up worker count is not yet the desired one.
294+
if total_jobs > total_greenlets and report_count == current_desired_count:
295+
desired_count = current_desired_count + 1
296+
297+
final_count = min(max(desired_count, profile["min_count"]), profile["max_count"])
298+
299+
if final_count != current_desired_count:
300+
log.debug("Autoscaling: Changing worker profile %s count from %s to %s" % (
301+
profileid, current_desired_count, final_count
302+
))
303+
209304
workers[profile["command"]] = {
210-
"desired_count": profile["min_count"], # TODO!
305+
"desired_count": final_count,
211306
"memory": profile["memory"],
212307
"cpu": profile["cpu"]
213308
}
214309

215310
return workers
216311

312+
def get_config_for_profile(self, profile):
313+
parser = argparse.ArgumentParser()
314+
add_parser_args(parser, "worker")
315+
parts = shlex.split(profile["command"])
316+
if "mrq-worker" in parts:
317+
parts = parts[parts.index("mrq-worker") + 1:]
318+
return parser.parse_args(parts)
319+
320+
def greenlet_queuestats(self):
321+
322+
interval = min(self.config["orchestrate_interval"], 1 * 60)
323+
lock_timeout = 5 * 60 + (interval * 2)
324+
325+
while True:
326+
lock = LuaLock(connections.redis, self.redis_queuestats_lock_key,
327+
timeout=lock_timeout, thread_local=False, blocking=False)
328+
with lock:
329+
lock_expires = time.time() + lock_timeout
330+
self.queue_etas = defaultdict(lambda: MovingETA(5))
331+
332+
while True:
333+
self.queuestats()
334+
335+
# Because queue stats can be expensive, we try to keep the lock on the same agent
336+
lock_extend = (time.time() + lock_timeout) - lock_expires
337+
lock_expires += lock_extend
338+
lock.extend(lock_extend)
339+
340+
time.sleep(interval)
341+
342+
time.sleep(interval)
343+
344+
@property
345+
def redis_queuestats_lock_key(self):
346+
""" Returns the global redis key used to ensure only one agent orchestrator runs at a time """
347+
return "%s:queuestatslock" % (get_current_config()["redis_prefix"])
348+
349+
@property
350+
def redis_queuestats_key(self):
351+
""" Returns the global HSET redis key used to store queue stats """
352+
return "%s:queuestats" % (get_current_config()["redis_prefix"])
353+
354+
def queuestats(self):
355+
""" Compute ETAs for every known queue & subqueue """
356+
357+
start_time = time.time()
358+
log.debug("Starting queue stats...")
359+
360+
# Fetch all known queues
361+
queues = list(Queue.instanciate_queues(Queue.known_queues.keys()))
362+
363+
new_queues = {queue.id for queue in queues}
364+
old_queues = set(self.queue_etas.keys())
365+
366+
for deleted_queue in old_queues.difference(new_queues):
367+
self.queue_etas.pop(deleted_queue)
368+
369+
t = time.time()
370+
stats = {}
371+
372+
for queue in queues:
373+
cnt = queue.count_jobs_to_dequeue()
374+
eta = self.queue_etas[queue.id].next(cnt, t=t)
375+
376+
# Number of jobs to dequeue, ETA, Time of stats
377+
stats[queue.id] = "%d %s %d" % (cnt, eta if eta is not None else "N", int(t))
378+
379+
with connections.redis.pipeline(transaction=True) as pipe:
380+
if random.randint(0, 100) == 0 or len(stats) == 0:
381+
pipe.delete(self.redis_queuestats_key)
382+
if len(stats) > 0:
383+
pipe.hmset(self.redis_queuestats_key, stats)
384+
pipe.execute()
385+
386+
log.debug("... done queue stats in %0.4fs" % (time.time() - start_time))
387+
217388
def fetch_worker_group_agents(self):
218389
return list(connections.mongodb_jobs.mrq_agents.find({"worker_group": self.worker_group}))
219390

391+
def fetch_worker_group_reports(self, projection=None):
392+
return list(connections.mongodb_jobs.mrq_workers.find({
393+
"config.worker_group": self.worker_group
394+
}, projection=projection))
395+
220396
def fetch_worker_group_definition(self):
221-
return connections.mongodb_jobs.mrq_workergroups.find_one({"_id": self.worker_group})
397+
definition = connections.mongodb_jobs.mrq_workergroups.find_one({"_id": self.worker_group})
398+
399+
# Prepend all commands by their worker profile.
400+
for profileid, profile in definition.get("profiles", {}).items():
401+
profile["command"] = "MRQ_WORKER_PROFILE=%s %s" % (profileid, profile["command"])
402+
403+
return definition

mrq/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,27 @@ def add_parser_args(parser, config_type):
358358
type=str,
359359
help='ID of the Agent this worker process is linked to')
360360

361+
parser.add_argument(
362+
'--worker_id',
363+
default="",
364+
action='store',
365+
type=str,
366+
help='ID of this worker process. Should be left empty to be autogenerated in most cases.')
367+
368+
parser.add_argument(
369+
'--worker_group',
370+
default="",
371+
action='store',
372+
type=str,
373+
help='Worker group of the agent this worker was launched from')
374+
375+
parser.add_argument(
376+
'--worker_profile',
377+
default="",
378+
action='store',
379+
type=str,
380+
help='Worker profile used by the agent to launch this worker')
381+
361382
parser.add_argument(
362383
'queues',
363384
nargs='*',

mrq/processes.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,16 @@ def spawn(self, command):
8787
env["MRQ_IS_SUBPROCESS"] = "1"
8888
env.update(self.extra_env or {})
8989

90-
p = subprocess.Popen(shlex.split(command), shell=False, close_fds=True, env=env, cwd=os.getcwd())
90+
# Extract env variables from shell commands.
91+
parts = shlex.split(command)
92+
for p in list(parts):
93+
if "=" in p:
94+
env[p.split("=")[0]] = p[len(p.split("=")[0]) + 1:]
95+
parts.pop(0)
96+
else:
97+
break
98+
99+
p = subprocess.Popen(parts, shell=False, close_fds=True, env=env, cwd=os.getcwd())
91100

92101
self.processes.append({
93102
"subprocess": p,

0 commit comments

Comments
 (0)