Skip to content

Commit a6d89c4

Browse files
authored
Merge pull request #204 from pricingassistant/refactorworkergroup
Refactorworkergroup
2 parents ea9c9a2 + e073a09 commit a6d89c4

8 files changed

Lines changed: 152 additions & 396 deletions

File tree

mrq/agent.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
from .context import get_current_config, connections, log, run_task
1+
from .context import get_current_config, connections, log, run_task, metric
22
import time
33
import datetime
44
import gevent
55
import argparse
66
import random
7+
import shlex
78
import traceback
89
from collections import defaultdict
910
from bson import ObjectId
1011
from redis.lock import LuaLock
1112
from .processes import Process, ProcessPool
12-
from .utils import MovingETA
13+
from .utils import MovingETA, normalize_command
1314
from .queue import Queue
1415

1516

@@ -26,7 +27,7 @@ def __init__(self, worker_group=None):
2627
})
2728
self.config = get_current_config()
2829
self.status = "started"
29-
30+
metric("agent", data={"worker_group": self.worker_group, "agent_id": self.id})
3031
self.dateorchestrated = None
3132

3233
# global redis key used to ensure only one agent orchestrator runs at a time
@@ -115,6 +116,7 @@ def get_agent_report(self):
115116
"datereported": datetime.datetime.utcnow(),
116117
"dateexpires": datetime.datetime.utcnow() + datetime.timedelta(seconds=(self.config["report_interval"] * 3) + 5)
117118
}
119+
metric("agent", data={"worker_group": self.worker_group, "agent_id": self.id, "worker_count": len(self.pool.processes)})
118120
return report
119121

120122
def greenlet_orchestrate(self):
@@ -192,7 +194,10 @@ def fetch_worker_group_definition(self):
192194
definition = connections.mongodb_jobs.mrq_workergroups.find_one({"_id": self.worker_group})
193195

194196
# Prepend all commands by their worker profile.
195-
for profileid, profile in (definition or {}).get("profiles", {}).items():
196-
profile["command"] = "MRQ_WORKER_PROFILE=%s %s" % (profileid, profile["command"])
197+
commands = []
198+
for command in definition.get("commands", []):
199+
simplified_command, worker_count = normalize_command(command, self.worker_group)
200+
commands.extend([simplified_command] * worker_count)
197201

202+
definition["commands"] = commands
198203
return definition

mrq/basetasks/orchestrator.py

Lines changed: 12 additions & 222 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import shlex
99
import argparse
1010
from ..config import add_parser_args
11+
from ..utils import normalize_command
1112
import traceback
1213
import datetime
1314
import re
@@ -47,143 +48,16 @@ def do_orchestrate(self, group):
4748

4849
agents = self.fetch_worker_group_agents(group)
4950

50-
desired_workers = self.get_desired_workers_for_group(group, agents)
51-
5251
# Evaluate what workers are currently, rightfully there. They won't be touched.
53-
current_workers = defaultdict(int)
5452
for agent in agents:
55-
agent["free_memory"] = agent["total_memory"]
56-
agent["free_cpu"] = agent["total_cpu"]
53+
desired_workers = self.get_desired_workers_for_agent(group, agent)
5754
agent["new_desired_workers"] = []
58-
for worker in agent.get("desired_workers", []):
59-
if worker in desired_workers:
60-
cpu = desired_workers[worker]["cpu"]
61-
memory = desired_workers[worker]["memory"]
62-
63-
# If no more memory for currently existing workers: their requirements must have changed.
64-
# We need to schedule it somewhere else
65-
if cpu <= agent["free_cpu"] and memory <= agent["free_memory"]:
66-
current_workers[worker] += 1
67-
agent["free_cpu"] -= cpu
68-
agent["free_memory"] -= memory
69-
agent["new_desired_workers"].append(worker)
70-
71-
# What changes need to be made in worker count
72-
deltas = {
73-
worker: (worker_info["desired_count"] - current_workers[worker])
74-
for worker, worker_info in desired_workers.items()
75-
if worker_info["desired_count"] != current_workers[worker]
76-
}
77-
78-
# Remove workers from the most loaded machines (TODO improve)
79-
for worker, delta in deltas.items():
80-
if delta >= 0:
81-
continue
82-
83-
for _ in range(delta, 0):
84-
found = False
85-
for agent in sorted(agents, key=lambda a: float(a["free_cpu"]) / a["total_cpu"]):
86-
for i in range(len(agent["new_desired_workers"])):
87-
if agent["new_desired_workers"][i] == worker:
88-
agent["new_desired_workers"].pop(i)
89-
agent["free_cpu"] += desired_workers[worker]["cpu"]
90-
agent["free_memory"] += desired_workers[worker]["memory"]
91-
found = True
92-
break
93-
if found:
94-
break
95-
96-
assert found
97-
98-
needs_new_agents = False
99-
100-
# Add new workers to the least loaded machines
101-
for worker, delta in deltas.items():
102-
if delta <= 0:
103-
continue
104-
105-
cpu = desired_workers[worker]["cpu"]
106-
memory = desired_workers[worker]["memory"]
107-
108-
for _ in range(delta):
109-
found = False
110-
for agent in sorted(agents, key=lambda a: float(a["free_cpu"]) / a["total_cpu"], reverse=True):
111-
if cpu <= agent["free_cpu"] and memory <= agent["free_memory"]:
112-
agent["new_desired_workers"].append(worker)
113-
agent["free_cpu"] -= cpu
114-
agent["free_memory"] -= memory
115-
found = True
116-
break
117-
118-
if not found:
119-
log.debug("Worker orchestration: no agent had enough CPU & memory (%s & %s) to schedule a new worker" % (cpu, memory))
120-
needs_new_agents = True
121-
break
122-
123-
# Worker diversity enforcement: if we are under-scaled, make sure that at least one worker
124-
# of each profile is launched. if not, make space forcefully on other workers.
125-
if needs_new_agents:
126-
127-
all_profiles = defaultdict(int)
128-
for agent in agents:
129-
for w in agent["new_desired_workers"]:
130-
all_profiles[w] += 1
131-
132-
removable = sorted([list(x) for x in all_profiles.items() if x[1] > 1], key=lambda item: item[1], reverse=True)
133-
134-
for profile in desired_workers:
135-
if desired_workers[profile]["desired_count"] == 0:
136-
continue
137-
if profile not in all_profiles:
138-
found = False
139-
# This profile is not currently represented in the workers. Make some space for it.
140-
for rem in removable:
141-
for agent in agents:
142-
for i in range(len(agent["new_desired_workers"])):
143-
if agent["new_desired_workers"][i] == rem[0] and rem[1] > 1:
144-
agent["new_desired_workers"][i] = None
145-
agent["free_cpu"] += desired_workers[rem[0]]["cpu"]
146-
agent["free_memory"] += desired_workers[rem[0]]["memory"]
147-
rem[1] -= 1
148-
if desired_workers[profile]["cpu"] <= agent["free_cpu"] and desired_workers[profile]["memory"] <= agent["free_memory"]:
149-
log.debug("Orchestration: enforcing worker diversity with %s => %s" % (rem[0], profile))
150-
agent["new_desired_workers"].append(profile)
151-
agent["free_cpu"] -= desired_workers[profile]["cpu"]
152-
agent["free_memory"] -= desired_workers[profile]["memory"]
153-
found = True
154-
break
155-
156-
agent["new_desired_workers"] = [x for x in agent["new_desired_workers"] if x is not None]
157-
if found:
158-
break
159-
160-
if found:
161-
break
162-
163-
if not found:
164-
log.debug("Orchestration couldn't enforce worker diversity for profile '%s'" % profile)
165-
166-
# User-provided autoscaling task
167-
if self.config.get("autoscaling_taskpath"):
168-
result = run_task(self.config["autoscaling_taskpath"], {
169-
"agents": agents,
170-
"needs_new_agents": needs_new_agents,
171-
"worker_group": group["_id"]
172-
})
173-
needs_new_agents = result["needs_new_agents"]
174-
agents = result["agents"]
175-
176-
# Save the new values in the DB. They will be applied by each agent process.
177-
connections.mongodb_jobs.worker_groups.update_one({"_id": group["_id"]}, {"$set": {
178-
"needs_new_agents": needs_new_agents
179-
}})
55+
agent["new_desired_workers"] = desired_workers
18056

18157
for agent in agents:
18258
if sorted(agent["new_desired_workers"]) != sorted(agent.get("desired_workers", [])):
18359
connections.mongodb_jobs.mrq_agents.update_one({"_id": agent["_id"]}, {"$set": {
184-
"desired_workers": agent["new_desired_workers"],
185-
"free_cpu": agent["free_cpu"],
186-
"free_memory": agent["free_memory"]
60+
"desired_workers": agent["new_desired_workers"]
18761
}})
18862

18963
# Remember the date of the last successful orchestration (will be reported)
@@ -195,95 +69,8 @@ def redis_queuestats_key(self):
19569
""" Returns the global HSET redis key used to store queue stats """
19670
return "%s:queuestats" % (get_current_config()["redis_prefix"])
19771

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

28875
def fetch_worker_group_reports(self, worker_group, projection=None):
28976
return list(connections.mongodb_jobs.mrq_workers.find({
@@ -295,9 +82,12 @@ def fetch_worker_group_definitions(self):
29582
definitions = list(connections.mongodb_jobs.mrq_workergroups.find())
29683

29784
for definition in definitions:
298-
# Prepend all commands by their worker profile.
299-
for profileid, profile in (definition or {}).get("profiles", {}).items():
300-
profile["command"] = "MRQ_WORKER_PROFILE=%s %s" % (profileid, profile["command"])
85+
commands = []
86+
# Prepend all commands by their worker group.
87+
for command in definition.get("commands", []):
88+
simplified_command, worker_count = normalize_command(command, definition["_id"])
89+
commands.extend([simplified_command] * worker_count)
90+
definition["commands"] = commands
30191

30292
return definitions
30393

mrq/config.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -409,13 +409,6 @@ def add_parser_args(parser, config_type):
409409
type=str,
410410
help='Worker group of the agent this worker was launched from')
411411

412-
parser.add_argument(
413-
'--worker_profile',
414-
default="",
415-
action='store',
416-
type=str,
417-
help='Worker profile used by the agent to launch this worker')
418-
419412
parser.add_argument(
420413
'--task_whitelist',
421414
default="",

mrq/context.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from .config import get_config
1515
from .subpool import subpool_map, subpool_imap
1616

17-
1817
# This should be MRQ's only Python object shared by all the jobs in the same process
1918
_GLOBAL_CONTEXT = {
2019

mrq/processes.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ def set_commands(self, commands, timeout=None):
5959
self.desired_commands = commands
6060

6161
target_commands = list(self.desired_commands)
62-
6362
for process in list(self.processes):
6463
found = False
6564
for i in range(len(target_commands)):

mrq/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,36 @@
1010
from collections import deque
1111
from bson import ObjectId
1212
import uuid
13+
import shlex
1314

1415
#
1516
# Utils are functions that should be independent from the rest of MRQ's codebase
1617
#
1718

1819

20+
def normalize_command(command, worker_group):
21+
if "--processes" in command:
22+
simplified_command = ""
23+
worker_count = 0
24+
skip_next = False
25+
for part in shlex.split(command):
26+
if skip_next:
27+
worker_count = part
28+
skip_next = False
29+
continue
30+
if part.startswith("--processes="):
31+
worker_count = part.split("=")[1]
32+
continue
33+
if part == "--processes":
34+
skip_next = True
35+
continue
36+
simplified_command += " %s" % part
37+
skip_next = False
38+
simplified_command = "MRQ_WORKER_GROUP=%s%s" % (worker_group, simplified_command)
39+
return simplified_command, int(worker_count)
40+
return "MRQ_WORKER_GROUP=%s %s" % (worker_group, command), 1
41+
42+
1943
def get_local_ip():
2044
""" Returns the local IP. Can be overwritten in the config with --local-ip so don't call
2145
this function directly, instead get the current value from the config """

mrq/worker.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,7 @@ def get_worker_report(self, with_memory=False):
292292
"local_ip",
293293
"external_ip",
294294
"agent_id",
295-
"worker_group",
296-
"worker_profile"
295+
"worker_group"
297296
]
298297

299298
io = None

0 commit comments

Comments
 (0)