This commit is contained in:
2026-10-10 11:05:32 +03:00
parent f4b49e450f
commit 991a0c8b56
16 changed files with 1078 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
@@ -0,0 +1,48 @@
name: distsys-mq
services:
rabbitmq:
image: rabbitmq:4.3.6-management-alpine
volumes:
- ./tests/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
ports:
- 15672:15672
- 5672:5672
server:
image: distsys-mq-server
build:
context: ./solution/server
volumes:
- data-volume:/data
ports:
- 5000:5000
server-fdv:
image: distsys-mq-server
build:
context: ./solution/server
volumes:
- fake-data-volume:/data
ports:
- 5000:5000
worker:
image: distsys-mq-worker
build:
context: ./solution/worker
volumes:
- data-volume:/data
deploy:
replicas: 2
network-fault:
image: distsys-mq-network-fault
build:
context: ./tests/network-fault
network_mode: service:server
cap_add:
- NET_ADMIN
volumes:
data-volume:
fake-data-volume:
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="434" viewBox="0 96 1000 434" role="img" aria-labelledby="title description">
<title id="title">Архитектура сервиса генерации описаний</title>
<desc id="description">Пользователь обращается к REST API. Сервер передаёт задачи через RabbitMQ воркерам. Воркеры сохраняют описания в общем Docker volume, откуда сервер читает их. Для компонента CALLBACKS воркеры также отправляют уведомления серверу через RabbitMQ.</desc>
<defs>
<marker id="message-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="#2563eb" stroke-width="2" stroke-linejoin="round"/>
</marker>
<marker id="notification-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="#7c3aed" stroke-width="2" stroke-linejoin="round"/>
</marker>
<marker id="file-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="#0f766e" stroke-width="2" stroke-linejoin="round"/>
</marker>
<style>
text { font-family: Arial, sans-serif; fill: #1e293b; }
.heading { font-size: 23px; font-weight: 700; }
.label { font-size: 20px; }
.detail { font-size: 17px; fill: #475569; }
.message { fill: none; stroke: #2563eb; stroke-width: 2.5; marker-end: url(#message-arrow); }
.notification { fill: none; stroke: #7c3aed; stroke-width: 2.5; stroke-dasharray: 7 5; marker-end: url(#notification-arrow); }
.file { fill: none; stroke: #0f766e; stroke-width: 2.5; marker-end: url(#file-arrow); }
</style>
</defs>
<rect width="1000" height="530" fill="#ffffff"/>
<!-- HTTP client and API server. -->
<rect x="20" y="185" width="140" height="100" rx="12" fill="#f8fafc" stroke="#94a3b8" stroke-width="2"/>
<text x="90" y="228" text-anchor="middle" class="label">Пользователь</text>
<text x="90" y="255" text-anchor="middle" class="detail">HTTP-клиент</text>
<rect x="220" y="140" width="180" height="180" rx="14" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="310" y="221" text-anchor="middle" class="heading">Сервер</text>
<text x="310" y="251" text-anchor="middle" class="label">REST API</text>
<path d="M 160 214 H 218" class="message"/>
<path d="M 220 265 H 162" class="message"/>
<text x="190" y="245" text-anchor="middle" class="detail">HTTP</text>
<!-- RabbitMQ and its two message flows. -->
<rect x="460" y="116" width="200" height="228" rx="14" fill="#fff7ed" stroke="#ea580c" stroke-width="2"/>
<text x="560" y="149" text-anchor="middle" class="heading">RabbitMQ</text>
<rect x="480" y="170" width="160" height="48" rx="8" fill="#ffffff" stroke="#fb923c" stroke-width="1.5"/>
<text x="560" y="201" text-anchor="middle" class="label">Задачи</text>
<rect x="480" y="260" width="160" height="48" rx="8" fill="#ffffff" stroke="#a78bfa" stroke-width="1.5" stroke-dasharray="6 4"/>
<text x="560" y="291" text-anchor="middle" class="label">Уведомления</text>
<!-- A worker pool: the service can use several workers. -->
<rect x="740" y="140" width="240" height="180" rx="14" fill="#f8fafc" stroke="#64748b" stroke-width="2"/>
<text x="860" y="171" text-anchor="middle" class="heading">Воркеры</text>
<rect x="760" y="188" width="200" height="45" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="860" y="217" text-anchor="middle" class="label">Воркер 1</text>
<rect x="760" y="249" width="200" height="45" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="860" y="278" text-anchor="middle" class="label">Воркер 2</text>
<path d="M 400 194 H 458" class="message"/>
<path d="M 660 194 H 738" class="message"/>
<path d="M 740 284 H 662" class="notification"/>
<path d="M 460 284 H 402" class="notification"/>
<!-- Files are stored independently of messages. -->
<rect x="220" y="410" width="760" height="82" rx="14" fill="#f0fdfa" stroke="#0f766e" stroke-width="2"/>
<text x="600" y="444" text-anchor="middle" class="heading">Общий Docker volume</text>
<text x="600" y="473" text-anchor="middle" class="label">Файлы с описаниями</text>
<path d="M 310 410 V 322" class="file"/>
<text x="325" y="372" class="detail" style="fill:#0f766e">Чтение описаний</text>
<path d="M 860 320 V 408" class="file"/>
<text x="845" y="372" text-anchor="end" class="detail" style="fill:#0f766e">Запись описаний</text>
<path d="M 220 518 H 261" class="notification"/>
<text x="273" y="524" class="detail">Уведомления для компонента CALLBACKS</text>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

+152
View File
@@ -0,0 +1,152 @@
# Практика с RabbitMQ
В этом задании вы научитесь работать с брокером сообщений [RabbitMQ](https://rabbitmq.com/getstarted.html). Вам предстоит реализовать сервис, который принимает URL изображения, асинхронно генерирует описание, сохраняет его в файл и возвращает по запросу. Генерация описания имитируется готовой функцией из заготовки.
## Архитектура и интерфейс сервиса
Очередь сообщений позволяет передать длительную задачу на асинхронную обработку. Она помогает распределять работу между несколькими воркерами и сглаживать всплески нагрузки. В этом задании вам также предстоит обеспечить обработку задач при временных отказах.
<img src="media/architecture.svg" alt="Архитектура сервиса" width="800">
1. Сервер с REST API принимает запросы пользователей и передаёт задачи на генерацию описаний через RabbitMQ.
2. Воркеры получают задачи, генерируют описания и записывают их в общее хранилище, откуда сервер читает результаты. В задании хранилище — это [Docker volume](https://docs.docker.com/engine/storage/volumes/), см. [docker-compose.yml](docker-compose.yml).
Версия RabbitMQ для проверки задана в [docker-compose.yml](docker-compose.yml). Используйте это окружение для локального тестирования.
### REST API
Тела запросов и успешных ответов передаются в JSON. Успешный запрос возвращает код `200`; формат тела ответа при ошибке не регламентируется. Идентификатор изображения может быть строкой или целым числом; в ответах POST и GET он должен иметь одинаковый тип.
```
POST /api/v1.0/images
Принимает URL изображения для обработки и возвращает id изображения.
Каждый запрос получает новый уникальный id, даже при повторной отправке того же URL.
Если JSON некорректен или image_url отсутствует, пуст или не является строкой,
верните код 400.
Тело запроса:
{
"image_url": str
}
Тело ответа:
{
"image_id": str | int
}
```
```
GET /api/v1.0/images
Возвращает id всех обработанных изображений без повторов. Порядок не важен.
Тело ответа:
{
"image_ids": List[str | int]
}
```
```
GET /api/v1.0/images/<image_id>
Возвращает описание для данного изображения, если оно уже было обработано,
и код 404, если id неизвестен или обработка ещё не завершена.
Тело ответа:
{
"caption": str
}
```
## Компоненты задания и оценивание
За выполнение задания можно получить 10 баллов:
| Группа тестов | Баллы | Что проверяется |
| --- | ---: | --- |
| `BASIC_API` | 1 | Пустой список результатов, отклонение некорректных запросов и ответ `404` для неизвестного ID. |
| `BASIC_PROCESSING` | 3 | Передача задач через RabbitMQ, обработка воркерами и получение результатов через REST API без отказов. |
| `CALLBACKS` | 2 | Уведомления о завершении обработки через RabbitMQ: сервер отвечает на `GET .../images` без чтения списка файлов в общей директории. |
| `FAULT_TOLERANCE_1` | 1 | Работа сервиса после простоя соединений с брокером. |
| `FAULT_TOLERANCE_2` | 1 | Сохранность запросов при недоступности и перезапуске брокера во время отправки задач. |
| `FAULT_TOLERANCE_3` | 1 | Завершение задач после отказа одного или обоих воркеров. |
| `FAULT_TOLERANCE_4` | 1 | Восстановление после отказов брокера и воркеров, сохранность уведомлений и обработка повторных доставок. |
Баллы за каждую группу начисляются при прохождении всех её тестов. Провал одной группы не обнуляет баллы за остальные. Группа `FAULT_TOLERANCE_4` предполагает реализацию уведомлений через RabbitMQ; передавать в них сами описания изображений не требуется.
При проверке отказоустойчивости уже запущенный REST API должен принимать корректные запросы с кодом `200` и возвращать готовые результаты, даже когда брокер или воркеры недоступны. Запрос считается принятым, если POST вернул `200`. После восстановления брокера, связи и хотя бы одного воркера каждый принятый запрос должен быть обработан, а его описание — доступно через API. Таймаут каждого HTTP-запроса в тестах — 3 секунды.
Сервер и общее хранилище в этих сценариях не отказывают: восстановление после их перезапуска или потери данных не требуется. Повторная доставка задач и уведомлений возможна. Повторная обработка задачи допустима, но её ID должен появляться в списке результатов только один раз.
Приложите отчёт `solution/readme.md` с описанием устройства решения и выполненных компонентов. Для отказоустойчивости обоснуйте, почему принятые запросы не теряются. На защите нужно объяснить работу реализованных компонентов и подтвердить это обоснование. Общие требования к отчёту приведены в [правилах курса](../readme.md#отчёт).
## Заготовки для решения
В папке `solution/server` находится заготовка для сервера с реализацией REST API на Flask.
В папке `solution/worker` содержится заготовка для воркера. Для генерации описания используйте функцию `produce_image_caption`, передавая ей строку `image_url`. Скачивать и сохранять само изображение не нужно: функция имитирует его обработку. В файл сохраняется только полученное описание в кодировке UTF-8.
Очередь задач должна называться `task_queue`, а описания должны сохраняться в файлы `/data/{image_id}.txt`. Эти соглашения используются в тестах.
Для взаимодействия с RabbitMQ на Python предлагается использовать библиотеку [Pika](https://github.com/pika/pika) (см. семинар 5).
Весь код решения должен размещаться в папке `solution`. При сдаче решения в тестирующую систему отправляется только эта папка, изменения вне неё учитываться не будут.
## Порядок выполнения задания
Начните с [материалов семинара 5](../../materials/05-indirect-comm/seminar/) и примера [work_queues](../../materials/05-indirect-comm/seminar/work_queues/). Дополнительно можно обращаться к официальным [туториалам RabbitMQ](https://www.rabbitmq.com/tutorials).
1. Реализуйте передачу задач от REST API через RabbitMQ, обработку воркерами и получение результатов. Проверьте группы `BASIC_API` и `BASIC_PROCESSING`. При необходимости измените Dockerfile сервера и воркера.
2. Добавьте уведомления сервера о завершении обработки и проверьте группу `CALLBACKS`. Полезный пример обмена запросами и ответами есть в [туториале по RPC](https://www.rabbitmq.com/tutorials/tutorial-six-python). При использовании Pika учитывайте [ограничения потокобезопасности](https://pika.github.io/pika/latest/faq/).
3. Работу над отказоустойчивостью начните с `test_heartbeats_timeout` (`FAULT_TOLERANCE_1`). Изучите [документацию по heartbeat](https://www.rabbitmq.com/docs/heartbeats) и настройки в [rabbitmq.conf](tests/rabbitmq.conf). Для `BlockingConnection` полезно [обсуждение работы соединения при редкой отправке сообщений](https://github.com/pika/pika/discussions/1382).
4. Подумайте, в каких случаях ваше решение может потерять принятый запрос. Изучите [подтверждения доставки в RabbitMQ](https://www.rabbitmq.com/docs/confirms); для Pika также полезен [пример асинхронного отправителя](https://github.com/pika/pika/blob/main/examples/asynchronous_publisher_example.py). Проверьте отдельно отказы брокера при отправке задач (`FAULT_TOLERANCE_2`) и отказы воркеров (`FAULT_TOLERANCE_3`).
5. Перейдите к комплексным сценариям `FAULT_TOLERANCE_4`: перезапуску брокера, сохранности уведомлений и повторной доставке. Сопоставьте поведение решения с [руководством RabbitMQ по надёжности](https://www.rabbitmq.com/docs/reliability). Подумайте, какие моменты отказа ещё стоит проверить самостоятельно.
6. Подготовьте отчёт с обоснованием отказоустойчивости реализованных компонентов, запустите все тесты и сдайте решение с отчётом в тестирующую систему.
При отладке сопоставляйте логи компонентов с состоянием очередей в веб-интерфейсе RabbitMQ: `http://localhost:15672`, логин и пароль — `guest`. Как на семинаре, обращайте внимание на сообщения Ready и Unacked.
## Тестирование решения
Тесты, проверяющие решение, находятся в папке `tests`.
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
### Локальное тестирование
Команды ниже выполняйте из папки задания. Для локального запуска тестов установите зависимости из `tests/requirements.txt`.
Перед запуском тестов соберите образы:
```
docker compose build
```
Запуск всех тестов выполняется с помощью команды:
```
python3 tests/main.py
```
Отдельный тест можно запустить так:
```
pytest -vs --tb=short tests/test_server.py::test_single_image
```
Для тестирования в окружении, аналогичном тестирующей системе, выполните из папки задания:
```bash
docker run --privileged --pull always --rm -v ./solution:/hw/solution distsys.ru/course/message-queue:latest
```
### Проверка в тестирующей системе
Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов.
## ЧаВо
**Можно ли реализовать решение не на Python?**
Да. Замените заготовки и Dockerfile сервера и воркера. Реализуйте аналог функции-заглушки, возвращающий строку по `image_url`; точное совпадение результата с Python-версией не требуется.
**Можно ли обрабатывать изображения локально на сервере?**
Нет. Описания должны генерироваться только воркерами. Решение, которое генерирует их на сервере, не засчитывается (0 баллов).
@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /server
COPY requirements.txt .
RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \
pip install -r requirements.txt
COPY . .
CMD ["python3", "-u", "server.py"]
@@ -0,0 +1,2 @@
flask==3.1.2
pika==1.3.2
@@ -0,0 +1,51 @@
from flask import Flask, request
from typing import List, Optional
class Server:
# TODO: Implement API server
def __init__(self, mq_host, mq_port, data_dir):
pass
def add_image(self, image_url: str) -> str:
raise NotImplementedError
def get_processed_images(self) -> List[str]:
raise NotImplementedError
def get_image_caption(self, image_id: str) -> Optional[str]:
raise NotImplementedError
def create_app() -> Flask:
app = Flask(__name__)
server = Server('rabbitmq', 5672, '/data')
@app.route('/api/v1.0/images', methods=['POST'])
def add_image():
body = request.get_json(force=True)
if not isinstance(body, dict) or not isinstance(body.get('image_url'), str) or not body['image_url']:
return 'image_url must be a nonempty string', 400
image_id = server.add_image(body['image_url'])
return {"image_id": image_id}
@app.route('/api/v1.0/images', methods=['GET'])
def get_processed_images():
image_ids = server.get_processed_images()
return {"image_ids": image_ids}
@app.route('/api/v1.0/images/<string:image_id>', methods=['GET'])
def get_image_caption(image_id):
result = server.get_image_caption(image_id)
if result is None:
return "Image not found.", 404
else:
return {'caption': result}
return app
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5000)
@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /worker
COPY requirements.txt .
RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \
pip install -r requirements.txt
COPY . .
CMD ["python3", "-u", "worker.py"]
@@ -0,0 +1 @@
pika==1.3.2
@@ -0,0 +1,15 @@
class Worker:
# TODO: Implement server
def __init__(self, mq_host, mq_port, data_dir):
pass
def produce_image_caption(self, image_url):
return str(abs(hash(image_url)) % (10 ** 8))
def run(self):
raise NotImplementedError
if __name__ == '__main__':
worker = Worker('rabbitmq', 5672, '/data')
worker.run()
+107
View File
@@ -0,0 +1,107 @@
import argparse
import pathlib
import pytest
from collections import defaultdict
SCRIPT_DIR = pathlib.Path(__file__).parent.resolve()
TEST_GROUPS = {
'BASIC_API': {
'tests': [
'test_empty_data_dir',
'test_bad_request',
'test_nonexistent_image',
],
'points': 1
},
'BASIC_PROCESSING': {
'tests': [
'test_task_queue',
'test_single_image',
'test_multiple_images',
'test_captions_generated_on_workers'
],
'points': 3
},
'CALLBACKS': {
'tests': [
'test_multiple_images_no_listdir'
],
'points': 2
},
'FAULT_TOLERANCE_1': {
'tests': [
'test_heartbeats_timeout'
],
'points': 1
},
'FAULT_TOLERANCE_2': {
'tests': [
'test_publisher_confirms'
],
'points': 1
},
'FAULT_TOLERANCE_3': {
'tests': [
'test_faulty_worker',
'test_two_faulty_workers',
],
'points': 1
},
'FAULT_TOLERANCE_4': {
'tests': [
'test_faulty_worker_and_rabbit_restart',
'test_total_eclipse_of_the_heart',
'test_notifications_survive_rabbit_restart',
'test_duplicate_deliveries'
],
'points': 1
}
}
class PassedCounter:
def __init__(self):
self.test_to_group = {}
for group_name, group in TEST_GROUPS.items():
for test in group['tests']:
self.test_to_group[test] = group_name
self.passed_by_group = defaultdict(int)
def pytest_report_teststatus(self, report, config):
if report.when == 'call' and report.passed:
test = report.nodeid.split('::')[1].split('[')[0]
group_name = self.test_to_group[test]
self.passed_by_group[group_name] += 1
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('--ci', action='store_true',
help='Fail unless all reference solution tests pass')
args = parser.parse_args(argv)
counter = PassedCounter()
test_status = pytest.main(
['-vs', '--tb=short', str(SCRIPT_DIR / 'test_server.py')], plugins=[counter])
score = 0
print()
for group_name, group in TEST_GROUPS.items():
total = len(group['tests'])
passed = counter.passed_by_group[group_name]
print(f'Test group {group_name}: passed {passed} of {total} tests')
if passed == total:
score += group['points']
print(f"\nSCORE: {score}")
if args.ci:
max_score = sum(group['points'] for group in TEST_GROUPS.values())
return int(test_status) or int(score != max_score)
# A failed student test still produces a valid partial score.
if test_status == pytest.ExitCode.TESTS_FAILED:
return 0
return int(test_status)
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,4 @@
FROM docker:dind
ENTRYPOINT ["sleep"]
CMD ["infinity"]
@@ -0,0 +1,6 @@
heartbeat = 5
# Refresh queue state before paused workers reach the heartbeat timeout.
collect_statistics_interval = 1000
# Allow basic solutions to use transient queues before adding fault tolerance.
# https://www.rabbitmq.com/docs/queues#durability
deprecated_features.permit.transient_nonexcl_queues = true
@@ -0,0 +1,4 @@
pytest==8.4.2
requests==2.32.5
docker==7.1.0
loguru==0.7.3
@@ -0,0 +1,583 @@
import io
import sys
import tarfile
import docker
import pytest
import requests
import subprocess
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import quote
from loguru import logger
IMAGES_ENDPOINT = 'http://localhost:5000/api/v1.0/images'
BROKER_ENDPOINT = 'http://guest:guest@localhost:15672/api'
TASK_QUEUE_ENDPOINT = f'{BROKER_ENDPOINT}/queues/%2F/task_queue'
logger.remove()
logger.add(sys.stderr, colorize=False, format="=== TEST ===| {time:YYYY-MM-DD HH:mm:ss.SSS} {level} {message}")
# Tests ===============================================================================================================
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_empty_data_dir(docker_tester):
logger.info(f"Sending GET {IMAGES_ENDPOINT}")
try:
response = requests.get(IMAGES_ENDPOINT, timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to get images: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'image_ids' in response.json()
assert len(response.json()['image_ids']) == 0
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_bad_request(docker_tester):
invalid_bodies = [{}, {'image_url': None}, {'image_url': 1},
{'image_url': ''}, [], None, 'image-url']
for body in invalid_bodies:
logger.info(f"Sending invalid POST: {body!r}")
response = requests.post(IMAGES_ENDPOINT, json=body, timeout=3)
assert response.status_code == 400, response.text
response = requests.post(IMAGES_ENDPOINT, data='{',
headers={'Content-Type': 'application/json'}, timeout=3)
assert response.status_code == 400, response.text
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_nonexistent_image(docker_tester):
nonexistent_image_id = str(uuid.uuid4())
logger.info(f"Sending GET {IMAGES_ENDPOINT}/{nonexistent_image_id}")
try:
response = requests.get(f'{IMAGES_ENDPOINT}/{nonexistent_image_id}', timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to get images: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 404
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_task_queue(docker_tester):
time.sleep(5)
check_task_queue(0, 10)
pending_ids = post_images(10)
check_unprocessed_images(pending_ids, docker_tester)
check_task_queue(10, 10)
time.sleep(5)
check_task_queue(10, 10)
check_unprocessed_images(pending_ids, docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_single_image(docker_tester):
pending_ids = post_images(1)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_multiple_images(docker_tester):
pending_ids = post_images(10)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_captions_generated_on_workers(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
wait_for_worker_ready(worker1)
wait_for_worker_ready(worker2)
worker1.pause()
worker2.pause()
pending_ids = post_images(10)
time.sleep(5)
check_unprocessed_images(pending_ids, docker_tester)
worker1.unpause()
worker2.unpause()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server-fdv', 'worker']])
def test_multiple_images_no_listdir(docker_tester):
pending_ids = post_images(10)
# This server has no access to the workers' result volume.
wait_and_check_results(pending_ids, 10, check_captions=False, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_heartbeats_timeout(docker_tester):
# With heartbeat=5, closing a connection without heartbeats can take 15s.
time.sleep(20)
pending_ids = post_images(10)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_publisher_confirms(docker_tester):
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
rabbit.pause()
pending_ids = post_images(10)
time.sleep(5)
rabbit.kill()
time.sleep(1)
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_faulty_worker(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
# Prevent the healthy worker from completing the whole batch before the fault.
worker2.kill()
subscribed = wait_for_worker_ready(worker1)
worker1.pause()
pending_ids = post_images(10)
wait_for_pending_tasks(subscribed)
worker1.kill()
worker2.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_two_faulty_workers(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
subscribed1 = wait_for_worker_ready(worker1)
subscribed2 = wait_for_worker_ready(worker2)
worker1.pause()
worker2.pause()
pending_ids = post_images(10)
wait_for_pending_tasks(subscribed1 or subscribed2)
worker1.kill()
worker2.kill()
worker1.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_faulty_worker_and_rabbit_restart(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
# No worker may finish tasks before the broker loses its in-memory state.
worker1.kill()
worker2.kill()
pending_ids = post_images(10)
check_task_queue(10, 10)
rabbit.kill()
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
worker2.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_total_eclipse_of_the_heart(docker_tester):
completed_ids = post_images(10)
wait_and_check_results(completed_ids, 10, docker_client=docker_tester)
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
worker1.kill()
worker2.kill()
rabbit.kill()
# These URLs repeat the first batch, but every request needs a new ID.
pending_ids = post_images(10, known_ids=completed_ids)
# Also accept requests after the client has detected the broken connection.
time.sleep(20)
later_ids = post_images(10, known_ids=completed_ids | pending_ids)
wait_and_check_results(completed_ids, 10, docker_client=docker_tester)
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
worker1.start()
worker2.start()
wait_and_check_results(completed_ids | pending_ids | later_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker', 'network-fault']])
def test_notifications_survive_rabbit_restart(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
fault = docker_tester.containers.get("distsys-mq-network-fault-1")
worker1.kill()
worker2.kill()
pending_ids = post_images(10)
check_task_queue(10, 10)
rabbit.reload()
address = next(network['IPAddress'] for network in
rabbit.attrs['NetworkSettings']['Networks'].values() if network['IPAddress'])
with block_amqp_link(fault, address):
worker1.start()
worker2.start()
wait_for_queued_notifications()
# Notifications cannot reach the API; tasks may still await acknowledgement.
wait_and_check_results(set(), 1, docker_client=docker_tester)
worker1.kill()
worker2.kill()
rabbit.kill()
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
worker1.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker', 'network-fault']])
def test_duplicate_deliveries(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
server = docker_tester.containers.get("distsys-mq-server-1")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
fault = docker_tester.containers.get("distsys-mq-network-fault-1")
worker1.kill()
worker2.kill()
pending_ids = post_images(10)
check_task_queue(10, 10)
task_copies = duplicate_queued_messages('task_queue')
assert task_copies == 20
check_task_queue(task_copies, 10)
rabbit.reload()
address = next(network['IPAddress'] for network in
rabbit.attrs['NetworkSettings']['Networks'].values() if network['IPAddress'])
with block_amqp_link(fault, address):
# Close the blocked connection so in-flight notifications become ready.
disconnect_from_broker(server)
worker1.start()
worker2.start()
queues = wait_for_queued_notifications(ready_only=True)
wait_and_check_results(set(), 1, docker_client=docker_tester)
worker1.kill()
worker2.kill()
notification_copies = {
queue['name']: duplicate_queued_messages(queue['name'])
for queue in queues if queue['name'] != 'task_queue' and queue.get('messages', 0) > 0
}
assert notification_copies
wait_for_queue_counts(notification_copies)
worker1.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
# The first complete result list may precede processing of later copies.
wait_for_queue_counts({name: 0 for name in notification_copies})
wait_and_check_results(pending_ids, 1, docker_client=docker_tester)
# Utils ===============================================================================================================
@pytest.fixture
def docker_tester(services):
print()
try:
run_docker_compose_up(services)
check_server_endpoint()
client = docker.from_env()
yield client
finally:
print()
run_docker_compose_down()
def run_docker_compose_up(services):
command = ["docker", "compose", "--ansi", "never", "up", "--force-recreate"]
for service in services:
command.append(service)
subprocess.Popen(command, cwd=Path(__file__).parent.parent.absolute(), stdout=None, stderr=None)
def run_docker_compose_down():
command = ["docker", "compose", "down", "--volumes"]
subprocess.run(command, cwd=Path(__file__).parent.parent.absolute(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def check_server_endpoint(max_attempts=20):
attempt = 0
while True:
attempt += 1
try:
logger.info(f"Sending GET {IMAGES_ENDPOINT}")
response = requests.get(IMAGES_ENDPOINT, timeout=3)
assert response.status_code == 200, response.text
logger.info(f"Attempt {attempt} succeeded: got response, server endpoint is ready")
return
except Exception as e:
logger.error(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
logger.error(f"Max attempts reached, give up")
pytest.fail("Server endpoint is not ready")
logger.info(f"Retry in 3 seconds...")
time.sleep(3)
def post_images(num_requests, known_ids=None):
known_ids = set() if known_ids is None else known_ids
pending_ids = set()
for i in range(num_requests):
input_data = {"image_url": f"https://somehost.com/some-image-{i}.jpg"}
logger.info(f"Sending POST {IMAGES_ENDPOINT} {input_data}")
try:
response = requests.post(IMAGES_ENDPOINT, json=input_data, timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to post image: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'image_id' in response.json()
image_id = response.json()['image_id']
assert type(image_id) in (str, int), 'image_id must be a string or integer'
assert image_id not in known_ids, f'Reused image_id: {image_id}'
assert image_id not in pending_ids
pending_ids.add(image_id)
return pending_ids
def wait_and_check_results(pending_ids, max_attempts, docker_client, check_captions=True):
expected_count = len(pending_ids)
attempt = 0
while True:
attempt += 1
try:
logger.info(f"Sending GET {IMAGES_ENDPOINT}")
response = requests.get(IMAGES_ENDPOINT, timeout=3)
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'image_ids' in response.json()
ready_ids = response.json()['image_ids']
assert isinstance(ready_ids, list), 'image_ids must be a list'
assert all(type(image_id) in (str, int) for image_id in ready_ids)
ready_set = set(ready_ids)
assert len(ready_ids) == len(ready_set), 'image_ids contains duplicates'
assert ready_set <= pending_ids, 'image_ids contains unexpected IDs'
count = len(ready_set)
if ready_set == pending_ids:
if check_captions:
for image_id in pending_ids:
check_image_caption(image_id, docker_client)
logger.info(f"Attempt {attempt} succeeded: got {count} results as expected")
return
else:
logger.info(f"Attempt {attempt} not succeeded: got {count} results, expect {expected_count}")
except Exception as e:
logger.error(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
logger.error(f"Max attempts reached, give up")
pytest.fail("Server didn't return expected results")
logger.info(f"Retry in 3 seconds...")
time.sleep(3)
def check_image_caption(image_id, docker_client):
logger.info(f"Sending GET {IMAGES_ENDPOINT}/{image_id}")
try:
response = requests.get(f'{IMAGES_ENDPOINT}/{image_id}', timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to get image caption: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'caption' in response.json()
assert isinstance(response.json()['caption'], str)
saved_caption = read_saved_caption(image_id, docker_client)
assert response.json()['caption'] == saved_caption, 'API caption differs from the saved result'
def read_saved_caption(image_id, docker_client):
# Docker's archive API works even if the solution image has no shell or Python.
server = docker_client.containers.get('distsys-mq-server-1')
stream, _ = server.get_archive(f'/data/{image_id}.txt')
with tarfile.open(fileobj=io.BytesIO(b''.join(stream))) as archive:
files = [member for member in archive.getmembers() if member.isfile()]
assert len(files) == 1, 'Expected one regular caption file'
with archive.extractfile(files[0]) as result:
return result.read().decode('utf-8')
def check_unprocessed_images(image_ids, docker_client):
wait_and_check_results(set(), 1, docker_client)
for image_id in image_ids:
response = requests.get(f'{IMAGES_ENDPOINT}/{image_id}', timeout=3)
assert response.status_code == 404, f'Result available without workers: {response.text}'
def wait_for_task_queue(predicate, description, max_attempts=10, interval=3):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(TASK_QUEUE_ENDPOINT, timeout=3)
assert response.status_code == 200, response.text
queue = response.json()
last_state = {key: queue.get(key) for key in
('messages_ready', 'messages_unacknowledged', 'consumers', 'message_stats')}
if predicate(queue):
logger.info(f"Task queue is ready: {description}")
return queue
except Exception as e:
last_state = str(e)
logger.info(f"Waiting for {description}: {last_state}")
if attempt < max_attempts:
time.sleep(interval)
pytest.fail(f"Task queue did not reach {description}; last state: {last_state}")
def check_task_queue(expected_count, max_attempts):
return wait_for_task_queue(lambda queue: queue['messages_ready'] == expected_count,
f'{expected_count} ready messages', max_attempts)
def wait_for_worker_ready(worker, max_attempts=10):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
worker.reload()
addresses = {network['IPAddress'] for network in
worker.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']}
response = requests.get(TASK_QUEUE_ENDPOINT, timeout=3)
assert response.status_code == 200, response.text
consumers = response.json().get('consumer_details', [])
if any(consumer['channel_details']['peer_host'] in addresses for consumer in consumers):
return True
# Polling with basic.get is also valid; it has no registered consumer.
response = requests.get(f'{BROKER_ENDPOINT}/channels', timeout=3)
assert response.status_code == 200, response.text
channels = response.json()
last_state = [{'peer_host': channel['connection_details']['peer_host'],
'message_stats': channel.get('message_stats')} for channel in channels
if channel['connection_details']['peer_host'] in addresses]
for channel in channels:
if channel['connection_details']['peer_host'] in addresses:
stats = channel.get('message_stats') or {}
if any(stats.get(name, 0) > 0 for name in ('get', 'get_no_ack', 'get_empty')):
return False
except Exception as e:
last_state = str(e)
if attempt < max_attempts:
time.sleep(3)
pytest.fail(f"Worker {worker.name} did not start consuming tasks; last state: {last_state}")
def wait_for_pending_tasks(subscribed):
if subscribed:
# Delivery statistics can arrive before the unacknowledged count.
# Automatic ACK is deliberately allowed through this barrier: after the
# kill, the missing results must expose the lost deliveries instead.
predicate = lambda queue: (queue['messages_unacknowledged'] > 0 or
any((queue.get('message_stats') or {}).get(name, 0) > 0
for name in ('deliver', 'deliver_no_ack')))
description = 'a task delivered to a paused worker'
else:
predicate = lambda queue: queue['messages_ready'] == 10
description = '10 tasks waiting for a polling worker'
# Statistics refresh every 1s in rabbitmq.conf.
return wait_for_task_queue(predicate, description, max_attempts=10, interval=1)
def wait_for_queued_notifications(max_attempts=20, ready_only=False):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(f'{BROKER_ENDPOINT}/queues/%2F', timeout=3)
assert response.status_code == 200, response.text
queues = response.json()
last_state = {queue['name']: queue.get('messages') for queue in queues}
# Notifications may be batched or spread across multiple queues.
# Workers may keep tasks unacknowledged until the API receives a result.
notification_count = sum(count or 0 for name, count in last_state.items()
if name != 'task_queue')
ready = sum(queue.get('messages_ready', 0) or 0 for queue in queues
if queue['name'] != 'task_queue')
if 'task_queue' in last_state and notification_count > 0 and (not ready_only or ready == notification_count):
return queues
except Exception as e:
last_state = str(e)
if attempt < max_attempts:
time.sleep(1)
pytest.fail(f'No queued notifications reached the required state; last state: {last_state}')
@contextmanager
def block_amqp_link(fault, address):
rules = [('INPUT', '-s', address, '--sport'), ('OUTPUT', '-d', address, '--dport')]
installed = []
try:
for chain, direction, host, port in rules:
rule = [chain, direction, host, '-p', 'tcp', port, '5672', '-j', 'DROP']
result = fault.exec_run(['iptables', '-I', *rule])
assert result.exit_code == 0, result.output.decode(errors='replace')
installed.append(rule)
yield
finally:
for rule in reversed(installed):
result = fault.exec_run(['iptables', '-D', *rule])
assert result.exit_code == 0, result.output.decode(errors='replace')
def duplicate_queued_messages(queue_name):
# Consumers/producers of this queue are stopped or disconnected by the test.
messages = []
while True:
response = requests.post(
f'{BROKER_ENDPOINT}/queues/%2F/{quote(queue_name, safe="")}/get',
json={'count': 100, 'ackmode': 'ack_requeue_false', 'encoding': 'base64'},
timeout=3)
assert response.status_code == 200, response.text
batch = response.json()
if not batch:
break
messages.extend(batch)
assert messages, f'No messages to duplicate in {queue_name}'
for message in messages:
for _ in range(2):
response = requests.post(f'{BROKER_ENDPOINT}/exchanges/%2F/amq.default/publish',
json={'routing_key': queue_name, 'properties': message['properties'],
'payload': message['payload'], 'payload_encoding': 'base64'}, timeout=3)
assert response.status_code == 200, response.text
assert response.json()['routed'] is True, f'Copy was not routed to {queue_name}'
logger.info(f'Queued {len(messages) * 2} copies in {queue_name}')
return len(messages) * 2
def disconnect_from_broker(container):
container.reload()
addresses = {network['IPAddress'] for network in
container.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']}
response = requests.get(f'{BROKER_ENDPOINT}/connections', timeout=3)
assert response.status_code == 200, response.text
for connection in response.json():
if connection['peer_host'] in addresses:
response = requests.delete(
f'{BROKER_ENDPOINT}/connections/{quote(connection["name"], safe="")}', timeout=3)
assert response.status_code in (204, 404), response.text
def wait_for_queue_counts(expected, max_attempts=30):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
last_state = {}
for name in expected:
response = requests.get(f'{BROKER_ENDPOINT}/queues/%2F/{quote(name, safe="")}', timeout=3)
assert response.status_code == 200, response.text
last_state[name] = response.json().get('messages')
if last_state == expected:
return
except Exception as e:
last_state = str(e)
if attempt < max_attempts:
time.sleep(1)
pytest.fail(f'Queues did not reach {expected}; last state: {last_state}')