diff --git a/.gitignore b/.gitignore index 6d66da24..84b362ab 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ develop-eggs lib lib64 __pycache__ +.cache # Installer logs pip-log.txt diff --git a/.travis.yml b/.travis.yml index f4c10126..0156532c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,4 +16,5 @@ before_install: # TODO: coveralls? script: - docker run -i -t -v `pwd`:/app:rw -w /app mrq_local $PYTHON_BIN -m pylint --errors-only --init-hook="import sys; sys.path.append('.')" -d E1103 --rcfile .pylintrc mrq +# - docker run -i -t -v `pwd`:/app:rw -w /app mrq_local $PYTHON_BIN -m pytest tests/ --collect-only - docker run -i -t -v `pwd`:/app:rw -w /app mrq_local $PYTHON_BIN -m pytest tests/ -v --junitxml=pytest-report.xml --cov mrq --cov-report term diff --git a/Dockerfile b/Dockerfile index 29ab4fdf..58e5ed9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,8 @@ RUN echo \ deb http://security.debian.org jessie/updates main\n' \ > /etc/apt/sources.list -RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10 -RUN echo "deb http://repo.mongodb.org/apt/debian wheezy/mongodb-org/3.0 main" > /etc/apt/sources.list.d/mongodb-org-3.0.list +RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6 +RUN echo "deb http://repo.mongodb.org/apt/debian jessie/mongodb-org/3.4 main" > /etc/apt/sources.list.d/mongodb-org-3.4.list RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl \ @@ -21,9 +21,10 @@ RUN apt-get update && \ python-pip \ python3-pip \ python3-dev \ + make \ git \ vim \ - mongodb-org-server \ + mongodb-org \ nginx redis-server \ && \ apt-get clean -y && \ @@ -52,6 +53,13 @@ RUN pip install -r /app/requirements-heroku.txt && \ RUN mkdir -p /data/db +RUN ln -s /app/mrq/bin/mrq_run.py /usr/bin/mrq-run +RUN ln -s /app/mrq/bin/mrq_worker.py /usr/bin/mrq-worker +RUN ln -s /app/mrq/bin/mrq_agent.py /usr/bin/mrq-agent +RUN ln -s /app/mrq/dashboard/app.py /usr/bin/mrq-dashboard + +ENV PYTHONPATH /app + VOLUME ["/data"] WORKDIR /app diff --git a/Makefile b/Makefile index 2f9d048e..ee9380a5 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,15 @@ test3: docker shell: sh -c "docker run --rm -i -t -p 27017:27017 -p 6379:6379 -p 5555:5555 -p 20020:20020 -p 8000:8000 -v `pwd`:/app:rw -w /app mrq_local bash" +reshell: + # Reconnect in the current taskqueue container + sh -c 'docker exec -t -i `docker ps | grep mrq_local | cut -f 1 -d " "` bash' + shell_noport: sh -c "docker run --rm -i -t -v `pwd`:/app:rw -w /app mrq_local bash" docs_serve: - sh -c "docker run --rm -i -t-p 8000:8000 -v `pwd`:/app:rw -w /app mrq_local mkdocs serve" + sh -c "docker run --rm -i -t -p 8000:8000 -v `pwd`:/app:rw -w /app mrq_local mkdocs serve" lint: docker docker run -i -t -v `pwd`:/app:rw -w /app mrq_local pylint --init-hook="import sys; sys.path.append('.')" --rcfile .pylintrc mrq diff --git a/docs/design.md b/docs/design.md deleted file mode 100644 index bce07d69..00000000 --- a/docs/design.md +++ /dev/null @@ -1,8 +0,0 @@ -# Design - -A talk with some slides about MRQ's design is upcoming. - -A couple things to know: - -- We use Redis as a main queue for task IDs -- We store metadata on the tasks in MongoDB so they can be browsable and managed more easily. diff --git a/docs/get-started.md b/docs/get-started.md index 89804385..2bd06fbe 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -108,5 +108,3 @@ This was a preview on the very basic features of MRQ. What makes it actually use * You can run multiple workers in parallel. Each worker can also run multiple greenlets in parallel. * Workers can dequeue from multiple queues * You can queue jobs from your Python code to avoid using `mrq-run` from the command-line. - -These features will be demonstrated in a future example of a simple web crawler. diff --git a/docs/jobs.md b/docs/jobs.md index 83bfc982..ffc25a81 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -24,7 +24,7 @@ However, to be reliable a task queue needs to prepare for everything that can go * ```retry```: The method `task.retry()` was called to interrupt the job but mark it for being retried later. This may be useful when calling unreliable 3rd-party services. * ```maxretries```: The task was retried too many times. Max retries default to 3 and can be configured globally or per task. At this point it should be up to you to cancel them or requeue them again. -Only jobs in statuses `success` and `cancel` will be cleaned from MongoDB after a delay of `result_ttl` seconds (see [Task configuration](configuration.md)) +Jobs in status `success` will be cleaned from MongoDB after a delay of `result_ttl` seconds (see [Task configuration](configuration.md)) ## Task API diff --git a/docs/performance.md b/docs/performance.md index c380babd..9867981e 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,4 +1,4 @@ -# Performance +# Worker performance Performance is an explicit goal of MRQ as it was first developed at [Pricing Assistant](http://www.pricingassistant.com/) for crawling billions of web pages. @@ -8,6 +8,8 @@ On a regular Macbook Pro, we see 1300 jobs/second in a single worker process wit However what we are really measuring there is MongoDB's write performance. An install of MRQ with properly scaled MongoDB and Redis instances is be capable of much more. +For more, see our tutorial on [Queue performance](queue-performance.md). + ## PyPy support Earlier in its development MRQ was tested successfully on PyPy but we are waiting for better PyPy+gevent support to continue working on it, as performance was worse than CPython. diff --git a/docs/queue-performance.md b/docs/queue-performance.md new file mode 100644 index 00000000..046cf9ef --- /dev/null +++ b/docs/queue-performance.md @@ -0,0 +1,211 @@ +This tutorial will guide you through the configuration of a MRQ queue for maximum performance. + +Code is available in the `examples/queue_performance` folder. To be able to run the commands below, you should enter the container first: + +``` +make shell +make stack +cd examples/queue_performance +``` + + + +## Regular queue + + + + +### Default setup + +Let's start with a simple task that squares integers, from the `tasks.py` file: + +``` +class Square(Task): + def run(self, params): + return int(params["n"]) ** 2 +``` + +You can enqueue it 200 times on a regular, MongoDB-backed queue named `square` with this code: + +``` +from mrq.job import queue_jobs +queue_jobs("tasks.Square", [{"n": 42} for _ in range(200)], queue="square") +``` + +For convenience, we will use the `enqueue.py` file to do this. Here is the command to enqueue the jobs and launch a worker to dequeue them: + +``` +./enqueue.py square 200 && mrq-worker square +``` + +You should see the output of the worker, with a line like this one at the end (performance numbers from a 2015 MacBook Pro): + +``` +[INFO] Worker spent 2.398 seconds performing 200 jobs (83.403 jobs/second) +``` + +As we have `DEQUEUE_STRATEGY = "burst"` in the `mrq-config.py` file, the worker exits as soon as there are no jobs left on the queue, which is more convenient for this tutorial. + +80 jobs per second is rather slow. The main bottleneck is that by default, `mrq-worker` uses a single process and a single greenlet. With this setup, jobs are executed sequentially and between each, the worker must fetch the next one from MongoDB. As a consequence, most of the time of the worker is spent on blocking I/O to MongoDB: not good! + + + + +### Multi-greenlet worker + +Fortunately, MRQ uses [gevent](http://gevent.org) and allows us to start many greenlets at once in the same worker. Let's try with 5 greenlets: + +``` +./enqueue.py square 200 && mrq-worker square --greenlets 5 +... +[INFO] Worker spent 0.652 seconds performing 200 jobs (306.554 jobs/second) +``` + +We got an almost linear increase in performance! What if we tried 50 greenlets? + +``` +./enqueue.py square 200 && mrq-worker square --greenlets 50 +... +[INFO] Worker spent 0.382 seconds performing 200 jobs (523.174 jobs/second) +``` + +A nice increase again, but definitely not linear anymore. Depending on your workload, the performance gains will stop at some point either because you hit a CPU bottleneck on the worker, or the concurrency limit of your MongoDB server. + +If MongoDB is the limiting factor, you have 2 choices to go further: + + - Scale your MongoDB instance ([many options](https://docs.mongodb.com/manual/administration/analyzing-mongodb-performance/) are available, including [sharding](https://docs.mongodb.com/manual/sharding/)) + - Switch to a Redis-backed queue (also called a *raw queue* in MRQ). + + + +## Raw queue + + + + +### Default setup + +A raw queue must be configured in `mrq-config.py` with its job factory function, which will transform a "raw" parameter string into a complete job definition: + +``` +RAW_QUEUES = { + "square_raw": { + "job_factory": lambda rawparam: { + "path": "tasks.Square", + "params": { + "n": rawparam + } + } + } +} +``` + +The only thing that will be queued in redis will be the raw parameter. This has the benefit of using much less storage than MongoDB-backed queues, but also of being faster to dequeue: + +``` +./enqueue.py square_raw 2000 && mrq-worker square_raw --greenlets 30 +... +[INFO] Worker spent 1.804 seconds performing 2000 jobs (1108.419 jobs/second) +``` + +Much better! If you use `top` while launching these commands (you can open a second shell in the same container with the `make reshell` command from the host), you will see that the python worker process is now maxing-out a CPU. + + + + +### Multi-process worker + +As you know, a single Python process can only use a single CPU. Let's try to use all the cores you have at your disposal to get better performance! + +mrq-worker can start multiple processes with the ```--processes``` flag. In this case it will use `supervisord` to manage the processes. If you use this option you will have to manually terminate the worker with a `ctrl-C` keystroke once it is finished: + +``` +./enqueue.py square_raw 20000 && mrq-worker square_raw --greenlets 30 --processes 5 +... +[INFO] Worker spent 6.697 seconds performing 4239 jobs (632.986 jobs/second) +... +[INFO] Worker spent 6.505 seconds performing 4307 jobs (662.075 jobs/second) +... +``` + +Each of the 5 worker processes handled its share of the jobs. The performance numbers aren't aggregated but you can see that the global throughput is now more than 3000 jobs per second. + +`top` reveals that the bottleneck is once again MongoDB. We are using a Redis-backed queue so jobs are not queued in MongoDB anymore but by default they are still inserted there once they are started. This is done to be able to see them in MRQ's dashboard as well as to store their results once they reach the `success` state. + + + + +### Redis-only queue + +If you don't need visibility on started jobs or on their results, you can actually bypass MongoDB altogether with this configuration: + +``` +RAW_QUEUES = { + "square_nostorage_raw": { + "statuses_no_storage": ("started", "success"), + "job_factory": lambda rawparam: { + "path": "tasks.Square", + "params": { + "n": rawparam + } + } + } +} +``` + +Let's try that with a single-process worker: + +``` +./enqueue.py square_nostorage_raw 20000 && mrq-worker square_nostorage_raw --greenlets 50 +... +[INFO] Worker spent 8.449 seconds performing 20000 jobs (2367.030 jobs/second) +``` + +Redis should be at less than 1% CPU load, so we can definitely keep adding processes: + +``` +./enqueue.py square_nostorage_raw 20000 && mrq-worker square_nostorage_raw --greenlets 50 --processes 5 +... +[INFO] Worker spent 11.884 seconds performing 20850 jobs (1754.444 jobs/second) +... +[INFO] Worker spent 11.851 seconds performing 20950 jobs (1767.750 jobs/second) +... +``` + +We are now close to 9000 jobs per second, maxing-out the local CPUs again! + +From there on, the sky is the limit! You should be able to run thousands of workers accross hundreds of machines before maxing-out a high-performance Redis instance. + +Beyond that, using using multiple queues on a [Redis Cluster](https://redis.io/topics/cluster-tutorial) will definitely allow you to run several million jobs per second. If you do, please drop us a line ;-) + + +## Choosing the right kind of queue + +### Queue types + +With the different settings explored in this tutorial, MRQ allows you to choose how much data you want to store in MongoDB and Redis. + +By choosing the right kind of queue for your jobs, you will strike a balance between performance, visibility in the dashboard, and safety guarantees. + +Here is a table to sum up the choices: + +| **Queue type** | **Regular** | **Raw** | **Raw with no_storage config** | +|----------------------------------------|-------------|-------------|--------------------------------| +| **Storage for queued jobs** | MongoDB | Redis | Redis | +| **Storage for started & success jobs** | MongoDB | MongoDB | None | +| **Performance** | + | ++ | +++ | +| **Visibility in the dashboard** | Full | After start | Job counts & failed jobs | +| **Safety** | +++ | ++ | + | + + + + +### Job safety + +A regular queue is guaranteed not to lose any jobs once they have been inserted in MongoDB. + +A raw queue can lose jobs if the worker abruptly exits in a short time window, between the dequeue from Redis and the insertion in MongoDB. + +A raw queue backed by Redis only won't be able to guarantee that a job is finished once it has been dequeued, if the worker abruptly exists. + +There are several ways to make raw queues safer. The easiest one is to use a `timed_set` raw queue backed by a Redis ZSET. We'll expand on this in an upcoming tutorial! diff --git a/docs/queues.md b/docs/queues.md index c77df9c2..c388a9ef 100644 --- a/docs/queues.md +++ b/docs/queues.md @@ -1,6 +1,6 @@ # Regular queues -With regular queues, MRQ stores the task metadata in MongoDB and the task IDs in a Redis list. This design allows a good compromise between performance and visibility. +With regular queues, MRQ stores the tasks in MongoDB. You can transform a queue into a [pile](https://en.wikipedia.org/wiki/LIFO_(computing)) by appending `_reverse` to its name: @@ -68,4 +68,6 @@ queue_raw_jobs("myqueue_timed_set", { }) ``` -For more examples of raw queue configuration, check [the tests](https://github.com/pricingassistant/mrq/blob/master/tests/fixtures/config-raw1.py) +For more examples of raw queue configuration, check [the tests](https://github.com/pricingassistant/mrq/blob/master/tests/fixtures/config-raw1.py). + +You should also read our tutorial on [Queue performance](queue-performance.md) to get a good overview of the different queue types. diff --git a/docs/tests.md b/docs/tests.md index 2f53973b..0c2e933d 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -15,5 +15,6 @@ You can also open a shell inside the docker (just like you would enter in a virt ``` $ make docker -$ make ssh +$ make shell +$ py.test tests/ -v ``` diff --git a/examples/queue_performance/enqueue.py b/examples/queue_performance/enqueue.py new file mode 100755 index 00000000..bd2848a0 --- /dev/null +++ b/examples/queue_performance/enqueue.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import sys +from mrq.context import setup_context +from mrq.job import queue_jobs, queue_raw_jobs + +setup_context() + +queue = sys.argv[1] +n = int(sys.argv[2]) + +if queue == "square": + queue_jobs("tasks.Square", [{"n": 42} for _ in range(n)], queue=queue) + +elif queue in ("square_raw", "square_nostorage_raw"): + queue_raw_jobs(queue, [42 for _ in range(n)]) diff --git a/examples/queue_performance/tasks.py b/examples/queue_performance/tasks.py new file mode 100644 index 00000000..40fe6d01 --- /dev/null +++ b/examples/queue_performance/tasks.py @@ -0,0 +1,23 @@ +from mrq.task import Task +import time + + +class Square(Task): + """ Returns the square of an integer """ + def run(self, params): + return int(params["n"]) ** 2 + + +class CPU(Task): + """ A CPU-intensive task """ + def run(self, params): + for n in range(int(params["n"])): + n ** n + return params["a"] + + +class IO(Task): + """ An IO-intensive task """ + def run(self, params): + time.sleep(float(params["sleep"])) + return params["a"] diff --git a/mkdocs.yml b/mkdocs.yml index d6d28661..96fab71a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,8 +17,8 @@ pages: - ["metrics.md", "Visibility", "Metrics"] - ["io-monitoring.md", "Visibility", "I/O Monitoring"] -- ["design.md", "Advanced", "Design and architecture"] -- ["performance.md", "Advanced", "Performance"] +- ["performance.md", "Advanced", "Worker performance"] +- ["queue-performance.md", "Advanced", "Queue performance"] - ["recurring-jobs.md", "Advanced", "Recurring jobs"] - ["jobs-maintenance.md", "Advanced", "Jobs maintenance"] - ["best-practices.md", "Advanced", "Best practices"] diff --git a/mrq/agent.py b/mrq/agent.py new file mode 100644 index 00000000..cdd6af2b --- /dev/null +++ b/mrq/agent.py @@ -0,0 +1,210 @@ +from future.builtins import object + +from .context import get_current_config, connections, log +import time +import datetime +import gevent +from bson import ObjectId +from collections import defaultdict +from .processes import Process, ProcessPool + + +class Agent(Process): + """ MRQ Agent manages its local worker pool and takes turns in orchestrating the others in its group. """ + + def __init__(self, worker_group=None): + self.greenlets = {} + self.id = ObjectId() + self.worker_group = worker_group or get_current_config()["worker_group"] + self.pool = ProcessPool() + self.config = get_current_config() + + def work(self): + + self.install_signal_handlers() + + self.greenlets["orchestrate"] = gevent.spawn(self.greenlet_orchestrate) + self.greenlets["orchestrate"].start() + + self.greenlets["manage"] = gevent.spawn(self.greenlet_manage) + self.greenlets["manage"].start() + + self.pool.start() + + try: + self.pool.wait() + finally: + self.shutdown_now() + connections.mongodb_jobs.mrq_agents.delete_one({"_id": self.id}) + + def shutdown_now(self): + self.pool.terminate() + + self.greenlets["orchestrate"].kill() + self.greenlets["manage"].kill() + + def shutdown_graceful(self): + self.pool.stop(timeout=None) + + def greenlet_manage(self): + """ This greenlet always runs in background to update current status + in MongoDB every N seconds. + """ + + while True: + try: + self.manage() + except Exception as e: # pylint: disable=broad-except + log.error("When reporting: %s" % e) + finally: + time.sleep(self.config["report_interval"]) + + def manage(self): + + report = self.get_agent_report() + + try: + db = connections.mongodb_jobs.mrq_agents.find_and_modify({ + "_id": ObjectId(self.id) + }, {"$set": report}, upsert=True) + if not db: + return + except Exception as e: # pylint: disable=broad-except + log.debug("Agent report failed: %s" % e) + return + + # If the desired_workers was changed by an orchestrator, apply the changes locally + if sorted(db.get("desired_workers", [])) != sorted(self.pool.desired_commands): + self.pool.set_commands(db.get("desired_workers", [])) + + def get_agent_report(self): + report = { + "current_workers": [p["command"] for p in self.pool.processes], + "available_cpu": get_current_config()["available_cpu"], + "available_memory": get_current_config()["available_memory"], + "worker_group": self.worker_group, + "datereported": datetime.datetime.utcnow(), + "dateexpires": datetime.datetime.utcnow() + datetime.timedelta(seconds=(self.config["report_interval"] * 3) + 5) + } + return report + + def greenlet_orchestrate(self): + + while True: + with connections.redis.lock(self.redis_agent_orchestrator_key, timeout=self.config["orchestrate_interval"] + 10): + self.orchestrate() + time.sleep(self.config["orchestrate_interval"]) + + @property + def redis_agent_orchestrator_key(self): + """ Returns the global redis key used to ensure only one agent orchestrator runs at a time """ + return "%s:agentorchestrator:%s" % (get_current_config()["redis_prefix"], self.worker_group) + + def orchestrate(self): + """ Executed periodically on one of the agents, to manage the desired workers of *all* the agents in its group """ + + group = self.fetch_worker_group_definition() + if not group: + log.error("Worker group %s has no definition yet. Can't orchestrate!" % self.worker_group) + return + + agents = self.fetch_worker_group_agents() + + desired_workers = self.get_desired_workers_for_group(group) + + # Evaluate what workers are currently, rightfully there. They won't be touched. + current_workers = defaultdict(int) + for agent in agents: + agent["free_memory"] = agent["available_memory"] + agent["free_cpu"] = agent["available_cpu"] + agent["new_desired_workers"] = [] + for worker in agent.get("desired_workers", []): + if worker in desired_workers: + cpu = desired_workers[worker]["cpu"] + memory = desired_workers[worker]["memory"] + + # If no more memory for currently existing workers: their requirements must have changed. + # We need to schedule it somewhere else + if cpu <= agent["free_cpu"] and memory <= agent["free_memory"]: + current_workers[worker] += 1 + agent["free_cpu"] -= cpu + agent["free_memory"] -= memory + agent["new_desired_workers"].append(worker) + + # What changes need to be made in worker count + deltas = { + worker: (worker_info["desired_count"] - current_workers[worker]) + for worker, worker_info in desired_workers.items() + if worker_info["desired_count"] != current_workers[worker] + } + + # Remove workers from the most loaded machines (TODO improve) + for worker, delta in deltas.items(): + if delta >= 0: + continue + + for _ in range(delta, 0): + found = False + for agent in sorted(agents, key=lambda a: float(a["free_cpu"]) / a["available_cpu"]): + for i in range(len(agent["new_desired_workers"])): + if agent["new_desired_workers"][i] == worker: + agent["new_desired_workers"].pop(i) + agent["free_cpu"] += desired_workers[worker]["cpu"] + agent["free_memory"] += desired_workers[worker]["memory"] + found = True + break + if found: + break + + assert found + + # Add new workers to the least loaded machines + for worker, delta in deltas.items(): + if delta <= 0: + continue + + cpu = desired_workers[worker]["cpu"] + memory = desired_workers[worker]["memory"] + + for _ in range(delta): + found = False + for agent in sorted(agents, key=lambda a: float(a["free_cpu"]) / a["available_cpu"], reverse=True): + if cpu <= agent["free_cpu"] and memory <= agent["free_memory"]: + agent["new_desired_workers"].append(worker) + agent["free_cpu"] -= cpu + agent["free_memory"] -= memory + found = True + break + + if not found: + log.debug("Worker orchestration: no agent had enough CPU & memory (%s & %s) to schedule a new worker" % (cpu, memory)) + # TODO: communicate the need for new resources + break + + for agent in agents: + if sorted(agent["new_desired_workers"]) != sorted(agent.get("desired_workers", [])): + # Commit the changes in DB + connections.mongodb_jobs.mrq_agents.update_one({"_id": agent["_id"]}, {"$set": { + "desired_workers": agent["new_desired_workers"], + "free_cpu": agent["free_cpu"], + "free_memory": agent["free_memory"] + }}) + + def get_desired_workers_for_group(self, group): + + workers = {} + + for profile in group.get("profiles", []): + workers[profile["command"]] = { + "desired_count": profile["min_count"], # TODO! + "memory": profile["memory"], + "cpu": profile["cpu"] + } + + return workers + + def fetch_worker_group_agents(self): + return list(connections.mongodb_jobs.mrq_agents.find({"worker_group": self.worker_group})) + + def fetch_worker_group_definition(self): + return connections.mongodb_jobs.mrq_workergroups.find_one({"_id": self.worker_group}) diff --git a/mrq/basetasks/cleaning.py b/mrq/basetasks/cleaning.py index 0567bdcf..d272e73b 100644 --- a/mrq/basetasks/cleaning.py +++ b/mrq/basetasks/cleaning.py @@ -1,4 +1,4 @@ -from builtins import str +from future.builtins import str from mrq.queue import Queue from mrq.task import Task from mrq.job import Job diff --git a/mrq/basetasks/utils.py b/mrq/basetasks/utils.py index c6652a8f..16d3c142 100644 --- a/mrq/basetasks/utils.py +++ b/mrq/basetasks/utils.py @@ -1,6 +1,6 @@ from __future__ import print_function from future.utils import itervalues -from builtins import str +from future.builtins import str from mrq.task import Task from mrq.queue import Queue from bson import ObjectId @@ -120,6 +120,7 @@ def perform_action(self, action, query, destination_queue): updates = { "status": "queued", + "datequeued": datetime.datetime.utcnow(), "dateupdated": datetime.datetime.utcnow() } @@ -133,11 +134,6 @@ def perform_action(self, action, query, destination_queue): "_id": {"$in": jobs_by_queue[queue]} }, {"$set": updates}, multi=True) - # Between these two lines, jobs can become "lost" too. - - Queue(destination_queue or queue, add_to_known_queues=True).enqueue_job_ids( - [str(x) for x in jobs_by_queue[queue]]) - print(stats) return stats diff --git a/mrq/bin/mrq_agent.py b/mrq/bin/mrq_agent.py new file mode 100644 index 00000000..2a6ab185 --- /dev/null +++ b/mrq/bin/mrq_agent.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +import os + +# Needed to make getaddrinfo() work in pymongo on Mac OS X +# Docs mention it's a better choice for Linux as well. +# This must be done asap in the worker +if "GEVENT_RESOLVER" not in os.environ: + os.environ["GEVENT_RESOLVER"] = "ares" + +from gevent import monkey +monkey.patch_all(subprocess=False) + +import sys +import argparse + +sys.path.insert(0, os.getcwd()) + +from mrq import config +from mrq.agent import Agent +from mrq.context import set_current_config + + +def main(): + + parser = argparse.ArgumentParser(description='Start a MRQ agent') + + cfg = config.get_config(parser=parser, config_type="agent", sources=("file", "env", "args")) + + set_current_config(cfg) + + agent = Agent() + + agent.work() + + sys.exit(agent.exitcode) + + +if __name__ == "__main__": + main() diff --git a/mrq/bin/mrq_worker.py b/mrq/bin/mrq_worker.py index ec58575c..f8ab0df4 100755 --- a/mrq/bin/mrq_worker.py +++ b/mrq/bin/mrq_worker.py @@ -1,6 +1,6 @@ #!/usr/bin/env python import os -from builtins import str +from future.builtins import str # Needed to make getaddrinfo() work in pymongo on Mac OS X # Docs mention it's a better choice for Linux as well. @@ -16,6 +16,7 @@ import signal import psutil import argparse +import pipes try: import subprocess32 as subprocess @@ -31,88 +32,29 @@ def main(): - parser = argparse.ArgumentParser(description='Start a RQ worker') + parser = argparse.ArgumentParser(description='Start a MRQ worker') cfg = config.get_config(parser=parser, config_type="worker", sources=("file", "env", "args")) - # If we are launching with a --processes option and without the SUPERVISOR_ENABLED env - # then we should just call supervisord. - if cfg["processes"] > 0 and not os.environ.get("SUPERVISOR_ENABLED"): + set_current_config(cfg) - # We wouldn't need to do all that if supervisord supported environment - # variables in all its config fields! - with open(cfg["supervisord_template"], "r") as f: - conf = f.read() + # If we are launching with a --processes option and without MRQ_IS_SUBPROCESS, we are a manager process + if cfg["processes"] > 0 and not os.environ.get("MRQ_IS_SUBPROCESS"): - fh, path = tempfile.mkstemp(prefix="mrqsupervisordconfig") - f = os.fdopen(fh, "w") + from mrq.supervisor import Supervisor - # We basically relaunch ourselves, but the config will contain the - # MRQ_SUPERVISORD_ISWORKER env. - conf = conf.replace("{{ SUPERVISORD_COMMAND }}", " ".join(sys.argv)) - conf = conf.replace( - "{{ SUPERVISORD_PROCESSES }}", str(cfg["processes"])) + command = " ".join(map(pipes.quote, sys.argv)) + w = Supervisor(command, numprocs=cfg["processes"]) + w.work() + sys.exit(w.exitcode) - f.write(conf) - f.close() - - try: - - # start_new_session=True avoids sending the current process' - # signals to the child. - process = subprocess.Popen( - ["supervisord", "-c", path], start_new_session=True) - - def sigint_handler(signum, frame): # pylint: disable=unused-argument - - # At this point we need to send SIGINT to all workers. Unfortunately supervisord - # doesn't support this, so we have to find all the children pids and send them the - # signal ourselves :-/ - # https://github.com/Supervisor/supervisor/issues/179 - # - psutil_process = psutil.Process(process.pid) - worker_processes = psutil_process.get_children(recursive=False) - - if len(worker_processes) == 0: - return process.send_signal(signal.SIGTERM) - - for child_process in worker_processes: - child_process.send_signal(signal.SIGINT) - - # Second time sigint is used, we should terminate supervisord itself which - # will send SIGTERM to all the processes anyway. - signal.signal(signal.SIGINT, sigterm_handler) - - # Wait for all the childs to finish - for child_process in worker_processes: - child_process.wait() - - # Then stop supervisord itself. - process.send_signal(signal.SIGTERM) - - def sigterm_handler(signum, frame): # pylint: disable=unused-argument - process.send_signal(signal.SIGTERM) - - signal.signal(signal.SIGINT, sigint_handler) - signal.signal(signal.SIGTERM, sigterm_handler) - - process.wait() - - finally: - os.remove(path) - - # If not, start the actual worker + # If not, start an actual worker else: worker_class = load_class_by_path(cfg["worker_class"]) - - set_current_config(cfg) - w = worker_class() - - exitcode = w.work() - - sys.exit(exitcode) + w.work() + sys.exit(w.exitcode) if __name__ == "__main__": main() diff --git a/mrq/config.py b/mrq/config.py index a76029cc..eda33ef4 100644 --- a/mrq/config.py +++ b/mrq/config.py @@ -1,9 +1,10 @@ from __future__ import print_function -from builtins import str +from future.builtins import str import argparse import os import sys import re +import psutil from .version import VERSION from .utils import get_local_ip, DelimiterArgParser import atexit @@ -255,8 +256,45 @@ def add_parser_args(parser, config_type): type=str, help='Bind the dashboard to this IP. Default is "0.0.0.0", use "127.0.0.1" to restrict access.') - # Worker-specific args + # Agent-specific args + elif config_type == "agent": + + parser.add_argument( + '--worker_group', + default="default", + action="store", + type=str, + help='The name of the worker group to manage') + + parser.add_argument( + '--available_memory', + default=int(psutil.virtual_memory().total * 0.8), + action="store", + type=int, + help="How much memory MB this agent's workers can use. Used for scheduling, not a hard limit.") + + parser.add_argument( + '--available_cpu', + default=psutil.cpu_count(logical=True) * 1024, + action="store", + type=int, + help="How much CPU units this agent's workers can use. We recommend using 1024 per CPU.") + + parser.add_argument( + '--orchestrate_interval', + default=30, + action="store", + type=float, + help="How much seconds to wait between orchestration runs.") + + parser.add_argument( + '--report_interval', + default=10, + action='store', + type=float, + help='Seconds between agent reports to MongoDB') + # Worker-specific args elif config_type == "worker": parser.add_argument( diff --git a/mrq/context.py b/mrq/context.py index 6474224c..4547616f 100644 --- a/mrq/context.py +++ b/mrq/context.py @@ -1,7 +1,6 @@ from future import standard_library standard_library.install_aliases() -from builtins import next -from builtins import map +from future.builtins import next, map from past.builtins import basestring from .logger import Logger import gevent diff --git a/mrq/dashboard/app.py b/mrq/dashboard/app.py index 793d73d2..d97cfd51 100644 --- a/mrq/dashboard/app.py +++ b/mrq/dashboard/app.py @@ -15,6 +15,7 @@ import json import argparse from werkzeug.serving import run_simple +from future.builtins import str sys.path.insert(0, os.getcwd()) @@ -122,6 +123,24 @@ def get_workers(): return jsonify(data) +@app.route('/api/workergroups', methods=["GET"]) +@requires_auth +def get_workergroups(): + collection = connections.mongodb_jobs.mrq_workergroups + data = {"workergroups": {str(row.pop("_id")): row for row in collection.find(sort=[("_id", 1)])}} + return jsonify(data) + + +@app.route('/api/workergroups', methods=["POST"]) +@requires_auth +def post_workergroups(): + workergroups = json.loads(request.form["workergroups"]) + for k, v in workergroups.iteritems(): + connections.mongodb_jobs.mrq_workergroups.update_one({"_id": k}, {"$set": v}, upsert=True) + + return jsonify({"status": "ok"}) + + def build_api_datatables_query(req): query = {} @@ -198,7 +217,7 @@ def api_datatables(unit): } if queue.is_sorted: - raw_config = cfg.get("raw_queues", {}).get(name, {}) + raw_config = queue.get_config() q["graph_config"] = raw_config.get("dashboard_graph", lambda: { "start": time.time() - (7 * 24 * 3600), "stop": time.time() + (7 * 24 * 3600), diff --git a/mrq/dashboard/static/js/router.js b/mrq/dashboard/static/js/router.js index 28e0c9d0..425378c9 100644 --- a/mrq/dashboard/static/js/router.js +++ b/mrq/dashboard/static/js/router.js @@ -12,7 +12,8 @@ define(["backbone", "underscore", "jquery"],function(Backbone, _, $) { 'jobs': 'jobs', 'io': 'io', 'scheduledjobs': 'scheduledjobs', - 'status': 'status' + 'status': 'status', + 'workergroups': 'workergroups' }, /** @@ -125,6 +126,11 @@ define(["backbone", "underscore", "jquery"],function(Backbone, _, $) { this.app.rootView.showChildPage('io', {"options": {"params": params || {}}}); }, + workergroups: function(params) { + this.setNavbar("workergroups"); + this.app.rootView.showChildPage('workergroups', {"options": {"params": params || {}}}); + }, + taskpaths: function(params) { this.setNavbar("taskpaths"); this.app.rootView.showChildPage('taskpaths', {"options": {"params": params || {}}}); diff --git a/mrq/dashboard/static/js/views/root.js b/mrq/dashboard/static/js/views/root.js index afcbfb5f..4d73aa69 100644 --- a/mrq/dashboard/static/js/views/root.js +++ b/mrq/dashboard/static/js/views/root.js @@ -2,11 +2,11 @@ * @fileoverview Defines the view container view that contains all views */ define(["views/generic/page", "jquery", - "views/queues", "views/workers", "views/jobs", "views/scheduledjobs", "views/index", "views/taskpaths", "views/status", "views/taskexceptions", "views/io"], + "views/queues", "views/workers", "views/jobs", "views/scheduledjobs", "views/index", "views/taskpaths", "views/status", "views/taskexceptions", "views/io", "views/workergroups"], function( Page, $, - QueuesView, WorkersView, JobsView, ScheduledJobsView, IndexView, TaskPathsView, StatusView, TaskExceptionsView, IOView + QueuesView, WorkersView, JobsView, ScheduledJobsView, IndexView, TaskPathsView, StatusView, TaskExceptionsView, IOView, WorkerGroupsView ) { return Page.extend({ @@ -116,6 +116,7 @@ define(["views/generic/page", "jquery", this.addChildPage('taskexceptions', new TaskExceptionsView()); this.addChildPage('index', new IndexView()); this.addChildPage('status', new StatusView()); + this.addChildPage('workergroups', new WorkerGroupsView()); return this; } diff --git a/mrq/dashboard/static/js/views/workergroups.js b/mrq/dashboard/static/js/views/workergroups.js new file mode 100644 index 00000000..d9d96718 --- /dev/null +++ b/mrq/dashboard/static/js/views/workergroups.js @@ -0,0 +1,37 @@ +define(["jquery", "underscore", "models", "views/generic/page"],function($, _, Models, Page) { + + return Page.extend({ + + el: '.js-page-workergroups', + + template:"#tpl-page-workergroups", + + events:{ + "click .submit": "submit" + }, + + render: function() { + var self = this; + $.get("/api/workergroups").done(function(data) { + self.renderTemplate(); + self.$("textarea").val(JSON.stringify(data["workergroups"], null, 8)); + }); + }, + + submit: function(el) { + var self = this; + + self.$("button")[0].innerHTML = "Wait..."; + + var val = self.$("textarea").val(); + + $.post("/api/workergroups", {"workergroups": val}).done(function(data) { + if (data.status != "ok") { + return alert("There was an error while saving!"); + } + self.$("button")[0].innerHTML = "Save"; + }); + } + }); + +}); diff --git a/mrq/dashboard/templates/index.html b/mrq/dashboard/templates/index.html index dec6b9d9..7ccdafe9 100644 --- a/mrq/dashboard/templates/index.html +++ b/mrq/dashboard/templates/index.html @@ -38,6 +38,9 @@