1- from future .builtins import object
2-
31from .context import get_current_config , connections , log
42import time
53import datetime
64import gevent
5+ import argparse
6+ import shlex
7+ import random
8+ import math
9+ import re
10+ from collections import defaultdict
711from bson import ObjectId
812from redis .lock import LuaLock
9- from collections import defaultdict
1013from .processes import Process , ProcessPool
14+ from .config import add_parser_args
15+ from .utils import MovingETA
16+ from .queue import Queue
1117
1218
1319class 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
0 commit comments