Skip to content

Commit a13bb1c

Browse files
committed
RabbitMQ, Docker deployment
1 parent debbf86 commit a13bb1c

16 files changed

+662
-95
lines changed

.dockerignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
frontend/node_modules/
2+
backend/db.sqlite3

Dockerfile

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Stage 1 - Build frontend
2+
FROM node:12 as build-frontend
3+
WORKDIR /usr/src/app
4+
COPY frontend/package-lock.json frontend/package.json ./frontend/
5+
6+
WORKDIR frontend
7+
RUN npm ci
8+
COPY frontend .
9+
RUN npm run build
10+
11+
# Stage 2 - Setup server
12+
FROM ubuntu:18.04
13+
14+
RUN apt-get update \
15+
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
16+
python3.8-dev \
17+
unzip \
18+
nginx \
19+
curl \
20+
vim \
21+
python3.8-distutils \
22+
gcc
23+
24+
RUN ln -s /usr/bin/python3.8 /usr/local/bin/python
25+
RUN python --version
26+
RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
27+
RUN python get-pip.py
28+
RUN pip --version
29+
RUN pip install poetry
30+
31+
#RUN curl -s https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-server/script.deb.sh | bash
32+
33+
WORKDIR /usr/src/app
34+
COPY poetry.lock pyproject.toml ./
35+
RUN poetry config virtualenvs.create false
36+
RUN LC_ALL=C.UTF-8 LANG=C.UTF-8 poetry install --extras "production" --no-dev
37+
38+
COPY --from=build-frontend /usr/src/app/frontend/build ./frontend/build
39+
COPY ./backend ./backend
40+
WORKDIR /usr/src/app/backend
41+
RUN ./manage.py collectstatic --noinput
42+
43+
COPY ./backend/nginx.conf /etc/nginx
44+
45+
EXPOSE 3000
46+
47+
CMD ["bash", "./entrypoint.sh"]

backend/book/settings.py

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929

3030
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN')
3131

32+
RABBITMQ_HOST = os.environ.get('RABBITMQ_HOST', '')
33+
SEPARATE_WORKER_PROCESS = os.environ.get('SEPARATE_WORKER_PROCESS', 'False')[0].upper() == 'T'
34+
3235
ALLOWED_HOSTS = [
3336
'alexmojaki.pythonanywhere.com',
3437
'localhost',

backend/entrypoint.sh

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
set -ex
3+
4+
nginx
5+
6+
#rabbitmq-server -detached
7+
8+
# We probably don't want this to be automatic but it makes life a lot easier
9+
# For setting up the cloud
10+
python manage.py migrate
11+
python manage.py init_db
12+
13+
#python -m main.worker &
14+
15+
gunicorn -c gunicorn_config.py book.wsgi:application

backend/gunicorn_config.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# user = 'root'
2+
bind = '0.0.0.0:8000'
3+
# workers = 1
4+
worker_class = 'gevent'
5+
# worker_connections = 1024
6+
# The number of seconds to wait for requests on a Keep-Alive connection.
7+
# Set higher than default to deal with latency.
8+
keepalive = 90
9+
# How max time worker can handle request after got restart signal.
10+
# If the time is up worker will be force killed.
11+
graceful_timeout = 10
12+
# If a worker does not notify the master process in this number of seconds it is
13+
# killed and a new worker is spawned to replace it.
14+
#
15+
# For the non sync workers it just means that the worker process is still
16+
# communicating and is not tied to the length of time required to handle a single
17+
# request.
18+
timeout = 600
19+
loglevel = 'debug'
20+
log_file = '-'
21+
# "-" means log to stdout
22+
accesslog = '-'
23+
errorlog = '-'
24+
# Sets the process title
25+
proc_name = 'gunicorn'

backend/main/utils.py

+17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import functools
2+
import threading
23
import traceback
4+
from functools import lru_cache
35
from io import StringIO
46
import re
57
import sys
@@ -64,3 +66,18 @@ def row_to_dict(row):
6466

6567
def rows_to_dicts(rows):
6668
return [row_to_dict(row) for row in rows]
69+
70+
71+
def thread_separate_lru_cache(*cache_args, **cache_kwargs):
72+
def decorator(func):
73+
@lru_cache(*cache_args, **cache_kwargs)
74+
def cached(_thread_id, *args, **kwargs):
75+
return func(*args, **kwargs)
76+
77+
@functools.wraps(func)
78+
def wrapper(*args, **kwargs):
79+
return cached(threading.get_ident(), *args, **kwargs)
80+
81+
return wrapper
82+
83+
return decorator

backend/main/views.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from main.chapters.c06_lists import UnderstandingProgramsWithPythonTutor
2323
from main.models import CodeEntry
2424
from main.text import Page, page_slugs_list, pages, ExerciseStep, clean_program
25-
from main.worker import worker_connection
25+
from main.worker import worker_result
2626

2727
log = logging.getLogger(__name__)
2828

@@ -91,16 +91,12 @@ def run_code(self, code, source):
9191
source=source,
9292
page_slug=self.user.page_slug,
9393
step_name=self.user.step_name,
94+
user_id=self.user.id,
9495
)
9596

96-
entry = CodeEntry.objects.create(
97-
**entry_dict,
98-
user=self.user,
99-
)
97+
entry = CodeEntry.objects.create(**entry_dict)
10098

101-
connection = worker_connection(self.user.id)
102-
connection.send(entry_dict)
103-
result = connection.recv()
99+
result = worker_result(entry_dict)
104100

105101
entry.output = result["output"]
106102
entry.save()

backend/main/worker.py

+93-47
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
import resource
1010
import sys
1111
from code import InteractiveConsole
12+
from collections import defaultdict
1213
from datetime import datetime
1314
from functools import lru_cache
1415
from importlib import import_module
15-
from multiprocessing import Pipe
16-
from multiprocessing.connection import Connection
1716
from multiprocessing.context import Process
1817
from threading import Thread
18+
from time import sleep
1919

2020
import birdseye.bird
2121
import snoop
@@ -26,6 +26,7 @@
2626

2727
from main.text import pages
2828
from main.utils import format_exception_string, rows_to_dicts
29+
from main.workers.communications import AbstractCommunications, ThreadCommunications
2930

3031
log = logging.getLogger(__name__)
3132

@@ -288,57 +289,53 @@ def readline():
288289
)
289290

290291

291-
def consumer(connection: Connection):
292-
manager = multiprocessing.Manager()
293-
task_queue = manager.Queue()
294-
input_queue = manager.Queue()
295-
result_queue = manager.Queue()
296-
process = None
297-
298-
def start_process():
299-
nonlocal process
300-
process = Process(
292+
class UserProcess:
293+
def __init__(self, manager):
294+
self.task_queue = manager.Queue()
295+
self.input_queue = manager.Queue()
296+
self.result_queue = manager.Queue()
297+
self.awaiting_input = False
298+
self.process = None
299+
self.start_process()
300+
301+
@atexit.register
302+
def cleanup():
303+
if self.process:
304+
self.process.terminate()
305+
306+
def start_process(self):
307+
self.process = Process(
301308
target=worker_loop_in_thread,
302-
args=(task_queue, input_queue, result_queue),
309+
args=(self.task_queue, self.input_queue, self.result_queue),
303310
daemon=True,
304311
)
305-
process.start()
306-
307-
start_process()
308-
309-
def cleanup():
310-
process.terminate()
311-
312-
atexit.register(cleanup)
312+
self.process.start()
313313

314-
awaiting_input = False
315-
316-
def run():
317-
task_queue.put(entry)
318-
319-
while True:
320-
entry = connection.recv()
314+
def handle_entry(self, entry):
321315
if entry["source"] == "shell":
322-
if awaiting_input:
323-
input_queue.put(entry["input"])
316+
if self.awaiting_input:
317+
self.input_queue.put(entry["input"])
324318
else:
325-
run()
319+
self.task_queue.put(entry)
326320
else:
327-
if not TESTING and awaiting_input:
328-
process.terminate()
329-
start_process()
321+
if not TESTING and self.awaiting_input:
322+
self.process.terminate()
323+
self.start_process()
330324

331-
run()
325+
self.task_queue.put(entry)
326+
327+
def await_result(self, callback):
328+
# TODO cancel if result was cancelled by a newer handle_entry
332329
result = None
333330
while result is None:
334331
try:
335-
result = result_queue.get(timeout=3)
332+
result = self.result_queue.get(timeout=3)
336333
except queue.Empty:
337-
alive = process.is_alive()
334+
alive = self.process.is_alive()
338335
print(f"Process {alive=}")
339336
if alive:
340-
process.terminate()
341-
start_process()
337+
self.process.terminate()
338+
self.start_process()
342339
result = dict(
343340
output_parts=[
344341
dict(color='red', text='The process died.\n'),
@@ -351,13 +348,62 @@ def run():
351348
awaiting_input=False,
352349
birdseye_objects=None,
353350
)
354-
awaiting_input = result["awaiting_input"]
355-
connection.send(result)
351+
self.awaiting_input = result["awaiting_input"]
352+
callback(result)
353+
354+
355+
def master_consumer_loop(comms: AbstractCommunications):
356+
comms = comms.make_master_side_communications()
357+
manager = multiprocessing.Manager()
358+
user_processes = defaultdict(lambda: UserProcess(manager))
359+
360+
while True:
361+
entry = comms.recv_entry()
362+
user_id = str(entry["user_id"])
363+
user_process = user_processes[user_id]
364+
user_process.handle_entry(entry)
365+
366+
def callback(result):
367+
comms.send_result(user_id, result)
368+
369+
Thread(
370+
target=user_process.await_result,
371+
args=[callback],
372+
).start()
373+
374+
375+
@lru_cache()
376+
def master_communications() -> AbstractCommunications:
377+
from django.conf import settings
378+
if settings.RABBITMQ_HOST:
379+
from .workers.pika import PikaCommunications
380+
comms = PikaCommunications()
381+
else:
382+
comms = ThreadCommunications()
383+
384+
if not settings.SEPARATE_WORKER_PROCESS:
385+
Thread(
386+
target=master_consumer_loop,
387+
args=[comms],
388+
daemon=True,
389+
name=master_consumer_loop.__name__,
390+
).start()
391+
392+
return comms
393+
394+
395+
def worker_result(entry):
396+
comms: AbstractCommunications = master_communications()
397+
comms.send_entry(entry)
398+
user_id = str(entry["user_id"])
399+
return comms.recv_result(user_id)
400+
401+
402+
def main():
403+
from main.workers.pika import PikaCommunications
404+
comms = PikaCommunications()
405+
master_consumer_loop(comms)
356406

357407

358-
@lru_cache(maxsize=None)
359-
def worker_connection(_user_id):
360-
parent_connection, child_connection = Pipe()
361-
p = Thread(target=consumer, args=(child_connection,), daemon=True)
362-
p.start()
363-
return parent_connection
408+
if __name__ == '__main__':
409+
main()

backend/main/workers/__init__.py

Whitespace-only changes.
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from abc import ABC, abstractmethod
2+
3+
4+
class AbstractCommunications(ABC):
5+
@abstractmethod
6+
def send_entry(self, entry): ...
7+
8+
@abstractmethod
9+
def recv_entry(self): ...
10+
11+
@abstractmethod
12+
def send_result(self, queue_name, result): ...
13+
14+
@abstractmethod
15+
def recv_result(self, queue_name): ...
16+
17+
@abstractmethod
18+
def make_master_side_communications(self) -> "AbstractCommunications": ...
19+
20+
21+
class ThreadCommunications(AbstractCommunications):
22+
def __init__(self):
23+
from multiprocessing.dummy import Pipe
24+
25+
self.server_connection, self.child_connection = Pipe()
26+
27+
def make_master_side_communications(self):
28+
return self
29+
30+
def send_entry(self, entry):
31+
self.server_connection.send(entry)
32+
33+
def recv_entry(self):
34+
return self.child_connection.recv()
35+
36+
# TODO doesn't handle multiple users (queues)
37+
def send_result(self, queue_name, result):
38+
self.child_connection.send(result)
39+
40+
def recv_result(self, queue_name):
41+
return self.server_connection.recv()

0 commit comments

Comments
 (0)