forked from course/hse-2026
Compare commits
17
Commits
11f14108b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf71ed374 | ||
|
|
a987b82834 | ||
|
|
4bb38fb0a8 | ||
|
|
9abecfdd33 | ||
|
|
94c1abb8a3 | ||
|
|
74718b0c96 | ||
|
|
1ee860859a | ||
|
|
991a0c8b56 | ||
|
|
f4b49e450f | ||
|
|
af8099db34 | ||
|
|
209c66683d | ||
|
|
6008cff9f6 | ||
|
|
6210b46c1f | ||
|
|
0a1eb51bf8 | ||
|
|
25fd4ed555 | ||
|
|
a5011dd94b | ||
|
|
43f49f4b2a |
No files matched your search
@@ -0,0 +1,113 @@
|
||||
# Коллоквиум
|
||||
|
||||
Коллоквиум по распределённым системам пройдёт **23–25 октября 2026 года онлайн**. Расписание и способ записи объявим отдельно.
|
||||
|
||||
- [Формат и подготовка](#формат-и-подготовка)
|
||||
- [Оценивание](#оценивание)
|
||||
- [Если не получается прийти](#если-не-получается-прийти)
|
||||
- [Примеры вопросов](#примеры-вопросов)
|
||||
- [Примеры задач](#примеры-задач)
|
||||
|
||||
## Формат и подготовка
|
||||
|
||||
Коллоквиум состоит из двух частей: вопроса по материалам курса и задачи. Обе части охватывают материалы лекций и семинаров **недель 1–7**. За каждую часть можно получить до **4 баллов**, ещё до **2 баллов** — за усложнение задачи.
|
||||
|
||||
За **30 минут** до назначенного времени вы получите в Telegram конкретный вопрос с указанием того, что нужно раскрыть в ответе. Во время подготовки можно пользоваться материалами курса, интернетом и LLM.
|
||||
|
||||
Вопрос проверяет понимание основных понятий, механизмов и компромиссов: может потребоваться объяснить принцип работы, сравнить подходы или разобрать небольшой пример. Ориентир для ответа вместе с уточнениями преподавателя — **3–4 минуты**. Уточнения остаются в пределах выданного вопроса. Во время ответа на первую часть можно пользоваться своими краткими заметками; объяснять ответ и отвечать на уточнения нужно самостоятельно.
|
||||
|
||||
Условие задачи вы получите непосредственно на защите, без предварительной подготовки. Нужно предложить решение, объяснить его и разобрать указанные ситуации. Ориентир для основной части — **6–7 минут**, включая чтение, обдумывание и уточнения. Достаточно схемы, последовательности действий или короткого псевдокода; нужно объяснить, что хранят участники и как обрабатывают сообщения.
|
||||
|
||||
Полные банки вопросов и задач не публикуются. Ниже приведены [примеры вопросов](#примеры-вопросов) и [примеры задач](#примеры-задач), которые показывают формат и ожидаемую глубину ответа, но не исчерпывают темы коллоквиума.
|
||||
|
||||
В назначенное время подключитесь по указанной ссылке **со включённой камерой**. На всю защиту выделено **15 минут**, включая переходы между частями. Указанные времена — примерные ориентиры: время между частями распределяется в пределах общего лимита. Основную часть задачи можно продолжать до конца защиты; превышение ориентировочных 6–7 минут само по себе не снижает оценку. Во время защиты пользоваться LLM нельзя: отвечать на вопросы и рассуждать нужно самостоятельно.
|
||||
|
||||
## Оценивание
|
||||
|
||||
За подготовленный вопрос и основную часть задачи выставляются целые оценки от 0 до 4:
|
||||
|
||||
| Балл | Подготовленный вопрос | Основная задача |
|
||||
| --- | --- | --- |
|
||||
| 0 | Нет содержательного ответа или понимание не продемонстрировано. | Нет содержательного продвижения к решению. |
|
||||
| 1 | Есть отдельные правильные факты, но нет связного объяснения. | Есть отдельные правильные идеи, но они не складываются в решение. |
|
||||
| 2 | Основная идея понятна, но есть существенные пробелы или ошибки. | Предложен разумный подход, но решение не выполняет одно из основных требований. |
|
||||
| 3 | Ответ в основном правильный, но существенный аспект раскрыт недостаточно. | Решение в основном корректно, но остаётся пробел в требуемом разборе или обосновании. |
|
||||
| 4 | Существенные аспекты раскрыты, студент объясняет их и отвечает на уточнения. | Основные требования выполнены, предусмотренные ситуации разобраны, решения обоснованы. |
|
||||
|
||||
Для полного ответа нужно раскрыть перечисленные в вопросе или задаче аспекты, объяснить механизмы и разобрать все указанные примеры и ситуации. Простого перечисления терминов или названий технологий недостаточно. Каждая часть оценивается целиком; отдельные баллы за подпункты не начисляются. Самостоятельно исправленная оговорка сама по себе не снижает оценку.
|
||||
|
||||
**Если за обе части получено по 4 балла и осталось время, проверяющий сразу даёт усложнение задачи.** Уровни усложнения последовательны: **0 баллов**, если первый уровень не выполнен, **1 балл** — за выполненный первый уровень, **2 балла** — за выполненные оба уровня. Конкретные требования приведены в [примерах задач](#примеры-задач).
|
||||
|
||||
Ориентир для обоих уровней вместе — **4–5 минут**, включая чтение, обдумывание и уточнения. Это не гарантированное время: на усложнения используется остаток общего времени защиты. На первом уровне достаточно одного изменения решения с объяснением. На втором нужно разобрать предложенную ситуацию или схему, показать её ограничение и обосновать исправление. Новые условия дополняют или явно изменяют предыдущие.
|
||||
|
||||
Итоговая оценка — сумма двух основных оценок и баллов за выполненные уровни усложнения. Если времени на усложнение не осталось, итог определяется двумя основными оценками и не превышает **8 баллов**. Максимум с усложнениями — **10 баллов**.
|
||||
|
||||
Вес коллоквиума в оценке за курс — **0,2**. Подробнее — в [правилах оценивания курса](https://distsys.ru/course/hse-2026/src/branch/main/README.md).
|
||||
|
||||
## Если не получается прийти
|
||||
|
||||
Если вы не подключитесь в назначенное время, за коллоквиум выставляется 0. Заранее проверьте, что сможете прийти и вас не будут отвлекать.
|
||||
|
||||
Если не можете прийти по уважительной причине, предупредите преподавателей курса. Если вы пропустите коллоквиум без предупреждения, вам могут отказать в досдаче.
|
||||
|
||||
## Примеры вопросов
|
||||
|
||||
### Пример 1. Производительность
|
||||
|
||||
Чем задержка отличается от пропускной способности? Почему средней задержки недостаточно для оценки сервиса? Что означает p99 времени ответа, равный 200 мс? Приведите пример решения, которое повышает пропускную способность, но увеличивает время ожидания отдельного запроса.
|
||||
|
||||
### Пример 2. JSON и Protocol Buffers
|
||||
|
||||
Сравните JSON и Protocol Buffers по представлению данных, роли схемы и удобству разработки и диагностики. Можно ли по меньшему размеру сериализованного сообщения сделать вывод, что клиент быстрее получит ответ? Какие ещё затраты нужно учитывать? Приведите ситуацию, в которой вы выбрали бы каждый вариант.
|
||||
|
||||
### Пример 3. Общий порядок доставки
|
||||
|
||||
Почему репликам сервиса бронирования может понадобиться одинаковый порядок обработки конкурентных заявок? Объясните реализацию Total Order через секвенсор и одно её ограничение. Гарантирует ли само свойство Total Order причинный порядок? Рассмотрите два сообщения, последовательно отправленные одним процессом: могут ли все получатели доставить их в обратном порядке?
|
||||
|
||||
## Примеры задач
|
||||
|
||||
### Пример 1. Два сервиса обрабатывают события
|
||||
|
||||
#### Основная часть — до 4 баллов
|
||||
|
||||
Сервис заказов публикует события о создании и изменении заказов. У сервиса уведомлений и сервиса аналитики по несколько обработчиков. Каждый сервис должен обработать все события, распределяя работу между своими обработчиками. События уже надёжно приняты брокером. Обработчики могут аварийно завершаться; после сбоя обработку продолжает другой или перезапущенный обработчик. Повторная обработка одного события безопасна. Иногда аналитика обрабатывает события медленнее, чем они поступают. В основной части места в брокере достаточно.
|
||||
|
||||
Предложите схему очередей или подписок и выберите момент подтверждения обработки. Разберите два случая: обработчик получил событие, но завершился до начала обработки; обработал событие, но завершился до отправки подтверждения. Объясните, что происходит с накоплением событий и обработкой уведомлений при замедлении аналитики.
|
||||
|
||||
#### Первый уровень усложнения — 1 дополнительный балл
|
||||
|
||||
Далее рассмотрим обработку событий внутри одного из сервисов. При каждой попытке обработать одно из событий обработчик аварийно завершается. Брокер умеет надёжно перемещать событие в отдельную очередь для разбора. Предложите правило ограничения числа автоматических попыток. Объясните, где и когда учитывать попытку, чтобы правило работало и при падении обработчика, а остальные события продолжали обрабатываться.
|
||||
|
||||
**Критерий выполнения:** Обосновано, что правило ограничивает число автоматических попыток даже при падениях обработчика, сохраняет событие для разбора и позволяет продолжать обработку остальных.
|
||||
|
||||
#### Второй уровень усложнения — 2 дополнительных балла за оба уровня
|
||||
|
||||
Теперь добавим требование к порядку обработки. Для каждого заказа события пронумерованы последовательно, начиная с 1. Их нужно применять без пропусков, в порядке номеров. Задержка одного заказа не должна останавливать обработку других заказов. Предлагается схема: отправив проблемное событие на разбор, обработчик продвигает номер последнего обработанного события, как после успеха, и продолжает работу. Доступно надёжное хранилище ожидающих событий.
|
||||
|
||||
Постройте контрпример к этой схеме и предложите исправленную схему: какое состояние хранить и как обрабатывать события. Объясните, что происходит с последующими событиями этого заказа, с другими заказами и после устранения причины сбоя.
|
||||
|
||||
**Критерий выполнения:** Самостоятельно построена история нарушения. Обоснованы сохранение порядка в исправленной схеме, независимая обработка других заказов и продолжение работы после устранения причины сбоя.
|
||||
|
||||
### Пример 2. Переназначение задания
|
||||
|
||||
#### Основная часть — до 4 баллов
|
||||
|
||||
Координатор выдаёт задания исполнителям A и B, хранит состояние и не падает. Исполнители возвращают результаты вычислений без внешних побочных эффектов. Сообщения задерживаются и повторяются; исполнители могут остановиться. Повторное вычисление допустимо, но координатор должен сохранить не более одного результата каждого задания. Если исполнитель перестал отвечать, задание переназначают; при работающем исполнителе и восстановлении связи оно должно завершиться.
|
||||
|
||||
Опишите, как координатор проверяет состояние исполнителей, когда переназначает задание и как принимает результаты разных попыток. Объясните, как ваше решение предотвращает сохранение нескольких результатов и позволяет завершить задание после отказа исполнителя.
|
||||
|
||||
Рассмотрите ситуацию: A перестал отвечать, и координатор переназначил задание B. Может ли A продолжать работу? Как поступить, если затем пришёл его результат? Что делать, если после завершения задания приходят повторное сообщение с результатом или запоздалое сообщение о ходе выполнения?
|
||||
|
||||
#### Первый уровень усложнения — 1 дополнительный балл
|
||||
|
||||
Задание состоит из последовательно обрабатываемых элементов. Исполнитель продолжает отправлять heartbeat, но больше не завершает обработку новых элементов. Предложите показатель прогресса и правило, по которому координатор заподозрит зависание текущей попытки. Как учитывать повторные и задержанные сообщения? Может ли такая проверка ошибочно заподозрить исправного исполнителя? Объясните почему.
|
||||
|
||||
**Критерий выполнения:** Предложены показатель полезного прогресса и правило его проверки. На примере повторных или задержанных сведений объяснены поведение проверки и ограничения выводов об отказе.
|
||||
|
||||
#### Второй уровень усложнения — 2 дополнительных балла за оба уровня
|
||||
|
||||
Теперь у вычислений появляются внешние побочные эффекты: исполнители записывают данные задания в общее хранилище. Для каждого задания координатор выдаёт возрастающие номера полномочий; каждая запись содержит такой номер. Для каждого задания хранилище запоминает наибольший увиденный номер. Проверка номера и изменение данных выполняются атомарно: запись с меньшим номером отклоняется. Хранилище доступно и допускает отдельную установку номера координатором.
|
||||
|
||||
Координатор выдал B новый номер и объявил передачу права завершённой, но хранилище узнает этот номер только из первой записи B. Может ли A после этого ещё записать данные? Покажите нарушение и исправьте порядок передачи так, чтобы после её завершения записи A отклонялись.
|
||||
|
||||
**Критерий выполнения:** Приведена допустимая история нарушения и исправлен порядок передачи полномочий. Объяснено, почему после завершения передачи прежний исполнитель не может записать данные, в том числе при задержках и повторах сообщений.
|
||||
@@ -0,0 +1,5 @@
|
||||
target
|
||||
**/target
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
*.pyc
|
||||
@@ -0,0 +1,125 @@
|
||||
x-tests-image: &tests-image
|
||||
image: grpc-solution-tests
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tests/Dockerfile
|
||||
|
||||
services:
|
||||
server:
|
||||
image: grpc-solution-server
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: server.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_PORT: 51075
|
||||
ports:
|
||||
- "${MESSENGER_SERVER_HOST_PORT:-51075}:51075"
|
||||
# Start the clients first to exercise their reconnect logic during manual runs.
|
||||
depends_on:
|
||||
- client1
|
||||
- client2
|
||||
|
||||
server-tests:
|
||||
image: grpc-solution-server
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: server.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_PORT: 51075
|
||||
|
||||
client1:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
ports:
|
||||
- "${MESSENGER_CLIENT1_HOST_PORT:-8080}:8080"
|
||||
|
||||
client2:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
ports:
|
||||
- "${MESSENGER_CLIENT2_HOST_PORT:-8081}:8080"
|
||||
|
||||
client-test-server:
|
||||
image: "${MESSENGER_CLIENT_TEST_SERVER_IMAGE:-distsys.ru/course/grpc-messenger:latest}"
|
||||
entrypoint:
|
||||
- /usr/local/bin/client-test-server
|
||||
environment:
|
||||
MESSENGER_CLIENT_TEST_SERVER_EXPECTED_STREAMS: 2
|
||||
MESSENGER_CLIENT_TEST_SERVER_PROTO: /submission/proto/messenger.proto
|
||||
MESSENGER_SERVER_PORT: 51075
|
||||
volumes:
|
||||
- type: bind
|
||||
source: "${MESSENGER_CLIENT_TEST_SERVER_PROTO_DIR:-./solution/proto}"
|
||||
target: /submission/proto
|
||||
read_only: true
|
||||
user: "65532:65532"
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
client-test1:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: client-test-server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
depends_on:
|
||||
- client-test-server
|
||||
|
||||
client-test2:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: client-test-server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
depends_on:
|
||||
- client-test-server
|
||||
|
||||
tests:
|
||||
<<: *tests-image
|
||||
environment:
|
||||
MESSENGER_TEST_CLIENT1_ADDR: client-test1:8080
|
||||
MESSENGER_TEST_CLIENT2_ADDR: client-test2:8080
|
||||
MESSENGER_TEST_SERVER_ADDR: server-tests:51075
|
||||
depends_on:
|
||||
- server-tests
|
||||
- client-test1
|
||||
- client-test2
|
||||
|
||||
server-test-runner:
|
||||
<<: *tests-image
|
||||
command:
|
||||
- --component
|
||||
- server
|
||||
environment:
|
||||
MESSENGER_TEST_SERVER_ADDR: server-tests:51075
|
||||
depends_on:
|
||||
- server-tests
|
||||
|
||||
client-tests:
|
||||
<<: *tests-image
|
||||
command:
|
||||
- --component
|
||||
- client
|
||||
environment:
|
||||
MESSENGER_TEST_CLIENT1_ADDR: client-test1:8080
|
||||
MESSENGER_TEST_CLIENT2_ADDR: client-test2:8080
|
||||
depends_on:
|
||||
- client-test1
|
||||
- client-test2
|
||||
@@ -0,0 +1,296 @@
|
||||
# Мессенджер на gRPC
|
||||
|
||||
Опишите gRPC-интерфейс и реализуйте **сервер** и **клиент** мессенджера с одним общим чатом. Сервер и клиент общаются по gRPC, пользователь обращается к клиенту по HTTP.
|
||||
|
||||
У сервера два RPC-метода: `SendMessage` отправляет сообщение в чат, `ReadMessages` открывает подписку на новые сообщения. Сервер должен обрабатывать несколько запросов одновременно, в том числе принимать сообщения при открытых подписках.
|
||||
|
||||
Клиент при запуске открывает подписку и сохраняет сообщения от сервера в буфере в порядке получения. Через HTTP пользователь отправляет сообщения и забирает содержимое буфера.
|
||||
|
||||
На схеме показаны сервер и два клиента. Тесты обращаются к клиентам от имени двух пользователей:
|
||||
|
||||
```mermaid
|
||||
flowchart RL
|
||||
subgraph Tests
|
||||
U1{User 1}
|
||||
U2{User 2}
|
||||
end
|
||||
|
||||
subgraph Clients with HTTP interface
|
||||
C1(fa:fa-comments Client 1)
|
||||
C2(fa:fa-comments Client 2)
|
||||
end
|
||||
|
||||
subgraph gRPC server
|
||||
S(fa:fa-server Server)
|
||||
end
|
||||
|
||||
|
||||
C1 -- SendMessage --> S
|
||||
S -. Stream ReadMessages .-> C1
|
||||
|
||||
U1 -- POST /sendMessage --> C1
|
||||
C1 -- Forward messages in /getAndFlushMessages --> U1
|
||||
|
||||
C2 -- SendMessage --> S
|
||||
S -. Stream ReadMessages .-> C2
|
||||
|
||||
U2 -- POST /sendMessage --> C2
|
||||
C2 -- Forward messages in /getAndFlushMessages --> U2
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
### Доставка сообщений
|
||||
|
||||
- Подписка начинается, когда сервер регистрирует вызов `ReadMessages`, и действует до отмены RPC или закрытия соединения. Сообщения, принятые до регистрации, в подписку не попадают.
|
||||
- Сервер передаёт каждое сообщение ровно один раз во все подписки, активные в момент его принятия, включая подписку отправителя. Восстанавливать историю после разрывов и перезапусков не нужно.
|
||||
- Сообщения, общие для нескольких подписчиков, должны идти в одинаковом порядке во всех их потоках. Клиенты сохраняют этот порядок. Для одновременных вызовов `SendMessage` сервер может выбрать любой порядок.
|
||||
- `sendTime` — серверное время принятия сообщения. В течение одного запуска эти значения должны быть уникальны и строго возрастать в порядке рассылки. Ответ `SendMessage` и сообщение во всех подписках содержат одинаковый `sendTime`.
|
||||
- Успешный ответ `SendMessage` означает, что сервер принял сообщение. Это не гарантирует, что все клиенты уже его получили.
|
||||
|
||||
### HTTP-интерфейс клиента
|
||||
|
||||
Пользователи и тесты обращаются к клиенту через два HTTP-метода.
|
||||
|
||||
При успехе оба метода возвращают HTTP `200` и JSON. Поле `sendTime` — строка в [JSON-формате `google.protobuf.Timestamp`](https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp), например `2025-09-20T10:58:42.665193557Z`.
|
||||
|
||||
```
|
||||
POST /sendMessage
|
||||
Отправляет одно сообщение в общий чат.
|
||||
|
||||
Тело запроса:
|
||||
{
|
||||
"author": "Ivan Ivanov",
|
||||
"text": "Hey guys"
|
||||
}
|
||||
|
||||
Тело ответа:
|
||||
{
|
||||
"sendTime": "..."
|
||||
}
|
||||
```
|
||||
```
|
||||
POST /getAndFlushMessages
|
||||
Возвращает накопленные сообщения в порядке получения и очищает буфер.
|
||||
|
||||
Тело запроса: нет
|
||||
|
||||
Тело ответа:
|
||||
[{
|
||||
"author": "Ivan Ivanov",
|
||||
"text": "Hey guys",
|
||||
"sendTime": "..."
|
||||
},{
|
||||
"author": "Petr Petrov",
|
||||
"text": "Hey Ivan",
|
||||
"sendTime": "..."
|
||||
}]
|
||||
```
|
||||
|
||||
Если буфер пуст, `getAndFlushMessages` сразу возвращает `[]`. Чтение и очистка буфера должны быть атомарными: сообщение, пришедшее во время этой операции, попадает в текущий или следующий ответ. Клиент не должен терять сообщения, выдавать их повторно или менять их порядок.
|
||||
|
||||
### gRPC-интерфейс сервера
|
||||
|
||||
Тесты проверяют сервер отдельно от клиента. Соблюдайте требования к интерфейсу:
|
||||
|
||||
- синтаксис — `proto3`, пакет — `mes_grpc`;
|
||||
- gRPC-сервис `MessengerServer` содержит два метода: `SendMessage` и `ReadMessages`;
|
||||
- `SendMessage` — унарный вызов. Запрос содержит одиночные строковые поля `author` и `text`, ответ — одиночное поле `sendTime` типа `google.protobuf.Timestamp`;
|
||||
- `ReadMessages` принимает один пустой запрос и возвращает поток сообщений. Можно описать свой тип пустого сообщения или взять готовый из библиотеки. Каждое сообщение в потоке содержит одиночные поля `author` и `text` типа `string` и `sendTime` типа `google.protobuf.Timestamp`.
|
||||
|
||||
Все перечисленные поля одного сообщения должны допускать одновременное заполнение.
|
||||
|
||||
Имена типов сообщений и номера полей выберите самостоятельно — тесты их не фиксируют.
|
||||
|
||||
## Оценивание
|
||||
|
||||
За задание можно получить 10 баллов:
|
||||
|
||||
- **2 балла** — протокол `messenger.proto`, проверяется в `test_proto.py`.
|
||||
- **4 балла** — сервер, проверяется в `test_server.py`.
|
||||
- **4 балла** — клиент, проверяется в `test_client.py`.
|
||||
|
||||
В отчёте `solution/readme.md` опишите структуру решения, какие компоненты вы реализовали и как работают методы сервера и клиента. Без отчёта тесты запускаются, но защита не проводится и решение не засчитывается — см. [общие правила сдачи](../readme.md#сдача-решения).
|
||||
|
||||
Баллы за компонент начисляются, только если прошли все его тесты: 2 или 0 за протокол, 4 или 0 за сервер, 4 или 0 за клиент. Значение `SCORE` в выводе тестов — предварительная оценка. Итоговую оценку преподаватель выставляет после защиты с учётом штрафов ниже.
|
||||
|
||||
Система собирает и проверяет сервер и клиент независимо. Если сервер не собирается или не запускается, он получает 0 баллов, но клиент всё равно проверяется, и наоборот. Протокол проверяется отдельно.
|
||||
|
||||
При независимом оценивании клиенты работают со служебным gRPC-сервером, построенным по вашему `messenger.proto`. Поэтому для проверки клиента нужен корректный протокол, но ошибки вашего сервера не влияют на баллы за клиент.
|
||||
|
||||
На защите можно потерять баллы за следующие ошибки:
|
||||
|
||||
- Сервер не может обрабатывать несколько запросов одновременно — 2 балла.
|
||||
- При конкурентном доступе сервер может потерять или продублировать сообщения, выдать их в разном порядке в потоках `ReadMessages` либо нарушить требования к `sendTime` — 2 балла.
|
||||
- Клиент теряет, повторно выдаёт или меняет порядок сообщений из потока `ReadMessages` — 2 балла.
|
||||
|
||||
За ошибки сервера снимаются только баллы за сервер, за ошибки клиента — только баллы за клиент, не больше 4 баллов в каждом случае. Баллы за протокол сохраняются. На защите нужно разобрать предложенный преподавателем сценарий конкурентного выполнения и объяснить по своему коду, почему решение работает правильно.
|
||||
|
||||
## Заготовки для клиента
|
||||
|
||||
В `templates` есть заготовки клиента на Python, Python с asyncio и Go. Официальная заготовка — `messenger-py/client`; она проверена для текущего задания. Остальные заготовки относятся к прошлым версиям задания: их можно использовать, но расхождения с условием нужно исправить самостоятельно. Можно выбрать и другой язык — тесты обращаются к решению через HTTP и gRPC.
|
||||
|
||||
## Порядок выполнения задания
|
||||
|
||||
Выполняйте команды из папки `homework/02-grpc-messenger`. Примеры с переменными окружения написаны для Bash. В Windows используйте WSL или задавайте переменные через PowerShell.
|
||||
|
||||
### Подготовка окружения
|
||||
|
||||
Установите Python 3.12 или новее и Docker по [общей инструкции](../readme.md#настройка-окружения). Для Python-заготовки и локального запуска тестов установите зависимости:
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r templates/messenger-py/client/requirements.txt -r tests/requirements.txt
|
||||
grpcurl -version
|
||||
```
|
||||
|
||||
Если `grpcurl` не найден, установите его по инструкции в разделе «Полезные материалы». На Windows используйте `python` вместо `python3`.
|
||||
|
||||
### Структура проекта
|
||||
|
||||
Разместите решение в папке `solution`. Сохраните пути к трём файлам, которые используют тесты и [docker-compose.yml](docker-compose.yml):
|
||||
|
||||
- `client.dockerfile` — сборка и запуск клиента;
|
||||
- `server.dockerfile` — сборка и запуск сервера;
|
||||
- `proto/messenger.proto` — описание gRPC-интерфейса; дополните начальный файл.
|
||||
|
||||
При сдаче отправляется только папка `solution`. Изменения за её пределами не учитываются.
|
||||
|
||||
Для официальной Python-заготовки скопируйте `templates/messenger-py/client` в `solution/client`, а образец `client.dockerfile` — в `solution/client.dockerfile`. Сервер разместите в `solution/server/server.py`. Заготовка использует пакет `solution` и импорты `from solution.proto import messenger_pb2, messenger_pb2_grpc`. В своей реализации можно выбрать другую структуру, сохранив три обязательных пути выше.
|
||||
|
||||
### Описание и компиляция gRPC-интерфейса
|
||||
|
||||
Опишите сообщения и сервис в `solution/proto/messenger.proto`.
|
||||
|
||||
Сгенерируйте код для выбранного языка с помощью `protoc`:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python3 -m grpc_tools.protoc -I. --python_out=. --pyi_out=. --grpc_python_out=. solution/proto/messenger.proto
|
||||
|
||||
# Go (после установки protoc и плагинов protoc-gen-go и protoc-gen-go-grpc)
|
||||
protoc -I solution/proto --go_out=solution/proto --go_opt=paths=source_relative --go-grpc_out=solution/proto --go-grpc_opt=paths=source_relative messenger.proto
|
||||
```
|
||||
|
||||
Для Go укажите в `option go_package` путь пакета в вашем модуле; пример есть в Go-заготовке. Закрепите версии генераторов, совместимые с вашей версией Go. Сгенерированные файлы включите в решение или генерируйте при сборке образа с закреплёнными версиями инструментов.
|
||||
|
||||
### Реализация сервера и клиента
|
||||
|
||||
Сервер реализуйте с нуля; заготовки для него нет. Можно использовать потоки или асинхронный код. Открытые подписки не должны мешать обработке других запросов.
|
||||
|
||||
Если используете Python-заготовку клиента, заполните места с пометкой TODO. HTTP-сервер в ней уже реализован. При запуске клиент должен дождаться сервера, открыть `ReadMessages` и принимать сообщения независимо от обработки HTTP-запросов.
|
||||
|
||||
Сервер и клиент должны брать настройки из переменных окружения:
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `MESSENGER_SERVER_PORT` | Порт gRPC-сервера, по умолчанию `51075` |
|
||||
| `MESSENGER_SERVER_ADDR` | Адрес gRPC-сервера для подключения клиента |
|
||||
| `MESSENGER_HTTP_PORT` | Порт HTTP-интерфейса клиента |
|
||||
|
||||
Сервер и HTTP-интерфейс клиента должны слушать на `0.0.0.0`.
|
||||
|
||||
В `solution/server.dockerfile` и `solution/client.dockerfile` опишите сборку и запуск сервера и клиента. Контекст сборки — папка `solution`. Включите в образы все нужные файлы и зависимости. За образец можно взять Dockerfile заготовки.
|
||||
|
||||
## Тестирование решения
|
||||
|
||||
Публичные тесты проверяют [протокол](tests/test_proto.py), [сервер](tests/test_server.py) и [клиент](tests/test_client.py). Сложные конкурентные сценарии вы разберёте на защите.
|
||||
|
||||
### Полная проверка
|
||||
|
||||
Рекомендуемый запуск в окружении тестирующей системы:
|
||||
|
||||
```bash
|
||||
docker run --privileged --pull always --rm -v ./solution:/hw/solution distsys.ru/course/grpc-messenger:latest
|
||||
```
|
||||
|
||||
Сервер и клиент собираются и проверяются независимо: ошибка сборки одного не мешает проверить другой.
|
||||
|
||||
### Отдельные компоненты через Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose build tests
|
||||
|
||||
# Протокол
|
||||
docker compose run --rm --no-deps tests --component proto
|
||||
|
||||
# Сервер
|
||||
docker compose build server-tests
|
||||
docker compose run --rm server-test-runner
|
||||
|
||||
# Клиент со служебным сервером
|
||||
docker compose build client-test1
|
||||
docker compose run --rm client-tests
|
||||
```
|
||||
|
||||
После изменения кода пересоберите соответствующий образ и повторите проверку. После изменения протокола заново сгенерируйте код и выполните:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose build tests server-tests client-test1
|
||||
docker compose run --rm tests
|
||||
```
|
||||
|
||||
Смотрите логи через `docker compose logs`, останавливайте контейнеры командой `docker compose down`. После обновления задания скачайте свежий служебный образ: `docker compose pull client-test-server`.
|
||||
|
||||
### Ручная отладка своей связки (необязательно)
|
||||
|
||||
Запустите свой сервер и два клиента через Compose:
|
||||
|
||||
```bash
|
||||
docker compose build server client1
|
||||
docker compose up -d server client1 client2
|
||||
```
|
||||
|
||||
Сервер доступен на `localhost:51075`, клиенты — на `localhost:8080` и `localhost:8081`. Compose запускает клиентов раньше сервера. После правок пересоберите соответствующий образ и повторите `up`; для логов и остановки используйте команды выше.
|
||||
|
||||
Без Docker запустите компоненты и тестер в отдельных терминалах из папки задания. Для официальной Python-заготовки и сервера в `solution/server/server.py`:
|
||||
|
||||
```bash
|
||||
# Терминал 1
|
||||
python3 -m solution.server.server
|
||||
# Терминал 2
|
||||
python3 -m solution.client.client
|
||||
# Терминал 3
|
||||
MESSENGER_HTTP_PORT=8081 python3 -m solution.client.client
|
||||
# Терминал 4
|
||||
python3 tests/main.py
|
||||
```
|
||||
|
||||
Если вы добавили зависимости, установите и их. Чтобы проверить один компонент, передайте тестеру `--component proto`, `--component server` или `--component client`. По умолчанию проверяются все компоненты (`--component all`).
|
||||
|
||||
Здесь клиенты работают с вашим сервером, поэтому его ошибки могут повлиять на клиентские тесты. Для независимой проверки клиента используйте Compose со служебным сервером.
|
||||
|
||||
Примеры HTTP-запросов к запущенному клиенту:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:8080/sendMessage -d '{"author": "alice", "text": "hey"}'
|
||||
curl -X POST localhost:8080/getAndFlushMessages
|
||||
```
|
||||
|
||||
### Сдача решения
|
||||
|
||||
Подготовьте `solution/readme.md` и отправьте решение по [общей инструкции](../readme.md#сдача-решения). В журнале проверки будут вывод сборки, результаты тестов (после строки `=== RUN TESTS`) и логи контейнеров.
|
||||
|
||||
## Полезные материалы
|
||||
|
||||
### grpcurl
|
||||
|
||||
[grpcurl](https://github.com/fullstorydev/grpcurl) позволяет вызывать gRPC-методы из терминала. Для Linux и Windows скачайте архив для своей ОС и архитектуры со [страницы релизов](https://github.com/fullstorydev/grpcurl/releases), распакуйте его и добавьте каталог с исполняемым файлом в `PATH`. В macOS: `brew install grpcurl`.
|
||||
|
||||
Для проверки сервера откройте подписку в одном терминале, а в другом отправьте сообщение:
|
||||
|
||||
```bash
|
||||
# Терминал 1: поток остаётся открытым; Ctrl+C отменяет вызов
|
||||
grpcurl -proto solution/proto/messenger.proto -plaintext localhost:51075 mes_grpc.MessengerServer/ReadMessages
|
||||
|
||||
# Терминал 2
|
||||
grpcurl -proto solution/proto/messenger.proto -d '{"author": "alice", "text": "hello"}' -plaintext localhost:51075 mes_grpc.MessengerServer/SendMessage
|
||||
```
|
||||
|
||||
### Конкурентная обработка в Python
|
||||
|
||||
При конкурентном доступе к общим изменяемым данным учитывайте возможные гонки. Структуры данных и способы синхронизации выберите самостоятельно.
|
||||
|
||||
- [gRPC Basics Tutorial](https://grpc.io/docs/languages/python/basics/) и [официальные примеры](https://github.com/grpc/grpc/blob/master/examples).
|
||||
- Документация Python: [`threading`](https://docs.python.org/3/library/threading.html), [`queue`](https://docs.python.org/3/library/queue.html), [`asyncio`](https://docs.python.org/3/library/asyncio.html).
|
||||
- Для работы с несколькими терминалами при желании можно использовать [tmux](https://github.com/tmux/tmux/wiki/Getting-Started).
|
||||
@@ -0,0 +1 @@
|
||||
# TODO: Write Docker file for client
|
||||
@@ -0,0 +1,5 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mes_grpc;
|
||||
|
||||
// TODO: Add messages and service
|
||||
@@ -0,0 +1 @@
|
||||
# TODO: Write Docker file for server
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /grpc-messenger
|
||||
COPY proto proto
|
||||
COPY client client
|
||||
RUN cd client && go mod download && go build .
|
||||
|
||||
FROM alpine:latest
|
||||
WORKDIR /grpc-messenger
|
||||
COPY --from=builder /grpc-messenger .
|
||||
CMD ["./client/client"]
|
||||
@@ -0,0 +1,12 @@
|
||||
module github.com/distsys-course/grpc-messenger/client
|
||||
|
||||
go 1.23
|
||||
|
||||
replace github.com/distsys-course/grpc-messenger/grpc => ../proto
|
||||
|
||||
require (
|
||||
github.com/distsys-course/grpc-messenger/grpc v0.0.0-00010101000000-000000000000
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang/protobuf v1.5.4
|
||||
google.golang.org/grpc v1.75.0
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mes_grpc "github.com/distsys-course/grpc-messenger/grpc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang/protobuf/jsonpb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Author string `json:"author"`
|
||||
Text string `json:"text"`
|
||||
SendTime time.Time `json:"sendTime"`
|
||||
}
|
||||
|
||||
type MessengerClient struct {
|
||||
pendingMessages []ChatMessage
|
||||
pendingMutex sync.Mutex
|
||||
grpcClient YourMessengerServerClient
|
||||
}
|
||||
|
||||
func NewMessengerClient(serverAddr string) *MessengerClient {
|
||||
// TODO
|
||||
}
|
||||
|
||||
func (c *MessengerClient) ReadMessages() {
|
||||
// TODO: implement messages consumer here
|
||||
}
|
||||
|
||||
func (c *MessengerClient) GetPending() (messages []ChatMessage) {
|
||||
c.pendingMutex.Lock()
|
||||
result := c.pendingMessages
|
||||
c.pendingMessages = nil
|
||||
c.pendingMutex.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
type MessageResponse struct {
|
||||
SendTime *time.Time `json:"sendTime"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
serverAddr := os.Getenv("MESSENGER_SERVER_ADDR")
|
||||
if serverAddr == "" {
|
||||
serverAddr = "localhost:51075"
|
||||
fmt.Println("Missing MESSENGER_SERVER_ADDR variable, using default value: " + serverAddr)
|
||||
}
|
||||
// TODO: create your grpc client with given address
|
||||
r.POST("/getAndFlushMessages", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, client.GetPending())
|
||||
})
|
||||
|
||||
r.POST("/sendMessage", func(c *gin.Context) {
|
||||
// TODO: implement send message here, that parses body into protobuf and sends to the server
|
||||
c.JSON(http.StatusOK, MessageResponse{SendTime: nil}) // TODO: do not forget to fill SendTime
|
||||
return
|
||||
})
|
||||
|
||||
// TODO: run consumer in a goroutine
|
||||
|
||||
addr := os.Getenv("MESSENGER_HTTP_PORT")
|
||||
if addr == "" {
|
||||
addr = "0.0.0.0:8080"
|
||||
fmt.Println("Missing MESSENGER_HTTP_PORT variable, using default value: 8080")
|
||||
} else {
|
||||
addr = "0.0.0.0:" + addr
|
||||
}
|
||||
if err := r.Run(addr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/distsys-course/grpc-messenger/grpc
|
||||
|
||||
go 1.23
|
||||
@@ -0,0 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mes_grpc;
|
||||
|
||||
option go_package = "proto/;mes_grpc";
|
||||
|
||||
// TODO: Add messages and service
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
# TODO: implement grpc client for messenger service
|
||||
|
||||
class MessengerHandler:
|
||||
_pendingMessages: List[dict] # list of messages, that have not been requested yet via get_messages
|
||||
_pendingMessagesLock: asyncio.Lock
|
||||
_grpcClient = None # grpc client of the messenger service
|
||||
|
||||
def __init__(self):
|
||||
self._pendingMessages = []
|
||||
self._pendingMessagesLock = asyncio.Lock()
|
||||
|
||||
async def send_message(self, request):
|
||||
"""
|
||||
Body should be of the form:
|
||||
{"author": "Ivan", "text": "hey guys"}
|
||||
:return web.json_response of the form {"sendTime": ... }
|
||||
"""
|
||||
j = await request.json() # TODO: use google.protobuf.json_format.ParseDict and raise BadRequest on error
|
||||
if 'author' not in j or 'text' not in j:
|
||||
raise web.HTTPBadRequest
|
||||
print('Got message to send:', json.dumps(j))
|
||||
|
||||
# TODO: your rpc call of the messenger here
|
||||
|
||||
raise NotImplementedError
|
||||
return web.json_response({'sendTime': ""}) # TODO: use google.protobuf.json_format.MessageToDict here
|
||||
|
||||
async def get_messages(self, _):
|
||||
async with self._pendingMessagesLock:
|
||||
res: List[dict] = copy.deepcopy(self._pendingMessages)
|
||||
self._pendingMessages = []
|
||||
return web.json_response(res)
|
||||
|
||||
# TODO: implement message stream consumer in async method, that fills self._pendingMessages
|
||||
# btw, consumption can be lazy and happen on get_messages, implement in any suitable way
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = web.Application()
|
||||
grpcServerAddr = os.environ.get('MESSENGER_SERVER_ADDR', 'localhost:51075')
|
||||
|
||||
# TODO: create your grpc client with given address and pass it to MessengerHandler constructor
|
||||
|
||||
handler = MessengerHandler()
|
||||
app.add_routes([web.post('/getAndFlushMessages', handler.get_messages)])
|
||||
app.add_routes([web.post('/sendMessage', handler.send_message)])
|
||||
|
||||
# TODO: run message stream consumer in a background coroutine
|
||||
|
||||
httpPort = os.environ.get('MESSENGER_HTTP_PORT', '8080')
|
||||
web.run_app(app, host='0.0.0.0', port=httpPort)
|
||||
@@ -0,0 +1,3 @@
|
||||
aiohttp==3.12.15
|
||||
grpcio==1.75.0
|
||||
grpcio-tools==1.75.0
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /grpc-messenger
|
||||
|
||||
COPY client/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY client/client.py solution/client/
|
||||
COPY proto solution/proto/
|
||||
|
||||
ENTRYPOINT ["python", "-m", "solution.client.client"]
|
||||
Whitespace-only changes.
@@ -0,0 +1,102 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http import HTTPStatus
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from typing import List, Dict
|
||||
|
||||
import google.protobuf.empty_pb2 # Empty
|
||||
import google.protobuf.json_format # ParseDict, MessageToDict
|
||||
import grpc
|
||||
|
||||
from solution.proto import messenger_pb2
|
||||
from solution.proto import messenger_pb2_grpc
|
||||
|
||||
|
||||
class PostBox:
|
||||
def __init__(self):
|
||||
self._messages: List[Dict] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def collect_messages(self) -> List[Dict]:
|
||||
with self._lock:
|
||||
messages = copy.deepcopy(self._messages)
|
||||
self._messages = []
|
||||
return messages
|
||||
|
||||
def put_message(self, message: Dict):
|
||||
with self._lock:
|
||||
self._messages.append(message)
|
||||
|
||||
|
||||
class MessageHandler(BaseHTTPRequestHandler):
|
||||
_stub = None
|
||||
_postbox: PostBox
|
||||
|
||||
def _read_content(self):
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
bytes_content = self.rfile.read(content_length)
|
||||
return bytes_content.decode('ascii')
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def do_POST(self):
|
||||
if self.path == '/sendMessage':
|
||||
response = self._send_message(self._read_content())
|
||||
elif self.path == '/getAndFlushMessages':
|
||||
response = self._get_messages()
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_IMPLEMENTED)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
response_bytes = json.dumps(response).encode('ascii')
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header('Content-Length', str(len(response_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response_bytes)
|
||||
|
||||
def _send_message(self, content: str) -> dict:
|
||||
json_request = json.loads(content)
|
||||
|
||||
# TODO: use google.protobuf.json_format.ParseDict
|
||||
|
||||
# TODO: your rpc call of the messenger here
|
||||
|
||||
# TODO: use google.protobuf.json_format.MessageToDict here
|
||||
return {'sendTime': ''}
|
||||
|
||||
def _get_messages(self) -> List[dict]:
|
||||
return self._postbox.collect_messages()
|
||||
|
||||
|
||||
def main():
|
||||
grpc_server_address = os.environ.get('MESSENGER_SERVER_ADDR', 'localhost:51075')
|
||||
|
||||
# TODO: create your grpc client and wait for the server to become available.
|
||||
# The client may start before the server.
|
||||
stub = None
|
||||
|
||||
# A list of messages obtained from the server-py but not yet requested by the user to be shown
|
||||
# (via the http's /getAndFlushMessages).
|
||||
postbox = PostBox()
|
||||
|
||||
# TODO: Implement and run a messages stream consumer in a background thread here.
|
||||
# It should fetch messages via the grpc client and store them in the postbox.
|
||||
|
||||
# Pass the stub and the postbox to the HTTP server.
|
||||
# Dirty, but this simple http server doesn't provide interface
|
||||
# for passing arguments to the handler c-tor.
|
||||
MessageHandler._stub = stub
|
||||
MessageHandler._postbox = postbox
|
||||
|
||||
http_port = os.environ.get('MESSENGER_HTTP_PORT', '8080')
|
||||
http_server_address = ('0.0.0.0', int(http_port))
|
||||
|
||||
# NB: handler_class is instantiated for every http request. Do not store any inter-request state in it.
|
||||
httpd = HTTPServer(http_server_address, MessageHandler)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
grpcio==1.75.0
|
||||
grpcio-tools==1.75.0
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mes_grpc;
|
||||
|
||||
// TODO: Add messages and service
|
||||
Whitespace-only changes.
@@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-alpine
|
||||
|
||||
COPY tests/requirements.txt .
|
||||
RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \
|
||||
pip install -r requirements.txt
|
||||
|
||||
ARG GRPCURL_VERSION=1.9.3
|
||||
ARG GRPCURL_SHA256=a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5
|
||||
RUN apk add --no-cache curl tini \
|
||||
&& curl --fail --silent --show-error --location \
|
||||
"https://github.com/fullstorydev/grpcurl/releases/download/v${GRPCURL_VERSION}/grpcurl_${GRPCURL_VERSION}_linux_x86_64.tar.gz" \
|
||||
--output /tmp/grpcurl.tar.gz \
|
||||
&& echo "${GRPCURL_SHA256} /tmp/grpcurl.tar.gz" | sha256sum -c \
|
||||
&& tar -xzf /tmp/grpcurl.tar.gz -C /usr/local/bin \
|
||||
&& rm -f /tmp/grpcurl.tar.gz
|
||||
|
||||
COPY solution/proto/ solution/proto/
|
||||
COPY tests/*.py tests/
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "python3", "-u", "tests/main.py"]
|
||||
@@ -0,0 +1,109 @@
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
SUITE_TIMEOUT_S = 120
|
||||
|
||||
|
||||
class PassedCounter:
|
||||
def __init__(self):
|
||||
self.passed = 0
|
||||
|
||||
def pytest_report_teststatus(self, report, config):
|
||||
if report.when == 'call' and report.passed:
|
||||
self.passed += 1
|
||||
|
||||
|
||||
def suite_score(passed, expected, maximum, exit_code=pytest.ExitCode.OK):
|
||||
return maximum if exit_code == pytest.ExitCode.OK and passed == expected else 0
|
||||
|
||||
|
||||
def run_suite(filename, expected, maximum):
|
||||
# Each component has its own deadline so a timeout cannot discard other scores.
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, '-u', str(pathlib.Path(__file__).resolve()),
|
||||
'--suite', str(SCRIPT_DIR / filename), str(expected)],
|
||||
start_new_session=os.name == 'posix',
|
||||
)
|
||||
try:
|
||||
exit_code = process.wait(timeout=SUITE_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.name == 'posix':
|
||||
# Include grpcurl and other subprocesses started by this test suite.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass # The suite may have exited just after wait() timed out.
|
||||
else:
|
||||
process.kill()
|
||||
process.wait()
|
||||
print(f'{filename} exceeded {SUITE_TIMEOUT_S} seconds; component score is zero.', flush=True)
|
||||
return 0
|
||||
return maximum if exit_code == 0 else 0
|
||||
|
||||
|
||||
def suite_exit_code(filename, expected):
|
||||
counter = PassedCounter()
|
||||
exit_code = pytest.main(['-vs', filename], plugins=[counter])
|
||||
return 0 if suite_score(counter.passed, expected, 1, exit_code) else 1
|
||||
|
||||
|
||||
def component_enabled(environment_name):
|
||||
return os.environ.get(environment_name, '1') == '1'
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
'--component',
|
||||
choices=('all', 'proto', 'server', 'client'),
|
||||
default='all',
|
||||
)
|
||||
parser.add_argument('--suite', nargs=2, metavar=('FILE', 'EXPECTED'), help=argparse.SUPPRESS)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if args.suite is not None:
|
||||
return suite_exit_code(args.suite[0], int(args.suite[1]))
|
||||
component = args.component
|
||||
score = 0
|
||||
|
||||
if component in ('all', 'proto'):
|
||||
proto_score = run_suite('test_proto.py', expected=1, maximum=2)
|
||||
score += proto_score
|
||||
print(f'Proto: {proto_score}/2')
|
||||
print()
|
||||
|
||||
if component in ('all', 'server'):
|
||||
if component_enabled('MESSENGER_SERVER_TESTS_ENABLED'):
|
||||
server_score = run_suite('test_server.py', expected=4, maximum=4)
|
||||
else:
|
||||
server_score = 0
|
||||
print('Server tests were not run because the server image did not build.')
|
||||
score += server_score
|
||||
print(f'Server: {server_score}/4')
|
||||
print()
|
||||
|
||||
if component in ('all', 'client'):
|
||||
if component_enabled('MESSENGER_CLIENT_TESTS_ENABLED'):
|
||||
client_score = run_suite('test_client.py', expected=3, maximum=4)
|
||||
else:
|
||||
client_score = 0
|
||||
print('Client tests were not run because the client image did not build.')
|
||||
score += client_score
|
||||
print(f'Client: {client_score}/4')
|
||||
|
||||
print(f'\nSCORE: {score}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
grpcio==1.75.0
|
||||
grpcio-tools==1.75.0
|
||||
protobuf==6.32.1
|
||||
pytest==8.4.2
|
||||
requests==2.32.5
|
||||
@@ -0,0 +1,179 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
HTTP_CONNECT_TIMEOUT_S = 1
|
||||
HTTP_READ_TIMEOUT_S = 5
|
||||
HTTP_READY_TIMEOUT_S = 20
|
||||
HTTP_RETRY_INTERVAL_S = 0.5
|
||||
HTTP_TIMEOUT = (HTTP_CONNECT_TIMEOUT_S, HTTP_READ_TIMEOUT_S)
|
||||
MESSAGE_TIMEOUT_S = 10
|
||||
MESSAGE_POLL_INTERVAL_S = 0.05
|
||||
|
||||
|
||||
def wait_for_http(url):
|
||||
deadline = time.monotonic() + HTTP_READY_TIMEOUT_S
|
||||
last_exception = None
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for HTTP endpoint {url}: {last_exception}',
|
||||
pytrace=False,
|
||||
)
|
||||
readiness_timeout = (
|
||||
min(HTTP_CONNECT_TIMEOUT_S, remaining),
|
||||
min(HTTP_READ_TIMEOUT_S, remaining),
|
||||
)
|
||||
try:
|
||||
response = requests.get(url, timeout=readiness_timeout)
|
||||
response.close()
|
||||
return
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
|
||||
last_exception = exc
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for HTTP endpoint {url}: {last_exception}',
|
||||
pytrace=False,
|
||||
)
|
||||
time.sleep(min(HTTP_RETRY_INTERVAL_S, remaining))
|
||||
|
||||
|
||||
def post_json(url, path, timeout=HTTP_TIMEOUT, **kwargs):
|
||||
endpoint = url + path
|
||||
try:
|
||||
response = requests.post(endpoint, timeout=timeout, **kwargs)
|
||||
except requests.exceptions.Timeout as exc:
|
||||
pytest.fail(f'timed out waiting for HTTP response from {endpoint}: {exc}', pytrace=False)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
pytest.fail(f'could not connect to HTTP endpoint {endpoint}: {exc}', pytrace=False)
|
||||
|
||||
try:
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def client1_ready_url():
|
||||
url = 'http://' + os.environ.get('MESSENGER_TEST_CLIENT1_ADDR', '127.0.0.1:8080')
|
||||
wait_for_http(url)
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def client2_ready_url():
|
||||
url = 'http://' + os.environ.get('MESSENGER_TEST_CLIENT2_ADDR', '127.0.0.1:8081')
|
||||
wait_for_http(url)
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client1_url(client1_ready_url):
|
||||
url = client1_ready_url
|
||||
get_messages(url) # we need to flush pending messages before and after each tests
|
||||
yield url
|
||||
get_messages(url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client2_url(client2_ready_url):
|
||||
url = client2_ready_url
|
||||
get_messages(url)
|
||||
yield url
|
||||
get_messages(url)
|
||||
|
||||
|
||||
def send_message(url, mes):
|
||||
return post_json(url, '/sendMessage', json=mes)
|
||||
|
||||
|
||||
def get_messages(url, **kwargs):
|
||||
return post_json(url, '/getAndFlushMessages', **kwargs)
|
||||
|
||||
|
||||
def wait_for_messages(url, expected):
|
||||
deadline = time.monotonic() + MESSAGE_TIMEOUT_S
|
||||
messages = []
|
||||
while len(messages) < len(expected):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for messages from {url}: '
|
||||
f'expected {expected!r}, received {messages!r}',
|
||||
pytrace=False,
|
||||
)
|
||||
# Divide the remaining budget between connecting and reading the response.
|
||||
timeout = (
|
||||
min(HTTP_CONNECT_TIMEOUT_S, remaining / 2),
|
||||
min(HTTP_READ_TIMEOUT_S, remaining / 2),
|
||||
)
|
||||
batch = get_messages(url, timeout=timeout)
|
||||
assert isinstance(batch, list), f'expected a message array, received {batch!r}'
|
||||
messages.extend(batch)
|
||||
assert messages == expected[:len(messages)], \
|
||||
f'expected {expected!r}, received {messages!r}'
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(f'timed out waiting for messages from {url}', pytrace=False)
|
||||
if len(messages) < len(expected):
|
||||
time.sleep(min(MESSAGE_POLL_INTERVAL_S, remaining))
|
||||
return messages
|
||||
|
||||
|
||||
def test_single_client_single_message(client1_url, client2_url):
|
||||
mes = {
|
||||
'author': 'TestSingleClient',
|
||||
'text': 'This is test text'
|
||||
}
|
||||
resp = send_message(client1_url, mes)
|
||||
mes['sendTime'] = resp['sendTime']
|
||||
assert wait_for_messages(client1_url, [mes]) == [mes]
|
||||
# Drain both subscriptions before the next test sends more messages.
|
||||
assert wait_for_messages(client2_url, [mes]) == [mes]
|
||||
|
||||
|
||||
def test_single_client_multiple_messages(client1_url, client2_url):
|
||||
mes = [{
|
||||
'author': 'TestSingleClient1',
|
||||
'text': 'This is test text'
|
||||
}, {
|
||||
'author': 'TestSingleClient2',
|
||||
'text': 'This is test text'
|
||||
}]
|
||||
for m in mes:
|
||||
resp = send_message(client1_url, m)
|
||||
m['sendTime'] = resp['sendTime']
|
||||
assert wait_for_messages(client1_url, mes) == mes
|
||||
assert wait_for_messages(client2_url, mes) == mes
|
||||
|
||||
|
||||
def test_two_clients_multiple_messages(client1_url, client2_url):
|
||||
client1_name = 'TestMultiClient1'
|
||||
client2_name = 'TestMultiClient2'
|
||||
mes = [{
|
||||
'author': client1_name,
|
||||
'text': 'This is test text #1'
|
||||
}, {
|
||||
'author': client1_name,
|
||||
'text': 'This is test text #2'
|
||||
}, {
|
||||
'author': client2_name,
|
||||
'text': 'This is test text #3'
|
||||
}, {
|
||||
'author': client2_name,
|
||||
'text': 'This is test text #4'
|
||||
}]
|
||||
times = set()
|
||||
for m in mes:
|
||||
resp = send_message(client1_url if m['author'] == client1_name else client2_url, m)
|
||||
m['sendTime'] = resp['sendTime']
|
||||
times.add(m['sendTime'])
|
||||
assert len(times) == len(mes)
|
||||
assert wait_for_messages(client1_url, mes) == mes
|
||||
assert wait_for_messages(client2_url, mes) == mes
|
||||
@@ -0,0 +1,140 @@
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import grpc_tools
|
||||
from google.protobuf import descriptor
|
||||
from google.protobuf import descriptor_pb2
|
||||
from google.protobuf import descriptor_pool
|
||||
from grpc_tools import protoc
|
||||
|
||||
|
||||
SCRIPT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
PROTO_DIR = SCRIPT_DIR.parent / 'solution' / 'proto'
|
||||
PROTO_FILE = PROTO_DIR / 'messenger.proto'
|
||||
WELL_KNOWN_PROTO_DIR = pathlib.Path(grpc_tools.__file__).parent / '_proto'
|
||||
|
||||
|
||||
def compile_descriptor_set(output_path):
|
||||
result = protoc.main([
|
||||
'grpc_tools.protoc',
|
||||
f'-I{PROTO_DIR}',
|
||||
f'-I{WELL_KNOWN_PROTO_DIR}',
|
||||
f'--descriptor_set_out={output_path}',
|
||||
'--include_imports',
|
||||
str(PROTO_FILE),
|
||||
])
|
||||
assert result == 0, 'messenger.proto must compile successfully'
|
||||
|
||||
descriptor_set = descriptor_pb2.FileDescriptorSet()
|
||||
descriptor_set.ParseFromString(output_path.read_bytes())
|
||||
return descriptor_set
|
||||
|
||||
|
||||
def build_descriptor_pool(descriptor_set):
|
||||
pool = descriptor_pool.DescriptorPool()
|
||||
remaining = list(descriptor_set.file)
|
||||
while remaining:
|
||||
deferred = []
|
||||
for file_descriptor in remaining:
|
||||
try:
|
||||
pool.Add(file_descriptor)
|
||||
except TypeError:
|
||||
deferred.append(file_descriptor)
|
||||
assert len(deferred) < len(remaining), 'messenger.proto imports could not be resolved'
|
||||
remaining = deferred
|
||||
return pool
|
||||
|
||||
|
||||
def require_singular_field(message_type, field_name, field_type, message_type_name=None):
|
||||
assert field_name in message_type.fields_by_name, \
|
||||
f'{message_type.full_name} must contain field {field_name}'
|
||||
field = message_type.fields_by_name[field_name]
|
||||
assert not field.is_repeated, f'{field.full_name} must be a singular field'
|
||||
assert field.type == field_type, f'{field.full_name} has an invalid type'
|
||||
if message_type_name is not None:
|
||||
assert field.message_type is not None
|
||||
assert field.message_type.full_name == message_type_name, \
|
||||
f'{field.full_name} has an invalid message type'
|
||||
|
||||
|
||||
def require_fields_can_coexist(message_type, field_names):
|
||||
fields_by_oneof = {}
|
||||
for field_name in field_names:
|
||||
field = message_type.fields_by_name[field_name]
|
||||
if field.containing_oneof is None:
|
||||
continue
|
||||
previous_field = fields_by_oneof.setdefault(field.containing_oneof.full_name, field_name)
|
||||
assert previous_field == field_name, \
|
||||
f'{message_type.full_name} fields must allow simultaneous values'
|
||||
|
||||
|
||||
def test_proto_contract():
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
descriptor_path = pathlib.Path(temporary_directory) / 'messenger.pb'
|
||||
descriptor_set = compile_descriptor_set(descriptor_path)
|
||||
|
||||
submitted_file = next(
|
||||
(file_descriptor for file_descriptor in descriptor_set.file
|
||||
if pathlib.PurePosixPath(file_descriptor.name).name == PROTO_FILE.name),
|
||||
None,
|
||||
)
|
||||
assert submitted_file is not None, 'messenger.proto descriptor is missing'
|
||||
assert submitted_file.syntax == 'proto3', 'messenger.proto must use proto3 syntax'
|
||||
assert submitted_file.package == 'mes_grpc', 'messenger.proto must use package mes_grpc'
|
||||
|
||||
pool = build_descriptor_pool(descriptor_set)
|
||||
try:
|
||||
messenger = pool.FindServiceByName('mes_grpc.MessengerServer')
|
||||
except KeyError:
|
||||
raise AssertionError('gRPC service must be named mes_grpc.MessengerServer') from None
|
||||
|
||||
assert 'SendMessage' in messenger.methods_by_name, \
|
||||
'MessengerServer must contain method SendMessage'
|
||||
send_message = messenger.methods_by_name['SendMessage']
|
||||
assert not send_message.client_streaming and not send_message.server_streaming, \
|
||||
'SendMessage must be unary'
|
||||
require_singular_field(
|
||||
send_message.input_type,
|
||||
'author',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_singular_field(
|
||||
send_message.input_type,
|
||||
'text',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_fields_can_coexist(send_message.input_type, ('author', 'text'))
|
||||
require_singular_field(
|
||||
send_message.output_type,
|
||||
'sendTime',
|
||||
descriptor.FieldDescriptor.TYPE_MESSAGE,
|
||||
'google.protobuf.Timestamp',
|
||||
)
|
||||
|
||||
assert 'ReadMessages' in messenger.methods_by_name, \
|
||||
'MessengerServer must contain method ReadMessages'
|
||||
read_messages = messenger.methods_by_name['ReadMessages']
|
||||
assert not read_messages.client_streaming and read_messages.server_streaming, \
|
||||
'ReadMessages must be a unary request with a server stream response'
|
||||
assert not read_messages.input_type.fields, \
|
||||
'ReadMessages request must not contain fields'
|
||||
require_singular_field(
|
||||
read_messages.output_type,
|
||||
'author',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_singular_field(
|
||||
read_messages.output_type,
|
||||
'text',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_singular_field(
|
||||
read_messages.output_type,
|
||||
'sendTime',
|
||||
descriptor.FieldDescriptor.TYPE_MESSAGE,
|
||||
'google.protobuf.Timestamp',
|
||||
)
|
||||
require_fields_can_coexist(
|
||||
read_messages.output_type,
|
||||
('author', 'text', 'sendTime'),
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import queue
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
test_message = {'author': 'alice', 'text': 'hello'}
|
||||
|
||||
PROTO_DIR = pathlib.Path(__file__).resolve().parent.parent / 'solution' / 'proto'
|
||||
|
||||
SOCKET_CONNECT_TIMEOUT_S = 1
|
||||
SERVICE_READY_TIMEOUT_S = 20
|
||||
SERVICE_RETRY_INTERVAL_S = 0.5
|
||||
GRPC_CALL_TIMEOUT_S = 5
|
||||
GRPC_PROCESS_TIMEOUT_S = 10
|
||||
GRPC_STREAM_TIMEOUT_S = 60
|
||||
PROCESS_STOP_TIMEOUT_S = 5
|
||||
MESSAGE_TIMEOUT_S = 10
|
||||
|
||||
|
||||
def wait_for_socket(host, port):
|
||||
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_S
|
||||
last_exception = None
|
||||
while True:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=SOCKET_CONNECT_TIMEOUT_S):
|
||||
pass
|
||||
return
|
||||
except OSError as exc:
|
||||
last_exception = exc
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for TCP endpoint {host}:{port}: {last_exception}',
|
||||
pytrace=False,
|
||||
)
|
||||
time.sleep(min(SERVICE_RETRY_INTERVAL_S, remaining))
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def server_addr():
|
||||
addr = os.environ.get('MESSENGER_TEST_SERVER_ADDR', '127.0.0.1:51075')
|
||||
host = addr.split(':')[0]
|
||||
port = int(addr.split(':')[1])
|
||||
wait_for_socket(host, port)
|
||||
yield addr
|
||||
|
||||
|
||||
def send_message(server_address, message: Dict[str, str]) -> Dict[str, str]:
|
||||
grpcurl_cmd = ['grpcurl',
|
||||
'-max-time', str(GRPC_CALL_TIMEOUT_S),
|
||||
'-import-path', str(PROTO_DIR),
|
||||
'-proto', 'messenger.proto',
|
||||
'-d',
|
||||
json.dumps(message),
|
||||
'-plaintext',
|
||||
server_address,
|
||||
'mes_grpc.MessengerServer/SendMessage']
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
grpcurl_cmd,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GRPC_PROCESS_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail(
|
||||
f'grpcurl did not finish within {GRPC_PROCESS_TIMEOUT_S} seconds',
|
||||
pytrace=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert len(completed.stderr) == 0, completed.stderr
|
||||
output_str = completed.stdout.decode('ascii')
|
||||
output = json.loads(output_str)
|
||||
|
||||
message_with_timestamp = copy.deepcopy(message)
|
||||
message_with_timestamp['sendTime'] = output['sendTime']
|
||||
return message_with_timestamp
|
||||
|
||||
|
||||
class MessageStream:
|
||||
def __init__(self, server_address):
|
||||
grpcurl_cmd = ['grpcurl',
|
||||
'-max-time', str(GRPC_STREAM_TIMEOUT_S),
|
||||
'-import-path', str(PROTO_DIR),
|
||||
'-proto', 'messenger.proto',
|
||||
'-plaintext',
|
||||
server_address,
|
||||
'mes_grpc.MessengerServer/ReadMessages']
|
||||
self._process = subprocess.Popen(
|
||||
grpcurl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self._messages = queue.Queue()
|
||||
self._reader_error = None
|
||||
self._reader_finished = threading.Event()
|
||||
self._closed = False
|
||||
self._reader = threading.Thread(target=self._read_messages, daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def _read_messages(self):
|
||||
try:
|
||||
message_lines = []
|
||||
for line in self._process.stdout:
|
||||
message_lines.append(line)
|
||||
if line.rstrip() == '}':
|
||||
self._messages.put(json.loads(''.join(message_lines)))
|
||||
message_lines = []
|
||||
except Exception as exc:
|
||||
self._reader_error = exc
|
||||
finally:
|
||||
self._reader_finished.set()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
def _failure_detail(self):
|
||||
if self._reader_error is not None:
|
||||
return f'reader failed: {self._reader_error}'
|
||||
returncode = self._process.poll()
|
||||
if returncode is not None:
|
||||
stderr = self._process.stderr.read().strip()
|
||||
return f'grpcurl exited with status {returncode}: {stderr}'
|
||||
return 'grpcurl is still running but produced no matching message'
|
||||
|
||||
def _get_message(self, deadline, timeout_message):
|
||||
while True:
|
||||
if self._reader_error is not None:
|
||||
raise AssertionError(self._failure_detail())
|
||||
if self._reader_finished.is_set() and self._messages.empty():
|
||||
raise AssertionError(self._failure_detail())
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise AssertionError(f'{timeout_message}: {self._failure_detail()}')
|
||||
try:
|
||||
return self._messages.get(timeout=min(remaining, 0.1))
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def wait_for_message(self, expected_message, timeout, preceding_messages=None):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
message = self._get_message(
|
||||
deadline,
|
||||
f'timed out waiting for message {expected_message}',
|
||||
)
|
||||
if message == expected_message:
|
||||
return
|
||||
if preceding_messages is not None:
|
||||
preceding_messages.append(message)
|
||||
|
||||
def read_messages(self, count, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
messages = []
|
||||
while len(messages) < count:
|
||||
messages.append(self._get_message(
|
||||
deadline,
|
||||
f'timed out after receiving {len(messages)} of {count} messages',
|
||||
))
|
||||
return messages
|
||||
|
||||
def close(self):
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if self._process.poll() is None:
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=PROCESS_STOP_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
try:
|
||||
self._process.wait(timeout=PROCESS_STOP_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail('grpcurl did not exit after SIGKILL', pytrace=False)
|
||||
self._reader.join(timeout=PROCESS_STOP_TIMEOUT_S)
|
||||
assert not self._reader.is_alive()
|
||||
|
||||
|
||||
def wait_for_streams(server_address, streams):
|
||||
preceding_messages = [[] for _ in streams]
|
||||
probes = []
|
||||
for attempt in range(10):
|
||||
probe = send_message(
|
||||
server_address,
|
||||
{'author': 'StreamProbe', 'text': f'probe #{attempt}'},
|
||||
)
|
||||
probes.append(probe)
|
||||
streams_ready = True
|
||||
for index, stream in enumerate(streams):
|
||||
try:
|
||||
stream.wait_for_message(
|
||||
probe,
|
||||
timeout=1,
|
||||
preceding_messages=preceding_messages[index],
|
||||
)
|
||||
except AssertionError:
|
||||
streams_ready = False
|
||||
if streams_ready:
|
||||
return [
|
||||
[message for message in messages if message not in probes]
|
||||
for messages in preceding_messages
|
||||
]
|
||||
raise AssertionError('ReadMessages streams did not become ready')
|
||||
|
||||
|
||||
def timestamp_key(timestamp):
|
||||
match = re.fullmatch(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z', timestamp)
|
||||
assert match is not None, f'invalid protobuf timestamp: {timestamp}'
|
||||
seconds = int(datetime.strptime(match.group(1), '%Y-%m-%dT%H:%M:%S')
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
nanos = int((match.group(2) or '').ljust(9, '0'))
|
||||
return seconds, nanos
|
||||
|
||||
|
||||
def test_send_smoke(server_addr):
|
||||
send_message(server_addr, test_message)
|
||||
|
||||
|
||||
def test_send_returns_ascending_time(server_addr):
|
||||
outputs = []
|
||||
for _ in range(10):
|
||||
outputs.append(send_message(server_addr, test_message))
|
||||
|
||||
for output1, output2 in zip(outputs, outputs[1:]):
|
||||
assert timestamp_key(output1['sendTime']) < timestamp_key(output2['sendTime'])
|
||||
|
||||
|
||||
def test_get_messages_smoke(server_addr):
|
||||
with MessageStream(server_addr) as stream:
|
||||
wait_for_streams(server_addr, [stream])
|
||||
test_message_with_timestamp = send_message(server_addr, test_message)
|
||||
messages = stream.read_messages(1, timeout=MESSAGE_TIMEOUT_S)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0] == test_message_with_timestamp
|
||||
|
||||
|
||||
def test_get_only_sends_new(server_addr):
|
||||
messages1 = []
|
||||
messages3 = []
|
||||
n1, n2, n3 = 2, 3, 4
|
||||
|
||||
with MessageStream(server_addr) as stream:
|
||||
messages_before_ready = wait_for_streams(server_addr, [stream])
|
||||
assert messages_before_ready == [[]]
|
||||
for _ in range(n1):
|
||||
message = send_message(server_addr, test_message)
|
||||
messages1.append(message)
|
||||
messages = stream.read_messages(n1, timeout=MESSAGE_TIMEOUT_S)
|
||||
|
||||
assert len(messages1) == len(messages)
|
||||
for m1, m2 in zip(messages1, messages):
|
||||
assert m1 == m2
|
||||
|
||||
for _ in range(n2):
|
||||
send_message(server_addr, test_message)
|
||||
|
||||
with MessageStream(server_addr) as stream:
|
||||
messages_before_ready = wait_for_streams(server_addr, [stream])
|
||||
assert messages_before_ready == [[]]
|
||||
for _ in range(n3):
|
||||
message = send_message(server_addr, test_message)
|
||||
messages3.append(message)
|
||||
messages = stream.read_messages(n3, timeout=MESSAGE_TIMEOUT_S)
|
||||
|
||||
assert len(messages3) == len(messages)
|
||||
for m1, m2 in zip(messages3, messages):
|
||||
assert m1 == m2
|
||||
@@ -0,0 +1,236 @@
|
||||
# HTTP-сервер
|
||||
|
||||
В этом задании вам предстоит реализовать простой HTTP-сервер для хранения файлов.
|
||||
|
||||
На практике вы, скорее всего, будете использовать готовые библиотеки, в которых работа с HTTP уже реализована. Но полезно хотя бы раз сделать это «руками», чтобы лучше понять протокол: как клиент передаёт запрос, как сервер его читает и формирует ответ.
|
||||
|
||||
Формат сообщений и работу соединений описывает [RFC 9112](https://www.rfc-editor.org/rfc/rfc9112.html), семантику методов и заголовков — [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html). В задании используется ограниченное подмножество HTTP/1.1, описанное ниже. Прочитайте условие целиком перед началом работы.
|
||||
|
||||
## Запуск сервера
|
||||
|
||||
При запуске сервер получает адрес и порт для приёма соединений, рабочую директорию и ожидаемое доменное имя. Эти настройки можно задать параметрами командной строки или переменными окружения:
|
||||
|
||||
| Параметр | Переменная окружения | По умолчанию | Назначение |
|
||||
|---|---|---|---|
|
||||
| `--host` | `SERVER_HOST` | `0.0.0.0` | Адрес прослушивания TCP-соединений |
|
||||
| `--port` | `SERVER_PORT` | `8080` | Порт сервера |
|
||||
| `--working-directory` | `SERVER_WORKING_DIRECTORY` | Нет | Абсолютный путь к существующей рабочей директории |
|
||||
| `--server-domain` | `SERVER_DOMAIN` | `localhost` | Ожидаемое доменное имя в заголовке `Host` |
|
||||
|
||||
Параметр командной строки имеет приоритет над переменной окружения, а переменная окружения — над значением по умолчанию. Если рабочая директория не указана ни одним способом или задана пустой строкой, сервер завершается с кодом 1. В Python-заготовке эта настройка уже реализована.
|
||||
|
||||
Параметры `host` и `port` определяют, где сервер принимает TCP-соединения. Параметр `server-domain` используется для проверки заголовка `Host` в запросах; он не меняет адрес прослушивания и не требует настройки DNS.
|
||||
|
||||
### Проверка готовности
|
||||
|
||||
Тестер ждёт готовности сервера не более **10 секунд**, отправляя запросы `GET / HTTP/1.1` с заголовком `Host`, равным `server-domain`. Готовность подтверждает любой корректный HTTP-ответ, включая ответ об ошибке.
|
||||
|
||||
**Сервер должен отвечать на GET `/` во всех группах тестов.** В G1 и G2 можно вернуть код 200 с коротким текстом в теле ответа. Начиная с G3, этот запрос должен возвращать список файлов и директорий в рабочей директории.
|
||||
|
||||
Если сервер не начнёт отвечать вовремя, тестер завершит запуск с ошибкой `server did not start in 10 seconds`. В этом случае проверьте вывод сервера в журнале тестирования: проблема может быть как в запуске, так и в обработке GET `/`.
|
||||
|
||||
## Работа с файлами
|
||||
|
||||
Сервер поддерживает четыре метода: GET читает файл или содержимое директории, POST создаёт файл или директорию, PUT заменяет содержимое существующего файла, а DELETE удаляет файл или директорию. Реализовывать HEAD и OPTIONS не требуется.
|
||||
|
||||
Метод задаёт действие, а путь в запросе — файл или директорию, к которым это действие применяется. Все четыре метода используют одну схему путей: путь отсчитывается от рабочей директории сервера, заданной при запуске. Например, при `--working-directory /srv/files` запросы к `/docs/file.txt` работают с `/srv/files/docs/file.txt`. Начальный `/` в запросе обозначает рабочую директорию сервера, а не корень файловой системы компьютера. Отдельные адреса вроде `/get` или `/upload` создавать не нужно.
|
||||
|
||||
Ниже записи вида `GET /docs/file.txt` обозначают метод и путь запроса. В передаваемом по TCP HTTP-сообщении к ним добавляются версия протокола, заголовки и, при необходимости, тело.
|
||||
|
||||
### GET: чтение файла или директории
|
||||
|
||||
Если путь указывает на файл, сервер возвращает его содержимое в теле ответа. Например, `GET /docs/file.txt` возвращает байты файла `/srv/files/docs/file.txt`.
|
||||
|
||||
Если путь указывает на директорию, сервер возвращает список её непосредственных дочерних файлов и директорий — листинг. Так, `GET /docs` возвращает имена объектов внутри `/srv/files/docs`, а `GET /` — внутри самой рабочей директории. Вложенные директории обходить не нужно.
|
||||
|
||||
В списке должны присутствовать все имена, включая скрытые файлы, но без `.` и `..`. Порядок и оформление произвольные: можно вернуть текст или HTML. Размеры, даты и права доступа не требуются; дополнительные сведения допустимы.
|
||||
|
||||
### POST: создание файла или директории
|
||||
|
||||
Путь в POST-запросе задаёт имя создаваемого объекта целиком. Например, `POST /docs/new.txt` создаёт файл `/srv/files/docs/new.txt` и записывает в него тело запроса. Директория `/srv/files/docs` к этому моменту должна существовать. Если тело пустое, создаётся пустой файл.
|
||||
|
||||
Чтобы создать директорию, клиент передаёт заголовок `Create-Directory: True`. Например, `POST /docs/archive` с этим заголовком создаёт пустую директорию `/srv/files/docs/archive`. Сервер создаёт только указанный объект: промежуточные директории автоматически не создаются. Если по этому пути уже есть файл или директория, POST завершается ошибкой и не заменяет их.
|
||||
|
||||
### PUT: замена содержимого файла
|
||||
|
||||
PUT записывает тело запроса в существующий файл по указанному пути. Например, `PUT /docs/file.txt` полностью заменяет содержимое `/srv/files/docs/file.txt`. Если новые данные короче прежних, остаток прежнего содержимого должен исчезнуть; пустое тело делает файл пустым.
|
||||
|
||||
В этом задании PUT применяется только к существующим файлам. Создавать новый файл или заменять директорию этим методом нельзя.
|
||||
|
||||
### DELETE: удаление файла или директории
|
||||
|
||||
Запрос `DELETE /docs/file.txt` удаляет файл `/srv/files/docs/file.txt`. Для удаления директории клиент должен явно передать `Remove-Directory: True`. Например, `DELETE /docs/archive` с этим заголовком удаляет `/srv/files/docs/archive` вместе со всеми вложенными файлами и директориями. Без разрешающего заголовка сервер должен отказать в удалении директории, даже если она пустая.
|
||||
|
||||
### Коды ответов и общие правила
|
||||
|
||||
В таблице собраны коды ответов для всех перечисленных операций. Там, где указаны два кода, можно выбрать любой из них.
|
||||
|
||||
| Запрос и состояние пути | Действие и ответ |
|
||||
|---|---|
|
||||
| GET существующего файла | Вернуть содержимое файла с кодом 200 |
|
||||
| GET существующей директории | Вернуть список её непосредственных дочерних файлов и директорий с кодом 200 |
|
||||
| GET отсутствующего пути | Вернуть 404 |
|
||||
| POST нового объекта, родительская директория существует | Создать объект и вернуть 200 или 201 |
|
||||
| POST существующего файла или директории | Вернуть 409 |
|
||||
| POST без родительской директории | Вернуть 404, не создавать промежуточные директории |
|
||||
| PUT существующего файла | Полностью заменить содержимое телом запроса и вернуть 200 или 204 |
|
||||
| PUT директории | Вернуть 409 |
|
||||
| PUT отсутствующего пути | Вернуть 404, не создавать файл |
|
||||
| DELETE существующего файла | Удалить файл и вернуть 200 |
|
||||
| DELETE `/` | Вернуть 403: удаление рабочей директории запрещено независимо от заголовка `Remove-Directory` |
|
||||
| DELETE директории с `Remove-Directory: True` | Удалить директорию со всем содержимым и вернуть 200 |
|
||||
| DELETE директории без `Remove-Directory: True` | Вернуть 406 |
|
||||
| DELETE отсутствующего пути | Вернуть 404 |
|
||||
|
||||
Заголовки `Create-Directory` и `Remove-Directory` включают соответствующее действие только со значением `True`. Значение `False` равнозначно отсутствию заголовка; других значений в запросах не будет.
|
||||
|
||||
Для упрощения работы с путями в тестах используются только ASCII-имена из букв, цифр, дефиса, подчёркивания и точки. Компоненты `.` и `..`, параметры после `?`, percent-encoding и символические ссылки исключены. Если один из промежуточных компонентов пути является файлом, такой путь обрабатывается как отсутствующий.
|
||||
|
||||
При ошибке файловая система должна остаться без изменений. Ответ об ошибке содержит непустой понятный текст; точная формулировка не задана. Успешные ответы на POST, PUT и DELETE могут иметь пустое тело. Ответ 204 всегда передаётся без тела.
|
||||
|
||||
## HTTP и заголовки
|
||||
|
||||
Каждое соединение содержит один запрос. Сервер отправляет ответ с `Connection: close` и закрывает соединение. Одновременная обработка нескольких соединений не требуется.
|
||||
|
||||
Запросы синтаксически корректны и используют HTTP/1.1. В них нет повторяющихся заголовков, `Transfer-Encoding` и `Expect`. Длина тела задаётся корректным `Content-Length`; если заголовка нет, тела нет. Имена заголовков сравниваются без учёта регистра. Неизвестные заголовки можно игнорировать.
|
||||
|
||||
TCP передаёт поток байтов: результат одного чтения из сокета может содержать часть заголовка либо конец заголовков вместе с началом тела. Сервер должен работать при любом таком разбиении и не ждать закрытия соединения клиентом, чтобы определить конец запроса. Файлы могут содержать произвольные байты.
|
||||
|
||||
| Заголовок | Требование |
|
||||
|---|---|
|
||||
| `Host` в запросе | В функциональных тестах содержит доменное имя без порта. Сравнивается с `server-domain` без учёта регистра; при несовпадении вернуть 400, не выполняя файловую операцию |
|
||||
| `Content-Length` в ответе | Обязателен, кроме ответа 204. Равен размеру передаваемого тела в байтах; для пустого тела — 0, для gzip — размеру сжатых данных. В ответе 204 этот заголовок запрещён |
|
||||
| `Connection` в ответе | `close` |
|
||||
| `Content-Type` в ответе | Описывает тип содержимого тела. Для непустого тела нужен корректный MIME-тип. Для листинга допустимы `text/plain` и `text/html`. Для любых файлов, включая текстовые, разрешено использовать `application/octet-stream`; определять MIME-тип по расширению не требуется |
|
||||
| `Server` в ответе | Непустое название сервера |
|
||||
| `Accept-Encoding` в запросе | Сообщает, что клиент готов принять сжатые данные. В задании поддерживается только значение `gzip` |
|
||||
| `Content-Encoding` в ответе | Сообщает, каким алгоритмом сжато тело ответа. При сжатии имеет значение `gzip`; в несжатом ответе отсутствует |
|
||||
|
||||
Проверка `Host`, `Content-Type` и `Server` входит в G5 и G7. Коды ответа, длина и границы тела, закрытие соединения и заданное условием содержимое проверяются во всех группах.
|
||||
|
||||
В G7 успешные GET-ответы на запрос с `Accept-Encoding: gzip` должны содержать сжатые данные. Это относится и к файлам, и к листингам. Без этого заголовка ответ не сжимается и не содержит `Content-Encoding`. Сжатие ошибок не требуется. Готовые библиотеки gzip использовать можно. `Transfer-Encoding` в ответах не используется.
|
||||
|
||||
## Компоненты и оценивание
|
||||
|
||||
Автоматические тесты определяют предварительную оценку. Можно реализовать часть задания: каждая группа проверяет только перечисленные для неё возможности. Например, в G1 достаточно чтения существующих текстовых файлов и ответа на служебный GET `/`, а создание и удаление файлов появляются в G4.
|
||||
|
||||
Для получения баллов за группу нужно пройти все её проверки. Группы оцениваются независимо: ошибка в одной группе не лишает баллов за успешно пройденные другие. За все семь групп можно получить 10 баллов.
|
||||
|
||||
| Группа | Баллы | Что проверяется |
|
||||
|---|---|---|
|
||||
| G1 | 3 | GET существующих текстовых файлов в ASCII, размером ≤ 8 МБ; настройка и запуск сервера |
|
||||
| G2 | 1 | GET существующих файлов с произвольными байтами, размером ≤ 8 МБ |
|
||||
| G3 | 1 | GET файлов и директорий, ошибки отсутствующих путей; файлы в ASCII, размером ≤ 8 МБ |
|
||||
| G4 | 2 | GET, POST, PUT и DELETE, листинг и ошибки; произвольные файлы размером ≤ 8 МБ |
|
||||
| G5 | 1 | Возможности G4 и проверка `Host`, `Content-Type`, `Server` |
|
||||
| G6 | 1 | Возможности G4 и работа с большими файлами |
|
||||
| G7 | 1 | Возможности G6, дополнительные заголовки из G5 и gzip |
|
||||
|
||||
В G1 и G3 используются текстовые файлы в кодировке ASCII. В остальных группах файлы могут содержать произвольные байты, поэтому их содержимое нельзя считать текстом.
|
||||
|
||||
В G1–G5 размер каждого файла не превышает 8 МБ. Это ограничение действует и на содержимое файлов, передаваемое в запросах POST и PUT. В G6 и G7 файлы могут быть больше 8 МБ. В этом задании 1 МБ = 1024 × 1024 байта.
|
||||
|
||||
В тестирующей системе решение работает в Docker с лимитом памяти **128 МБ**. В G6 и G7 файлы могут превышать объём доступной памяти: нужно уметь и отдавать их клиенту, и принимать при POST и PUT, не загружая целиком в память. Проверить соблюдение лимита можно локально с Docker по инструкции ниже. Запуск без Docker этот лимит не проверяет.
|
||||
|
||||
Для передачи большого файла с gzip и корректным `Content-Length` можно предварительно сжать его во временный файл, определить размер результата и затем передать его клиенту по частям. Само сжатие также должно укладываться в ограничение памяти.
|
||||
|
||||
### Отчёт и защита
|
||||
|
||||
Вместе с кодом сдайте краткий отчёт `solution/readme.md`. Опишите устройство решения и используемые библиотеки, а также объясните следующие механизмы, указав соответствующие файлы и функции в своём коде:
|
||||
|
||||
- как определяются границы заголовков и тела;
|
||||
- как обрабатываются большие файлы и от чего зависит расход памяти, если реализованы G6/G7;
|
||||
- как определяется длина сжатого ответа, если реализована G7.
|
||||
|
||||
Без отчёта автоматические тесты запускаются, но решение не засчитывается по [общим правилам сдачи](../readme.md). Использование LLM укажите согласно [политике курса](../../llm-policy.md).
|
||||
|
||||
На защите нужно объяснить сданную реализацию, разобрать предложенный сценарий и при необходимости внести небольшую правку либо проследить выполнение кода. Ответы подтверждают баллы соответствующих компонентов; прохождение тестов само по себе не гарантирует итоговую оценку.
|
||||
|
||||
Если вы используете заготовку, на защите нужно понимать, как ваш код взаимодействует с ней: как запускается сервер, откуда берутся параметры и как соединение передаётся обработчику запроса.
|
||||
|
||||
## Заготовка и ограничения реализации
|
||||
|
||||
Нельзя использовать готовые библиотеки HTTP или парсеры HTTP-сообщений, в том числе из стандартной библиотеки языка. Можно использовать TCP-сокеты, `socketserver`, библиотеки для файлов, командной строки и gzip.
|
||||
|
||||
В `solution` находится Python-заготовка с настройкой параметров, TCP-сервером, логированием и структурами сообщений. Вам предстоит реализовать чтение и разбор запроса, обработку методов и формирование ответа. Методы в `http_messages.py` предназначены для стартовой строки и заголовков; тело обрабатывается отдельно. Наличие констант других методов не означает, что их нужно поддерживать.
|
||||
|
||||
Можно изменить структуру заготовки или выбрать другой язык; использовать именно её классы необязательно. Решение должно поддерживать описанные параметры командной строки и переменные окружения. В `solution/Dockerfile` должно быть описание сборки образа и запуска вашего сервера. Если вы меняете язык или способ запуска, обновите этот файл.
|
||||
|
||||
Весь код решения, необходимые для сборки файлы и отчёт разместите в папке `solution`. При сдаче отправляется только эта папка; изменения в тестах, `tests/launch.tmpl` и других файлах вне неё в тестирующую систему не попадут.
|
||||
|
||||
## Тестирование
|
||||
|
||||
Тесты написаны на Go и находятся в папке `tests`; точка входа — [TestHW](./tests/hw_test.go). Во время разработки удобно запускать их без Docker для быстрой проверки после изменений. Перед сдачей проверьте решение и с Docker: этот режим воспроизводит окружение тестирующей системы и ограничение памяти.
|
||||
|
||||
### Как устроены тесты
|
||||
|
||||
Внутри каждой группы есть несколько запусков. Для каждого запуска тестер создаёт набор файлов и директорий, запускает ваш сервер с соответствующей рабочей директорией и отправляет ему последовательность HTTP-запросов. Сервер работает до конца этого запуска, поэтому изменения после POST, PUT и DELETE влияют на последующие запросы. Запуск считается успешным, если сервер правильно обработал все запросы; для получения баллов нужно пройти все запуски группы.
|
||||
|
||||
Наборы файлов и запросов генерируются случайным образом, но воспроизводятся при повторном запуске той же версии тестов. Числовые идентификаторы в журнале позволяют повторить нужную последовательность. Помимо сгенерированных запросов, есть отдельные проверки конкретных случаев, например пустого файла или разбиения запроса между несколькими чтениями TCP-потока. Тесты помогают находить ошибки, но не заменяют проверку соответствия всему условию.
|
||||
|
||||
### Без Docker
|
||||
|
||||
Установите Go. В `tests/launch.tmpl` задана команда, которой тестер запускает сервер. При необходимости измените её для своей системы или языка; в Windows вместо `python3` может потребоваться `python`. Сохраните в шаблоне `{{.CommandLineArgs}}`: на это место тестер подставляет параметры запуска.
|
||||
|
||||
Если используете Python-заготовку, установите её зависимости. Затем запустите тесты следующими командами из папки задания:
|
||||
|
||||
```sh
|
||||
python3 -m pip install -r solution/requirements.txt
|
||||
cd tests
|
||||
go test
|
||||
```
|
||||
|
||||
Для отдельной группы или запуска используйте фильтр:
|
||||
|
||||
```sh
|
||||
go test -run 'TestHW/G1'
|
||||
go test -run 'TestHW/G1/1337'
|
||||
```
|
||||
|
||||
Имена проверок видны в журнале. Например, `TestHW/G2/93/42300` обозначает группу G2, запуск 93 и запрос 42300. В G1 и G2 запросы только читают файлы, поэтому можно повторить отдельный запрос, указав его полный идентификатор: `go test -run 'TestHW/G2/93/42300'`.
|
||||
|
||||
В группах с POST, PUT и DELETE запросы используют общее изменяемое состояние файлов. Поэтому ошибку в такой группе воспроизводите всем запуском: отдельный запрос может получить другое начальное состояние.
|
||||
|
||||
### С Docker
|
||||
|
||||
Перед первым запуском и после объявления об обновлении тестов загрузите образ:
|
||||
|
||||
```sh
|
||||
docker pull distsys.ru/course/http-server:latest
|
||||
```
|
||||
|
||||
Запускайте из папки задания:
|
||||
|
||||
```sh
|
||||
docker run --privileged --rm -v ./solution:/hw/solution distsys.ru/course/http-server:latest
|
||||
```
|
||||
|
||||
Для отдельной группы добавьте `-run 'TestHW/G1'` после имени образа. Тестер собирает контейнер вашего сервера и запускает его с лимитом памяти 128 МБ. Общий лимит запуска образа тестов — 10 минут. Скорость зависит от компьютера и Docker-окружения.
|
||||
|
||||
Можно собрать образ сервера самостоятельно и проверить его локальным тестером:
|
||||
|
||||
```sh
|
||||
cd solution
|
||||
docker build -t hw3img .
|
||||
cd ../tests
|
||||
go test --docker -timeout 10m
|
||||
```
|
||||
|
||||
Для сборки самого образа тестов выполните из папки задания:
|
||||
|
||||
```sh
|
||||
docker build -t hw3tests ./tests
|
||||
docker run --privileged --rm -v ./solution:/hw/solution hw3tests
|
||||
```
|
||||
|
||||
### Как читать результаты
|
||||
|
||||
В журнале видны команда запуска с параметрами и переменными окружения, весь вывод сервера в stdout/stderr и причины ошибок. При отладке начните с первого неудачного запроса: его идентификатор позволяет повторить соответствующий запуск с помощью фильтра `-run`.
|
||||
|
||||
После ошибки оставшиеся запросы этого запуска и оставшиеся запуски группы пропускаются, а за группу начисляется 0 баллов. Тестирование других групп продолжается. В конце выводятся баллы по группам и строка `SCORE: N`. Если использовался фильтр, эта оценка учитывает только выбранные проверки; оценку за всё задание показывает полный прогон.
|
||||
|
||||
После локальной проверки отправьте решение по [общей инструкции](../readme.md).
|
||||
|
||||
### Бонус за пробелы в тестах
|
||||
|
||||
Если найдёте ошибку в тестах или требование, нарушение которого они не обнаруживают, опишите ситуацию в отчёте; при необходимости приложите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл, а за тест, обнаруживающий её, или описание его логики — ещё 1 балл.
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /http/server
|
||||
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENTRYPOINT ["python3", "server.py"]
|
||||
@@ -0,0 +1,124 @@
|
||||
import dataclasses
|
||||
import typing as t
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HTTPRequest:
|
||||
method: str
|
||||
path: str
|
||||
version: str
|
||||
parameters: t.Dict[str, str]
|
||||
headers: t.Dict[str, str]
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> "HTTPRequest":
|
||||
# data contains the request line and headers, including the final CRLF.
|
||||
# Read the body separately in HTTPHandler; it may exceed available RAM.
|
||||
# Query parameters are not required: parameters can be an empty dict.
|
||||
# TODO: Parse the request line and headers.
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HTTPResponse:
|
||||
version: str
|
||||
status: str
|
||||
headers: t.Dict[str, str]
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
# Return the status line and headers, ending with an empty line.
|
||||
# HTTPHandler sends the body separately.
|
||||
# TODO: Serialize the response headers.
|
||||
pass
|
||||
|
||||
# Common HTTP strings and constants
|
||||
|
||||
|
||||
CR = b'\r'
|
||||
LF = b'\n'
|
||||
CRLF = CR + LF
|
||||
|
||||
HTTP_VERSION = "1.1"
|
||||
|
||||
OPTIONS = 'OPTIONS'
|
||||
GET = 'GET'
|
||||
HEAD = 'HEAD'
|
||||
POST = 'POST'
|
||||
PUT = 'PUT'
|
||||
DELETE = 'DELETE'
|
||||
|
||||
METHODS = [
|
||||
OPTIONS,
|
||||
GET,
|
||||
HEAD,
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
]
|
||||
|
||||
# Only GET, POST, PUT and DELETE are required in this assignment.
|
||||
|
||||
HEADER_HOST = "Host"
|
||||
HEADER_CONTENT_LENGTH = "Content-Length"
|
||||
HEADER_CONTENT_TYPE = "Content-Type"
|
||||
HEADER_CONTENT_ENCODING = "Content-Encoding"
|
||||
HEADER_ACCEPT_ENCODING = "Accept-Encoding"
|
||||
HEADER_CREATE_DIRECTORY = "Create-Directory"
|
||||
HEADER_SERVER = "Server"
|
||||
HEADER_REMOVE_DIRECTORY = "Remove-Directory"
|
||||
|
||||
GZIP = "gzip"
|
||||
|
||||
TEXT_PLAIN = "text/plain"
|
||||
APPLICATION_OCTET_STREAM = "application/octet-stream"
|
||||
APPLICATION_GZIP = "application/gzip"
|
||||
|
||||
OK = "200"
|
||||
BAD_REQUEST = "400"
|
||||
NOT_FOUND = "404"
|
||||
METHOD_NOT_ALLOWED = "405"
|
||||
NOT_ACCEPTABLE = "406"
|
||||
CONFLICT = "409"
|
||||
|
||||
HTTP_REASON_BY_STATUS = {
|
||||
"100": "Continue",
|
||||
"101": "Switching Protocols",
|
||||
"200": "OK",
|
||||
"201": "Created",
|
||||
"202": "Accepted",
|
||||
"203": "Non-Authoritative Information",
|
||||
"204": "No Content",
|
||||
"205": "Reset Content",
|
||||
"206": "Partial Content",
|
||||
"300": "Multiple Choices",
|
||||
"301": "Moved Permanently",
|
||||
"302": "Found",
|
||||
"303": "See Other",
|
||||
"304": "Not Modified",
|
||||
"305": "Use Proxy",
|
||||
"307": "Temporary Redirect",
|
||||
"400": "Bad Request",
|
||||
"401": "Unauthorized",
|
||||
"402": "Payment Required",
|
||||
"403": "Forbidden",
|
||||
"404": "Not Found",
|
||||
"405": "Method Not Allowed",
|
||||
"406": "Not Acceptable",
|
||||
"407": "Proxy Authentication Required",
|
||||
"408": "Request Time-out",
|
||||
"409": "Conflict",
|
||||
"410": "Gone",
|
||||
"411": "Length Required",
|
||||
"412": "Precondition Failed",
|
||||
"413": "Request Entity Too Large",
|
||||
"414": "Request-URI Too Large",
|
||||
"415": "Unsupported Media Type",
|
||||
"416": "Requested range not satisfiable",
|
||||
"417": "Expectation Failed",
|
||||
"500": "Internal Server Error",
|
||||
"501": "Not Implemented",
|
||||
"502": "Bad Gateway",
|
||||
"503": "Service Unavailable",
|
||||
"504": "Gateway Time-out",
|
||||
"505": "HTTP Version not supported",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
click==8.3.0
|
||||
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from socketserver import StreamRequestHandler
|
||||
import typing as t
|
||||
import click
|
||||
import socket
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTTPServer:
|
||||
server_address: t.Tuple[str, int]
|
||||
socket: socket.socket
|
||||
server_domain: str
|
||||
working_directory: pathlib.Path
|
||||
|
||||
|
||||
class HTTPHandler(StreamRequestHandler):
|
||||
server: HTTPServer
|
||||
|
||||
# Use self.rfile and self.wfile to interact with the client
|
||||
# Access domain and working directory with self.server.{attr}
|
||||
def handle(self) -> None:
|
||||
first_line = self.rfile.readline()
|
||||
logger.info(f"Handle connection from {self.client_address}, first_line {first_line}")
|
||||
|
||||
# TODO: Read the remaining headers, handle the request and send a response.
|
||||
# Read the body separately using its Content-Length, not until EOF.
|
||||
# Keep memory bounded when processing large files.
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--host", envvar="SERVER_HOST", default="0.0.0.0", type=str)
|
||||
@click.option("--port", envvar="SERVER_PORT", default=8080, type=int)
|
||||
@click.option("--server-domain", envvar="SERVER_DOMAIN", default="localhost", type=str)
|
||||
@click.option("--working-directory", envvar="SERVER_WORKING_DIRECTORY", type=str)
|
||||
def main(host, port, server_domain, working_directory):
|
||||
if not working_directory:
|
||||
raise SystemExit(1)
|
||||
|
||||
working_directory_path = pathlib.Path(working_directory)
|
||||
|
||||
logger.info(
|
||||
f"Starting server on {host}:{port}, domain {server_domain}, working directory {working_directory}"
|
||||
)
|
||||
|
||||
# Create a server socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
|
||||
# Set SO_REUSEADDR option
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
# Bind the socket object to the address and port
|
||||
s.bind((host, port))
|
||||
# Start listening for incoming connections
|
||||
s.listen()
|
||||
|
||||
logger.info(f"Listening at {s.getsockname()}")
|
||||
server = HTTPServer((host, port), s, server_domain, working_directory_path)
|
||||
|
||||
while True:
|
||||
# Accept any new connection (request, client_address)
|
||||
try:
|
||||
conn, addr = s.accept()
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
# Handle the request
|
||||
HTTPHandler(conn, addr, server)
|
||||
|
||||
# Close the connection
|
||||
conn.shutdown(socket.SHUT_WR)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
target
|
||||
**/target
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
*.pyc
|
||||
tmp/
|
||||
tests.log
|
||||
@@ -0,0 +1 @@
|
||||
tmp
|
||||
@@ -0,0 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM docker:dind
|
||||
|
||||
RUN apk add --no-cache bash ca-certificates go wget
|
||||
|
||||
WORKDIR /hw
|
||||
COPY go.mod go.sum tests/
|
||||
RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||
cd tests && go mod download
|
||||
COPY . tests
|
||||
RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,id=distsys-course-go-build,target=/root/.cache/go-build,sharing=locked \
|
||||
cd tests && go test -c -o /usr/local/bin/hw3test .
|
||||
COPY entrypoint.sh /usr/local/bin/
|
||||
COPY verify-zram-scratch.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/verify-zram-scratch.sh
|
||||
|
||||
ENTRYPOINT ["timeout", "-k", "10", "600", "entrypoint.sh"]
|
||||
@@ -0,0 +1,124 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
// RequireFileContent requires that the contents of the file match the contents of the reader.
|
||||
func RequireFileContent(t *TC, workdir, p string, f *EnvFile) {
|
||||
fullpath := path.Join(workdir, p)
|
||||
file, err := os.Open(fullpath)
|
||||
require.NoError(t, err, "failed to open file %s, expected it exists on disk", p)
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
require.NoError(t, err, "failed to stat file %s", p)
|
||||
require.Equal(t, f.Size, stat.Size(), "file %s has wrong size", p)
|
||||
require.NoError(t, CompareFileContent(t, file, f), "file %s has wrong content", p)
|
||||
}
|
||||
|
||||
// CompareFileContent compares first f.Size bytes of the reader with generated content.
|
||||
func CompareFileContent(t *TC, r io.Reader, f *EnvFile) error {
|
||||
actual := make([]byte, 64*1024)
|
||||
expected := make([]byte, len(actual))
|
||||
var generator io.Reader
|
||||
if f.TextOnly {
|
||||
generator = f.Open()
|
||||
} else {
|
||||
generator = newComparisonReader(f.GenSeed)
|
||||
}
|
||||
for offset := int64(0); offset < f.Size; {
|
||||
count := int64(len(actual))
|
||||
if remaining := f.Size - offset; remaining < count {
|
||||
count = remaining
|
||||
}
|
||||
if _, err := io.ReadFull(r, actual[:count]); err != nil {
|
||||
return fmt.Errorf("unexpected file error at position %d: %w", offset, err)
|
||||
}
|
||||
if _, err := io.ReadFull(generator, expected[:count]); err != nil {
|
||||
return fmt.Errorf("unexpected generator error at position %d: %w", offset, err)
|
||||
}
|
||||
if !bytes.Equal(actual[:count], expected[:count]) {
|
||||
for i := int64(0); i < count; i++ {
|
||||
if actual[i] != expected[i] {
|
||||
return fmt.Errorf("position %d, expected byte %d, got %d", offset+i, expected[i], actual[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += count
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// comparisonReader emits the same bytes as rand.New(source).Read, seven bytes
|
||||
// from each Int63 value. It fills complete seven-byte groups without the
|
||||
// per-byte branch in math/rand.Read. Request bodies still use EnvFile.Open.
|
||||
type comparisonReader struct {
|
||||
source rand.Source
|
||||
value uint64
|
||||
remaining uint8
|
||||
}
|
||||
|
||||
func newComparisonReader(seed int64) *comparisonReader {
|
||||
return &comparisonReader{source: rand.NewSource(seed)}
|
||||
}
|
||||
|
||||
func (r *comparisonReader) Read(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
for len(p) > 0 && r.remaining > 0 {
|
||||
p[0] = byte(r.value)
|
||||
r.value >>= 8
|
||||
r.remaining--
|
||||
p = p[1:]
|
||||
}
|
||||
for len(p) >= 7 {
|
||||
value := uint64(r.source.Int63())
|
||||
p[0] = byte(value)
|
||||
p[1] = byte(value >> 8)
|
||||
p[2] = byte(value >> 16)
|
||||
p[3] = byte(value >> 24)
|
||||
p[4] = byte(value >> 32)
|
||||
p[5] = byte(value >> 40)
|
||||
p[6] = byte(value >> 48)
|
||||
p = p[7:]
|
||||
}
|
||||
if len(p) > 0 {
|
||||
r.value = uint64(r.source.Int63())
|
||||
r.remaining = 7
|
||||
for i := range p {
|
||||
p[i] = byte(r.value)
|
||||
r.value >>= 8
|
||||
r.remaining--
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RequireDir ensures that the directory exists on disk.
|
||||
func RequireDir(t *TC, workdir string, p string, dir *EnvDir) {
|
||||
fullpath := path.Join(workdir, p)
|
||||
stat, err := os.Stat(fullpath)
|
||||
require.NoError(t, err, "failed to stat directory %s, expected it exists on disk", p)
|
||||
require.True(t, stat.IsDir(), "expected %s to be a directory", p)
|
||||
}
|
||||
|
||||
// RequireNotExists ensures that the file/directory does not exist on disk.
|
||||
func RequireNotExists(t *TC, workdir string, p string) {
|
||||
fullpath := path.Join(workdir, p)
|
||||
_, err := os.Stat(fullpath)
|
||||
require.True(t, os.IsNotExist(err), "expected %s to not exist on disk", p)
|
||||
}
|
||||
|
||||
// RequireExists ensures that the file/directory exists on disk.
|
||||
func RequireExists(t *TC, workdir string, p string) {
|
||||
fullpath := path.Join(workdir, p)
|
||||
_, err := os.Stat(fullpath)
|
||||
require.NoError(t, err, "failed to stat %s, expected it exists on disk", p)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComparisonReaderMatchesEnvFile(t *testing.T) {
|
||||
for _, seed := range []int64{-7824, 0, 1, 7824, 9223372036854775807} {
|
||||
for _, chunkSize := range []int{1, 6, 7, 8, 13, 64 * 1024, 131071} {
|
||||
const total = 1024*1024 + 19
|
||||
expected := make([]byte, total)
|
||||
actual := make([]byte, total)
|
||||
_, err := io.ReadFull(rand.New(rand.NewSource(seed)), expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := newComparisonReader(seed)
|
||||
for offset := 0; offset < total; {
|
||||
end := offset + chunkSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
if _, err := io.ReadFull(reader, actual[offset:end]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
if !bytes.Equal(actual, expected) {
|
||||
t.Fatalf("different bytes for seed %d and chunk size %d", seed, chunkSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkComparisonReaders(b *testing.B) {
|
||||
const size = 128 * 1024 * 1024
|
||||
buffer := make([]byte, 64*1024)
|
||||
for _, candidate := range []struct {
|
||||
name string
|
||||
open func() io.Reader
|
||||
}{
|
||||
{"math/rand", func() io.Reader { return rand.New(rand.NewSource(7824)) }},
|
||||
{"comparison", func() io.Reader { return newComparisonReader(7824) }},
|
||||
} {
|
||||
b.Run(candidate.name, func(b *testing.B) {
|
||||
b.SetBytes(size)
|
||||
for i := 0; i < b.N; i++ {
|
||||
reader := candidate.open()
|
||||
for remaining := size; remaining > 0; remaining -= len(buffer) {
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Contract cases use a small separate tree, so snapshots never scan G6/G7 files.
|
||||
func RunContractTests(t *TC, runner Runner, tmpRoot, group string) {
|
||||
t.RunByName("contract", func(t *TC) {
|
||||
workdir, err := os.MkdirTemp(tmpRoot, "contract-")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(workdir)
|
||||
require.NoError(t, os.Chmod(workdir, 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workdir, "dir", "nested"), 0755))
|
||||
ascii := []byte("hello\r\nworld\n")
|
||||
binary := []byte{0, 255, 13, 10, 13, 10, 128, 1}
|
||||
if group == "G1" || group == "G3" {
|
||||
binary = ascii
|
||||
}
|
||||
for name, body := range map[string][]byte{
|
||||
"alpha.txt": ascii, "binary": binary, "empty": {},
|
||||
".hidden": []byte("hidden"), "dir/nested/child": []byte("child"),
|
||||
} {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workdir, filepath.FromSlash(name)), body, 0644))
|
||||
}
|
||||
port, err := GetFreePort()
|
||||
require.NoError(t, err)
|
||||
opts := RunOpts{Port: port, WorkingDirectory: workdir, ListenAddr: "0.0.0.0", ServerDomain: "localhost"}
|
||||
opts.GenerateRunConfig(t, rand.New(rand.NewSource(9103)), &EnvGen{AllowEnv: true})
|
||||
stop, err := runner.Run(t, opts)
|
||||
require.NoError(t, err)
|
||||
defer stop()
|
||||
require.NoError(t, WaitForServer(t, opts))
|
||||
extra := group == "G5" || group == "G7"
|
||||
|
||||
request := func(method, path, headers string, body []byte) string {
|
||||
return fmt.Sprintf("%s %s HTTP/1.1\r\nhOsT: LOCALHOST\r\ncOnTeNt-LeNgTh: %d\r\n%s\r\n%s", method, path, len(body), headers, body)
|
||||
}
|
||||
run := func(name, raw string, split bool, codes []int, expected []byte, unchanged bool) {
|
||||
t.RunByName(name, func(t *TC) {
|
||||
before, err := snapshotTree(workdir)
|
||||
require.NoError(t, err)
|
||||
parts := [][]byte{[]byte(raw)}
|
||||
if split {
|
||||
// Split inside a header and inside CRLFCRLF; coalesce its end with body bytes.
|
||||
boundary := strings.Index(raw, "\r\n\r\n")
|
||||
parts = [][]byte{[]byte(raw[:9]), []byte(raw[9 : boundary+3]), []byte(raw[boundary+3:])}
|
||||
}
|
||||
resp, body := contractExchange(t, opts, parts)
|
||||
require.Contains(t, codes, resp.StatusCode)
|
||||
if resp.StatusCode >= 400 {
|
||||
require.NotEmpty(t, bytes.TrimSpace(body), "error explanation is empty")
|
||||
}
|
||||
if extra {
|
||||
require.NotEmpty(t, resp.Header.Get("Server"))
|
||||
if len(body) > 0 {
|
||||
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||
require.NoError(t, err)
|
||||
if methodIsDirectoryGet(raw) && resp.StatusCode == 200 {
|
||||
require.Contains(t, []string{"text/plain", "text/html"}, mediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
if expected != nil {
|
||||
require.Empty(t, resp.Header.Get("Content-Encoding"))
|
||||
require.Equal(t, expected, body)
|
||||
}
|
||||
if unchanged {
|
||||
after, err := snapshotTree(workdir)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, compareSnapshots(before, after))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if group == "G4" || group == "G5" || group == "G6" || group == "G7" {
|
||||
run("post-root-file", request("POST", "/", "", binary), false, []int{409}, nil, true)
|
||||
run("post-root-dir", request("POST", "/", "Create-Directory: True\r\n", nil), false, []int{409}, nil, true)
|
||||
run("put-root", request("PUT", "/", "", binary), false, []int{409}, nil, true)
|
||||
for _, value := range []string{"absent", "False", "True"} {
|
||||
header := ""
|
||||
if value != "absent" {
|
||||
header = "Remove-Directory: " + value + "\r\n"
|
||||
}
|
||||
run("delete-root-"+value, request("DELETE", "/", header, nil), false, []int{403}, nil, true)
|
||||
}
|
||||
if extra {
|
||||
raw := strings.Replace(request("DELETE", "/", "Remove-Directory: True\r\n", nil), "LOCALHOST", "wrong.example", 1)
|
||||
run("wrong-host-delete-root", raw, false, []int{400}, nil, true)
|
||||
}
|
||||
}
|
||||
|
||||
switch group {
|
||||
case "G1":
|
||||
run("split-headers", request("GET", "/alpha.txt", "", nil), true, []int{200}, ascii, true)
|
||||
// No Content-Length is also valid for an empty request.
|
||||
run("no-request-body", "GET /alpha.txt HTTP/1.1\r\nHost: localhost\r\n\r\n", false, []int{200}, ascii, true)
|
||||
run("empty-file", request("GET", "/empty", "", nil), false, []int{200}, []byte{}, true)
|
||||
case "G2":
|
||||
run("binary-body", request("GET", "/binary", "", nil), true, []int{200}, binary, true)
|
||||
case "G3":
|
||||
run("missing", request("GET", "/missing", "", nil), false, []int{404}, nil, true)
|
||||
t.RunByName("root-listing", func(t *TC) {
|
||||
resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", "/", "", nil))})
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
require.Empty(t, resp.Header.Get("Content-Encoding"))
|
||||
for _, name := range []string{"alpha.txt", "binary", "empty", ".hidden", "dir"} {
|
||||
require.Contains(t, string(body), name)
|
||||
}
|
||||
})
|
||||
case "G4", "G5":
|
||||
for _, tc := range []struct {
|
||||
name, method, path, headers string
|
||||
code int
|
||||
}{
|
||||
{"post-existing-file", "POST", "/alpha.txt", "", 409},
|
||||
{"post-existing-dir", "POST", "/dir", "Create-Directory: True\r\n", 409},
|
||||
{"post-missing-parent", "POST", "/missing/new", "", 404},
|
||||
{"post-dir-missing-parent", "POST", "/missing/new", "Create-Directory: True\r\n", 404},
|
||||
{"post-file-as-parent", "POST", "/alpha.txt/child", "", 404},
|
||||
{"put-missing", "PUT", "/missing", "", 404},
|
||||
{"put-dir", "PUT", "/dir", "", 409},
|
||||
{"delete-missing", "DELETE", "/missing", "", 404},
|
||||
{"delete-dir-absent", "DELETE", "/dir", "", 406},
|
||||
{"delete-dir-false", "DELETE", "/dir", "rEmOvE-dIrEcToRy: False\r\n", 406},
|
||||
} {
|
||||
body := []byte(nil)
|
||||
if tc.method == "PUT" || (tc.method == "POST" && tc.headers == "") {
|
||||
body = binary
|
||||
}
|
||||
run(tc.name, request(tc.method, tc.path, tc.headers, body), false, []int{tc.code}, nil, true)
|
||||
}
|
||||
run("create-file-false", request("POST", "/new", "cReAtE-dIrEcToRy: False\r\n", binary), true, []int{200, 201}, nil, false)
|
||||
run("read-created", request("GET", "/new", "", nil), false, []int{200}, binary, true)
|
||||
run("create-empty", request("POST", "/new-empty", "", nil), false, []int{200, 201}, nil, false)
|
||||
run("read-empty", request("GET", "/new-empty", "", nil), false, []int{200}, []byte{}, true)
|
||||
run("replace-shorter", request("PUT", "/alpha.txt", "", []byte("x")), true, []int{200, 204}, nil, false)
|
||||
run("read-shorter", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte("x"), true)
|
||||
run("replace-empty", request("PUT", "/alpha.txt", "", nil), false, []int{200, 204}, nil, false)
|
||||
run("read-replaced-empty", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte{}, true)
|
||||
run("create-dir", request("POST", "/created-dir", "cReAtE-dIrEcToRy: True\r\n", nil), false, []int{200, 201}, nil, false)
|
||||
t.RunByName("created-dir-on-disk", func(t *TC) {
|
||||
entries, err := os.ReadDir(filepath.Join(workdir, "created-dir"))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, entries)
|
||||
})
|
||||
run("recursive-delete", request("DELETE", "/dir", "rEmOvE-dIrEcToRy: True\r\n", nil), false, []int{200}, nil, false)
|
||||
t.RunByName("deleted-tree-on-disk", func(t *TC) {
|
||||
_, err := os.Stat(filepath.Join(workdir, "dir"))
|
||||
require.True(t, os.IsNotExist(err), "directory must be removed")
|
||||
})
|
||||
if extra {
|
||||
for _, method := range []string{"GET", "POST", "PUT", "DELETE"} {
|
||||
raw := strings.Replace(request(method, "/alpha.txt", "", []byte("changed")), "LOCALHOST", "wrong.example", 1)
|
||||
run("wrong-host-"+method, raw, false, []int{400}, nil, true)
|
||||
}
|
||||
}
|
||||
case "G7":
|
||||
for _, path := range []string{"/binary", "/dir", "/empty"} {
|
||||
t.RunByName("gzip-"+strings.TrimPrefix(path, "/"), func(t *TC) {
|
||||
before, err := snapshotTree(workdir)
|
||||
require.NoError(t, err)
|
||||
resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", path, "aCcEpT-eNcOdInG: gzip\r\n", nil))})
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"))
|
||||
require.NotEmpty(t, resp.Header.Get("Server"))
|
||||
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||
require.NoError(t, err)
|
||||
gz, err := gzip.NewReader(bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
decoded, err := io.ReadAll(gz)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
if path == "/binary" {
|
||||
require.Equal(t, binary, decoded)
|
||||
}
|
||||
if path == "/empty" {
|
||||
require.Empty(t, decoded)
|
||||
}
|
||||
if path == "/dir" {
|
||||
require.Contains(t, string(decoded), "nested")
|
||||
require.Contains(t, []string{"text/plain", "text/html"}, mediaType)
|
||||
}
|
||||
after, err := snapshotTree(workdir)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, compareSnapshots(before, after))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func methodIsDirectoryGet(raw string) bool { return strings.HasPrefix(raw, "GET / HTTP/") }
|
||||
|
||||
// The client leaves its sending side open: a server reading to EOF must time out.
|
||||
func contractExchange(t *TC, opts RunOpts, parts [][]byte) (*http.Response, []byte) {
|
||||
req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(bytes.Join(parts, nil))))
|
||||
require.NoError(t, err)
|
||||
defer req.Body.Close()
|
||||
address, err := url.Parse(opts.Address())
|
||||
require.NoError(t, err)
|
||||
conn, err := net.DialTimeout("tcp", address.Host, 10*time.Second)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
|
||||
for i, part := range parts {
|
||||
_, err := io.Copy(conn, bytes.NewReader(part))
|
||||
require.NoError(t, err)
|
||||
if i+1 < len(parts) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
resp, body, err := readContractResponse(bufio.NewReader(conn), req.ContentLength > 0)
|
||||
require.NoError(t, err)
|
||||
return resp, body
|
||||
}
|
||||
|
||||
func readContractResponse(reader *bufio.Reader, requestHasBody bool) (*http.Response, []byte, error) {
|
||||
resp, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := validateResponseFraming(resp); err != nil {
|
||||
return resp, nil, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp, nil, err
|
||||
}
|
||||
if resp.StatusCode != 204 && int64(len(body)) != resp.ContentLength {
|
||||
return resp, nil, fmt.Errorf("response length mismatch")
|
||||
}
|
||||
if err := checkResponseEnd(reader, requestHasBody); err != nil {
|
||||
return resp, nil, err
|
||||
}
|
||||
return resp, body, nil
|
||||
}
|
||||
|
||||
type treeEntry struct {
|
||||
Mode os.FileMode
|
||||
Size int64
|
||||
Digest [32]byte
|
||||
}
|
||||
|
||||
func snapshotTree(root string) (map[string]treeEntry, error) {
|
||||
result := make(map[string]treeEntry)
|
||||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := treeEntry{Mode: info.Mode()}
|
||||
if info.Mode().IsRegular() {
|
||||
entry.Size = info.Size()
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := sha256.New()
|
||||
_, copyErr := io.Copy(h, f)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
copy(entry.Digest[:], h.Sum(nil))
|
||||
}
|
||||
result[rel] = entry
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func compareSnapshots(before, after map[string]treeEntry) error {
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
return fmt.Errorf("request changed the file system unexpectedly")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContractResponseRejectsBadFraming(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, response string
|
||||
valid bool
|
||||
}{
|
||||
{"ok", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\ndata", true},
|
||||
{"short", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 5\r\n\r\ndata", false},
|
||||
{"long", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\ndata", false},
|
||||
{"no-close", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", false},
|
||||
{"no-length", "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", false},
|
||||
{"204", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", true},
|
||||
{"204-length", "HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", false},
|
||||
{"204-body", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\nx", false},
|
||||
{"chunked", "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := readContractResponse(bufio.NewReader(strings.NewReader(tc.response)), false)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatalf("valid=%v, error=%v", tc.valid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type resetReader struct{}
|
||||
|
||||
func (resetReader) Read([]byte) (int, error) {
|
||||
return 0, &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset")}
|
||||
}
|
||||
|
||||
func TestContractResponseAfterEarlyRejection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
requestHasBody bool
|
||||
valid bool
|
||||
}{
|
||||
{"complete error then reset", "error", true, true},
|
||||
{"reset without request body", "error", false, false},
|
||||
{"truncated error", "err", true, false},
|
||||
{"extra response byte", "error!", true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wire := "HTTP/1.1 409 Conflict\r\nConnection: close\r\nContent-Length: 5\r\n\r\n" + tc.body
|
||||
reader := bufio.NewReader(io.MultiReader(strings.NewReader(wire), resetReader{}))
|
||||
_, _, err := readContractResponse(reader, tc.requestHasBody)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatalf("valid=%v, error=%v", tc.valid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDetectsErrorSideEffects(t *testing.T) {
|
||||
for _, mutation := range []string{"overwrite", "create", "delete", "mkdir"} {
|
||||
t.Run(mutation, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "file")
|
||||
if err := os.WriteFile(file, []byte("before"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := snapshotTree(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := snapshotTree(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := compareSnapshots(before, after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch mutation {
|
||||
case "overwrite":
|
||||
err = os.WriteFile(file, []byte("after!"), 0644)
|
||||
case "create":
|
||||
err = os.WriteFile(filepath.Join(root, "new"), nil, 0644)
|
||||
case "delete":
|
||||
err = os.Remove(file)
|
||||
case "mkdir":
|
||||
err = os.Mkdir(filepath.Join(root, "new"), 0755)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err = snapshotTree(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := compareSnapshots(before, after); err == nil {
|
||||
t.Fatal("side effect was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingPathActionsAndRoot(t *testing.T) {
|
||||
env := &Env{RootDir: &EnvDir{Listing: map[string]EnvNode{"file": &EnvFile{}, "dir": &EnvDir{Listing: map[string]EnvNode{}}}}}
|
||||
opts := &RunOpts{ServerDomain: "localhost"}
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{
|
||||
{"POST", "missing/new", 404}, {"POST", "file/new", 404},
|
||||
{"POST", "dir", 409}, {"PUT", "missing", 404}, {"PUT", "dir", 409}, {"DELETE", "missing", 404},
|
||||
{"POST", "/", 409}, {"PUT", "/", 409}, {"DELETE", "/", 403},
|
||||
} {
|
||||
q := Query{Method: tc.method, Path: tc.path, HostHeader: "LOCALHOST"}
|
||||
action, ok := q.Action(env, opts).(HttpErrorAction)
|
||||
if !ok || action.Status != tc.status {
|
||||
t.Errorf("%s %s: got %#v", tc.method, tc.path, q.Action(env, opts))
|
||||
}
|
||||
}
|
||||
q := Query{Method: "GET", Path: "", HostHeader: "LOCALHOST"}
|
||||
if _, ok := q.Action(env, opts).(GetDirAction); !ok {
|
||||
t.Fatal("root GET is not a listing")
|
||||
}
|
||||
for _, root := range []string{"", "/"} {
|
||||
for _, remove := range []bool{false, true} {
|
||||
q := Query{Method: "DELETE", Path: root, HostHeader: "localhost", RemoveDirectory: remove}
|
||||
action, ok := q.Action(env, opts).(HttpErrorAction)
|
||||
if !ok || action.Status != 403 {
|
||||
t.Fatalf("root DELETE with RemoveDirectory=%v: got %#v", remove, action)
|
||||
}
|
||||
q.HostHeader = "wrong.example"
|
||||
action, ok = q.Action(env, opts).(HttpErrorAction)
|
||||
if !ok || action.Status != 400 {
|
||||
t.Fatalf("Host must be checked before root DELETE: got %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
fatal() {
|
||||
printf 'harness: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_docker() {
|
||||
attempts=30
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
docker info >/dev/null 2>&1 && return 0
|
||||
attempts=$((attempts - 1))
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
docker ps -q --filter ancestor=hw3img | while IFS= read -r container_id; do
|
||||
[ -n "$container_id" ] || continue
|
||||
docker rm --force "$container_id" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
local arg
|
||||
local -a original_args=("$@")
|
||||
local -a test_args=(-docker -test.timeout=10m)
|
||||
while (($#)); do
|
||||
arg="$1"
|
||||
shift
|
||||
case "$arg" in
|
||||
-run|-count|-timeout|-parallel|-skip)
|
||||
if (($# == 0)); then
|
||||
fatal "$arg requires a value"
|
||||
fi
|
||||
test_args+=("-test.${arg#-}" "$1")
|
||||
shift
|
||||
;;
|
||||
-run=*|-count=*|-timeout=*|-parallel=*|-skip=*)
|
||||
test_args+=("-test.${arg#-}")
|
||||
;;
|
||||
-v|-short|-failfast|-v=*|-short=*|-failfast=*)
|
||||
test_args+=("-test.${arg#-}")
|
||||
;;
|
||||
-test.*|-docker)
|
||||
test_args+=("$arg")
|
||||
;;
|
||||
*)
|
||||
# Keep Go's flag handling for less common test and build options.
|
||||
go test --docker -timeout 10m "${original_args[@]}"
|
||||
return $?
|
||||
;;
|
||||
esac
|
||||
done
|
||||
/usr/local/bin/hw3test "${test_args[@]}"
|
||||
}
|
||||
|
||||
validate_score_file() {
|
||||
score_lines="$(grep -c '^SCORE:' "$1" || true)"
|
||||
valid_score_lines="$(grep -Ec '^SCORE: [0-9]+([.][0-9]{1,2})?$' "$1" || true)"
|
||||
[ "$score_lines" -eq 1 ] && [ "$valid_score_lines" -eq 1 ] \
|
||||
|| fatal "test command must report exactly one valid SCORE line"
|
||||
}
|
||||
|
||||
configure_registry_proxy() {
|
||||
proxy_url="${DISTSYS_REGISTRY_PROXY_URL:-}"
|
||||
ca_file="/run/distsys-registry-proxy/ca.crt"
|
||||
|
||||
[ -n "$proxy_url" ] || return 1
|
||||
printf '%s' "$proxy_url" | grep -Eq '^http://[A-Za-z0-9.-]+(:[0-9]+)?$' || {
|
||||
printf 'warning: invalid registry proxy URL; using direct Docker pulls\n' >&2
|
||||
return 1
|
||||
}
|
||||
[ -f "$ca_file" ] && [ ! -L "$ca_file" ] || {
|
||||
printf 'warning: registry proxy CA is unavailable; using direct Docker pulls\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
downloaded_ca="$(mktemp)" || return 1
|
||||
if ! wget -q -T 5 -O "$downloaded_ca" "${proxy_url}/ca.crt" \
|
||||
|| ! cmp -s "$ca_file" "$downloaded_ca"; then
|
||||
rm -f "$downloaded_ca"
|
||||
printf 'warning: registry proxy is unavailable or its CA differs; using direct Docker pulls\n' >&2
|
||||
return 1
|
||||
fi
|
||||
rm -f "$downloaded_ca"
|
||||
|
||||
cp "$ca_file" /usr/local/share/ca-certificates/distsys-registry-proxy.crt
|
||||
update-ca-certificates >/dev/null
|
||||
proxy_no_proxy='localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
|
||||
export HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" NO_PROXY="$proxy_no_proxy"
|
||||
export http_proxy="$proxy_url" https_proxy="$proxy_url" no_proxy="$proxy_no_proxy"
|
||||
}
|
||||
|
||||
configure_registry_proxy || unset HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy
|
||||
|
||||
if [ "${HW3_REQUIRE_ZRAM:-0}" = 1 ]; then
|
||||
/usr/local/bin/verify-zram-scratch.sh || fatal "HW3_REQUIRE_ZRAM=1 but /hw/tests/tmp is not mounted from a zram ext4 device"
|
||||
fi
|
||||
|
||||
# Start docker daemon
|
||||
trap cleanup EXIT
|
||||
dockerd-entrypoint.sh --storage-driver=overlay2 >/var/log/dockerd-entrypoint.log 2>&1 &
|
||||
if ! wait_for_docker; then
|
||||
tail -n 200 /var/log/dockerd-entrypoint.log >&2 || true
|
||||
fatal "inner Docker daemon did not become ready"
|
||||
fi
|
||||
|
||||
# Build server image
|
||||
docker build solution -t hw3img || fatal "could not build solution image"
|
||||
|
||||
# Run tests
|
||||
cd tests
|
||||
export NO_COLOR=1
|
||||
test_status=0
|
||||
run_tests "$@" 2>&1 | tee tests.log || test_status=$?
|
||||
validate_score_file tests.log
|
||||
|
||||
# Go reports a non-zero status when some grading groups fail. A valid score is
|
||||
# the grading result; absence of a score remains an infrastructure failure.
|
||||
if [ "$test_status" -ne 0 ]; then
|
||||
printf 'harness: grading tests exited with status %s after reporting a score\n' \
|
||||
"$test_status" >&2
|
||||
fi
|
||||
@@ -0,0 +1,233 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env contains information about all files in the test environment.
|
||||
// Can be written to disk.
|
||||
type Env struct {
|
||||
RootDir *EnvDir
|
||||
}
|
||||
|
||||
// Lookup returns the node for the given path.
|
||||
func (e *Env) Lookup(p string) (*EnvDir, EnvNode) {
|
||||
p = path.Clean(strings.TrimPrefix(p, "/"))
|
||||
if p == "." {
|
||||
return nil, e.RootDir
|
||||
}
|
||||
dir, file := path.Split(p)
|
||||
|
||||
dirs := strings.Split(dir, "/")
|
||||
dirs = dirs[:len(dirs)-1]
|
||||
|
||||
parent := e.RootDir
|
||||
for _, d := range dirs {
|
||||
nxt, ok := parent.Listing[d]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
parent, ok = nxt.(*EnvDir)
|
||||
if !ok {
|
||||
// not a directory
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
nxt := parent.Listing[file]
|
||||
return parent, nxt
|
||||
}
|
||||
|
||||
// Clone returns deep clone of an Env.
|
||||
func (e *Env) Clone() *Env {
|
||||
return &Env{
|
||||
RootDir: e.RootDir.Clone().(*EnvDir),
|
||||
}
|
||||
}
|
||||
|
||||
// EnvNode is a file/dir.
|
||||
type EnvNode interface {
|
||||
// WriteToDisk persists node and its children to disk.
|
||||
WriteToDisk(path string) error
|
||||
|
||||
// Stats aggregates stats of the node and its children.
|
||||
Stats(path string, stats *Stats)
|
||||
|
||||
// Clone creates a deep copy of the node.
|
||||
Clone() EnvNode
|
||||
}
|
||||
|
||||
// EnvDir is a virtual directory, that can be written to disk.
|
||||
type EnvDir struct {
|
||||
Listing map[string]EnvNode
|
||||
Depth int
|
||||
}
|
||||
|
||||
func (d *EnvDir) WriteToDisk(p string) error {
|
||||
err := os.Mkdir(p, 0777)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for name, writter := range d.Listing {
|
||||
nxt := path.Join(p, name)
|
||||
err := writter.WriteToDisk(nxt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteToDiskSelected keeps the size and directory structure of every initial
|
||||
// file, but only materializes contents for paths that a query may read.
|
||||
func (d *EnvDir) WriteToDiskSelected(p string, needed map[string]bool) error {
|
||||
return d.writeToDiskSelected(p, "", needed)
|
||||
}
|
||||
|
||||
func (d *EnvDir) writeToDiskSelected(p, relative string, needed map[string]bool) error {
|
||||
if err := os.Mkdir(p, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, node := range d.Listing {
|
||||
childPath := filepath.Join(p, name)
|
||||
childRelative := path.Join(relative, name)
|
||||
switch child := node.(type) {
|
||||
case *EnvDir:
|
||||
if err := child.writeToDiskSelected(childPath, childRelative, needed); err != nil {
|
||||
return err
|
||||
}
|
||||
case *EnvFile:
|
||||
if needed[childRelative] {
|
||||
if err := child.WriteToDisk(childPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := child.WriteSparseToDisk(childPath); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported environment node %T", child)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *EnvDir) Stats(p string, stats *Stats) {
|
||||
stats.Dirs++
|
||||
stats.DirPaths = append(stats.DirPaths, p)
|
||||
for name, node := range d.Listing {
|
||||
node.Stats(path.Join(p, name), stats)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *EnvDir) Clone() EnvNode {
|
||||
newDir := &EnvDir{
|
||||
Listing: map[string]EnvNode{},
|
||||
Depth: d.Depth,
|
||||
}
|
||||
|
||||
for name, node := range d.Listing {
|
||||
newDir.Listing[name] = node.Clone()
|
||||
}
|
||||
return newDir
|
||||
}
|
||||
|
||||
func (d *EnvDir) CreateDir(name string) (dir *EnvDir, exist bool) {
|
||||
_, exist = d.Listing[name]
|
||||
if exist {
|
||||
return nil, exist
|
||||
}
|
||||
|
||||
newDir := &EnvDir{
|
||||
Listing: map[string]EnvNode{},
|
||||
Depth: d.Depth + 1,
|
||||
}
|
||||
d.Listing[name] = newDir
|
||||
return newDir, false
|
||||
}
|
||||
|
||||
// EnvFile is a virtual file, that can be written to disk.
|
||||
type EnvFile struct {
|
||||
GenSeed int64
|
||||
Size int64
|
||||
TextOnly bool
|
||||
}
|
||||
|
||||
func (f *EnvFile) Open() io.Reader {
|
||||
var gen io.Reader = rand.New(rand.NewSource(f.GenSeed))
|
||||
if f.TextOnly {
|
||||
gen = &TextReader{gen}
|
||||
}
|
||||
gen = io.LimitReader(gen, f.Size)
|
||||
return gen
|
||||
}
|
||||
|
||||
func (f *EnvFile) WriteToDisk(p string) error {
|
||||
file, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := f.Open()
|
||||
_, err = io.CopyBuffer(file, reader, make([]byte, 256*1024))
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *EnvFile) WriteSparseToDisk(p string) error {
|
||||
file, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
return file.Truncate(f.Size)
|
||||
}
|
||||
|
||||
func (f *EnvFile) Stats(p string, stats *Stats) {
|
||||
stats.Files++
|
||||
stats.Size += f.Size
|
||||
stats.FilePaths = append(stats.FilePaths, p)
|
||||
}
|
||||
|
||||
func (f *EnvFile) Clone() EnvNode {
|
||||
return &EnvFile{
|
||||
GenSeed: f.GenSeed,
|
||||
Size: f.Size,
|
||||
TextOnly: f.TextOnly,
|
||||
}
|
||||
}
|
||||
|
||||
// Stats is a helper for listing all files/dirs in environment.
|
||||
type Stats struct {
|
||||
// Number of directories in the environment.
|
||||
Files int
|
||||
|
||||
// Count of directories in the environment.
|
||||
Dirs int
|
||||
|
||||
// Size of all files in the environment.
|
||||
Size int64
|
||||
|
||||
// FilePaths to all files in the environment.
|
||||
FilePaths []string
|
||||
|
||||
// DirPaths to all directories in the environment.
|
||||
DirPaths []string
|
||||
}
|
||||
|
||||
// Normalize will fix stats to be deterministic and don't contain root dir.
|
||||
func (s *Stats) Normalize() {
|
||||
sort.Strings(s.FilePaths)
|
||||
sort.Strings(s.DirPaths)
|
||||
|
||||
if len(s.DirPaths) > 0 && s.DirPaths[0] == "" {
|
||||
s.DirPaths = s.DirPaths[1:]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package hw3test
|
||||
|
||||
import "math/rand"
|
||||
|
||||
// EnvGen contains config for generating test environment.
|
||||
// Test environment is a directory with files and subdirectories.
|
||||
type EnvGen struct {
|
||||
// Generated file tree will not contain subdirectories deeper than this depth.
|
||||
MaxDepth int
|
||||
// Generated file tree will not contain more than this number of subdirs.
|
||||
MaxDirs int
|
||||
// Generated file tree will not contain more than this number of files.
|
||||
MaxFiles int
|
||||
// Generated files will be text files.
|
||||
TextOnly bool
|
||||
// Maximum file size in KB
|
||||
MaxFileSizeKB int
|
||||
// TempDirectory will contain subdirectory for every new environment.
|
||||
TempDirectory string
|
||||
// SparseUnusedFiles avoids writing initial file contents that no query can read.
|
||||
SparseUnusedFiles bool
|
||||
// Allow to use env for configuration.
|
||||
AllowEnv bool
|
||||
// Filename generator.
|
||||
FilenameGen func(r *rand.Rand) string
|
||||
}
|
||||
|
||||
// GenerateFile returns file with random contents.
|
||||
func (g *EnvGen) GenerateFile(r *rand.Rand) *EnvFile {
|
||||
return &EnvFile{
|
||||
GenSeed: r.Int63(),
|
||||
Size: r.Int63n(1024 * int64(g.MaxFileSizeKB)),
|
||||
TextOnly: g.TextOnly,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate generates whole file tree.
|
||||
func (g *EnvGen) Generate(seed int64) (*Env, error) {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
root := &EnvDir{Listing: map[string]EnvNode{}}
|
||||
|
||||
dirs := []*EnvDir{root}
|
||||
|
||||
for i := 0; i < g.MaxDirs; i++ {
|
||||
parent := dirs[r.Intn(len(dirs))]
|
||||
if parent.Depth >= g.MaxDepth {
|
||||
continue
|
||||
}
|
||||
|
||||
name := g.FilenameGen(r)
|
||||
newDir, exist := parent.CreateDir(name)
|
||||
if exist {
|
||||
continue
|
||||
}
|
||||
dirs = append(dirs, newDir)
|
||||
}
|
||||
|
||||
for i := 0; i < g.MaxFiles; i++ {
|
||||
parent := dirs[r.Intn(len(dirs))]
|
||||
|
||||
name := g.FilenameGen(r)
|
||||
_, exist := parent.Listing[name]
|
||||
if exist {
|
||||
continue
|
||||
}
|
||||
|
||||
parent.Listing[name] = g.GenerateFile(r)
|
||||
}
|
||||
|
||||
return &Env{
|
||||
RootDir: root,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteToDiskSelected(t *testing.T) {
|
||||
selected := &EnvFile{GenSeed: 42, Size: 4096}
|
||||
unused := &EnvFile{GenSeed: 43, Size: 4096}
|
||||
root := &EnvDir{Listing: map[string]EnvNode{
|
||||
"nested": &EnvDir{Listing: map[string]EnvNode{
|
||||
"selected": selected,
|
||||
"unused": unused,
|
||||
}},
|
||||
}}
|
||||
location := filepath.Join(t.TempDir(), "environment")
|
||||
if err := root.WriteToDiskSelected(location, map[string]bool{"nested/selected": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotSelected, err := os.ReadFile(filepath.Join(location, "nested", "selected"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantSelected, err := io.ReadAll(selected.Open())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(gotSelected, wantSelected) {
|
||||
t.Fatal("selected file contents differ from generated contents")
|
||||
}
|
||||
|
||||
gotUnused, err := os.ReadFile(filepath.Join(location, "nested", "unused"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gotUnused) != int(unused.Size) || !bytes.Equal(gotUnused, make([]byte, unused.Size)) {
|
||||
t.Fatal("unused file must retain its size without generated contents")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// validateResponseFraming checks headers before reading the response body.
|
||||
func validateResponseFraming(resp *http.Response) error {
|
||||
// net/http removes Connection: close from Header and records it in Close.
|
||||
// With HTTP/1.1 and explicit framing, Close reflects that header.
|
||||
closeFound := resp.ProtoMajor == 1 && resp.ProtoMinor == 1 && resp.Close
|
||||
for _, value := range resp.Header.Values("Connection") {
|
||||
for _, token := range strings.Split(value, ",") {
|
||||
closeFound = closeFound || strings.EqualFold(strings.TrimSpace(token), "close")
|
||||
}
|
||||
}
|
||||
if !closeFound {
|
||||
return fmt.Errorf("expected Connection: close")
|
||||
}
|
||||
if len(resp.TransferEncoding) != 0 || len(resp.Header.Values("Transfer-Encoding")) != 0 {
|
||||
return fmt.Errorf("Transfer-Encoding is not supported")
|
||||
}
|
||||
lengths := resp.Header.Values("Content-Length")
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
if len(lengths) != 0 {
|
||||
return fmt.Errorf("204 must not contain Content-Length")
|
||||
}
|
||||
} else if len(lengths) != 1 || resp.ContentLength < 0 {
|
||||
return fmt.Errorf("expected exactly one valid Content-Length")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bound a stalled peer, without imposing a total duration on large transfers.
|
||||
type idleDeadlineReader struct {
|
||||
net.Conn
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (r *idleDeadlineReader) Read(p []byte) (int, error) {
|
||||
if err := r.Conn.SetReadDeadline(time.Now().Add(r.Timeout)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.Conn.Read(p)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func SimpleFilenameGenerator(maxLen int) func(r *rand.Rand) string {
|
||||
return func(r *rand.Rand) string {
|
||||
return GenFilename(r, maxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func GenFilename(r *rand.Rand, maxLen int) string {
|
||||
// TODO: better filename generation, right now it's only uppercase english letters
|
||||
n := r.Intn(maxLen) + 1
|
||||
b := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
b[i] = byte(r.Intn(26) + 65)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TextReader is io.Reader filtering out non-text characters.
|
||||
type TextReader struct {
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (t *TextReader) Read(p []byte) (n int, err error) {
|
||||
for {
|
||||
n, err = t.r.Read(p)
|
||||
if n == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m := 0
|
||||
for i := 0; i < n; i++ {
|
||||
// p[i] is a plain text character
|
||||
if p[i] == '\n' || (p[i] >= 32 && p[i] <= 126) {
|
||||
p[m] = p[i]
|
||||
m++
|
||||
}
|
||||
}
|
||||
if m == 0 {
|
||||
continue
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module hw3test
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.8.0
|
||||
go.uber.org/zap v1.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
|
||||
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
|
||||
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,309 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
useDocker bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&useDocker, "docker", false, "Run solution in Docker")
|
||||
}
|
||||
|
||||
func TestHW(tt *testing.T) {
|
||||
if !flag.Parsed() {
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
t := NewTestContext(tt)
|
||||
Info(t, "Starting tests")
|
||||
|
||||
// loading commandline args template from disk, to run solution with it
|
||||
launchTmpl := os.Getenv("LAUNCH_TMPL")
|
||||
if launchTmpl == "" {
|
||||
launchTmpl = "./launch.tmpl"
|
||||
if useDocker {
|
||||
launchTmpl = "./launch_docker.tmpl"
|
||||
}
|
||||
}
|
||||
|
||||
tmplContent, err := os.ReadFile(launchTmpl)
|
||||
require.NoError(t, err, "failed to read template file")
|
||||
launchTemplate := template.Must(template.New("launch").Parse(string(tmplContent)))
|
||||
|
||||
runner := NewCmdRunner(launchTemplate, useDocker)
|
||||
workdir, err := os.Getwd()
|
||||
require.NoError(t, err, "failed to get current directory")
|
||||
tmpRoot := filepath.Join(workdir, "tmp")
|
||||
require.NoError(t, os.MkdirAll(tmpRoot, 0777), "failed to create tmp dir")
|
||||
workspace, err := filepath.EvalSymlinks(workdir)
|
||||
require.NoError(t, err, "failed to resolve test directory")
|
||||
tmpRoot, err = filepath.EvalSymlinks(tmpRoot)
|
||||
require.NoError(t, err, "failed to resolve tmp dir")
|
||||
rel, err := filepath.Rel(workspace, tmpRoot)
|
||||
require.NoError(t, err, "failed to verify tmp dir")
|
||||
require.Equal(t, "tmp", rel, "tmp dir must stay inside the test directory")
|
||||
tmpDir, err := os.MkdirTemp(tmpRoot, "run-")
|
||||
require.NoError(t, err, "failed to create run-specific tmp dir")
|
||||
require.NoError(t, os.Chmod(tmpDir, 0755), "failed to make tmp dir accessible to the server")
|
||||
t.Cleanup(func() {
|
||||
resolved, err := filepath.EvalSymlinks(tmpDir)
|
||||
if os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "failed to resolve run-specific tmp dir")
|
||||
rel, err := filepath.Rel(tmpRoot, resolved)
|
||||
require.NoError(t, err, "failed to verify run-specific tmp dir")
|
||||
require.Equal(t, filepath.Base(tmpDir), rel, "run-specific tmp dir must stay inside tmp dir")
|
||||
require.NoError(t, os.RemoveAll(tmpDir), "failed to remove run-specific tmp dir")
|
||||
})
|
||||
|
||||
score := 0
|
||||
|
||||
type RunResult struct {
|
||||
Name string
|
||||
Scored int
|
||||
Max int
|
||||
}
|
||||
runResults := []RunResult{}
|
||||
|
||||
runGroup := func(name string, points int, f func(t *TC)) {
|
||||
t.RunByName(name, func(t *TC) {
|
||||
Info(t, "Starting tests group", zap.Int("points", points))
|
||||
t.Cleanup(func() {
|
||||
r := RunResult{
|
||||
Name: name,
|
||||
Max: points,
|
||||
}
|
||||
|
||||
ok := !t.Failed()
|
||||
if ok {
|
||||
score += points
|
||||
r.Scored = points
|
||||
|
||||
Info(t, "Tests group passed", zap.String("name", name), zap.Int("score", score))
|
||||
} else {
|
||||
Warn(t, "Tests group failed", zap.String("name", name), zap.Int("score", score))
|
||||
}
|
||||
|
||||
runResults = append(runResults, r)
|
||||
})
|
||||
f(t)
|
||||
})
|
||||
}
|
||||
|
||||
textEnv := &EnvGen{
|
||||
MaxDepth: 1,
|
||||
MaxDirs: 3,
|
||||
MaxFiles: 5,
|
||||
TextOnly: true,
|
||||
MaxFileSizeKB: 64,
|
||||
TempDirectory: tmpDir,
|
||||
FilenameGen: SimpleFilenameGenerator(8),
|
||||
AllowEnv: false,
|
||||
}
|
||||
binaryEnv := &EnvGen{
|
||||
MaxDepth: 4,
|
||||
MaxDirs: 16,
|
||||
MaxFiles: 25,
|
||||
TextOnly: false,
|
||||
MaxFileSizeKB: 1024,
|
||||
TempDirectory: tmpDir,
|
||||
FilenameGen: SimpleFilenameGenerator(16),
|
||||
AllowEnv: true,
|
||||
}
|
||||
largeEnv := &EnvGen{
|
||||
MaxDepth: 1,
|
||||
MaxDirs: 6,
|
||||
MaxFiles: 6,
|
||||
TextOnly: false,
|
||||
MaxFileSizeKB: 192 * 1024, // 192 MB
|
||||
TempDirectory: tmpDir,
|
||||
SparseUnusedFiles: true,
|
||||
FilenameGen: SimpleFilenameGenerator(16),
|
||||
AllowEnv: true,
|
||||
}
|
||||
|
||||
// Simple GET queries for existing text files, 3 points.
|
||||
runGroup("G1", 3, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G1")
|
||||
RunTestEmptyWorkDir(t, 42, runner)
|
||||
|
||||
env := textEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 20,
|
||||
GetFile: true,
|
||||
GetFileNoErrors: true,
|
||||
GetDirectory: false,
|
||||
Post: false,
|
||||
Put: false,
|
||||
Delete: false,
|
||||
Compression: false,
|
||||
AllHeaders: false,
|
||||
}
|
||||
RunTests(t, 1337, runner, env, queries)
|
||||
RunTests(t, 1338, runner, env, queries)
|
||||
RunTests(t, 1339, runner, env, queries)
|
||||
})
|
||||
|
||||
// Simple GET queries for existing binary files, 1 point.
|
||||
runGroup("G2", 1, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G2")
|
||||
env := binaryEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 40,
|
||||
GetFile: true,
|
||||
GetFileNoErrors: true,
|
||||
GetDirectory: false,
|
||||
Post: false,
|
||||
Put: false,
|
||||
Delete: false,
|
||||
Compression: false,
|
||||
AllHeaders: false,
|
||||
}
|
||||
RunTests(t, 93, runner, env, queries)
|
||||
RunTests(t, 2945, runner, env, queries)
|
||||
RunTests(t, 3110, runner, env, queries)
|
||||
})
|
||||
|
||||
// Any GET queries, 1 point.
|
||||
runGroup("G3", 1, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G3")
|
||||
env := textEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 30,
|
||||
GetFile: true,
|
||||
GetFileNoErrors: false,
|
||||
GetDirectory: true,
|
||||
Post: false,
|
||||
Put: false,
|
||||
Delete: false,
|
||||
Compression: false,
|
||||
AllHeaders: false,
|
||||
}
|
||||
RunTests(t, 5311, runner, env, queries)
|
||||
RunTests(t, 2863, runner, env, queries)
|
||||
RunTests(t, 6712, runner, env, queries)
|
||||
RunTests(t, 7233, runner, env, queries)
|
||||
RunTests(t, 7067, runner, env, queries)
|
||||
RunTests(t, 3930, runner, env, queries)
|
||||
})
|
||||
|
||||
// Simple file server, 2 points.
|
||||
runGroup("G4", 2, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G4")
|
||||
env := binaryEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 30,
|
||||
GetFile: true,
|
||||
GetDirectory: true,
|
||||
Post: true,
|
||||
Put: true,
|
||||
Delete: true,
|
||||
Compression: false,
|
||||
AllHeaders: false,
|
||||
}
|
||||
RunTests(t, 3152, runner, env, queries)
|
||||
RunTests(t, 2929, runner, env, queries)
|
||||
RunTests(t, 6554, runner, env, queries)
|
||||
RunTests(t, 1388, runner, env, queries)
|
||||
RunTests(t, 1672, runner, env, queries)
|
||||
RunTests(t, 1769, runner, env, queries)
|
||||
})
|
||||
|
||||
// Extra headers, 1 point.
|
||||
runGroup("G5", 1, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G5")
|
||||
env := binaryEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 30,
|
||||
GetFile: true,
|
||||
GetDirectory: true,
|
||||
Post: true,
|
||||
Put: true,
|
||||
Delete: true,
|
||||
Compression: false,
|
||||
AllHeaders: true,
|
||||
}
|
||||
RunTests(t, 7942, runner, env, queries)
|
||||
RunTests(t, 1479, runner, env, queries)
|
||||
RunTests(t, 3324, runner, env, queries)
|
||||
RunTests(t, 6519, runner, env, queries)
|
||||
RunTests(t, 3746, runner, env, queries)
|
||||
RunTests(t, 1961, runner, env, queries)
|
||||
})
|
||||
|
||||
// Large files, 1 point.
|
||||
runGroup("G6", 1, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G6")
|
||||
env := largeEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 25,
|
||||
GetFile: true,
|
||||
GetDirectory: true,
|
||||
LargePuts: &LargePutPolicy{
|
||||
RunSeed: 3793,
|
||||
LargeSizes: []int64{160 << 20, 191 << 20},
|
||||
OtherMaxBytes: 8 << 20,
|
||||
},
|
||||
Post: true,
|
||||
Put: true,
|
||||
Delete: true,
|
||||
Compression: false,
|
||||
AllHeaders: false,
|
||||
}
|
||||
RunTests(t, 7824, runner, env, queries)
|
||||
RunTests(t, 1671, runner, env, queries)
|
||||
RunTests(t, 3793, runner, env, queries)
|
||||
RunTests(t, 272, runner, env, queries)
|
||||
RunTests(t, 2715, runner, env, queries)
|
||||
RunTests(t, 1436, runner, env, queries)
|
||||
})
|
||||
|
||||
// Large files and gzip, 1 point.
|
||||
runGroup("G7", 1, func(t *TC) {
|
||||
RunContractTests(t, runner, tmpDir, "G7")
|
||||
env := largeEnv
|
||||
queries := &QueriesGen{
|
||||
Count: 30,
|
||||
GetFile: true,
|
||||
GetDirectory: true,
|
||||
LargePuts: &LargePutPolicy{
|
||||
RunSeed: 3224,
|
||||
LargeSizes: []int64{160 << 20, 191 << 20},
|
||||
OtherMaxBytes: 8 << 20,
|
||||
},
|
||||
Post: true,
|
||||
Put: true,
|
||||
Delete: true,
|
||||
Compression: true,
|
||||
AllHeaders: true,
|
||||
}
|
||||
RunTests(t, 3224, runner, env, queries)
|
||||
RunTests(t, 7507, runner, env, queries)
|
||||
RunTests(t, 4172, runner, env, queries)
|
||||
RunTests(t, 7777, runner, env, queries)
|
||||
RunTests(t, 6666, runner, env, queries)
|
||||
RunTests(t, 6094, runner, env, queries)
|
||||
RunTests(t, 6442, runner, env, queries)
|
||||
})
|
||||
|
||||
for _, r := range runResults {
|
||||
Info(t, fmt.Sprintf("Score for group [%s]: %d / %d", r.Name, r.Scored, r.Max))
|
||||
}
|
||||
|
||||
Info(t, "Tests finished", zap.Int("score", score))
|
||||
fmt.Println("==================================================================================")
|
||||
fmt.Printf("SCORE: %d\n", score)
|
||||
fmt.Println("==================================================================================")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLargePutPlansKeepBoundaryWritesAndQueries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
runSeeds []int64
|
||||
count int
|
||||
largeSeed int64
|
||||
allHeaders bool
|
||||
compression bool
|
||||
}{
|
||||
{"G6", []int64{7824, 1671, 3793, 272, 2715, 1436}, 25, 3793, false, false},
|
||||
{"G7", []int64{3224, 7507, 4172, 7777, 6666, 6094, 6442}, 30, 3224, true, true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
context := NewTestContext(t)
|
||||
envGen := &EnvGen{
|
||||
MaxDepth: 1,
|
||||
MaxDirs: 6,
|
||||
MaxFiles: 6,
|
||||
MaxFileSizeKB: 192 * 1024,
|
||||
FilenameGen: SimpleFilenameGenerator(16),
|
||||
}
|
||||
policy := &LargePutPolicy{
|
||||
RunSeed: test.largeSeed,
|
||||
LargeSizes: []int64{160 << 20, 191 << 20},
|
||||
OtherMaxBytes: 8 << 20,
|
||||
}
|
||||
options := RunOpts{ServerDomain: "localhost"}
|
||||
largePosts := 0
|
||||
largeGets := 0
|
||||
largeGzipGets := 0
|
||||
for _, runSeed := range test.runSeeds {
|
||||
env, err := envGen.Generate(runSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := rand.New(rand.NewSource(runSeed))
|
||||
seeds := make([]int64, test.count)
|
||||
for i := range seeds {
|
||||
seeds[i] = r.Int63n(100000)
|
||||
}
|
||||
generator := &QueriesGen{
|
||||
Count: test.count, GetFile: true, GetDirectory: true,
|
||||
Post: true, Put: true, Delete: true,
|
||||
Compression: test.compression, AllHeaders: test.allHeaders,
|
||||
LargePuts: policy,
|
||||
}
|
||||
queries := generator.Generate(context, env, envGen, seeds, options, runSeed)
|
||||
generator.LargePuts = nil
|
||||
baseline := generator.Generate(context, env, envGen, seeds, options, runSeed)
|
||||
if len(queries) != len(baseline) {
|
||||
t.Fatalf("run %d changed query count", runSeed)
|
||||
}
|
||||
state := env.Clone()
|
||||
var largePutSizes []int64
|
||||
for i, query := range queries {
|
||||
if query.Method == "PUT" {
|
||||
baseline[i].FileContent.Size = query.FileContent.Size
|
||||
}
|
||||
if !reflect.DeepEqual(query, baseline[i]) {
|
||||
t.Fatalf("run %d changed query %d beyond PUT body size", runSeed, i)
|
||||
}
|
||||
action := query.Action(state, &options)
|
||||
if query.Path == "" && query.Method != "GET" {
|
||||
t.Fatal("generated a modifying request to the working directory")
|
||||
}
|
||||
if get, ok := action.(GetFileAction); ok && get.File.Size > 128<<20 {
|
||||
largeGets++
|
||||
if get.Compression {
|
||||
largeGzipGets++
|
||||
}
|
||||
}
|
||||
if query.Method == "PUT" {
|
||||
if query.FileContent.Size > 128<<20 {
|
||||
if _, ok := action.(ReplaceFileAction); !ok {
|
||||
t.Fatalf("run %d has a large PUT that cannot update a file", runSeed)
|
||||
}
|
||||
largePutSizes = append(largePutSizes, query.FileContent.Size)
|
||||
} else if query.FileContent.Size > policy.OtherMaxBytes {
|
||||
t.Fatalf("run %d has an unbounded PUT", runSeed)
|
||||
}
|
||||
}
|
||||
if query.Method == "POST" && query.FileContent != nil && query.FileContent.Size > 128<<20 {
|
||||
if _, ok := action.(CreateFileAction); ok {
|
||||
largePosts++
|
||||
}
|
||||
}
|
||||
if action != nil {
|
||||
action.ApplyEnv(context, state)
|
||||
}
|
||||
}
|
||||
if runSeed == test.largeSeed {
|
||||
if !reflect.DeepEqual(largePutSizes, policy.LargeSizes) {
|
||||
t.Fatalf("run %d large PUT sizes: got %v, want %v", runSeed, largePutSizes, policy.LargeSizes)
|
||||
}
|
||||
} else if len(largePutSizes) != 0 {
|
||||
t.Fatalf("run %d has unexpected large PUTs", runSeed)
|
||||
}
|
||||
}
|
||||
if largePosts == 0 {
|
||||
t.Fatal("no successful POST larger than the solution memory limit")
|
||||
}
|
||||
if largeGets == 0 {
|
||||
t.Fatal("no successful GET larger than the solution memory limit")
|
||||
}
|
||||
if test.compression && largeGzipGets == 0 {
|
||||
t.Fatal("no gzip GET larger than the solution memory limit")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{{- /*gotype: hw3test.RunOpts */ -}}
|
||||
python3 ../solution/server.py {{.CommandLineArgs}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{{- /*gotype: hw3test.RunOpts */ -}}
|
||||
docker run --memory=128m --memory-swap=128m --memory-swappiness=0 --rm {{.DockerVolumeArgs}} {{.DockerPortArgs}} {{.DockerEnvArgs}} hw3img {{.DockerCommandLineArgs}}
|
||||
@@ -0,0 +1,94 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func logLevelFromEnv() zap.AtomicLevel {
|
||||
levelStr := os.Getenv("LOG_LEVEL")
|
||||
if levelStr == "" {
|
||||
return zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
}
|
||||
|
||||
level, err := zap.ParseAtomicLevel(levelStr)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to parse log level: %w", err))
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
var global *zap.Logger
|
||||
var globalOnce sync.Once
|
||||
|
||||
func Global() *zap.Logger {
|
||||
if global == nil {
|
||||
globalOnce.Do(func() {
|
||||
encoder := zapcore.EncoderConfig{
|
||||
// Keys can be anything except the empty string.
|
||||
TimeKey: "",
|
||||
LevelKey: "L",
|
||||
NameKey: "_",
|
||||
CallerKey: "C",
|
||||
FunctionKey: "F",
|
||||
MessageKey: "M",
|
||||
StacktraceKey: "S",
|
||||
LineEnding: zapcore.DefaultLineEnding,
|
||||
EncodeLevel: zapcore.CapitalColorLevelEncoder,
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeDuration: zapcore.StringDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
}
|
||||
cfg := zap.Config{
|
||||
Level: logLevelFromEnv(),
|
||||
Development: true,
|
||||
DisableCaller: true,
|
||||
DisableStacktrace: true,
|
||||
Encoding: "console",
|
||||
EncoderConfig: encoder,
|
||||
OutputPaths: []string{"stderr"},
|
||||
ErrorOutputPaths: []string{"stderr"},
|
||||
}
|
||||
options := []zap.Option{
|
||||
zap.AddCallerSkip(1),
|
||||
}
|
||||
|
||||
var err error
|
||||
global, err = cfg.Build(options...)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to initialize global logger: %w", err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return global
|
||||
}
|
||||
|
||||
func Logger(t *TC) *zap.Logger {
|
||||
logger := Global()
|
||||
logger = logger.Named(t.Name())
|
||||
return logger
|
||||
}
|
||||
|
||||
func Debug(t *TC, msg string, args ...zap.Field) {
|
||||
Logger(t).Debug(msg, args...)
|
||||
_ = Logger(t).Sync()
|
||||
}
|
||||
|
||||
func Info(t *TC, msg string, args ...zap.Field) {
|
||||
Logger(t).Info(msg, args...)
|
||||
_ = Logger(t).Sync()
|
||||
}
|
||||
|
||||
func Warn(t *TC, msg string, args ...zap.Field) {
|
||||
Logger(t).Warn(msg, args...)
|
||||
_ = Logger(t).Sync()
|
||||
}
|
||||
|
||||
func Error(t *TC, msg string, args ...zap.Field) {
|
||||
Logger(t).Error(msg, args...)
|
||||
_ = Logger(t).Sync()
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseContentType(value string) (string, error) {
|
||||
mediaType, _, err := mime.ParseMediaType(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.Contains(mediaType, "/") {
|
||||
return "", fmt.Errorf("content type %q has no subtype", value)
|
||||
}
|
||||
return mediaType, nil
|
||||
}
|
||||
|
||||
// Query represents a query to the solution server.
|
||||
type Query struct {
|
||||
// Seed is used as a query ID.
|
||||
Seed int64
|
||||
|
||||
// Method of a request, e.g. "GET", "POST", "PUT", "DELETE".
|
||||
Method string
|
||||
|
||||
// Simple file path, splitted by slashes. E.g. "foo/bar/baz".
|
||||
Path string
|
||||
|
||||
// If true, will use `Accept-Encoding` for GET query.
|
||||
Gzip bool
|
||||
|
||||
// If true, will pass `Create-Directory: True` in POST query.
|
||||
CreateDirectory bool
|
||||
|
||||
// If true, will pass `Remove-Directory: True` in DELETE query.
|
||||
RemoveDirectory bool
|
||||
|
||||
// Pass the Host header.
|
||||
HostHeader string
|
||||
|
||||
// Verify all server headers: `Content-Length`, `Content-Type`, `Server`.
|
||||
VerifyHeaders bool
|
||||
|
||||
// FileContent when creating a file.
|
||||
FileContent *EnvFile
|
||||
}
|
||||
|
||||
func (q *Query) CreateRequest(t *TC, queryURL string) *http.Request {
|
||||
var body io.Reader
|
||||
if q.FileContent != nil {
|
||||
body = q.FileContent.Open()
|
||||
if q.FileContent.Size == 0 {
|
||||
body = http.NoBody
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(q.Method, queryURL, body)
|
||||
require.NoError(t, err, "failed to create go request")
|
||||
|
||||
if q.FileContent != nil {
|
||||
req.ContentLength = q.FileContent.Size
|
||||
}
|
||||
if q.CreateDirectory {
|
||||
req.Header.Set("Create-Directory", "True")
|
||||
}
|
||||
if q.RemoveDirectory {
|
||||
req.Header.Set("Remove-Directory", "True")
|
||||
}
|
||||
if q.HostHeader != "" {
|
||||
req.Host = q.HostHeader
|
||||
}
|
||||
if q.Gzip {
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func (q *Query) CommonValidate(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.NoError(t, validateResponseFraming(resp))
|
||||
if q.VerifyHeaders {
|
||||
require.NotEmpty(t, resp.Header.Get("Server"), "missing Server header")
|
||||
}
|
||||
}
|
||||
|
||||
type Action interface {
|
||||
// VerifyBefore verifies file content on disk before sending query to the server.
|
||||
VerifyBefore(t *TC, workdir string)
|
||||
|
||||
// VerifyAfter verifies file content on disk after sending query to the server.
|
||||
VerifyAfter(t *TC, workdir string)
|
||||
|
||||
// ApplyEnv applies the action to the environment.
|
||||
ApplyEnv(t *TC, env *Env) (changed bool)
|
||||
|
||||
// VerifyResponse verifies the response from the server.
|
||||
VerifyResponse(t *TC, r *http.Request, resp *http.Response)
|
||||
}
|
||||
|
||||
// HttpErrorAction is an Action, which expect specific status code in response.
|
||||
type HttpErrorAction struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
func (h HttpErrorAction) VerifyBefore(t *TC, workdir string) {
|
||||
return
|
||||
}
|
||||
|
||||
func (h HttpErrorAction) VerifyAfter(t *TC, workdir string) {
|
||||
return
|
||||
}
|
||||
|
||||
func (h HttpErrorAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h HttpErrorAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.Equal(t, h.Status, resp.StatusCode, "expected http status code")
|
||||
require.Positive(t, resp.ContentLength, "error response must contain an explanation")
|
||||
}
|
||||
|
||||
// GetFileAction is an Action, which returns file content in response.
|
||||
type GetFileAction struct {
|
||||
Path string
|
||||
File *EnvFile
|
||||
Compression bool
|
||||
VerifyHeaders bool
|
||||
}
|
||||
|
||||
func (g GetFileAction) VerifyBefore(t *TC, workdir string) {
|
||||
RequireFileContent(t, workdir, g.Path, g.File)
|
||||
}
|
||||
|
||||
func (g GetFileAction) VerifyAfter(t *TC, workdir string) {
|
||||
RequireFileContent(t, workdir, g.Path, g.File)
|
||||
}
|
||||
|
||||
func (g GetFileAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (g GetFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.Equal(t, 200, resp.StatusCode, "expected OK")
|
||||
var body io.Reader = resp.Body
|
||||
if g.Compression {
|
||||
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response")
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
require.NoError(t, err, "expected valid gzip response")
|
||||
defer gz.Close()
|
||||
body = gz
|
||||
} else {
|
||||
require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding")
|
||||
require.Equal(t, g.File.Size, resp.ContentLength, "expected content length")
|
||||
}
|
||||
if g.VerifyHeaders && g.File.Size > 0 {
|
||||
_, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||
require.NoError(t, err, "expected valid content type")
|
||||
}
|
||||
require.NoError(t, CompareFileContent(t, body, g.File), "file content mismatch")
|
||||
remaining, err := io.Copy(io.Discard, body)
|
||||
require.NoError(t, err, "failed to read remaining response body")
|
||||
require.Zero(t, remaining, "response contains extra file content")
|
||||
}
|
||||
|
||||
// GetDirAction is an Action, which returns directory listing in response.
|
||||
type GetDirAction struct {
|
||||
Path string
|
||||
Dir *EnvDir
|
||||
Compression bool
|
||||
VerifyHeaders bool
|
||||
}
|
||||
|
||||
func (g GetDirAction) VerifyBefore(t *TC, workdir string) {
|
||||
RequireDir(t, workdir, g.Path, g.Dir)
|
||||
}
|
||||
|
||||
func (g GetDirAction) VerifyAfter(t *TC, workdir string) {
|
||||
RequireDir(t, workdir, g.Path, g.Dir)
|
||||
}
|
||||
|
||||
func (g GetDirAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (g GetDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.Equal(t, 200, resp.StatusCode, "expected OK")
|
||||
var body io.Reader = resp.Body
|
||||
if g.Compression {
|
||||
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response")
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
require.NoError(t, err, "expected valid gzip response")
|
||||
defer gz.Close()
|
||||
body = gz
|
||||
} else {
|
||||
require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding")
|
||||
}
|
||||
content, err := io.ReadAll(body)
|
||||
require.NoError(t, err, "expected response to be read without errors")
|
||||
if g.VerifyHeaders && len(content) > 0 {
|
||||
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||
require.NoError(t, err, "expected valid content type")
|
||||
require.Contains(t, []string{"text/plain", "text/html"}, mediaType, "expected directory listing content type")
|
||||
}
|
||||
|
||||
strlist := string(content)
|
||||
for name := range g.Dir.Listing {
|
||||
require.Contains(t, strlist, name, "expected dir listing to contain child %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDirAction is an Action, which creates a directory.
|
||||
type CreateDirAction struct {
|
||||
Path string
|
||||
Name string
|
||||
Parent *EnvDir
|
||||
PathOnDisk string
|
||||
}
|
||||
|
||||
func (c CreateDirAction) VerifyBefore(t *TC, workdir string) {
|
||||
RequireNotExists(t, workdir, c.Path)
|
||||
}
|
||||
|
||||
func (c CreateDirAction) VerifyAfter(t *TC, workdir string) {
|
||||
RequireDir(t, workdir, c.Path, c.Parent)
|
||||
entries, err := os.ReadDir(c.PathOnDisk)
|
||||
require.NoError(t, err, "expected to read dir %s without errors", c.PathOnDisk)
|
||||
require.Empty(t, entries, "expected dir %s to be empty", c.PathOnDisk)
|
||||
}
|
||||
|
||||
func (c CreateDirAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
_, exist := c.Parent.CreateDir(c.Name)
|
||||
require.False(t, exist)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c CreateDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation")
|
||||
}
|
||||
|
||||
// CreateFileAction is an Action, which creates a file.
|
||||
type CreateFileAction struct {
|
||||
Path string
|
||||
Name string
|
||||
Parent *EnvDir
|
||||
Content *EnvFile
|
||||
}
|
||||
|
||||
func (c CreateFileAction) VerifyBefore(t *TC, workdir string) {
|
||||
RequireNotExists(t, workdir, c.Path)
|
||||
}
|
||||
|
||||
func (c CreateFileAction) VerifyAfter(t *TC, workdir string) {
|
||||
RequireFileContent(t, workdir, c.Path, c.Content)
|
||||
}
|
||||
|
||||
func (c CreateFileAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
c.Parent.Listing[c.Name] = c.Content
|
||||
return true
|
||||
}
|
||||
|
||||
func (c CreateFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation")
|
||||
}
|
||||
|
||||
// ReplaceFileAction is an Action, which replaces file content.
|
||||
type ReplaceFileAction struct {
|
||||
Path string
|
||||
Name string
|
||||
Parent *EnvDir
|
||||
OldContent *EnvFile
|
||||
NewContent *EnvFile
|
||||
}
|
||||
|
||||
func (r ReplaceFileAction) VerifyBefore(t *TC, workdir string) {
|
||||
RequireFileContent(t, workdir, r.Path, r.OldContent)
|
||||
}
|
||||
|
||||
func (r ReplaceFileAction) VerifyAfter(t *TC, workdir string) {
|
||||
RequireFileContent(t, workdir, r.Path, r.NewContent)
|
||||
}
|
||||
|
||||
func (r ReplaceFileAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
r.Parent.Listing[r.Name] = r.NewContent
|
||||
return true
|
||||
}
|
||||
|
||||
func (r ReplaceFileAction) VerifyResponse(t *TC, req *http.Request, resp *http.Response) {
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusNoContent}, resp.StatusCode, "expected successful update")
|
||||
}
|
||||
|
||||
// DeleteAction is an Action, which deletes a file or directory.
|
||||
type DeleteAction struct {
|
||||
Path string
|
||||
Parent *EnvDir
|
||||
Name string
|
||||
}
|
||||
|
||||
func (d DeleteAction) VerifyBefore(t *TC, workdir string) {
|
||||
RequireExists(t, workdir, d.Path)
|
||||
}
|
||||
|
||||
func (d DeleteAction) VerifyAfter(t *TC, workdir string) {
|
||||
RequireNotExists(t, workdir, d.Path)
|
||||
}
|
||||
|
||||
func (d DeleteAction) ApplyEnv(t *TC, env *Env) bool {
|
||||
delete(d.Parent.Listing, d.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d DeleteAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||
require.Equal(t, 200, resp.StatusCode, "expected OK")
|
||||
}
|
||||
|
||||
// Action returns action for the query, or nil if query shouldn't do anything.
|
||||
func (q *Query) Action(env *Env, opts *RunOpts) Action {
|
||||
if !strings.EqualFold(q.HostHeader, opts.ServerDomain) {
|
||||
return HttpErrorAction{
|
||||
Status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
parent, child := env.Lookup(q.Path)
|
||||
|
||||
switch q.Method {
|
||||
case "GET":
|
||||
if child == nil {
|
||||
return HttpErrorAction{
|
||||
Status: 404,
|
||||
}
|
||||
}
|
||||
if f, ok := child.(*EnvFile); ok {
|
||||
return GetFileAction{
|
||||
Path: q.Path,
|
||||
File: f,
|
||||
Compression: q.Gzip,
|
||||
VerifyHeaders: q.VerifyHeaders,
|
||||
}
|
||||
}
|
||||
if d, ok := child.(*EnvDir); ok {
|
||||
return GetDirAction{
|
||||
Path: q.Path,
|
||||
Dir: d,
|
||||
Compression: q.Gzip,
|
||||
VerifyHeaders: q.VerifyHeaders,
|
||||
}
|
||||
}
|
||||
|
||||
// shouldn't get here
|
||||
return nil
|
||||
case "POST":
|
||||
if child != nil {
|
||||
return HttpErrorAction{
|
||||
Status: 409,
|
||||
}
|
||||
}
|
||||
if parent == nil {
|
||||
return HttpErrorAction{Status: http.StatusNotFound}
|
||||
}
|
||||
_, name := path.Split(q.Path)
|
||||
if q.CreateDirectory {
|
||||
return CreateDirAction{
|
||||
Path: q.Path,
|
||||
Name: name,
|
||||
Parent: parent,
|
||||
PathOnDisk: path.Join(opts.WorkingDirectory, q.Path),
|
||||
}
|
||||
}
|
||||
return CreateFileAction{
|
||||
Path: q.Path,
|
||||
Name: name,
|
||||
Parent: parent,
|
||||
Content: q.FileContent,
|
||||
}
|
||||
case "PUT":
|
||||
if child == nil {
|
||||
return HttpErrorAction{Status: http.StatusNotFound}
|
||||
}
|
||||
f, ok := child.(*EnvFile)
|
||||
if !ok {
|
||||
return HttpErrorAction{
|
||||
Status: 409,
|
||||
}
|
||||
}
|
||||
|
||||
_, name := path.Split(q.Path)
|
||||
return ReplaceFileAction{
|
||||
Path: q.Path,
|
||||
Name: name,
|
||||
Parent: parent,
|
||||
OldContent: f,
|
||||
NewContent: q.FileContent,
|
||||
}
|
||||
case "DELETE":
|
||||
if child == env.RootDir {
|
||||
return HttpErrorAction{Status: http.StatusForbidden}
|
||||
}
|
||||
if child == nil {
|
||||
return HttpErrorAction{Status: http.StatusNotFound}
|
||||
}
|
||||
_, ok := child.(*EnvDir)
|
||||
if ok && !q.RemoveDirectory {
|
||||
return HttpErrorAction{
|
||||
Status: 406,
|
||||
}
|
||||
}
|
||||
_, name := path.Split(q.Path)
|
||||
return DeleteAction{
|
||||
Path: q.Path,
|
||||
Parent: parent,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// LargePutPolicy keeps a small number of guaranteed successful writes above
|
||||
// the solution's memory limit, while bounding the remaining PUT bodies.
|
||||
type LargePutPolicy struct {
|
||||
RunSeed int64
|
||||
LargeSizes []int64
|
||||
OtherMaxBytes int64
|
||||
}
|
||||
|
||||
// QueriesGen contains config for generating queries.
|
||||
type QueriesGen struct {
|
||||
// Number of queries to generate.
|
||||
Count int
|
||||
|
||||
GetFile bool // Allow GET requests for files.
|
||||
GetFileNoErrors bool // Disable requests for non-existing files.
|
||||
GetDirectory bool // Allow GET requests for directories.
|
||||
Compression bool // Allow compression in GET requests.
|
||||
Post bool // Allow POST requests.
|
||||
Put bool // Allow PUT requests.
|
||||
Delete bool // Allow DELETE requests.
|
||||
AllHeaders bool // Allow extra headers.
|
||||
LargePuts *LargePutPolicy
|
||||
}
|
||||
|
||||
func (g *QueriesGen) Generate(t *TC, env *Env, gen *EnvGen, seeds []int64, opts RunOpts, runSeed int64) []Query {
|
||||
env = env.Clone()
|
||||
largePuts := 0
|
||||
|
||||
stats := &Stats{}
|
||||
env.RootDir.Stats("", stats)
|
||||
stats.Normalize()
|
||||
|
||||
var methods []string
|
||||
if g.GetFile || g.GetDirectory {
|
||||
methods = append(methods, "GET")
|
||||
}
|
||||
if g.Post {
|
||||
methods = append(methods, "POST")
|
||||
}
|
||||
if g.Put {
|
||||
methods = append(methods, "PUT")
|
||||
}
|
||||
if g.Delete {
|
||||
methods = append(methods, "DELETE")
|
||||
}
|
||||
|
||||
var queries []Query
|
||||
for _, seed := range seeds {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
method := methods[r.Intn(len(methods))]
|
||||
|
||||
var genPaths []string
|
||||
if len(stats.DirPaths) > 0 && !(method == "GET" && !g.GetDirectory) {
|
||||
genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))])
|
||||
genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))])
|
||||
}
|
||||
if len(stats.FilePaths) > 0 && !(method == "GET" && !g.GetFile) {
|
||||
genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))])
|
||||
genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))])
|
||||
}
|
||||
if !(method == "GET" && g.GetFileNoErrors) {
|
||||
// TODO: better non-existing path generator
|
||||
randomPath := gen.FilenameGen(r) + "/" + gen.FilenameGen(r)
|
||||
genPaths = append(genPaths, randomPath)
|
||||
|
||||
if len(stats.DirPaths) > 0 {
|
||||
randomDir := stats.DirPaths[r.Intn(len(stats.DirPaths))]
|
||||
num := 2
|
||||
if method == "POST" {
|
||||
num = 4
|
||||
}
|
||||
|
||||
for i := 0; i < num; i++ {
|
||||
genPaths = append(genPaths, randomDir+"/"+gen.FilenameGen(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(genPaths) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hostHeader := opts.ServerDomain
|
||||
if g.AllHeaders && r.Intn(5) == 0 {
|
||||
// to get 400
|
||||
hostHeader = "hse.ru"
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(genPaths[r.Intn(len(genPaths))], "/")
|
||||
if path == "" && method != "GET" {
|
||||
// Keep the existing large-file scenarios and RNG sequence.
|
||||
// Requests targeting the root are covered on a small tree in contract.go.
|
||||
path = "MISSING_ROOT_TARGET"
|
||||
}
|
||||
gzipRequested := g.Compression && r.Intn(2) == 1
|
||||
query := Query{
|
||||
Seed: seed,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Gzip: method == "GET" && gzipRequested,
|
||||
CreateDirectory: method == "POST" && (r.Intn(2) == 1),
|
||||
RemoveDirectory: method == "DELETE" && (r.Intn(2) == 1),
|
||||
HostHeader: hostHeader,
|
||||
VerifyHeaders: g.AllHeaders,
|
||||
}
|
||||
|
||||
if (method == "POST" && !query.CreateDirectory) || method == "PUT" {
|
||||
query.FileContent = gen.GenerateFile(r)
|
||||
}
|
||||
|
||||
action := query.Action(env, &opts)
|
||||
if query.Method == "PUT" && g.LargePuts != nil {
|
||||
policy := g.LargePuts
|
||||
if _, ok := action.(ReplaceFileAction); ok && runSeed == policy.RunSeed && largePuts < len(policy.LargeSizes) {
|
||||
query.FileContent.Size = policy.LargeSizes[largePuts]
|
||||
largePuts++
|
||||
} else if query.FileContent.Size > policy.OtherMaxBytes {
|
||||
query.FileContent.Size = policy.OtherMaxBytes
|
||||
}
|
||||
}
|
||||
queries = append(queries, query)
|
||||
|
||||
if action != nil {
|
||||
changed := action.ApplyEnv(t, env)
|
||||
if changed {
|
||||
stats = &Stats{}
|
||||
env.RootDir.Stats("", stats)
|
||||
stats.Normalize()
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.LargePuts != nil && runSeed == g.LargePuts.RunSeed {
|
||||
require.Equal(t, len(g.LargePuts.LargeSizes), largePuts, "not enough successful PUT queries for large-file coverage")
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWaitForServerUsesConfiguredHost(t *testing.T) {
|
||||
t.Setenv("SOLUTION_HOST", "127.0.0.1")
|
||||
for _, tc := range []struct {
|
||||
domain string
|
||||
status int
|
||||
}{
|
||||
{"localhost", http.StatusOK},
|
||||
{"files.example.com", http.StatusNotFound},
|
||||
{"redirect.example.com", http.StatusFound},
|
||||
} {
|
||||
t.Run(tc.domain, func(t *testing.T) {
|
||||
type probe struct{ method, target, host string }
|
||||
received := make(chan probe, 16)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case received <- probe{r.Method, r.RequestURI, r.Host}:
|
||||
default:
|
||||
}
|
||||
if tc.status == http.StatusFound {
|
||||
w.Header().Set("Location", "/redirected")
|
||||
}
|
||||
w.WriteHeader(tc.status)
|
||||
}))
|
||||
defer server.Close()
|
||||
opts := RunOpts{
|
||||
Port: server.Listener.Addr().(*net.TCPAddr).Port,
|
||||
ServerDomain: tc.domain,
|
||||
}
|
||||
if err := WaitForServer(NewTestContext(t), opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if want := (probe{"GET", "/", tc.domain}); got != want {
|
||||
t.Fatalf("readiness request = %+v, want %+v", got, want)
|
||||
}
|
||||
default:
|
||||
t.Fatal("server was marked ready without receiving a request")
|
||||
}
|
||||
select {
|
||||
case extra := <-received:
|
||||
t.Fatalf("unexpected extra readiness request: %+v", extra)
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResponseEndChecksWireBytes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
valid bool
|
||||
}{
|
||||
{"valid body", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndata", true},
|
||||
{"extra body bytes", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndataextra", false},
|
||||
{"valid empty body", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", true},
|
||||
{"body after zero length", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\nextra", false},
|
||||
{"valid no content", "HTTP/1.1 204 No Content\r\n\r\n", true},
|
||||
{"body after no content", "HTTP/1.1 204 No Content\r\n\r\nextra", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
server, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer server.Close()
|
||||
_, _ = io.WriteString(server, tc.response)
|
||||
}()
|
||||
client, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
reader := bufio.NewReader(client)
|
||||
response, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if _, err := io.Copy(io.Discard, response.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkResponseEnd(reader, false); (err == nil) != tc.valid {
|
||||
t.Fatalf("valid=%v, checkResponseEnd error: %v", tc.valid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContentTypeRequiresSubtype(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
valid bool
|
||||
}{
|
||||
{"application/octet-stream", true},
|
||||
{"text/plain; charset=utf-8", true},
|
||||
{"nonsense", false},
|
||||
{"text/", false},
|
||||
{"", false},
|
||||
} {
|
||||
_, err := parseContentType(tc.value)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Errorf("content type %q: valid=%v, error=%v", tc.value, tc.valid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryAcceptsEarlyErrorResponse(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
server, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer server.Close()
|
||||
reader := bufio.NewReader(server)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil || line == "\r\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror")
|
||||
}()
|
||||
t.Setenv("SOLUTION_HOST", "127.0.0.1")
|
||||
RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{
|
||||
Port: listener.Addr().(*net.TCPAddr).Port,
|
||||
ServerDomain: "localhost",
|
||||
}, Query{
|
||||
Method: "PUT",
|
||||
Path: "item",
|
||||
HostHeader: "other.example",
|
||||
FileContent: &EnvFile{GenSeed: 42, Size: 8 * 1024 * 1024},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunQueryWaitsForLargeRequestBeforeCloseTimeout(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
server, err := listener.Accept()
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer server.Close()
|
||||
request, err := http.ReadRequest(bufio.NewReader(server))
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror"); err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
_, err = io.Copy(io.Discard, request.Body)
|
||||
done <- err
|
||||
}()
|
||||
t.Setenv("SOLUTION_HOST", "127.0.0.1")
|
||||
RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{
|
||||
Port: listener.Addr().(*net.TCPAddr).Port,
|
||||
ServerDomain: "localhost",
|
||||
}, Query{
|
||||
Method: "POST",
|
||||
Path: "item",
|
||||
HostHeader: "other.example",
|
||||
FileContent: &EnvFile{GenSeed: 42, Size: 160 << 20},
|
||||
})
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleDeadlineWriter(t *testing.T) {
|
||||
t.Run("stalled write", func(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond}
|
||||
_, err := writer.Write([]byte("x"))
|
||||
var netErr net.Error
|
||||
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("expected write timeout, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("request body timeout", func(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
go func() {
|
||||
_, _ = http.ReadRequest(bufio.NewReader(server))
|
||||
<-release
|
||||
}()
|
||||
writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond}
|
||||
request := (&Query{Method: "POST", FileContent: &EnvFile{GenSeed: 42, Size: 1 << 20}}).
|
||||
CreateRequest(NewTestContext(t), "http://localhost/item")
|
||||
if err := request.Write(writer); err == nil {
|
||||
t.Fatal("expected request write to time out")
|
||||
}
|
||||
var netErr net.Error
|
||||
if !errors.As(writer.lastError, &netErr) || !netErr.Timeout() {
|
||||
t.Fatalf("expected underlying write timeout, got %v", writer.lastError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompressedGetResponseValidation(t *testing.T) {
|
||||
file := &EnvFile{GenSeed: 42, Size: 1024}
|
||||
directory := &EnvDir{Listing: map[string]EnvNode{"item": file}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body io.Reader
|
||||
validate func(*TC, *http.Response)
|
||||
}{
|
||||
{
|
||||
name: "file",
|
||||
body: file.Open(),
|
||||
validate: func(t *TC, resp *http.Response) {
|
||||
GetFileAction{File: file, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "directory",
|
||||
body: bytes.NewBufferString("item\n"),
|
||||
validate: func(t *TC, resp *http.Response) {
|
||||
GetDirAction{Dir: directory, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp)
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(tt *testing.T) {
|
||||
testContext := NewTestContext(tt)
|
||||
var encoded bytes.Buffer
|
||||
writer := gzip.NewWriter(&encoded)
|
||||
_, err := io.Copy(writer, tc.body)
|
||||
if err != nil {
|
||||
tt.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
tt.Fatal(err)
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
ContentLength: int64(encoded.Len()),
|
||||
Header: http.Header{
|
||||
"Content-Length": {strconv.Itoa(encoded.Len())},
|
||||
"Content-Encoding": {"gzip"},
|
||||
"Content-Type": {"text/plain"},
|
||||
"Server": {"test-server"},
|
||||
"Connection": {"close"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader(encoded.Bytes())),
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
q := Query{Gzip: true, VerifyHeaders: true}
|
||||
req := q.CreateRequest(testContext, "http://localhost/item")
|
||||
if got := req.Header.Get("Accept-Encoding"); got != "gzip" {
|
||||
tt.Fatalf("expected Accept-Encoding: gzip, got %q", got)
|
||||
}
|
||||
q.CommonValidate(testContext, req, resp)
|
||||
tc.validate(testContext, resp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareFileContentBlocks(t *testing.T) {
|
||||
file := &EnvFile{GenSeed: 17, Size: 64*1024 + 17}
|
||||
context := NewTestContext(t)
|
||||
if err := CompareFileContent(context, file.Open(), file); err != nil {
|
||||
t.Fatalf("valid file was rejected: %v", err)
|
||||
}
|
||||
if err := CompareFileContent(context, io.LimitReader(file.Open(), file.Size-1), file); err == nil {
|
||||
t.Fatal("truncated file was accepted")
|
||||
}
|
||||
content, err := io.ReadAll(file.Open())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content[len(content)-1] ^= 1
|
||||
if err := CompareFileContent(context, bytes.NewReader(content), file); err == nil {
|
||||
t.Fatal("changed final byte was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RunOpts contains command line arguments for the solution.
|
||||
// Those are passed to the template.
|
||||
type RunOpts struct {
|
||||
// Solution options
|
||||
Port int
|
||||
WorkingDirectory string
|
||||
ListenAddr string // Host
|
||||
ServerDomain string
|
||||
|
||||
// Full run config that will be provided to the solution.
|
||||
CommandLineArgs string
|
||||
Env []string
|
||||
|
||||
// Full run config if solution will be running in docker.
|
||||
DockerCommandLineArgs string
|
||||
DockerEnvArgs string
|
||||
DockerPortArgs string
|
||||
DockerVolumeArgs string
|
||||
|
||||
// Hack to get exitcode of the solution.
|
||||
ExitCode chan int
|
||||
}
|
||||
|
||||
func (o *RunOpts) Address() string {
|
||||
host := os.Getenv("SOLUTION_HOST")
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%d", host, o.Port)
|
||||
}
|
||||
|
||||
func (o *RunOpts) GenerateRunConfig(t *TC, r *rand.Rand, gen *EnvGen) {
|
||||
o.CommandLineArgs, o.Env = o.BuildConfig(r, gen)
|
||||
|
||||
dirsInDocker := []string{
|
||||
"/files",
|
||||
"/files0",
|
||||
"/files1",
|
||||
"/files2",
|
||||
"/files3",
|
||||
}
|
||||
dirInDocker := dirsInDocker[r.Intn(len(dirsInDocker))]
|
||||
|
||||
if o.WorkingDirectory == "" {
|
||||
dirInDocker = ""
|
||||
} else {
|
||||
o.DockerVolumeArgs = fmt.Sprintf(`-v "%s:%s"`, o.WorkingDirectory, dirInDocker)
|
||||
}
|
||||
|
||||
o.DockerPortArgs = fmt.Sprintf("-p %d:%d", o.Port, o.Port)
|
||||
|
||||
dockerArgs, dockerEnv := RunOpts{
|
||||
ListenAddr: o.ListenAddr,
|
||||
Port: o.Port,
|
||||
WorkingDirectory: dirInDocker,
|
||||
ServerDomain: o.ServerDomain,
|
||||
}.BuildConfig(r, gen)
|
||||
|
||||
o.DockerEnvArgs = ""
|
||||
for _, env := range dockerEnv {
|
||||
o.DockerEnvArgs += "--env \"" + env + "\" "
|
||||
}
|
||||
|
||||
o.DockerCommandLineArgs = dockerArgs
|
||||
}
|
||||
|
||||
// BuildConfig uses ListenAddr, Port, WorkingDirectory, ServerDomain.
|
||||
func (o RunOpts) BuildConfig(r *rand.Rand, gen *EnvGen) (args string, env []string) {
|
||||
if o.ListenAddr == "0.0.0.0" && r.Intn(2) == 1 {
|
||||
// can omit default value
|
||||
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||
// use env
|
||||
env = append(env, fmt.Sprintf("SERVER_HOST=%s", o.ListenAddr))
|
||||
} else if o.ListenAddr != "" {
|
||||
// use plain cmdline args
|
||||
args += fmt.Sprintf(" \"--host=%s\"", o.ListenAddr)
|
||||
|
||||
if r.Intn(2) == 1 {
|
||||
// pass dummy env
|
||||
env = append(env, fmt.Sprintf("SERVER_HOST=%s", "8.8.8.8"))
|
||||
}
|
||||
}
|
||||
|
||||
if o.Port == 8080 && r.Intn(2) == 1 {
|
||||
// can omit default value
|
||||
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||
// use env
|
||||
env = append(env, fmt.Sprintf("SERVER_PORT=%d", o.Port))
|
||||
} else {
|
||||
// use plain cmdline args
|
||||
args += fmt.Sprintf(" \"--port=%d\"", o.Port)
|
||||
|
||||
if r.Intn(2) == 1 {
|
||||
// pass dummy env
|
||||
env = append(env, fmt.Sprintf("SERVER_PORT=%d", 80))
|
||||
}
|
||||
}
|
||||
|
||||
if o.WorkingDirectory == "" && r.Intn(2) == 1 {
|
||||
// can omit default value
|
||||
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||
// use env
|
||||
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", o.WorkingDirectory))
|
||||
} else if o.WorkingDirectory != "" {
|
||||
// use plain cmdline args
|
||||
args += fmt.Sprintf(" \"--working-directory=%s\"", o.WorkingDirectory)
|
||||
|
||||
if r.Intn(2) == 1 {
|
||||
// pass dummy env
|
||||
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", "/"))
|
||||
}
|
||||
}
|
||||
|
||||
if o.ServerDomain == "localhost" && r.Intn(2) == 1 {
|
||||
// can omit default value
|
||||
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||
// use env
|
||||
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", o.ServerDomain))
|
||||
} else if o.ServerDomain != "" {
|
||||
// use plain cmdline args
|
||||
args += fmt.Sprintf(" \"--server-domain=%s\"", o.ServerDomain)
|
||||
|
||||
if r.Intn(2) == 1 {
|
||||
// pass dummy env
|
||||
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", "example.com"))
|
||||
}
|
||||
}
|
||||
return args, env
|
||||
}
|
||||
|
||||
// Runner is a helper for running HTTP server solution.
|
||||
type Runner interface {
|
||||
// Run the solution with the given options.
|
||||
// Returns a function that can be used to stop the solution.
|
||||
Run(t *TC, opts RunOpts) (stop func(), err error)
|
||||
}
|
||||
|
||||
// CmdRunner runs command based on template.
|
||||
type CmdRunner struct {
|
||||
tmpl template.Template
|
||||
useDocker bool
|
||||
}
|
||||
|
||||
func NewCmdRunner(tmpl *template.Template, useDocker bool) *CmdRunner {
|
||||
return &CmdRunner{
|
||||
tmpl: *tmpl,
|
||||
useDocker: useDocker,
|
||||
}
|
||||
}
|
||||
|
||||
// splitCommand preserves quoted arguments without invoking a shell.
|
||||
func splitCommand(command string) ([]string, error) {
|
||||
var args []string
|
||||
var arg strings.Builder
|
||||
quoted := false
|
||||
started := false
|
||||
for _, char := range command {
|
||||
switch {
|
||||
case char == '"':
|
||||
quoted = !quoted
|
||||
started = true
|
||||
case unicode.IsSpace(char) && !quoted:
|
||||
if started {
|
||||
args = append(args, arg.String())
|
||||
arg.Reset()
|
||||
started = false
|
||||
}
|
||||
default:
|
||||
arg.WriteRune(char)
|
||||
started = true
|
||||
}
|
||||
}
|
||||
if quoted {
|
||||
return nil, fmt.Errorf("unclosed quote in launch command")
|
||||
}
|
||||
if started {
|
||||
args = append(args, arg.String())
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("empty launch command")
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func (r *CmdRunner) Run(t *TC, opts RunOpts) (stop func(), err error) {
|
||||
var b bytes.Buffer
|
||||
err = r.tmpl.Execute(&b, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute template: %v", err)
|
||||
}
|
||||
|
||||
envOpts := opts.Env
|
||||
if r.useDocker {
|
||||
envOpts = nil
|
||||
}
|
||||
|
||||
cmdParts, err := splitCommand(b.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cidDir, cidFile string
|
||||
if r.useDocker {
|
||||
if len(cmdParts) < 2 || !strings.EqualFold(filepath.Base(cmdParts[0]), "docker") || cmdParts[1] != "run" {
|
||||
return nil, fmt.Errorf("Docker launch template must start with docker run")
|
||||
}
|
||||
cidDir, err = os.MkdirTemp("", "hw3-container-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create container ID directory: %w", err)
|
||||
}
|
||||
cidFile = filepath.Join(cidDir, "id")
|
||||
cmdParts = append(append([]string{}, cmdParts[:2]...), append([]string{"--cidfile", cidFile}, cmdParts[2:]...)...)
|
||||
}
|
||||
Info(t, "Running command", zap.Strings("command", cmdParts), zap.Strings("env", envOpts))
|
||||
|
||||
cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
|
||||
cmd.Env = append(os.Environ(), envOpts...)
|
||||
cmd.Stdout = NewProxyWriter(os.Stderr)
|
||||
cmd.Stderr = NewProxyWriter(os.Stderr)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
if cidDir != "" {
|
||||
_ = os.Remove(cidDir)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to run command: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil && err.Error() != "signal: killed" && err.Error() != "exit status 1" {
|
||||
Warn(t, "Command finished with error", zap.Error(err))
|
||||
}
|
||||
if opts.ExitCode != nil {
|
||||
if e, ok := err.(*exec.ExitError); ok {
|
||||
opts.ExitCode <- e.ExitCode()
|
||||
} else {
|
||||
opts.ExitCode <- 0
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
if cmd.Process != nil {
|
||||
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
Error(t, "Failed to kill command", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if r.useDocker {
|
||||
id, err := os.ReadFile(cidFile)
|
||||
if err == nil && len(strings.TrimSpace(string(id))) > 0 {
|
||||
output, removeErr := exec.Command("docker", "rm", "-f", strings.TrimSpace(string(id))).CombinedOutput()
|
||||
if removeErr != nil && !strings.Contains(string(output), "No such container") && !strings.Contains(string(output), "is already in progress") {
|
||||
Error(t, "Failed to remove Docker container", zap.Error(removeErr), zap.ByteString("output", output))
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
Error(t, "Failed to read container ID", zap.Error(err))
|
||||
}
|
||||
_ = os.Remove(cidFile)
|
||||
_ = os.Remove(cidDir)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProxyWriter is used to forward solution output to standard output.
|
||||
type ProxyWriter struct {
|
||||
w io.Writer
|
||||
disable bool
|
||||
}
|
||||
|
||||
func NewProxyWriter(w io.Writer) *ProxyWriter {
|
||||
return &ProxyWriter{
|
||||
w: w,
|
||||
disable: boolFromEnv("DISABLE_SOLUTION_OUTPUT", false),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ProxyWriter) Write(b []byte) (n int, err error) {
|
||||
if w.disable {
|
||||
return len(b), nil
|
||||
}
|
||||
// TODO: if there will be sync problems, we can take a global lock
|
||||
// and read until \n, then flush and apply color
|
||||
return w.w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitCommand(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
command string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "working directory with spaces",
|
||||
command: `python3 "../solution with spaces/server.py" "--working-directory=C:\Users\student name\files"`,
|
||||
want: []string{"python3", "../solution with spaces/server.py", `--working-directory=C:\Users\student name\files`},
|
||||
},
|
||||
{
|
||||
name: "Docker volume with spaces",
|
||||
command: `docker run -v "C:\Users\student name\files:/files" hw3img`,
|
||||
want: []string{"docker", "run", "-v", `C:\Users\student name\files:/files`, "hw3img"},
|
||||
},
|
||||
{name: "empty command", command: " ", wantErr: true},
|
||||
{name: "unclosed quote", command: `python3 "unterminated`, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := splitCommand(test.command)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("splitCommand(%q) error = %v, wantErr %v", test.command, err, test.wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("splitCommand(%q) = %#v, want %#v", test.command, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Run from the assignment directory: python -m unittest discover -s tests -p test_starter.py."""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
STARTER = Path(__file__).resolve().parents[1] / "solution" / "server.py"
|
||||
spec = importlib.util.spec_from_file_location("hw3_starter", STARTER)
|
||||
starter = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = starter
|
||||
spec.loader.exec_module(starter)
|
||||
ENV_NAMES = ("SERVER_HOST", "SERVER_PORT", "SERVER_DOMAIN", "SERVER_WORKING_DIRECTORY")
|
||||
|
||||
|
||||
class StarterTests(unittest.TestCase):
|
||||
def run_config(self, args, env):
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in ENV_NAMES}
|
||||
clean_env.update(env)
|
||||
with patch.dict(os.environ, clean_env, clear=True), patch.object(starter.socket, "socket") as factory, patch.object(starter, "HTTPServer") as server:
|
||||
factory.return_value.accept.side_effect = OSError("stop after bind")
|
||||
result = CliRunner().invoke(starter.main, args)
|
||||
return result, factory, server
|
||||
|
||||
def test_defaults(self):
|
||||
result, factory, server = self.run_config(["--working-directory", str(STARTER.parent)], {})
|
||||
self.assertEqual(result.exit_code, 0, result.output)
|
||||
factory.return_value.bind.assert_called_once_with(("0.0.0.0", 8080))
|
||||
self.assertEqual(server.call_args.args[2:], ("localhost", STARTER.parent))
|
||||
|
||||
def test_environment(self):
|
||||
env = dict(zip(ENV_NAMES, ("127.0.0.1", "9090", "example.com", str(STARTER.parent))))
|
||||
result, factory, server = self.run_config([], env)
|
||||
self.assertEqual(result.exit_code, 0, result.output)
|
||||
factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9090))
|
||||
self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent))
|
||||
|
||||
def test_cli_overrides_environment(self):
|
||||
env = dict(zip(ENV_NAMES, ("8.8.8.8", "80", "wrong.example", "wrong-directory")))
|
||||
result, factory, server = self.run_config(
|
||||
["--host", "127.0.0.1", "--port", "9091", "--server-domain", "example.com",
|
||||
"--working-directory", str(STARTER.parent)], env)
|
||||
self.assertEqual(result.exit_code, 0, result.output)
|
||||
factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9091))
|
||||
self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent))
|
||||
|
||||
def test_missing_directory(self):
|
||||
for args, env in [([], {}), ([], {"SERVER_WORKING_DIRECTORY": ""}),
|
||||
(["--working-directory", ""], {"SERVER_WORKING_DIRECTORY": str(STARTER.parent)})]:
|
||||
with self.subTest(args=args, env=env):
|
||||
result, factory, _ = self.run_config(args, env)
|
||||
self.assertEqual(result.exit_code, 1, result.output)
|
||||
factory.assert_not_called()
|
||||
|
||||
def test_tcp_startup(self):
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
with tempfile.TemporaryDirectory() as workdir:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(STARTER), "--host", "127.0.0.1", "--port", str(port),
|
||||
"--working-directory", workdir],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
self.assertIsNone(process.poll(), "starter exited before accepting a connection")
|
||||
try:
|
||||
client = socket.create_connection(("127.0.0.1", port), timeout=0.5)
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
self.fail("starter did not bind within 5 seconds")
|
||||
time.sleep(0.05)
|
||||
with client:
|
||||
client.sendall(b"GET / HTTP/1.1\r\n")
|
||||
# The starter has no HTTP handler yet, but its TCP lifecycle works.
|
||||
self.assertEqual(client.recv(1), b"")
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TC implements *testing.T and context.Context at the same time.
|
||||
// Also has some helper methods.
|
||||
type TC struct {
|
||||
*testing.T
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewTestContext(t *testing.T) *TC {
|
||||
return &TC{
|
||||
ctx: context.Background(),
|
||||
T: t,
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TC) Errorf(format string, args ...any) {
|
||||
str := fmt.Sprintf(format, args...)
|
||||
if strings.HasPrefix(str, "\n\tError Trace:") {
|
||||
_, after, ok := strings.Cut(str, "\tError:")
|
||||
if ok {
|
||||
str = "\n\tError:" + after
|
||||
}
|
||||
}
|
||||
Error(tc, str)
|
||||
tc.T.Errorf(str)
|
||||
}
|
||||
|
||||
func (tc *TC) RunByName(name string, f func(*TC)) bool {
|
||||
return tc.Run(name, func(t *testing.T) {
|
||||
f(tc.derive(t))
|
||||
})
|
||||
}
|
||||
|
||||
func (tc *TC) RunBySeed(seed int64, f func(*TC)) bool {
|
||||
return tc.Run(strconv.FormatInt(seed, 10), func(t *testing.T) {
|
||||
f(tc.derive(t))
|
||||
})
|
||||
}
|
||||
|
||||
func (tc *TC) Done() <-chan struct{} {
|
||||
return tc.ctx.Done()
|
||||
}
|
||||
|
||||
func (tc *TC) Err() error {
|
||||
return tc.ctx.Err()
|
||||
}
|
||||
|
||||
func (tc *TC) Value(key any) any {
|
||||
return tc.ctx.Value(key)
|
||||
}
|
||||
|
||||
// derive copies *TC and replaces *testing.T
|
||||
func (tc TC) derive(t *testing.T) *TC {
|
||||
tc.T = t
|
||||
return &tc
|
||||
}
|
||||
|
||||
// Get bool from envvars
|
||||
func boolFromEnv(key string, def bool) bool {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return def
|
||||
}
|
||||
|
||||
value = strings.ToLower(value)
|
||||
switch value {
|
||||
case "t":
|
||||
return true
|
||||
case "true":
|
||||
return true
|
||||
case "y":
|
||||
return true
|
||||
case "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFreePort asks the kernel for a free open port that is ready to use.
|
||||
func GetFreePort() (int, error) {
|
||||
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
l, err := net.ListenTCP("tcp", addr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package hw3test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RunTestEmptyWorkDir will check that server does exit(1) if working directory is empty.
|
||||
func RunTestEmptyWorkDir(t *TC, seed int64, runner Runner) {
|
||||
t.RunBySeed(seed, func(t *TC) {
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
|
||||
port, err := GetFreePort()
|
||||
require.NoError(t, err, "failed to get free port for the server")
|
||||
runOpts := RunOpts{
|
||||
Port: port,
|
||||
WorkingDirectory: "",
|
||||
ServerDomain: "localhost",
|
||||
ListenAddr: "0.0.0.0",
|
||||
ExitCode: make(chan int),
|
||||
}
|
||||
|
||||
// Start the solution.
|
||||
runOpts.GenerateRunConfig(t, r, &EnvGen{})
|
||||
stop, err := runner.Run(t, runOpts)
|
||||
require.NoError(t, err, "failed to start solution")
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * 10):
|
||||
require.FailNow(t, "Server didn't exit(1) in 10 seconds")
|
||||
case ec := <-runOpts.ExitCode:
|
||||
require.Equal(t, 1, ec, "Server exited with wrong code")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func RunTests(t *TC, seed int64, runner Runner, envGen *EnvGen, queriesGen *QueriesGen) {
|
||||
failed := !t.RunBySeed(seed, func(t *TC) {
|
||||
env, err := envGen.Generate(seed)
|
||||
require.NoError(t, err, "failed to generate environment")
|
||||
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
|
||||
queriesSeeds := make([]int64, queriesGen.Count)
|
||||
for i := range queriesSeeds {
|
||||
queriesSeeds[i] = r.Int63n(100000)
|
||||
}
|
||||
|
||||
envDirName := GenFilename(r, 16)
|
||||
envDir := filepath.Join(envGen.TempDirectory, envDirName)
|
||||
|
||||
runOpts := RunOpts{
|
||||
WorkingDirectory: envDir,
|
||||
ServerDomain: "localhost",
|
||||
ListenAddr: "0.0.0.0",
|
||||
}
|
||||
if queriesGen.AllHeaders {
|
||||
possibleDomains := []string{
|
||||
"localhost",
|
||||
"cs.hse.ru",
|
||||
"example.com",
|
||||
"z0r.de",
|
||||
"distsys-course.homework.net",
|
||||
}
|
||||
runOpts.ServerDomain = possibleDomains[r.Intn(len(possibleDomains))]
|
||||
}
|
||||
|
||||
queries := queriesGen.Generate(t, env, envGen, queriesSeeds, runOpts, seed)
|
||||
if envGen.SparseUnusedFiles {
|
||||
needed := make(map[string]bool)
|
||||
for _, query := range queries {
|
||||
if query.Method == http.MethodGet || query.Method == http.MethodPut {
|
||||
needed[path.Clean(query.Path)] = true
|
||||
}
|
||||
}
|
||||
err = env.RootDir.WriteToDiskSelected(envDir, needed)
|
||||
} else {
|
||||
err = env.RootDir.WriteToDisk(envDir)
|
||||
}
|
||||
require.NoError(t, err, "failed to write environment to disk")
|
||||
defer os.RemoveAll(envDir)
|
||||
port, err := GetFreePort()
|
||||
require.NoError(t, err, "failed to get free port for the server")
|
||||
runOpts.Port = port
|
||||
|
||||
// Start the solution.
|
||||
runOpts.GenerateRunConfig(t, r, envGen)
|
||||
stop, err := runner.Run(t, runOpts)
|
||||
require.NoError(t, err, "failed to start solution")
|
||||
defer stop()
|
||||
|
||||
// Await server to bind to port.
|
||||
err = WaitForServer(t, runOpts)
|
||||
require.NoError(t, err, "failed to wait for server")
|
||||
|
||||
// Run the queries.
|
||||
for i, query := range queries {
|
||||
query := query
|
||||
ok := t.RunBySeed(query.Seed, func(t *TC) {
|
||||
RunQuery(t, env, envDir, runOpts, query)
|
||||
})
|
||||
shouldAbort := !ok
|
||||
if shouldAbort {
|
||||
Warn(t, "Skipping next queries because of the failed query", zap.Int("skipped", len(queries)-1-i), zap.String("failed", fmt.Sprintf("%s/%v", t.Name(), query.Seed)))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
shouldAbort := failed
|
||||
if shouldAbort {
|
||||
Warn(t, "Skipping next tests in a group because last test has failed")
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func WaitForServer(t *TC, opts RunOpts) error {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var lastErr error
|
||||
for attempt := 0; time.Until(deadline) > 0; attempt++ {
|
||||
probeTimeout := time.Until(deadline)
|
||||
if probeTimeout > time.Second {
|
||||
probeTimeout = time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t, probeTimeout)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", opts.Address(), nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("failed to create context request: %w", err)
|
||||
}
|
||||
req.Host = opts.ServerDomain
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
lastErr = err
|
||||
if attempt == 0 || attempt%10 == 0 {
|
||||
Debug(t, "Waiting for server startup", zap.Int("attempt", attempt), zap.Error(err))
|
||||
}
|
||||
if remaining := time.Until(deadline); remaining > 0 {
|
||||
if remaining > 100*time.Millisecond {
|
||||
remaining = 100 * time.Millisecond
|
||||
}
|
||||
time.Sleep(remaining)
|
||||
}
|
||||
}
|
||||
Warn(t, "Server didn't get up in time, aborting", zap.Error(lastErr))
|
||||
return fmt.Errorf("server did not start in 10 seconds: %w", lastErr)
|
||||
}
|
||||
|
||||
// RunQuery runs a single query. Server address and its configuration is taken from runOpts.
|
||||
// Initial environment is described by env, but it may be changed with queries. Actual
|
||||
// environment is located in envDir. Query describes the query itself.
|
||||
func RunQuery(t *TC, env *Env, envDir string, opts RunOpts, query Query) {
|
||||
queryURL := fmt.Sprintf("%s/%s", opts.Address(), query.Path)
|
||||
Debug(
|
||||
t,
|
||||
"Sending query",
|
||||
zap.String("method", query.Method),
|
||||
zap.String("path", query.Path),
|
||||
zap.String("url", queryURL),
|
||||
)
|
||||
|
||||
action := query.Action(env, &opts)
|
||||
if action != nil {
|
||||
action.VerifyBefore(t, envDir)
|
||||
}
|
||||
|
||||
req := query.CreateRequest(t, queryURL)
|
||||
req.Close = true
|
||||
conn, err := net.DialTimeout("tcp", req.URL.Host, 10*time.Second)
|
||||
require.NoError(t, err, "failed to connect to server")
|
||||
defer conn.Close()
|
||||
writeDone := make(chan error, 1)
|
||||
writer := &idleDeadlineWriter{Conn: conn, Timeout: 15 * time.Second}
|
||||
go func() { writeDone <- req.Write(writer) }()
|
||||
reader := bufio.NewReader(&idleDeadlineReader{Conn: conn, Timeout: 30 * time.Second})
|
||||
|
||||
resp, err := http.ReadResponse(reader, req)
|
||||
require.NoError(t, err, "failed to run query on server")
|
||||
defer resp.Body.Close()
|
||||
body := &countingBody{ReadCloser: resp.Body}
|
||||
resp.Body = body
|
||||
|
||||
query.CommonValidate(t, req, resp)
|
||||
|
||||
if action != nil {
|
||||
action.VerifyResponse(t, req, resp)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
require.NoError(t, err, "failed to read response body")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
require.Equal(t, resp.ContentLength, body.Size, "Content-Length differs from response body size")
|
||||
}
|
||||
endDone := make(chan error, 1)
|
||||
go func() { endDone <- checkResponseEnd(reader, req.Body != nil) }()
|
||||
var endErr, writeErr error
|
||||
writeCompleted := false
|
||||
select {
|
||||
case endErr = <-endDone:
|
||||
// The server closed the connection before the request writer finished.
|
||||
case writeErr = <-writeDone:
|
||||
writeCompleted = true
|
||||
var netErr net.Error
|
||||
// http.Request.Write wraps body write errors without preserving Unwrap.
|
||||
// Inspect the original socket error captured by the writer instead.
|
||||
if errors.As(writer.lastError, &netErr) && netErr.Timeout() {
|
||||
require.NoError(t, writer.lastError, "request upload stalled")
|
||||
}
|
||||
// A server may finish reading a large request after sending its response.
|
||||
// Start the close timeout only after the request writer has finished.
|
||||
closeTimeout := 2 * time.Second
|
||||
if req.Body != nil {
|
||||
closeTimeout = 10 * time.Second
|
||||
}
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(closeTimeout)))
|
||||
endErr = <-endDone
|
||||
}
|
||||
require.NoError(t, endErr, "response contains bytes beyond Content-Length or connection was not closed")
|
||||
// The server may reply without consuming the whole request body. In that
|
||||
// case the response and the file-system checks determine correctness.
|
||||
if req.Body == nil {
|
||||
if !writeCompleted {
|
||||
writeErr = <-writeDone
|
||||
}
|
||||
require.NoError(t, writeErr, "failed to send query to server")
|
||||
}
|
||||
if query.VerifyHeaders && body.Size > 0 {
|
||||
_, err = parseContentType(resp.Header.Get("Content-Type"))
|
||||
require.NoError(t, err, "expected valid content type")
|
||||
}
|
||||
if action != nil {
|
||||
action.VerifyAfter(t, envDir)
|
||||
action.ApplyEnv(t, env)
|
||||
}
|
||||
}
|
||||
|
||||
// idleDeadlineWriter bounds pauses in sending a request without limiting the
|
||||
// total time for a large request that continues to make progress.
|
||||
type idleDeadlineWriter struct {
|
||||
net.Conn
|
||||
Timeout time.Duration
|
||||
lastError error
|
||||
}
|
||||
|
||||
func (w *idleDeadlineWriter) Write(p []byte) (int, error) {
|
||||
if err := w.Conn.SetWriteDeadline(time.Now().Add(w.Timeout)); err != nil {
|
||||
w.lastError = err
|
||||
return 0, err
|
||||
}
|
||||
n, err := w.Conn.Write(p)
|
||||
if err != nil {
|
||||
w.lastError = err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func checkResponseEnd(reader *bufio.Reader, requestHasBody bool) error {
|
||||
_, err := reader.ReadByte()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
// Closing a connection with an unread request body can produce a TCP reset
|
||||
// after a complete error response. Its error code differs by platform.
|
||||
var netErr *net.OpError
|
||||
if requestHasBody && errors.As(err, &netErr) && netErr.Op == "read" && !netErr.Timeout() {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unexpected byte after response body")
|
||||
}
|
||||
|
||||
type countingBody struct {
|
||||
io.ReadCloser
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (b *countingBody) Read(p []byte) (int, error) {
|
||||
n, err := b.ReadCloser.Read(p)
|
||||
b.Size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Docker exposes the filesystem source after the separator in mountinfo.
|
||||
# A bind mount whose host source is an ext4 filesystem on zram retains that
|
||||
# source; a plain host directory on the HDD must not pass this check.
|
||||
awk '
|
||||
$5 == "/hw/tests/tmp" {
|
||||
for (i = 6; i <= NF - 2; i++) {
|
||||
if ($i == "-" && $(i + 1) == "ext4" && $(i + 2) ~ /^\/dev\/zram[0-9]+$/)
|
||||
found = 1
|
||||
}
|
||||
}
|
||||
END { exit !found }
|
||||
' /proc/self/mountinfo
|
||||
@@ -0,0 +1,126 @@
|
||||
# Надежная упорядоченная рассылка
|
||||
|
||||
В этом задании вам предстоит реализовать механизм рассылки сообщений с описанными далее гарантиями по надежности и порядку доставки. Выполнение и тестирование решения будет происходить в фреймворке AnySystem, уже знакомом вам по первому заданию.
|
||||
|
||||
Пусть имеется распределенное приложение типа чата, пользователи которого общаются путем обмена сообщениями. Для простоты будем считать, что число пользователей фиксировано, чат один, и сообщения в нём доставляются всем пользователям, включая отправителя. С каждым пользователем связан процесс, который принимает его сообщения, рассылает их остальным процессам и доставляет сообщения чата своему пользователю. Процессы выполняются на разных узлах системы, связанных сетью. Как обычно, взаимодействие с пользователем (прием и доставка сообщений) реализуется в AnySystem с помощью локальных сообщений.
|
||||
|
||||
Процессы взаимодействуют путем обмена сообщениями по сети. Изначально вам доступна только отправка сообщения одному процессу (unicast), поверх чего вам надо реализовать рассылку сообщения всем процессам (broadcast).
|
||||
|
||||
Узлы и процессы на них могут внезапно остановиться, например во время рассылки. Отказавший процесс останавливается навсегда и не возвращается в систему. Процесс называется _корректным_, если он не отказал за всё рассматриваемое выполнение системы. Процесс, который сейчас работает, но позднее упадёт, корректным не является. Это определение используется для анализа выполнения: алгоритм не знает заранее, какие процессы откажут. Корректные процессы всегда составляют _большинство_: например, из пяти процессов могут отказать не более двух. (Подсказка: это требуется для реализации свойства 4 ниже.)
|
||||
|
||||
Вам предоставлен надежный транспорт: каждое сетевое сообщение между корректными процессами в конце концов будет получено ровно один раз (exactly once). Транспорт не создаёт и не дублирует сообщения, но порядок их получения не гарантируется. Однако если отправитель упал до получения сообщения адресатом, доставка этого сообщения уже _не гарантируется_, даже если оно было отправлено задолго до отказа. Обеспечивать надежность отдельных сетевых передач в решении не нужно. При этом ваш алгоритм рассылки может использовать собственные подтверждения и другие служебные сообщения.
|
||||
|
||||
Различайте три события: пользователь отправляет сообщение своему процессу (`SEND`), процесс получает сетевое сообщение от другого процесса, процесс доставляет сообщение своему пользователю (`DELIVER`). Получение по сети само по себе не является доставкой пользователю. В свойствах ниже под доставкой понимается именно `DELIVER`.
|
||||
|
||||
Ваша реализация рассылки должна удовлетворять следующим свойствам (см. лекцию 4):
|
||||
|
||||
1. **No Duplication:** Сообщения доставляются пользователю не более одного раза (нет повторов).
|
||||
2. **No Creation:** Если пользователю доставлено сообщение _m_ от пользователя _u_, то _m_ было ранее отправлено _u_.
|
||||
3. **Validity:** Если пользователь корректного процесса _n_ отправил сообщение _m_, то _n_ должен в конце концов доставить _m_ пользователю.
|
||||
4. **Uniform Agreement:** Если сообщение _m_ было доставлено некоторым (необязательно корректным) процессом, то _m_ будет в конце концов доставлено каждым корректным процессом.
|
||||
5. **Causal Order:** Сообщения доставляются с сохранением причинного порядка - если процесс _n_ доставил сообщение _m_ от пользователя _u_, то перед этим _n_ должен доставить все сообщения, которые могли повлиять на _m_ (сообщения, отправленные или доставленные _u_ до отправки им _m_).
|
||||
|
||||
Дополнительная часть задания на 9-10 баллов посвящена масштабируемой рассылке.
|
||||
|
||||
## Реализация
|
||||
|
||||
Для реализации и тестирования решения используется фреймворк AnySystem (см. материалы первого семинара). В папке `solution` размещена заготовка для решения [broadcast.py](solution/broadcast.py). Доработайте класс `BroadcastProcess`, чтобы рассылка удовлетворяла перечисленным свойствам.
|
||||
|
||||
Ваши реализации процессов AnySystem должны соблюдать правило [изоляции процессов](../readme.md#изоляция-процессов-anysystem): изменяемое состояние каждого процесса должно храниться только в `self`, а общая изменяемая память между процессами запрещена. Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
|
||||
|
||||
При инициализации процессу передается его уникальный id (он же является id локального пользователя), а также список id всех процессов в системе. Отправка сообщения пользователем реализована с помощью локального сообщения `SEND` со строковым полем `text`. Все пользовательские сообщения в пределах одного выполнения системы имеют разные значения `text`. Для доставки сообщения пользователю используйте локальное сообщение `DELIVER` с исходным значением `text` (см. заготовку). Для взаимодействия между процессами вы можете использовать любые собственные типы сообщений.
|
||||
|
||||
Основная реализация не должна делать предположений о максимальных временах доставки сообщений или пытаться определить жив ли некоторый процесс или упал. Позже в курсе мы научимся реализовывать детектор отказов, в основной части этого задания вам надо обойтись без него. Не используйте бесконечно повторяющиеся таймеры: обычные тесты ожидают опустошения очереди событий. Разовые таймеры допустимы, но для решения этой задачи они не нужны.
|
||||
|
||||
## Дополнительная часть: масштабируемая рассылка
|
||||
|
||||
Реализуйте отдельный вариант рассылки из лекции 4: например, gossip, рассылку по дереву или flooding по разреженному графу. Для дерева обязателен механизм восстановления доставки при отказе посредника. Можно предложить другую сопоставимую схему. Новизна идеи не требуется. Экономия памяти или уменьшение константы без изменения схемы распространения сами по себе эту часть не заменяют.
|
||||
|
||||
Сдайте дополнительный вариант в `solution/broadcast_scalable.py`: класс `BroadcastProcess` с теми же аргументами конструктора и сообщениями `SEND`/`DELIVER`, что и в основном решении. Основное решение остаётся в `solution/broadcast.py`, отчёт для обеих версий - в `solution/readme.md`.
|
||||
|
||||
В дополнительном варианте можно ослабить гарантии или использовать дополнительные предположения, включая тайм-ауты и периодические таймеры. В отчёте опишите механизм рассылки и восстановления, сохраняемые и ослабленные свойства, условия применимости и ограничения. Изоляция процессов обязательна для обеих версий. Ошибка, противоречащая заявленным свойствам протокола, не считается допустимым компромиссом.
|
||||
|
||||
Первый дополнительный балл даётся за работающий масштабируемый протокол с таким описанием. Второй - за воспроизводимое сравнение с основным решением на предоставленном тесте `OPTIMIZATION`: приведите команды и параметры запуска, таблицу или график результатов и объясните, как рост системы, отказы и задержки влияют на затраты и доставку. Сокращение трафика рассматривайте вместе с охватом: меньше отправленных сообщений за счёт недоставленных сообщений ещё не означает улучшение. Единого порога выигрыша в процентах нет.
|
||||
|
||||
## Оценивание
|
||||
|
||||
| Компонент | Баллы |
|
||||
| --- | ---: |
|
||||
| Основная реализация удовлетворяет свойствам 1–4 | 5 |
|
||||
| Дополнительно обеспечен Causal Order | до +2 |
|
||||
| В отчёте обосновано достижение заявленных свойств | +1 |
|
||||
| Реализован масштабируемый вариант рассылки | +1 |
|
||||
| Проведено экспериментальное сравнение и объяснены компромиссы | +1 |
|
||||
|
||||
За Causal Order начисляются 2 балла при прохождении всех тестов. Если нарушений не найдено, но `MODEL CHECKING CAUSAL ORDER` не завершён из-за ресурсного лимита (`INCOMPLETE`), начисляется 1 балл из 2. Обнаруженное нарушение Causal Order в любом тесте означает 0 баллов за этот компонент. Эти баллы доступны при выполнении свойств 1–4.
|
||||
|
||||
Отчёт с описанием вашего решения в `solution/readme.md` обязателен, см. [общие правила](../readme.md#отчёт). Для балла за обоснование отдельно объясните в отчёте, почему алгоритм обеспечивает каждое заявленное свойство. Строгих доказательств не требуется, достаточно понятного и убедительного объяснения, связанного с вашим кодом и учитывающего отказы процессов.
|
||||
|
||||
Полное основное решение с обоснованием получает 8 баллов. Баллы за дополнительную часть доступны только после получения всех 8 основных; балл за эксперимент требует реализации масштабируемого варианта. Автоматический `SCORE` равен 0, 5, 6 или 7 и учитывает только основную реализацию. Остальные баллы выставляются по коду и отчёту.
|
||||
|
||||
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Локальное тестирование
|
||||
|
||||
Тесты находятся в папке `tests`. Есть два варианта их запуска.
|
||||
|
||||
Рекомендуемый вариант запуска тестов - через готовый Docker-образ. В этом случае используемое окружение будет аналогично тестирующей системе. Для запуска тестов выполните команду:
|
||||
|
||||
```commandline
|
||||
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/broadcast:latest [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
|
||||
```
|
||||
|
||||
Для запуска полного набора тестов с теми же параметрами, что и в тестирующей системе, выполните команду:
|
||||
|
||||
```commandline
|
||||
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/broadcast:latest -m 100
|
||||
```
|
||||
|
||||
Вы также можете запустить тесты, скомпилировав их локально с помощью компилятора Rust. Такой вариант может быть удобен, если вы хотите лучше изучить или доработать тесты. Скомпилируйте тесты с помощью команды `cargo install --locked --path tests`. Для запуска тестов выполните команду:
|
||||
|
||||
```commandline
|
||||
distsys-broadcast [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
|
||||
```
|
||||
|
||||
Тестер выводит пояснения к сценариям, найденные нарушения и финальную сводку. Подробную трассу событий и истории `SEND`/`DELIVER` можно включить флагом `-d`, например: `distsys-broadcast -t "TWO CRASHES" -d`. Все доступные опции показаны в справке `-h`. Основные тесты по умолчанию запускаются на системе из пяти процессов; это число можно изменить опцией `-p`. При тестировании в системе число прогонов `CHAOS MONKEY` увеличено до 100 (опция `-m 100`).
|
||||
|
||||
Если найдено нарушение свойств 1–4 или превышен ресурсный лимит до причинного model checking, основной результат уже равен нулю: тестер прекращает проверку и помечает оставшиеся тесты как `SKIPPED`. При нарушении только Causal Order тестер продолжает проверять остальные свойства, а причинный MC пропускает. Опция `--keep-going` позволяет собрать больше ошибок, в том числе запустить причинный MC после найденного нарушения. Превышение лимита или аварийное завершение процесса останавливает проверку и с этой опцией.
|
||||
|
||||
Обычный тест ограничен 15 секундами, вся серия `CHAOS MONKEY` - 30 секундами, `SCALABILITY` с системами до 50 процессов - 60 секундами. На основные тесты без `OPTIMIZATION` отводится суммарно 300 секунд. Общий лимит запуска в Docker - 420 секунд. Лимиты защищают от бесконечных обработчиков, таймеров и обмена сообщениями.
|
||||
|
||||
В тестах сообщения процессов, которые позднее упадут, могут теряться ещё до их остановки. Так моделируется, например, ситуация, когда сообщение потерялось в сети, а отправитель упал до повторной передачи на уровне транспорта. До отказа отправитель может продолжать работать, а другие его сообщения - доходить. Реализовывать транспортные повторные передачи в решении не нужно.
|
||||
|
||||
В `CHAOS MONKEY` перед каждым прогоном указаны два процесса, которые позднее упадут: потери исходящих сообщений включаются только для них. Для повторения прогона с номером _k_ используйте указанный базовый seed через `-s` и запустите первые _k_ прогонов через `-m k`.
|
||||
|
||||
В тесте `TWO CRASHES` процессы 0 и 1 могут общаться друг с другом, но изолированы от остальных; связь внутри оставшейся группы сохранена. Процесс 0 начинает рассылку, затем 0 и 1 падают. Если никто не доставил сообщение пользователю, свойства не нарушены: отправитель отказал, а предпосылка Uniform Agreement не выполнена. Если же 0 или 1 доставил сообщение, все корректные процессы тоже должны его доставить. Поэтому ошибка Uniform Agreement показывает обе стороны нарушения: кто уже доставил сообщение и какие корректные процессы его не доставили.
|
||||
|
||||
Тест `QUORUM` проверяет системы из 7 и 10 процессов в двух независимых сценариях. В первом отправитель находится в изолированном меньшинстве: процессы этой группы могут общаться только между собой, а затем падают. Тест проверяет, что до отказа ни один из них не доставляет сообщение пользователю. Во втором меньшинство падает до начала рассылки, а корректный отправитель и остальные процессы должны завершить доставку. Нарушения требуемых свойств в этом тесте учитываются при оценивании.
|
||||
|
||||
Тесты `MODEL CHECKING ...` используют три процесса независимо от `-p`. В `MODEL CHECKING CAUSAL ORDER` два пользователя последовательно отправляют по одному сообщению. Тест перебирает возможные порядки доставки сообщений и останавливается, если при этом требуется проверить больше 100 000 состояний системы. Этот лимит общий для обоих этапов теста. На каждый этап поиска отводится до 55 секунд, на весь тест - до 60 секунд. Превышение одного из этих пределов помечается `INCOMPLETE`: причинная проверка не завершилась, но контрпример не найден. Если предыдущие тесты прошли, автоматический результат равен 6 баллам: 5 за свойства 1–4 и 1 из 2 за Causal Order.
|
||||
|
||||
Частая причина `INCOMPLETE` - избыточный сетевой обмен: model checking перебирает разные порядки сообщений, и число состояний быстро растёт. Проверьте, не повторяет ли инициатор свою первоначальную рассылку после получения сетевой копии и не вызывает ли каждая новая копия сообщения повторную рассылку без новой информации. Сократите лишний обмен, сохранив требуемые свойства рассылки. Для ориентира: известные нам решения проходят этот тест за несколько десятков секунд или быстрее.
|
||||
|
||||
Тест `SCALABILITY` проверяет корректность основной реализации при разных размерах системы и измеряет число сетевых сообщений. Это число само по себе не влияет на оценку. Нарушения требуемых свойств учитываются так же, как в остальных обычных тестах.
|
||||
|
||||
Время выполнения тестов при локальном запуске зависит от компьютера и способа запуска. Подробнее см. в [общей инструкции](../readme.md#локальное-тестирование-решения).
|
||||
|
||||
### Сравнение масштабируемого варианта
|
||||
|
||||
При полном запуске тестер автоматически выполняет `OPTIMIZATION`, если основной результат равен `SCORE: 7` и рядом с основным файлом есть `broadcast_scalable.py`. На сравнение обеих версий отводится общий бюджет 90 секунд. Результаты сохраняются в журнале тестирующей системы. Ошибка или превышение лимита в измерениях не меняет основной `SCORE`. Для отдельного запуска используйте:
|
||||
|
||||
```commandline
|
||||
distsys-broadcast -t OPTIMIZATION
|
||||
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/broadcast:latest -t OPTIMIZATION
|
||||
```
|
||||
|
||||
Основной файл можно указать через `-i`, дополнительный - через `--optimized-impl`. Тест сравнивает версии на одинаковой нагрузке при разных размерах системы: без отказов, с отказами и с большими сетевыми задержками. Рядом выводятся число и объём сетевых сообщений, включая служебные, максимальное число отправок одним процессом, охват корректных процессов, задержки и ошибки доставки.
|
||||
|
||||
Наблюдение ограничено по модельному времени, поэтому периодические таймеры допустимы. При сравнении задержек и сетевых затрат учитывайте долю доставленных сообщений: быстрая доставка части сообщений с небольшими затратами может сопровождаться недоставкой остальных. При этом сообщения, не доставленные за время теста, могут быть доставлены позже — тест этого не проверяет. Превышение лимитов помечается как `INCOMPLETE`, невыполненные измерения - `NOT MEASURED`.
|
||||
|
||||
Параметры сценариев и расчёт метрик можно изучить в [коде теста](tests/src/optimization.rs), опции запуска - в справке `-h`. Результаты используются для анализа в отчёте; автоматического порога для получения дополнительных баллов нет.
|
||||
|
||||
### Проверка в тестирующей системе
|
||||
|
||||
Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов.
|
||||
@@ -0,0 +1,31 @@
|
||||
from anysystem import Context, Message, Process
|
||||
from typing import List
|
||||
|
||||
|
||||
class BroadcastProcess(Process):
|
||||
def __init__(self, proc_id: str, processes: List[str]):
|
||||
self._id = proc_id
|
||||
self._processes = processes
|
||||
|
||||
def on_local_message(self, msg: Message, ctx: Context):
|
||||
if msg.type == 'SEND':
|
||||
bcast_msg = Message('BCAST', {
|
||||
'text': msg['text']
|
||||
})
|
||||
# best-effort broadcast
|
||||
for proc in self._processes:
|
||||
ctx.send(bcast_msg, proc)
|
||||
|
||||
def on_start(self, ctx: Context):
|
||||
pass
|
||||
|
||||
def on_message(self, msg: Message, sender: str, ctx: Context):
|
||||
if msg.type == 'BCAST':
|
||||
# deliver message to the local user
|
||||
deliver_msg = Message('DELIVER', {
|
||||
'text': msg['text']
|
||||
})
|
||||
ctx.send_local(deliver_msg)
|
||||
|
||||
def on_timer(self, timer_name: str, ctx: Context):
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
target
|
||||
**/target
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
*.pyc
|
||||
Generated
+985
@@ -0,0 +1,985 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anysystem"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01badd3bc0d042e4399f8f685c394a79a72e7f233b3d3b5eabb3b94734f4dbe"
|
||||
dependencies = [
|
||||
"colored",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"indexmap 2.14.0",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"ordered-float",
|
||||
"pyo3",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"regex",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simcore",
|
||||
"sugars",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"bitflags",
|
||||
"clap_derive",
|
||||
"clap_lex",
|
||||
"indexmap 1.9.3",
|
||||
"once_cell",
|
||||
"strsim",
|
||||
"termcolor",
|
||||
"textwrap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "3.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||
dependencies = [
|
||||
"os_str_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distsys-broadcast"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anysystem",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"pyo3",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sugars",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"humantime",
|
||||
"log",
|
||||
"regex",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.175"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
|
||||
dependencies = [
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
|
||||
|
||||
[[package]]
|
||||
name = "relative-path"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "rstest"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"rstest_macros",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rstest_macros"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"glob",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"relative-path",
|
||||
"rustc_version",
|
||||
"syn 2.0.106",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.143"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_type_name"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92acc4cd6ae20767c54d6cf1a075624e7f4d9e99d7ebc685398ff243144d8714"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simcore"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a20dfec85e94e569fa5b810b2d992c68b361f31bfba471447427536488c9c292"
|
||||
dependencies = [
|
||||
"colored",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"erased-serde",
|
||||
"futures",
|
||||
"log",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_type_name",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "sugars"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc0db74f9ee706e039d031a560bd7d110c7022f016051b3d33eeff9583e3e67a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "distsys-broadcast"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
resolver = "3"
|
||||
|
||||
[dependencies]
|
||||
anysystem = "=0.3.0"
|
||||
indexmap = "2.12"
|
||||
clap = { version = "3.2.17", features = ["cargo", "derive"] }
|
||||
env_logger = "0.9.0"
|
||||
log = "0.4.17"
|
||||
pyo3 = { version = "=0.29.2", features = ["auto-initialize"] }
|
||||
rand = "0.8.5"
|
||||
rand_pcg = "0.3.1"
|
||||
serde = { version = "1.0.142", features = ["derive"] }
|
||||
serde_json = "1.0.83"
|
||||
sugars = "3.0.1"
|
||||
@@ -0,0 +1,20 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential python3-dev
|
||||
|
||||
WORKDIR /tests
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=distsys-course-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=distsys-course-cargo-target-rust-1-97-1,target=/tests/target,sharing=locked \
|
||||
cargo install --locked --path . --root /opt/course
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y python3-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /opt/course/bin/distsys-broadcast /usr/local/bin/distsys-broadcast
|
||||
|
||||
WORKDIR /solution
|
||||
# 300s for correctness tests, 90s shared by both measurements, plus cleanup.
|
||||
ENTRYPOINT ["timeout", "-k", "10", "420", "distsys-broadcast", "-i", "broadcast.py"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
use serde::Serialize;
|
||||
use sugars::boxed;
|
||||
|
||||
use anysystem::python::PyProcessFactory;
|
||||
use anysystem::System;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct TestConfig<'a> {
|
||||
pub proc_factory: &'a PyProcessFactory,
|
||||
pub proc_count: u64,
|
||||
pub seed: u64,
|
||||
pub monkeys: u32,
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BroadcastMessage<'a> {
|
||||
pub text: &'a str,
|
||||
}
|
||||
|
||||
pub fn build_system(config: &TestConfig) -> System {
|
||||
let mut sys = System::new(config.seed);
|
||||
let mut proc_names = Vec::new();
|
||||
for n in 0..config.proc_count {
|
||||
proc_names.push(format!("{n}"));
|
||||
}
|
||||
for proc_name in &proc_names {
|
||||
let proc = config
|
||||
.proc_factory
|
||||
.build((proc_name, proc_names.clone()), config.seed);
|
||||
// process and node on which it runs have the same name
|
||||
sys.add_node(proc_name);
|
||||
sys.add_process(proc_name, boxed!(proc), proc_name);
|
||||
}
|
||||
sys
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
mod common;
|
||||
mod optimization;
|
||||
mod runner;
|
||||
mod shared_state;
|
||||
mod tests;
|
||||
mod tests_mc;
|
||||
|
||||
use anysystem::test::TestResult;
|
||||
use clap::Parser;
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Broadcast Homework Tests
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(about, long_about = None)]
|
||||
struct Args {
|
||||
/// Path to Python file with solution
|
||||
#[clap(long = "impl", short = 'i', default_value = "solution/broadcast.py")]
|
||||
solution_path: String,
|
||||
|
||||
/// Test to run (optional)
|
||||
#[clap(long = "test", short)]
|
||||
test: Option<String>,
|
||||
|
||||
/// Print execution trace
|
||||
#[clap(long, short)]
|
||||
debug: bool,
|
||||
|
||||
/// Random seed used in tests
|
||||
#[clap(long, short, default_value = "2023")]
|
||||
seed: u64,
|
||||
|
||||
/// Number of processes
|
||||
#[clap(long, short, default_value = "5")]
|
||||
proc_count: u64,
|
||||
|
||||
/// Number of chaos monkey runs
|
||||
#[clap(long, short, default_value = "10")]
|
||||
monkeys: u32,
|
||||
|
||||
/// Do not run model checking tests
|
||||
#[clap(long)]
|
||||
disable_mc_tests: bool,
|
||||
|
||||
/// Optional scalable solution (defaults to broadcast_scalable.py beside --impl)
|
||||
#[clap(long)]
|
||||
optimized_impl: Option<String>,
|
||||
|
||||
/// Repetitions per OPTIMIZATION scenario and system size
|
||||
#[clap(long, default_value = "10", value_parser = clap::value_parser!(u32).range(1..=100))]
|
||||
optimization_runs: u32,
|
||||
|
||||
/// Shared wall-clock budget in seconds for both implementations in OPTIMIZATION
|
||||
#[clap(long, default_value = "90", value_parser = clap::value_parser!(u64).range(1..=90))]
|
||||
optimization_timeout: u64,
|
||||
|
||||
#[clap(long, hide = true)]
|
||||
optimization_worker: bool,
|
||||
|
||||
/// Wall-clock limit in seconds per ordinary test
|
||||
#[clap(long, default_value = "15", value_parser = clap::value_parser!(u64).range(1..=300))]
|
||||
test_timeout: u64,
|
||||
|
||||
/// Wall-clock limit in seconds for SCALABILITY (up to 50 processes)
|
||||
#[clap(long, default_value = "60", value_parser = clap::value_parser!(u64).range(1..=300))]
|
||||
scalability_timeout: u64,
|
||||
|
||||
/// Wall-clock limit in seconds for all CHAOS MONKEY runs
|
||||
#[clap(long, default_value = "30", value_parser = clap::value_parser!(u64).range(1..=300))]
|
||||
chaos_timeout: u64,
|
||||
|
||||
/// External MC test limit (NORMAL DELIVERY is capped at 110s, CAUSAL ORDER at 60s)
|
||||
#[clap(long, default_value = "210", value_parser = clap::value_parser!(u64).range(1..=210))]
|
||||
mc_timeout: u64,
|
||||
|
||||
/// Total wall-clock budget in seconds for correctness tests
|
||||
#[clap(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..=300))]
|
||||
suite_timeout: u64,
|
||||
|
||||
/// Continue after failures for local diagnostics; resource limits and crashes still stop
|
||||
#[clap(long)]
|
||||
keep_going: bool,
|
||||
|
||||
#[clap(long, hide = true, requires = "test-result")]
|
||||
test_worker: Option<String>,
|
||||
|
||||
#[clap(long, hide = true)]
|
||||
test_result: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
if args.test_worker.is_some() {
|
||||
runner::worker(&args);
|
||||
return;
|
||||
}
|
||||
if args.optimization_worker {
|
||||
optimization::worker(&args);
|
||||
return;
|
||||
}
|
||||
if args.test.as_deref() == Some("OPTIMIZATION") {
|
||||
optimization::run(&args);
|
||||
return;
|
||||
}
|
||||
if !shared_state::validate_or_report(&args.solution_path, &["BroadcastProcess"]) {
|
||||
return;
|
||||
}
|
||||
let results = runner::run(&args);
|
||||
if args.test.is_none() {
|
||||
let score = score(results);
|
||||
optimization::run_if_present(&args, score);
|
||||
println!("SCORE: {score}\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn score(results: IndexMap<String, TestResult>) -> f32 {
|
||||
let mut violated = HashSet::new();
|
||||
let mut causal_incomplete = false;
|
||||
for (name, result) in results {
|
||||
if let Err(e) = result {
|
||||
if e.starts_with("Violated") {
|
||||
for prop in e.replace("Violated ", "").split(", ") {
|
||||
violated.insert(prop.to_string());
|
||||
}
|
||||
} else if runner::causal_mc_incomplete(&name, &e) {
|
||||
// Keep partial credit separate from a discovered violation.
|
||||
causal_incomplete = true;
|
||||
} else {
|
||||
return 0.;
|
||||
}
|
||||
}
|
||||
}
|
||||
let violated: HashSet<&str> = violated.iter().map(|s| s.as_str()).collect();
|
||||
let base_props = HashSet::from([
|
||||
"NO DUPLICATION",
|
||||
"NO CREATION",
|
||||
"VALIDITY",
|
||||
"UNIFORM AGREEMENT",
|
||||
]);
|
||||
if violated.is_disjoint(&base_props) {
|
||||
if !violated.is_empty() {
|
||||
5.
|
||||
} else if causal_incomplete {
|
||||
6.
|
||||
} else {
|
||||
7.
|
||||
}
|
||||
} else {
|
||||
0.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod score_tests {
|
||||
use super::score;
|
||||
use indexmap::IndexMap;
|
||||
|
||||
#[test]
|
||||
fn incomplete_causal_mc_gets_partial_credit() {
|
||||
for error in [
|
||||
"time limit of 55s exceeded",
|
||||
"test time limit of 60s exceeded",
|
||||
"suite time limit of 300s exceeded",
|
||||
"causal model-checking state limit of 100000 exceeded",
|
||||
] {
|
||||
let mut results = IndexMap::new();
|
||||
results.insert("MODEL CHECKING CAUSAL ORDER".to_string(), Err(error.into()));
|
||||
assert_eq!(score(results), 6., "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_violation_takes_precedence_over_incomplete_search() {
|
||||
for (error, expected) in [
|
||||
("Violated CAUSAL ORDER", 5.),
|
||||
("Violated VALIDITY", 0.),
|
||||
("Violated VALIDITY, CAUSAL ORDER", 0.),
|
||||
] {
|
||||
for incomplete_first in [false, true] {
|
||||
let mut entries = vec![
|
||||
("CAUSAL ORDER", error),
|
||||
("MODEL CHECKING CAUSAL ORDER", "time limit of 55s exceeded"),
|
||||
];
|
||||
if incomplete_first {
|
||||
entries.reverse();
|
||||
}
|
||||
let results = entries
|
||||
.into_iter()
|
||||
.map(|(name, error)| (name.to_string(), Err(error.to_string())))
|
||||
.collect();
|
||||
assert_eq!(score(results), expected, "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_failures_do_not_get_partial_credit() {
|
||||
for (name, error) in [
|
||||
("NORMAL", "test time limit of 15s exceeded"),
|
||||
(
|
||||
"MODEL CHECKING NORMAL DELIVERY",
|
||||
"time limit of 100s exceeded",
|
||||
),
|
||||
(
|
||||
"MODEL CHECKING CAUSAL ORDER",
|
||||
"test worker exited with exit code: 1",
|
||||
),
|
||||
(
|
||||
"MODEL CHECKING CAUSAL ORDER",
|
||||
"nothing left to do to reach the goal",
|
||||
),
|
||||
] {
|
||||
let mut results = IndexMap::new();
|
||||
results.insert(name.to_string(), Err(error.to_string()));
|
||||
assert_eq!(score(results), 0., "{name}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Bounded measurements, deliberately separate from correctness tests and SCORE.
|
||||
use std::cell::Cell;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anysystem::logger::LogEntry;
|
||||
use anysystem::python::PyProcessFactory;
|
||||
use anysystem::{Context, Message, Process, System};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::common::{BroadcastMessage, TestConfig};
|
||||
use crate::{shared_state, Args};
|
||||
|
||||
const SIZES: [u64; 4] = [5, 10, 20, 50];
|
||||
const SCENARIOS: [&str; 3] = ["NORMAL", "CRASHES", "DELAYED"];
|
||||
const MESSAGES: usize = 10;
|
||||
const HORIZON: f64 = 109.; // 100 model-time units after the last SEND at t=9.
|
||||
const EVENT_LIMIT: u64 = 100_000;
|
||||
const OUTPUT_LIMIT: u64 = 2 * 1024 * 1024;
|
||||
const RESULT_PREFIX: &str = "OPTIMIZATION RESULT ";
|
||||
|
||||
// Coverage counts first valid DELIVERs to correct processes, including the
|
||||
// sender. Missing deliveries at the horizon do not prove a liveness violation.
|
||||
// Latencies use model time and only those deliveries. Network counters include
|
||||
// service traffic; duplicate/unexpected/malformed outputs count all processes.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Measurement {
|
||||
scenario: String,
|
||||
n: u64,
|
||||
seed: u64,
|
||||
status: String,
|
||||
observed_until: f64,
|
||||
sent: usize,
|
||||
messages: u64,
|
||||
bytes: u64,
|
||||
max_sent: u64,
|
||||
delivered: usize,
|
||||
expected: usize,
|
||||
latency_mean: Option<f64>,
|
||||
latency_max: Option<f64>,
|
||||
duplicates: usize,
|
||||
unexpected: usize,
|
||||
malformed: usize,
|
||||
}
|
||||
|
||||
// A native test driver schedules external actions independently of the student's
|
||||
// traffic. It has no network traffic and is excluded from all measurements.
|
||||
#[derive(Clone)]
|
||||
struct Workload {
|
||||
action: Rc<Cell<usize>>,
|
||||
crashes: bool,
|
||||
}
|
||||
|
||||
impl Process for Workload {
|
||||
fn on_local_message(&mut self, _: Message, ctx: &mut Context) -> Result<(), String> {
|
||||
for i in 1..MESSAGES {
|
||||
ctx.set_timer(&i.to_string(), i as f64);
|
||||
}
|
||||
if self.crashes {
|
||||
ctx.set_timer("10", 4.5);
|
||||
}
|
||||
ctx.set_timer("11", HORIZON);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_message(&mut self, _: Message, _: String, _: &mut Context) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_timer(&mut self, timer: String, _: &mut Context) -> Result<(), String> {
|
||||
self.action.set(timer.parse().unwrap());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn send(sys: &mut System, index: usize) {
|
||||
let text = format!("{index:02}:{}", "x".repeat(61)); // fixed 64-byte payload
|
||||
sys.send_local_message(
|
||||
"0",
|
||||
Message::json("SEND", &BroadcastMessage { text: &text }),
|
||||
);
|
||||
}
|
||||
|
||||
fn measure(config: &TestConfig, scenario: &str) -> Measurement {
|
||||
let mut sys = System::new(config.seed);
|
||||
if scenario == "DELAYED" {
|
||||
sys.network().set_delays(1., 10.);
|
||||
} else {
|
||||
sys.network().set_delays(1., 3.);
|
||||
}
|
||||
let processes: Vec<String> = (0..config.proc_count).map(|i| i.to_string()).collect();
|
||||
for proc in &processes {
|
||||
let implementation = config
|
||||
.proc_factory
|
||||
.build((proc, processes.clone()), config.seed);
|
||||
sys.add_node(proc);
|
||||
sys.add_process(proc, Box::new(implementation), proc);
|
||||
}
|
||||
let action = Rc::new(Cell::new(0));
|
||||
sys.add_node("_workload");
|
||||
sys.add_process(
|
||||
"_workload",
|
||||
Box::new(Workload {
|
||||
action: action.clone(),
|
||||
crashes: scenario == "CRASHES",
|
||||
}),
|
||||
"_workload",
|
||||
);
|
||||
sys.send_local_message("_workload", Message::new("START", "{}"));
|
||||
send(&mut sys, 0);
|
||||
let mut status = "event limit";
|
||||
for _ in 0..EVENT_LIMIT {
|
||||
if !sys.step() {
|
||||
status = "queue exhausted";
|
||||
break;
|
||||
}
|
||||
match action.replace(0) {
|
||||
0 => {}
|
||||
index @ 1..=9 => send(&mut sys, index),
|
||||
10 => {
|
||||
sys.crash_node("1");
|
||||
sys.crash_node("2");
|
||||
}
|
||||
11 => {
|
||||
status = "horizon";
|
||||
break;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
summarize(&sys, config, scenario, status)
|
||||
}
|
||||
|
||||
fn summarize(sys: &System, config: &TestConfig, scenario: &str, status: &str) -> Measurement {
|
||||
let mut latencies = Vec::new();
|
||||
let mut duplicates = 0;
|
||||
let mut unexpected = 0;
|
||||
let mut malformed = 0;
|
||||
let mut network_sends = BTreeMap::<String, u64>::new();
|
||||
let mut sent = BTreeMap::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
// Trace order matters: a guessed DELIVER before SEND is unexpected even
|
||||
// when the two events have the same model timestamp.
|
||||
for entry in sys.logger().trace() {
|
||||
match entry {
|
||||
LogEntry::MessageSent {
|
||||
src_proc, dst_proc, ..
|
||||
} if src_proc != dst_proc => {
|
||||
*network_sends.entry(src_proc.clone()).or_default() += 1;
|
||||
}
|
||||
LogEntry::LocalMessageReceived {
|
||||
time, proc, msg, ..
|
||||
} if proc == "0" && msg.tip == "SEND" => {
|
||||
let data: serde_json::Value = serde_json::from_str(&msg.data).unwrap();
|
||||
sent.insert(data["text"].as_str().unwrap().to_string(), *time);
|
||||
}
|
||||
LogEntry::LocalMessageSent {
|
||||
time, proc, msg, ..
|
||||
} => {
|
||||
let data = serde_json::from_str::<serde_json::Value>(&msg.data).ok();
|
||||
let text = data.as_ref().and_then(|v| v["text"].as_str());
|
||||
if msg.tip != "DELIVER" || text.is_none() {
|
||||
malformed += 1;
|
||||
continue;
|
||||
}
|
||||
let text = text.unwrap();
|
||||
if !seen.insert((proc.clone(), text.to_string())) {
|
||||
duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(sent_at) = sent.get(text) {
|
||||
if scenario != "CRASHES" || (proc != "1" && proc != "2") {
|
||||
latencies.push(time - sent_at);
|
||||
}
|
||||
} else {
|
||||
unexpected += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let correct_count = config.proc_count - if scenario == "CRASHES" { 2 } else { 0 };
|
||||
let network = sys.network();
|
||||
Measurement {
|
||||
scenario: scenario.to_string(),
|
||||
n: config.proc_count,
|
||||
seed: config.seed,
|
||||
status: status.to_string(),
|
||||
observed_until: sys.time(),
|
||||
sent: sent.len(),
|
||||
messages: network.network_message_count(),
|
||||
bytes: network.traffic(),
|
||||
max_sent: network_sends.into_values().max().unwrap_or(0),
|
||||
delivered: latencies.len(),
|
||||
expected: MESSAGES * correct_count as usize,
|
||||
latency_mean: (!latencies.is_empty())
|
||||
.then(|| latencies.iter().sum::<f64>() / latencies.len() as f64),
|
||||
latency_max: latencies.into_iter().reduce(f64::max),
|
||||
duplicates,
|
||||
unexpected,
|
||||
malformed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn worker(args: &Args) {
|
||||
// This process may panic, hang in Python, or print SCORE from the validator.
|
||||
// The supervisor captures its output and never forwards it verbatim.
|
||||
if !shared_state::validate_or_report(&args.solution_path, &["BroadcastProcess"]) {
|
||||
return;
|
||||
}
|
||||
let factory = PyProcessFactory::new(&args.solution_path, "BroadcastProcess");
|
||||
let mut rows = Vec::new();
|
||||
'measurements: for scenario in SCENARIOS {
|
||||
for n in SIZES {
|
||||
for run in 0..args.optimization_runs {
|
||||
let config = TestConfig {
|
||||
proc_factory: &factory,
|
||||
proc_count: n,
|
||||
seed: args.seed.wrapping_add(run as u64),
|
||||
monkeys: 0,
|
||||
debug: false,
|
||||
};
|
||||
let result = measure(&config, scenario);
|
||||
let incomplete = result.status == "event limit";
|
||||
rows.push(result);
|
||||
if incomplete {
|
||||
break 'measurements;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(factory);
|
||||
// Emit one final record only after all simulator callbacks and cleanup.
|
||||
// Earlier stdout belongs to the solution and is diagnostic text, even if it
|
||||
// resembles this record. This framing is not a sandbox for hostile Python.
|
||||
println!("\n{RESULT_PREFIX}{}", serde_json::to_string(&rows).unwrap());
|
||||
std::io::stdout().flush().unwrap();
|
||||
}
|
||||
|
||||
struct WorkerResult {
|
||||
rows: Vec<Measurement>,
|
||||
problem: Option<String>,
|
||||
diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
fn read_bounded(
|
||||
reader: impl Read + Send + 'static,
|
||||
exceeded: Arc<AtomicBool>,
|
||||
) -> thread::JoinHandle<Vec<u8>> {
|
||||
thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = reader.take(OUTPUT_LIMIT + 1).read_to_end(&mut bytes);
|
||||
if bytes.len() as u64 > OUTPUT_LIMIT {
|
||||
exceeded.store(true, Ordering::Relaxed);
|
||||
}
|
||||
bytes
|
||||
})
|
||||
}
|
||||
|
||||
fn run_worker(args: &Args, path: &str, deadline: Instant) -> std::io::Result<WorkerResult> {
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(WorkerResult {
|
||||
rows: Vec::new(),
|
||||
problem: Some("shared wall-clock budget exhausted".to_string()),
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
}
|
||||
let mut child = Command::new(std::env::current_exe()?)
|
||||
.args([
|
||||
"--optimization-worker",
|
||||
"-i",
|
||||
path,
|
||||
"-s",
|
||||
&args.seed.to_string(),
|
||||
"--optimization-runs",
|
||||
&args.optimization_runs.to_string(),
|
||||
])
|
||||
.env("PYTHONUNBUFFERED", "1")
|
||||
// The validator also initializes Python, so set the seed before spawn.
|
||||
.env("PYTHONHASHSEED", args.seed.to_string())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let exceeded = Arc::new(AtomicBool::new(false));
|
||||
let out = read_bounded(child.stdout.take().unwrap(), exceeded.clone());
|
||||
let err = read_bounded(child.stderr.take().unwrap(), exceeded.clone());
|
||||
let mut problem = None;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
problem = Some(format!("worker exited with {status}"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
problem = Some(error.to_string());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if exceeded.load(Ordering::Relaxed) {
|
||||
problem = Some("output limit".to_string());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
problem = Some(format!(
|
||||
"shared wall-clock limit ({}s)",
|
||||
args.optimization_timeout
|
||||
));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let stdout = out.join().unwrap_or_default();
|
||||
let stderr = err.join().unwrap_or_default();
|
||||
if stdout.len() as u64 > OUTPUT_LIMIT || stderr.len() as u64 > OUTPUT_LIMIT {
|
||||
problem = Some("output limit".to_string());
|
||||
}
|
||||
let output = String::from_utf8_lossy(&stdout);
|
||||
let (diagnostic_output, result_line) = output
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(&output)
|
||||
.rsplit_once('\n')
|
||||
.unwrap_or(("", output.as_ref()));
|
||||
let mut rows = Vec::new();
|
||||
if problem.is_none() {
|
||||
match parse_measurements(result_line.trim_end_matches('\r'), args) {
|
||||
Ok(measurements) => rows = measurements,
|
||||
Err(error) => problem = Some(error),
|
||||
}
|
||||
}
|
||||
let mut diagnostics = Vec::new();
|
||||
for line in diagnostic_output.lines() {
|
||||
if !line.is_empty() && diagnostics.len() < 4 {
|
||||
diagnostics.push(line.chars().take(240).collect());
|
||||
}
|
||||
}
|
||||
// A missing or malformed final record is also useful diagnostic output.
|
||||
if rows.is_empty() && !result_line.is_empty() && diagnostics.len() < 4 {
|
||||
diagnostics.push(result_line.chars().take(240).collect());
|
||||
}
|
||||
for line in String::from_utf8_lossy(&stderr).lines().take(4) {
|
||||
diagnostics.push(line.chars().take(240).collect());
|
||||
}
|
||||
Ok(WorkerResult {
|
||||
rows,
|
||||
problem,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_measurements(line: &str, args: &Args) -> Result<Vec<Measurement>, String> {
|
||||
let json = line
|
||||
.strip_prefix(RESULT_PREFIX)
|
||||
.ok_or("worker did not return measurements")?;
|
||||
let rows: Vec<Measurement> =
|
||||
serde_json::from_str(json).map_err(|e| format!("invalid measurements: {e}"))?;
|
||||
let expected = SCENARIOS.iter().flat_map(|scenario| {
|
||||
SIZES.iter().flat_map(move |n| {
|
||||
(0..args.optimization_runs)
|
||||
.map(move |run| (*scenario, *n, args.seed.wrapping_add(run as u64)))
|
||||
})
|
||||
});
|
||||
let total = SCENARIOS.len() * SIZES.len() * args.optimization_runs as usize;
|
||||
if rows.is_empty()
|
||||
|| rows.len() > total
|
||||
|| rows.iter().zip(expected).any(|(row, (scenario, n, seed))| {
|
||||
row.scenario != scenario || row.n != n || row.seed != seed
|
||||
})
|
||||
|| rows[..rows.len() - 1]
|
||||
.iter()
|
||||
.any(|row| row.status == "event limit")
|
||||
|| (rows.len() < total && rows.last().unwrap().status != "event limit")
|
||||
{
|
||||
return Err("worker returned an incomplete or unexpected measurement sequence".to_string());
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn optimized_path(args: &Args) -> PathBuf {
|
||||
args.optimized_impl
|
||||
.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(&args.solution_path).with_file_name("broadcast_scalable.py")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_if_present(args: &Args, score: f32) {
|
||||
if args.optimized_impl.is_some() || optimized_path(args).exists() {
|
||||
if score == 7. {
|
||||
run(args);
|
||||
} else {
|
||||
println!("SKIPPED: OPTIMIZATION (requires main SCORE: 7)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(row: Option<&Measurement>) -> String {
|
||||
let Some(r) = row else {
|
||||
return "NOT MEASURED".to_string();
|
||||
};
|
||||
let latency = |value: Option<f64>| {
|
||||
value
|
||||
.map(|v| format!("{v:.3}"))
|
||||
.unwrap_or_else(|| "n/a".to_string())
|
||||
};
|
||||
format!(
|
||||
"{} {} {} {}/{} {} {} {} {} {} [{}; t={:.3}; sent={}]",
|
||||
r.messages,
|
||||
r.bytes,
|
||||
r.max_sent,
|
||||
r.delivered,
|
||||
r.expected,
|
||||
latency(r.latency_mean),
|
||||
latency(r.latency_max),
|
||||
r.duplicates,
|
||||
r.unexpected,
|
||||
r.malformed,
|
||||
if r.status == "event limit" {
|
||||
"INCOMPLETE: event limit"
|
||||
} else {
|
||||
&r.status
|
||||
},
|
||||
r.observed_until,
|
||||
r.sent
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run(args: &Args) {
|
||||
println!("\n--- OPTIMIZATION ---\n");
|
||||
let optimized = optimized_path(args);
|
||||
if !optimized.is_file() {
|
||||
println!(
|
||||
"SKIPPED: scalable implementation not found: {}",
|
||||
optimized.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Both implementations receive the same external workload: ten unique
|
||||
// 64-byte messages from process 0 at t=0..9, observed until HORIZON.
|
||||
// Run indices start at zero; internal traffic depends on the implementation.
|
||||
// Measurements have no performance threshold or automatic optimization score.
|
||||
// Event/time/output limits mark incomplete measurements without changing SCORE.
|
||||
println!("Workload: N={SIZES:?}, messages={MESSAGES} x 64B, sender=0, send_t=0..9, horizon={HORIZON}");
|
||||
println!("Delays: NORMAL/CRASHES=[1,3], DELAYED=[1,10]; CRASHES: [1,2] at t=4.5");
|
||||
println!("Runs={}, base_seed={}; limits: {EVENT_LIMIT} events/case, {}s/comparison, {OUTPUT_LIMIT} bytes/stream", args.optimization_runs, args.seed, args.optimization_timeout);
|
||||
let deadline = Instant::now() + Duration::from_secs(args.optimization_timeout);
|
||||
let mut results = Vec::new();
|
||||
for (label, path) in [
|
||||
("BASE", args.solution_path.as_str()),
|
||||
("SCALABLE", optimized.to_str().unwrap_or("")),
|
||||
] {
|
||||
if label == "SCALABLE"
|
||||
&& results.first().is_some_and(|r: &WorkerResult| {
|
||||
r.problem.is_some() || r.rows.iter().any(|row| row.status == "event limit")
|
||||
})
|
||||
{
|
||||
println!("SKIPPED: SCALABLE (BASE measurement incomplete)");
|
||||
results.push(WorkerResult {
|
||||
rows: Vec::new(),
|
||||
problem: None,
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
println!("Measuring {label}: {path}");
|
||||
let result = run_worker(args, path, deadline).unwrap_or_else(|e| WorkerResult {
|
||||
rows: Vec::new(),
|
||||
problem: Some(e.to_string()),
|
||||
diagnostics: Vec::new(),
|
||||
});
|
||||
if let Some(problem) = &result.problem {
|
||||
println!("{label}: INCOMPLETE: {problem}");
|
||||
}
|
||||
for line in &result.diagnostics {
|
||||
println!("{label} diagnostic: {line}");
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
println!("scenario N seed | BASE: messages bytes max_sent delivered/expected latency_mean latency_max dup unexpected malformed | SCALABLE: same columns");
|
||||
for scenario in SCENARIOS {
|
||||
for n in SIZES {
|
||||
for run in 0..args.optimization_runs {
|
||||
let seed = args.seed.wrapping_add(run as u64);
|
||||
let find = |side: usize| {
|
||||
results[side]
|
||||
.rows
|
||||
.iter()
|
||||
.find(|r| r.scenario == scenario && r.n == n && r.seed == seed)
|
||||
};
|
||||
println!(
|
||||
"{scenario} {n} {seed} | {} | {}",
|
||||
cell(find(0)),
|
||||
cell(find(1))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Run each correctness test in a killable process. Student callbacks can block
|
||||
//! inside Python, so an in-simulator deadline alone cannot enforce wall time.
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anysystem::python::PyProcessFactory;
|
||||
use anysystem::test::TestResult;
|
||||
use env_logger::Builder;
|
||||
use indexmap::IndexMap;
|
||||
use log::LevelFilter;
|
||||
|
||||
use crate::common::TestConfig;
|
||||
use crate::tests::*;
|
||||
use crate::tests_mc::*;
|
||||
use crate::Args;
|
||||
|
||||
struct Test {
|
||||
name: &'static str,
|
||||
run: fn(&TestConfig) -> TestResult,
|
||||
}
|
||||
|
||||
const TESTS: &[Test] = &[
|
||||
Test {
|
||||
name: "NORMAL",
|
||||
run: test_normal,
|
||||
},
|
||||
Test {
|
||||
name: "SENDER CRASH",
|
||||
run: test_sender_crash,
|
||||
},
|
||||
Test {
|
||||
name: "SENDER CRASH 2",
|
||||
run: test_sender_crash2,
|
||||
},
|
||||
Test {
|
||||
name: "TWO CRASHES",
|
||||
run: test_two_crashes,
|
||||
},
|
||||
Test {
|
||||
name: "TWO CRASHES 2",
|
||||
run: test_two_crashes2,
|
||||
},
|
||||
Test {
|
||||
name: "TWO CRASHES AFTER DELIVERY",
|
||||
run: test_two_crashes_after_delivery,
|
||||
},
|
||||
Test {
|
||||
name: "CAUSAL ORDER",
|
||||
run: test_causal_order,
|
||||
},
|
||||
Test {
|
||||
name: "QUORUM",
|
||||
run: test_quorum,
|
||||
},
|
||||
Test {
|
||||
name: "CHAOS MONKEY",
|
||||
run: test_chaos_monkey,
|
||||
},
|
||||
Test {
|
||||
name: "SCALABILITY",
|
||||
run: test_scalability,
|
||||
},
|
||||
Test {
|
||||
name: "MODEL CHECKING NORMAL DELIVERY",
|
||||
run: test_mc_normal_delivery,
|
||||
},
|
||||
Test {
|
||||
name: "MODEL CHECKING SENDER CRASH",
|
||||
run: test_mc_sender_crash,
|
||||
},
|
||||
Test {
|
||||
name: "MODEL CHECKING CAUSAL ORDER",
|
||||
run: test_mc_causal_order,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn worker(args: &Args) {
|
||||
let test = TESTS
|
||||
.iter()
|
||||
.find(|test| Some(test.name) == args.test_worker.as_deref())
|
||||
.unwrap();
|
||||
if args.debug {
|
||||
Builder::new()
|
||||
.filter(Some("anysystem"), LevelFilter::Debug)
|
||||
.format(|buf, record| writeln!(buf, "{}", record.args()))
|
||||
.init();
|
||||
}
|
||||
let factory = PyProcessFactory::new(&args.solution_path, "BroadcastProcess");
|
||||
let config = TestConfig {
|
||||
proc_factory: &factory,
|
||||
proc_count: if test.name.starts_with("MODEL CHECKING") {
|
||||
3
|
||||
} else {
|
||||
args.proc_count
|
||||
},
|
||||
seed: args.seed,
|
||||
monkeys: args.monkeys,
|
||||
debug: args.debug,
|
||||
};
|
||||
let result = (test.run)(&config);
|
||||
// Keep the structured result separate from student prints and debug traces.
|
||||
fs::write(
|
||||
args.test_result.as_ref().unwrap(),
|
||||
serde_json::to_vec(&result).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct ResultFile(PathBuf);
|
||||
|
||||
impl ResultFile {
|
||||
fn new() -> std::io::Result<Self> {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"broadcast-test-{}-{nonce}.json",
|
||||
std::process::id()
|
||||
));
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ResultFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_one(args: &Args, test: &Test, suite_deadline: Instant) -> TestResult {
|
||||
let seconds = if test.name == "CHAOS MONKEY" {
|
||||
args.chaos_timeout
|
||||
} else if test.name == "SCALABILITY" {
|
||||
args.scalability_timeout
|
||||
} else if test.name.starts_with("MODEL CHECKING") {
|
||||
// The process deadline also catches a callback hanging inside the MC.
|
||||
args.mc_timeout
|
||||
.min(if test.name == "MODEL CHECKING NORMAL DELIVERY" {
|
||||
110
|
||||
} else if test.name == "MODEL CHECKING CAUSAL ORDER" {
|
||||
60
|
||||
} else {
|
||||
210
|
||||
})
|
||||
} else {
|
||||
args.test_timeout
|
||||
};
|
||||
let test_deadline = Instant::now() + Duration::from_secs(seconds);
|
||||
let deadline = test_deadline.min(suite_deadline);
|
||||
let timeout = if suite_deadline <= test_deadline {
|
||||
format!("suite time limit of {}s exceeded", args.suite_timeout)
|
||||
} else {
|
||||
format!("test time limit of {seconds}s exceeded")
|
||||
};
|
||||
if Instant::now() >= deadline {
|
||||
return Err(timeout);
|
||||
}
|
||||
let result_file = ResultFile::new().map_err(|e| format!("test worker error: {e}"))?;
|
||||
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
|
||||
command
|
||||
.args([
|
||||
"--test-worker",
|
||||
test.name,
|
||||
"-i",
|
||||
&args.solution_path,
|
||||
"-s",
|
||||
&args.seed.to_string(),
|
||||
"-p",
|
||||
&args.proc_count.to_string(),
|
||||
"-m",
|
||||
&args.monkeys.to_string(),
|
||||
"--test-result",
|
||||
])
|
||||
.arg(&result_file.0)
|
||||
.env("PYTHONUNBUFFERED", "1")
|
||||
// Python must see the seed before interpreter initialization.
|
||||
.env("PYTHONHASHSEED", args.seed.to_string());
|
||||
if args.debug {
|
||||
command.arg("-d");
|
||||
}
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| format!("test worker error: {e}"))?;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
return Err(format!("test worker exited with {status}"));
|
||||
}
|
||||
return serde_json::from_slice(
|
||||
&fs::read(&result_file.0).map_err(|e| format!("test worker result: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("test worker result: {e}"))?;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("test worker error: {error}"));
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(timeout);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn must_stop(result: &TestResult, keep_going: bool) -> bool {
|
||||
let Err(error) = result else {
|
||||
return false;
|
||||
};
|
||||
// Resource exhaustion and process failures always stop, including in local
|
||||
// diagnostic mode. A pure causal violation must not hide a later base error.
|
||||
if error.contains("time limit")
|
||||
|| error.starts_with("causal model-checking ")
|
||||
|| error.starts_with("test worker")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if keep_going {
|
||||
return false;
|
||||
}
|
||||
!error.strip_prefix("Violated ").is_some_and(|properties| {
|
||||
properties
|
||||
.split(", ")
|
||||
.all(|property| property == "CAUSAL ORDER")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn causal_mc_incomplete(name: &str, error: &str) -> bool {
|
||||
name == "MODEL CHECKING CAUSAL ORDER"
|
||||
&& (error.contains("time limit") || error.starts_with("causal model-checking "))
|
||||
}
|
||||
|
||||
pub fn run(args: &Args) -> IndexMap<String, TestResult> {
|
||||
let tests: Vec<_> = TESTS
|
||||
.iter()
|
||||
.filter(|test| {
|
||||
if let Some(name) = &args.test {
|
||||
test.name == name
|
||||
} else {
|
||||
!args.disable_mc_tests || !test.name.starts_with("MODEL CHECKING")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let deadline = Instant::now() + Duration::from_secs(args.suite_timeout);
|
||||
let mut results = IndexMap::new();
|
||||
let mut stopped = None;
|
||||
let mut passed = 0;
|
||||
let mut causal_violated = false;
|
||||
for test in &tests {
|
||||
if let Some(failed_test) = stopped {
|
||||
println!("SKIPPED: {} (stopped after {failed_test})", test.name);
|
||||
continue;
|
||||
}
|
||||
if test.name == "MODEL CHECKING CAUSAL ORDER" && causal_violated && !args.keep_going {
|
||||
println!("SKIPPED: {} (CAUSAL ORDER already violated)", test.name);
|
||||
continue;
|
||||
}
|
||||
println!("\n--- {} ---\n", test.name);
|
||||
std::io::stdout().flush().unwrap();
|
||||
let result = run_one(args, test, deadline);
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
println!("\nPASSED\n");
|
||||
passed += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
causal_violated |= error.strip_prefix("Violated ").is_some_and(|properties| {
|
||||
properties
|
||||
.split(", ")
|
||||
.any(|property| property == "CAUSAL ORDER")
|
||||
});
|
||||
println!("\nFAILED: {error}");
|
||||
if causal_mc_incomplete(test.name, error) {
|
||||
println!("INCOMPLETE: causal model checking did not finish within its resource budget");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
if must_stop(&result, args.keep_going) {
|
||||
stopped = Some(test.name);
|
||||
}
|
||||
results.insert(test.name.to_string(), result);
|
||||
}
|
||||
if let Some(name) = &args.test {
|
||||
if tests.is_empty() {
|
||||
println!("FAILED: unknown test {name}");
|
||||
}
|
||||
} else {
|
||||
println!("-------------------------------------------------------------------------------");
|
||||
println!("\nPassed {passed} from {} tests\n", tests.len());
|
||||
if results.len() != tests.len() {
|
||||
println!("Skipped {} tests\n", tests.len() - results.len());
|
||||
}
|
||||
if results.values().any(Result::is_err) {
|
||||
println!("Failed tests:");
|
||||
for (name, result) in &results {
|
||||
if let Err(error) = result {
|
||||
println!("- {name}: {error}");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::must_stop;
|
||||
|
||||
#[test]
|
||||
fn mc_timeout_always_stops_but_causal_violation_does_not() {
|
||||
assert!(must_stop(&Err("time limit of 100s exceeded".into()), false));
|
||||
assert!(must_stop(&Err("time limit of 100s exceeded".into()), true));
|
||||
assert!(!must_stop(&Err("Violated CAUSAL ORDER".into()), false));
|
||||
assert!(must_stop(
|
||||
&Err("Violated VALIDITY, CAUSAL ORDER".into()),
|
||||
false
|
||||
));
|
||||
assert!(!must_stop(&Err("Violated VALIDITY".into()), true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyList, PyModule};
|
||||
|
||||
const VALIDATOR_CODE: &str = include_str!("../shared_state_validator.py");
|
||||
|
||||
fn validator_module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
|
||||
let code = CString::new(VALIDATOR_CODE).expect("validator source contains a null byte");
|
||||
PyModule::from_code(
|
||||
py,
|
||||
&code,
|
||||
c"shared_state_validator.py",
|
||||
c"shared_state_validator",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn validate_source(
|
||||
source: &str,
|
||||
filename: &str,
|
||||
class_names: &[&str],
|
||||
) -> Result<Vec<String>, String> {
|
||||
Python::attach(|py| {
|
||||
let module = validator_module(py).map_err(|error| error.to_string())?;
|
||||
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
|
||||
module
|
||||
.getattr("validate_source")
|
||||
.and_then(|function| function.call1((source, filename, class_names)))
|
||||
.and_then(|result| result.extract::<Vec<String>>())
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_solution(path: &str, class_names: &[&str]) -> Result<Vec<String>, String> {
|
||||
Python::attach(|py| {
|
||||
let module = validator_module(py).map_err(|error| error.to_string())?;
|
||||
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
|
||||
module
|
||||
.getattr("validate_solution")
|
||||
.and_then(|function| function.call1((path, class_names)))
|
||||
.and_then(|result| result.extract::<Vec<String>>())
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_or_report(path: &str, class_names: &[&str]) -> bool {
|
||||
match validate_solution(path, class_names) {
|
||||
Ok(violations) if violations.is_empty() => true,
|
||||
Ok(violations) => {
|
||||
println!("ERROR: shared state between AnySystem processes is forbidden");
|
||||
for violation in violations {
|
||||
println!("{violation}");
|
||||
}
|
||||
println!("\nSCORE: 0\n");
|
||||
false
|
||||
}
|
||||
Err(error) => panic!("failed to validate solution for shared state: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_source;
|
||||
|
||||
#[test]
|
||||
fn embeds_validator_and_rejects_shared_state() {
|
||||
let violations = validate_source(
|
||||
"class ProcessImpl:\n shared = {}\n",
|
||||
"solution.py",
|
||||
&["ProcessImpl"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(violations.len(), 1);
|
||||
assert!(violations[0].contains("ProcessImpl.shared"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use rand::prelude::*;
|
||||
use rand_pcg::Pcg64;
|
||||
use serde_json::Value;
|
||||
|
||||
use anysystem::test::TestResult;
|
||||
use anysystem::{Message, ProcessEvent, System};
|
||||
|
||||
use crate::common::{build_system, BroadcastMessage, TestConfig};
|
||||
|
||||
pub fn test_normal(config: &TestConfig) -> TestResult {
|
||||
let mut sys = build_system(config);
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, HashSet::from([text.to_string()]))
|
||||
}
|
||||
|
||||
pub fn test_sender_crash(config: &TestConfig) -> TestResult {
|
||||
println!("Process 0 will crash after another process receives a network message.");
|
||||
let mut sys = build_system(config);
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
// run until the message is received by one other process
|
||||
let mut received = false;
|
||||
while !received {
|
||||
if !sys.step() {
|
||||
return Err("no events left before another process received the message".to_string());
|
||||
}
|
||||
for n in 1..config.proc_count {
|
||||
if sys.received_message_count(&n.to_string()) == 1 {
|
||||
received = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// crash source node
|
||||
sys.crash_node("0");
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, HashSet::from([text.to_string()]))
|
||||
}
|
||||
|
||||
pub fn test_sender_crash2(config: &TestConfig) -> TestResult {
|
||||
println!("Process 0 is isolated and will crash; its messages cannot reach other processes.");
|
||||
let mut sys = build_system(config);
|
||||
let text = "0:Hello";
|
||||
// let the message to be received only by the sender itself by disconnecting it
|
||||
sys.network().disconnect_node("0");
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.step();
|
||||
sys.crash_node("0");
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, HashSet::from([text.to_string()]))
|
||||
}
|
||||
|
||||
pub fn test_two_crashes(config: &TestConfig) -> TestResult {
|
||||
if config.proc_count < 5 {
|
||||
return Err(
|
||||
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
|
||||
);
|
||||
};
|
||||
let mut sys = build_system(config);
|
||||
let survivors: Vec<String> = (2..config.proc_count).map(|n| n.to_string()).collect();
|
||||
println!(
|
||||
"Processes [0, 1] are isolated from [{}] and will crash; links within each group remain enabled.",
|
||||
survivors.join(", ")
|
||||
);
|
||||
sys.network().make_partition(
|
||||
&["0", "1"],
|
||||
&survivors.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.steps(config.proc_count.pow(2));
|
||||
sys.crash_node("0");
|
||||
sys.crash_node("1");
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, HashSet::from([text.to_string()]))
|
||||
}
|
||||
|
||||
pub fn test_two_crashes2(config: &TestConfig) -> TestResult {
|
||||
if config.proc_count < 5 {
|
||||
return Err(
|
||||
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
|
||||
);
|
||||
};
|
||||
let mut sys = build_system(config);
|
||||
println!(
|
||||
"Outgoing messages from processes [1, 2] are dropped; both processes will later crash."
|
||||
);
|
||||
// Model messages that remain undelivered until their senders crash.
|
||||
sys.network().drop_outgoing("1");
|
||||
sys.network().drop_outgoing("2");
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.steps(config.proc_count.pow(2));
|
||||
sys.crash_node("1");
|
||||
sys.crash_node("2");
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, HashSet::from([text.to_string()]))
|
||||
}
|
||||
|
||||
pub fn test_two_crashes_after_delivery(config: &TestConfig) -> TestResult {
|
||||
if config.proc_count < 5 {
|
||||
return Err(
|
||||
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
|
||||
);
|
||||
}
|
||||
println!("Process 0 and the first other process to deliver to its local user will crash immediately after that delivery.");
|
||||
let mut sys = build_system(config);
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
// Crash the sender and the first other process to deliver to its user.
|
||||
// Uniform agreement must still hold even though that recipient has crashed.
|
||||
while sys.step() {
|
||||
let recipient = (1..config.proc_count)
|
||||
.map(|n| n.to_string())
|
||||
.find(|proc| !sys.local_outbox(proc).is_empty());
|
||||
if let Some(recipient) = recipient {
|
||||
println!("Process {recipient} delivered to its local user; crashing processes [0, {recipient}].");
|
||||
sys.crash_node("0");
|
||||
sys.crash_node(&recipient);
|
||||
break;
|
||||
}
|
||||
}
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, HashSet::from([text.to_string()]))
|
||||
}
|
||||
|
||||
pub fn test_quorum(config: &TestConfig) -> TestResult {
|
||||
for node_count in [7, 10] {
|
||||
let mut run_config = *config;
|
||||
run_config.proc_count = node_count;
|
||||
let failures = (node_count - 1) / 2;
|
||||
let text = "0:Hello";
|
||||
|
||||
println!("- N={node_count}: {failures} isolated processes crash");
|
||||
let mut sys = build_system(&run_config);
|
||||
let minority: Vec<String> = (0..failures).map(|n| n.to_string()).collect();
|
||||
let majority: Vec<String> = (failures..node_count).map(|n| n.to_string()).collect();
|
||||
println!(
|
||||
"Processes [{}] are isolated from [{}] and will crash; links within each group remain enabled.",
|
||||
minority.join(", "),
|
||||
majority.join(", ")
|
||||
);
|
||||
sys.network().make_partition(
|
||||
&minority.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
&majority.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.step_until_no_events();
|
||||
for n in 0..failures {
|
||||
sys.crash_node(&n.to_string());
|
||||
}
|
||||
sys.step_until_no_events();
|
||||
check(&sys, &run_config, HashSet::from([text.to_string()]))?;
|
||||
|
||||
println!("- N={node_count}: {failures} processes crash before broadcast");
|
||||
println!(
|
||||
"Processes [{}] will crash; the remaining majority must deliver to their local users.",
|
||||
((node_count - failures)..node_count)
|
||||
.map(|n| n.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
let mut sys = build_system(&run_config);
|
||||
// The surviving majority, including the sender, must complete delivery.
|
||||
for n in (node_count - failures)..node_count {
|
||||
sys.crash_node(&n.to_string());
|
||||
}
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.step_until_no_events();
|
||||
check(&sys, &run_config, HashSet::from([text.to_string()]))?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn test_causal_order(config: &TestConfig) -> TestResult {
|
||||
let mut sys = build_system(config);
|
||||
sys.network().set_delays(100., 200.);
|
||||
let texts = ["0:Hello", "1:How?", "0:Fine!"];
|
||||
sys.send_local_message(
|
||||
"0",
|
||||
Message::json("SEND", &BroadcastMessage { text: texts[0] }),
|
||||
);
|
||||
while sys.event_log("1").is_empty() {
|
||||
if !sys.step() {
|
||||
return Err("no events left before the second SEND".to_string());
|
||||
}
|
||||
}
|
||||
sys.network().set_delays(10., 20.);
|
||||
sys.send_local_message(
|
||||
"1",
|
||||
Message::json("SEND", &BroadcastMessage { text: texts[1] }),
|
||||
);
|
||||
while sys.event_log("0").len() < 3 {
|
||||
if !sys.step() {
|
||||
return Err("no events left before the third SEND".to_string());
|
||||
}
|
||||
}
|
||||
sys.network().set_delay(1.);
|
||||
sys.send_local_message(
|
||||
"0",
|
||||
Message::json("SEND", &BroadcastMessage { text: texts[2] }),
|
||||
);
|
||||
sys.step_until_no_events();
|
||||
let sent_messages = HashSet::from_iter(texts.into_iter().map(String::from));
|
||||
check(&sys, config, sent_messages)
|
||||
}
|
||||
|
||||
pub fn test_chaos_monkey(config: &TestConfig) -> TestResult {
|
||||
if config.proc_count < 5 {
|
||||
return Err(
|
||||
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
|
||||
);
|
||||
};
|
||||
let mut rand = Pcg64::seed_from_u64(config.seed);
|
||||
println!("Only outgoing messages from processes that will later crash may be dropped.");
|
||||
for i in 1..=config.monkeys {
|
||||
// Preserve the random sequence: this draw was previously printed as an unused seed.
|
||||
let _ = rand.next_u64();
|
||||
let mut sys = build_system(config);
|
||||
let victim1 = format!("{}", rand.gen_range(0..config.proc_count));
|
||||
let mut victim2 = format!("{}", rand.gen_range(0..config.proc_count));
|
||||
while victim2 == victim1 {
|
||||
victim2 = format!("{}", rand.gen_range(0..config.proc_count));
|
||||
}
|
||||
println!(
|
||||
"- Run {i} (base seed: {}; processes that will crash: [{victim1}, {victim2}])",
|
||||
config.seed
|
||||
);
|
||||
let mut sent_messages = HashSet::new();
|
||||
for i in 0..10 {
|
||||
let sender = format!("{}", rand.gen_range(0..config.proc_count));
|
||||
let text = format!("{sender}:{i}");
|
||||
sent_messages.insert(text.clone());
|
||||
if i % 2 == 0 {
|
||||
sys.network().set_delays(10., 20.);
|
||||
} else {
|
||||
sys.network().set_delays(1., 2.);
|
||||
}
|
||||
for j in 0..8 {
|
||||
if rand.gen_range(0.0..1.0) > 0.3 {
|
||||
sys.network().drop_outgoing(&victim1);
|
||||
} else {
|
||||
sys.network().pass_outgoing(&victim1);
|
||||
}
|
||||
if rand.gen_range(0.0..1.0) > 0.3 {
|
||||
sys.network().drop_outgoing(&victim2);
|
||||
} else {
|
||||
sys.network().pass_outgoing(&victim2);
|
||||
}
|
||||
if j == 0 {
|
||||
sys.send_local_message(
|
||||
&sender,
|
||||
Message::json("SEND", &BroadcastMessage { text: &text }),
|
||||
);
|
||||
} else {
|
||||
sys.step();
|
||||
}
|
||||
}
|
||||
}
|
||||
sys.crash_node(&victim1);
|
||||
sys.crash_node(&victim2);
|
||||
sys.step_until_no_events();
|
||||
check(&sys, config, sent_messages)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn test_scalability(config: &TestConfig) -> TestResult {
|
||||
let sys_sizes = [
|
||||
config.proc_count,
|
||||
config.proc_count * 2,
|
||||
config.proc_count * 4,
|
||||
config.proc_count * 10,
|
||||
];
|
||||
let mut msg_counts = Vec::new();
|
||||
for node_count in sys_sizes {
|
||||
let mut run_config = *config;
|
||||
run_config.proc_count = node_count;
|
||||
let mut sys = build_system(&run_config);
|
||||
let text = "0:Hello!";
|
||||
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
|
||||
sys.step_until_no_events();
|
||||
check(&sys, &run_config, HashSet::from([text.to_string()]))?;
|
||||
msg_counts.push(sys.network().network_message_count());
|
||||
}
|
||||
println!("\nMessage count:");
|
||||
for i in 0..sys_sizes.len() {
|
||||
let baseline = sys_sizes[i] * (sys_sizes[i] - 1);
|
||||
println!(
|
||||
"- N={}: {} (baseline {})",
|
||||
sys_sizes[i], msg_counts[i], baseline
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn check(sys: &System, config: &TestConfig, all_sent_messages: HashSet<String>) -> TestResult {
|
||||
let mut sent = BTreeMap::new();
|
||||
let mut delivered = BTreeMap::new();
|
||||
let mut all_delivered = BTreeSet::new();
|
||||
let mut histories = BTreeMap::new();
|
||||
let mut debug_histories = BTreeMap::new();
|
||||
for proc in sys.process_names() {
|
||||
let mut history = Vec::new();
|
||||
let mut debug_history = Vec::new();
|
||||
let mut sent_msgs = Vec::new();
|
||||
let mut delivered_msgs = Vec::new();
|
||||
for e in sys.event_log(&proc) {
|
||||
match e.event {
|
||||
ProcessEvent::LocalMessageReceived { msg: m } => {
|
||||
let data: Value = serde_json::from_str(&m.data).unwrap();
|
||||
let message = data["text"].as_str().unwrap().to_string();
|
||||
sent_msgs.push(message.clone());
|
||||
if config.debug {
|
||||
debug_history.push(format!("SEND {message:?}"));
|
||||
}
|
||||
history.push(message);
|
||||
}
|
||||
ProcessEvent::LocalMessageSent { msg: m } => {
|
||||
let data: Value = serde_json::from_str(&m.data).unwrap();
|
||||
let message = data["text"].as_str().unwrap().to_string();
|
||||
delivered_msgs.push(message.clone());
|
||||
all_delivered.insert(message.clone());
|
||||
if config.debug {
|
||||
debug_history.push(format!("DELIVER {message:?}"));
|
||||
}
|
||||
history.push(message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sent.insert(proc.clone(), sent_msgs);
|
||||
delivered.insert(proc.clone(), delivered_msgs);
|
||||
debug_histories.insert(proc.clone(), debug_history);
|
||||
histories.insert(proc, history);
|
||||
}
|
||||
|
||||
if config.debug {
|
||||
println!(
|
||||
"Messages sent across network: {}",
|
||||
sys.network().network_message_count()
|
||||
);
|
||||
println!("Process histories:");
|
||||
for proc in sys.process_names() {
|
||||
println!(
|
||||
"- [{}] {}",
|
||||
proc,
|
||||
debug_histories.get(&proc).unwrap().join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// NO DUPLICATION
|
||||
let mut no_duplication = true;
|
||||
for (proc, delivered_msgs) in &delivered {
|
||||
let mut uniq = HashSet::new();
|
||||
for msg in delivered_msgs {
|
||||
if uniq.contains(msg) {
|
||||
println!("NO DUPLICATION: process {proc} delivered message {msg:?} to its local user more than once.");
|
||||
no_duplication = false;
|
||||
};
|
||||
uniq.insert(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// NO CREATION
|
||||
let mut no_creation = true;
|
||||
for (proc, delivered_msgs) in &delivered {
|
||||
for msg in delivered_msgs {
|
||||
if !all_sent_messages.contains(msg) {
|
||||
println!("NO CREATION: process {proc} delivered message {msg:?} to its local user, but no user sent it.");
|
||||
no_creation = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VALIDITY
|
||||
let mut validity = true;
|
||||
for (proc, sent_msgs) in &sent {
|
||||
if sys.proc_node_is_crashed(proc) {
|
||||
continue;
|
||||
}
|
||||
let delivered_msgs = delivered.get(proc).unwrap();
|
||||
for msg in sent_msgs {
|
||||
if !delivered_msgs.contains(msg) {
|
||||
println!("VALIDITY: correct process {proc} received SEND {msg:?} from its local user, but did not deliver it back with DELIVER.");
|
||||
validity = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UNIFORM AGREEMENT
|
||||
let mut uniform_agreement = true;
|
||||
for msg in all_delivered.iter() {
|
||||
let mut delivered_by = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
for (proc, delivered_msgs) in &delivered {
|
||||
if delivered_msgs.contains(msg) {
|
||||
delivered_by.push(if sys.proc_node_is_crashed(proc) {
|
||||
format!("{proc} (later crashed)")
|
||||
} else {
|
||||
proc.clone()
|
||||
});
|
||||
} else if !sys.proc_node_is_crashed(proc) {
|
||||
missing.push(proc.clone());
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
println!("UNIFORM AGREEMENT violated for message {msg:?}:");
|
||||
println!(
|
||||
" Delivered to local users by processes: {}.",
|
||||
delivered_by.join(", ")
|
||||
);
|
||||
println!(
|
||||
" Not delivered by correct processes: {}.",
|
||||
missing.join(", ")
|
||||
);
|
||||
uniform_agreement = false;
|
||||
}
|
||||
}
|
||||
|
||||
// CAUSAL ORDER
|
||||
let mut causal_order = true;
|
||||
for (src, sent_msgs) in &sent {
|
||||
for msg in sent_msgs.iter() {
|
||||
if !all_delivered.contains(msg) {
|
||||
continue;
|
||||
}
|
||||
// build sender past for send message event
|
||||
let mut src_past = HashSet::new();
|
||||
for e in histories.get(src).unwrap() {
|
||||
if e != msg {
|
||||
src_past.insert(e.clone());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// check that other correct processes have delivered all past events before delivering the message
|
||||
for (dst, delivered_msgs) in &delivered {
|
||||
if sys.proc_node_is_crashed(dst) {
|
||||
continue;
|
||||
}
|
||||
let mut dst_past = HashSet::new();
|
||||
for e in delivered_msgs {
|
||||
if e != msg {
|
||||
dst_past.insert(e.clone());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !dst_past.is_superset(&src_past) {
|
||||
let mut missing = src_past
|
||||
.difference(&dst_past)
|
||||
.cloned()
|
||||
.collect::<Vec<String>>();
|
||||
missing.sort();
|
||||
println!(
|
||||
"CAUSAL ORDER: process {} did not deliver messages {:?} before message {:?} from process {}.",
|
||||
dst,
|
||||
missing,
|
||||
msg,
|
||||
src
|
||||
);
|
||||
causal_order = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if no_duplication & no_creation & validity & uniform_agreement & causal_order {
|
||||
Ok(true)
|
||||
} else {
|
||||
let mut violated = Vec::new();
|
||||
if !no_duplication {
|
||||
violated.push("NO DUPLICATION")
|
||||
}
|
||||
if !no_creation {
|
||||
violated.push("NO CREATION")
|
||||
}
|
||||
if !validity {
|
||||
violated.push("VALIDITY")
|
||||
}
|
||||
if !uniform_agreement {
|
||||
violated.push("UNIFORM AGREEMENT")
|
||||
}
|
||||
if !causal_order {
|
||||
violated.push("CAUSAL ORDER")
|
||||
}
|
||||
Err(format!("Violated {}", violated.join(", ")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
use sugars::boxed;
|
||||
|
||||
use anysystem::logger::LogEntry;
|
||||
use anysystem::test::TestResult;
|
||||
use anysystem::Message;
|
||||
|
||||
use anysystem::mc::predicates::{collects, goals, invariants, prunes};
|
||||
use anysystem::mc::strategies::Bfs;
|
||||
use anysystem::mc::strategy::{GoalFn, InvariantFn, PruneFn, StrategyConfig};
|
||||
use anysystem::mc::{McState, ModelChecker};
|
||||
|
||||
use crate::common::{build_system, BroadcastMessage, TestConfig};
|
||||
|
||||
// The causal test explores two broadcasts on three processes. The state budget
|
||||
// is checked as an invariant, so exhausting it cannot silently pass a pruned search.
|
||||
const CAUSAL_MC_STATE_LIMIT: usize = 100_000;
|
||||
|
||||
pub fn test_mc_normal_delivery(config: &TestConfig) -> TestResult {
|
||||
let mut sys = build_system(config);
|
||||
let proc_names = sys.process_names();
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message(
|
||||
proc_names[0].as_str(),
|
||||
Message::json("SEND", &BroadcastMessage { text }),
|
||||
);
|
||||
let goal = goals::all_goals(
|
||||
proc_names
|
||||
.iter()
|
||||
.map(|name| goals::got_n_local_messages(name, name, 1))
|
||||
.collect::<Vec<GoalFn>>(),
|
||||
);
|
||||
let strategy_config = StrategyConfig::default()
|
||||
.goal(goal)
|
||||
.prune(prunes::any_prune(vec![
|
||||
prunes::state_depth(10),
|
||||
mc_prune_proc_permutations(&proc_names[1..]),
|
||||
// Prune states with more than 2 messages received from any process
|
||||
mc_prune_msg_per_proc_limit(&proc_names, 2),
|
||||
]))
|
||||
.invariant(invariants::all_invariants(vec![
|
||||
mc_invariant(proc_names.clone(), text.to_string()),
|
||||
invariants::time_limit(Duration::from_secs(100)),
|
||||
]));
|
||||
let mut mc = ModelChecker::new(&sys);
|
||||
let res = mc.run::<Bfs>(strategy_config);
|
||||
if let Err(err) = res {
|
||||
err.print_trace();
|
||||
Err(err.message())
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_mc_sender_crash(config: &TestConfig) -> TestResult {
|
||||
let mut sys = build_system(config);
|
||||
let proc_names = sys.process_names();
|
||||
let text = "0:Hello";
|
||||
sys.send_local_message(
|
||||
proc_names[0].as_str(),
|
||||
Message::json("SEND", &BroadcastMessage { text }),
|
||||
);
|
||||
let goal = goals::all_goals(
|
||||
proc_names
|
||||
.iter()
|
||||
.map(|name| goals::got_n_local_messages(name, name, 1))
|
||||
.collect::<Vec<GoalFn>>(),
|
||||
);
|
||||
|
||||
let strategy_config = StrategyConfig::default()
|
||||
.prune(prunes::any_prune(vec![
|
||||
prunes::state_depth(4),
|
||||
mc_prune_proc_permutations(&proc_names[1..]),
|
||||
]))
|
||||
.goal(goal)
|
||||
.invariant(invariants::all_invariants(vec![
|
||||
mc_invariant(proc_names.clone(), text.to_string()),
|
||||
invariants::time_limit(Duration::from_secs(100)),
|
||||
]))
|
||||
.collect(collects::any_collect(
|
||||
proc_names[1..]
|
||||
.iter()
|
||||
.map(|proc| collects::got_n_local_messages(proc, proc, 1))
|
||||
.collect(),
|
||||
));
|
||||
|
||||
let mut mc = ModelChecker::new(&sys);
|
||||
let res = mc.run::<Bfs>(strategy_config);
|
||||
let intermediate_states = res
|
||||
.map_err(|err| {
|
||||
err.print_trace();
|
||||
err.message()
|
||||
})?
|
||||
.collected_states;
|
||||
if intermediate_states.is_empty() {
|
||||
return Err("no states collected after first stage".to_string());
|
||||
}
|
||||
|
||||
// Crash first node in the list
|
||||
let left_proc_names = proc_names[1..].to_vec();
|
||||
let goal = goals::all_goals(
|
||||
left_proc_names
|
||||
.iter()
|
||||
.map(|name| goals::got_n_local_messages(name, name, 1))
|
||||
.collect::<Vec<GoalFn>>(),
|
||||
);
|
||||
let strategy_config = StrategyConfig::default()
|
||||
.goal(goal)
|
||||
.invariant(invariants::all_invariants(vec![
|
||||
mc_invariant(left_proc_names, text.to_string()),
|
||||
invariants::time_limit(Duration::from_secs(100)),
|
||||
]))
|
||||
.prune(prunes::any_prune(vec![
|
||||
prunes::state_depth(6),
|
||||
mc_prune_proc_permutations(&proc_names[1..]),
|
||||
// Prune states with more than 4 messages received from any process
|
||||
mc_prune_msg_per_proc_limit(&proc_names, 4),
|
||||
]));
|
||||
let res = mc.run_from_states_with_change::<Bfs>(strategy_config, intermediate_states, |sys| {
|
||||
sys.crash_node(proc_names[0].clone());
|
||||
});
|
||||
if let Err(err) = res {
|
||||
err.print_trace();
|
||||
Err(err.message())
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Send "a" from P2, then send "b" from P1 after P1 delivers "a",
|
||||
// which gives the causal order "a" -> "b", then check that all processes
|
||||
// deliver both messages and deliver "a" before "b".
|
||||
pub fn test_mc_causal_order(config: &TestConfig) -> TestResult {
|
||||
let mut sys = build_system(config);
|
||||
sys.send_local_message("2", Message::json("SEND", &BroadcastMessage { text: "a" }));
|
||||
let proc_names = sys.process_names();
|
||||
let report_error = |err: anysystem::mc::McError| {
|
||||
err.print_trace();
|
||||
err.message()
|
||||
};
|
||||
let checked = Rc::new(Cell::new(0));
|
||||
let make_strategy = |texts: Vec<&'static str>| {
|
||||
let goal_procs = proc_names.clone();
|
||||
let invariant_procs = proc_names.clone();
|
||||
let checked = checked.clone();
|
||||
StrategyConfig::default()
|
||||
// Stop this branch when everyone has delivered the messages
|
||||
// sent in this stage. If events end before that, the test fails.
|
||||
.goal(boxed!(move |state: &McState| {
|
||||
goal_procs
|
||||
.iter()
|
||||
.all(|proc| {
|
||||
texts
|
||||
.iter()
|
||||
.all(|text| mc_delivery_index(state, proc, text).is_some())
|
||||
})
|
||||
.then(|| "all messages have been delivered by every process".to_string())
|
||||
}))
|
||||
// Since P1 sends "b" after it delivers "a",
|
||||
// every process must also deliver "a" before "b".
|
||||
.invariant(invariants::all_invariants(vec![
|
||||
boxed!(move |state: &McState| {
|
||||
for proc in &invariant_procs {
|
||||
if let Some(b) = mc_delivery_index(state, proc, "b") {
|
||||
if !mc_delivery_index(state, proc, "a").is_some_and(|a| a < b) {
|
||||
return Err("Violated CAUSAL ORDER".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
boxed!(move |_: &McState| {
|
||||
let next = checked.get() + 1;
|
||||
checked.set(next);
|
||||
if next > CAUSAL_MC_STATE_LIMIT {
|
||||
Err(format!(
|
||||
"causal model-checking state limit of {CAUSAL_MC_STATE_LIMIT} exceeded"
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}),
|
||||
invariants::time_limit(Duration::from_secs(55)),
|
||||
]))
|
||||
};
|
||||
let mut mc = ModelChecker::new(&sys);
|
||||
|
||||
// Explore all schedules of "a" before P1 sends "b". The first stage
|
||||
// checks delivery of "a" on every branch and retains every state where
|
||||
// P1 can send "b" after delivering "a".
|
||||
let states = mc
|
||||
.run::<Bfs>(make_strategy(vec!["a"]).collect(boxed!(|state: &McState| {
|
||||
mc_delivery_index(state, "1", "a").is_some()
|
||||
})))
|
||||
.map_err(&report_error)?
|
||||
.collected_states;
|
||||
if states.is_empty() {
|
||||
return Err("No state with delivery of a on process 1 was reached".to_string());
|
||||
}
|
||||
|
||||
// Explore every continuation after sending "b" from each collected state.
|
||||
// Resource exhaustion is reported as incomplete rather than pruning paths.
|
||||
mc.run_from_states_with_change::<Bfs>(make_strategy(vec!["a", "b"]), states, |sys| {
|
||||
sys.send_local_message(
|
||||
"1",
|
||||
"1",
|
||||
Message::json("SEND", &BroadcastMessage { text: "b" }),
|
||||
);
|
||||
})
|
||||
.map(|_| true)
|
||||
.map_err(report_error)
|
||||
}
|
||||
|
||||
fn mc_delivery_index(state: &McState, proc: &str, text: &str) -> Option<usize> {
|
||||
state.node_states[proc].proc_states[proc]
|
||||
.local_outbox
|
||||
.iter()
|
||||
.position(|msg| {
|
||||
msg.tip == "DELIVER"
|
||||
&& serde_json::from_str::<Value>(&msg.data).unwrap()["text"] == text
|
||||
})
|
||||
}
|
||||
|
||||
fn mc_prune_proc_permutations(equivalent_procs: &[String]) -> PruneFn {
|
||||
let equivalent_procs = equivalent_procs.to_vec();
|
||||
boxed!(move |state| {
|
||||
let proc_names = HashSet::<String>::from_iter(equivalent_procs.clone().into_iter());
|
||||
let mut used_proc_names = HashSet::<String>::new();
|
||||
let mut waiting_for_proc = 0;
|
||||
for entry in &state.trace {
|
||||
match entry {
|
||||
LogEntry::McMessageReceived { src: proc, .. }
|
||||
| LogEntry::McTimerFired { proc, .. } => {
|
||||
if used_proc_names.contains(proc) || !proc_names.contains(proc) {
|
||||
continue;
|
||||
}
|
||||
if equivalent_procs[waiting_for_proc] != *proc {
|
||||
return Some(
|
||||
"state is the same as another state with renumerated processes"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
used_proc_names.insert(proc.clone());
|
||||
waiting_for_proc += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn mc_prune_msg_per_proc_limit(proc_names: &[String], limit: usize) -> PruneFn {
|
||||
prunes::events_limit_per_proc(
|
||||
|entry: &LogEntry, proc: &String| match entry {
|
||||
LogEntry::McMessageReceived { src, .. } => src == proc,
|
||||
_ => false,
|
||||
},
|
||||
proc_names.to_owned(),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn mc_invariant(proc_names: Vec<String>, sent_message: String) -> InvariantFn {
|
||||
boxed!(move |state: &McState| {
|
||||
for name in &proc_names {
|
||||
let outbox = &state.node_states[name].proc_states[name].local_outbox;
|
||||
let mut message_data = HashSet::new();
|
||||
for message in outbox {
|
||||
let data: Value = serde_json::from_str(&message.data).unwrap();
|
||||
let message = data["text"].as_str().unwrap().to_string();
|
||||
|
||||
if message_data.contains(&message) {
|
||||
return Err("Violated NO DUPLICATION".to_owned());
|
||||
}
|
||||
message_data.insert(message.clone());
|
||||
if message != sent_message {
|
||||
return Err("Violated NO CREATION".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -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()
|
||||
@@ -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}')
|
||||
@@ -0,0 +1,138 @@
|
||||
# Group Membership
|
||||
|
||||
В этом задании вам предстоит реализовать распределённый сервис для отслеживания состава группы. Он должен позволять процессам вступать в группу, покидать её и узнавать её текущий состав.
|
||||
|
||||
В системе _N_ узлов, на каждом из которых запущен процесс с уникальным идентификатором _id_. Процессы вступают в _группу_ по команде `JOIN` и покидают её по команде `LEAVE`. Сам по себе запуск процесса не делает его участником группы.
|
||||
|
||||
Помимо добровольного выхода, состав группы может меняться из-за отказов. В нашей системе возможны:
|
||||
|
||||
- _Падение процесса или всего узла_. Процесс прекращает выполнение и теряет состояние в памяти. Позднее он может быть перезапущен с тем же _id_ и чистым состоянием. Это модель _crash-recovery_ из лекции 6. Работающий процесс будем называть _живым_.
|
||||
- _Потери сообщений_ между некоторыми парами узлов, в обоих или только в одном направлении. Живые процессы могут оказаться полностью или частично изолированы от остальных. Сетевые сбои могут быть временными, после чего связь восстанавливается.
|
||||
|
||||
Для обнаружения недоступных участников вам потребуется _детектор отказов_ (см. лекцию 6). Он сообщает о подозрении: отсутствие ответа может быть вызвано как падением процесса, так и проблемами сети. От детектора требуется _полнота_: падение участника должно в конечном счёте обнаруживаться всеми оставшимися участниками. Подберите баланс между скоростью обнаружения, числом ложных подозрений и расходами на обмен сообщениями.
|
||||
|
||||
Списки участников на разных процессах могут временно различаться после входа, выхода, отказа или восстановления. Требуется _согласованность в конечном счёте_: когда условия работы перестают меняться, списки должны стабилизироваться. Каждый участник должен видеть в своём списке себя и всех живых участников, с которыми возможен обмен сообщениями в обе стороны, в том числе через других участников.
|
||||
|
||||
Упавшие и добровольно вышедшие процессы должны автоматически исключаться из списков остальных участников. То же относится к живому процессу, который не может обмениваться сообщениями в обе стороны ни с одним другим участником.
|
||||
|
||||
Если сеть распалась на подгруппы, между которыми нет двусторонней связи, происходит _разделение сети_. В этом случае списки должны совпадать внутри каждой подгруппы, включая меньшую, и содержать всех её участников, без процессов из других подгрупп. В списке участников изолированного процесса должен остаться только он сам. Односторонней связи между подгруппами недостаточно для сохранения общей группы.
|
||||
|
||||
После перезапуска процесс снова вступает в группу по команде `JOIN`, и остальные участники должны вернуть его в свои списки. При восстановлении связи с временно недоступным живым участником они должны сделать это автоматически, без новой команды `JOIN`. Это нужно учитывать и при использовании идей SWIM из лекции: исключение из локального списка не должно навсегда препятствовать возвращению участника.
|
||||
|
||||
Чтобы получить максимальный балл, нужно также обеспечить масштабируемость решения. При увеличении числа узлов в _X_ раз сетевой трафик и число сообщений в единицу времени должны увеличиваться не более чем в _2X_ раз. Нагрузка на процессы измеряется суммой отправленных и полученных сообщений; отношение максимальной нагрузки к минимальной не должно превышать 5.
|
||||
|
||||
Подробные сценарии проверок приведены в тестах — они являются частью условия.
|
||||
|
||||
## Реализация
|
||||
|
||||
Для реализации и тестирования решения используется фреймворк AnySystem (см. материалы первого семинара). В папке `solution` размещена заготовка для решения [member.py](solution/member.py). Вам надо доработать реализацию процесса в классе `GroupMember` так, чтобы проходили все тесты.
|
||||
|
||||
Из библиотек разрешены только AnySystem и стандартная библиотека Python. Можно добавлять собственные вспомогательные модули. Сжимать сетевые сообщения запрещено, в том числе с помощью `zlib`, `gzip`, `bz2`, `lzma` или собственного алгоритма сжатия. Снижайте сетевые расходы за счёт устройства протокола: например, передавайте только изменения состава группы вместо полного списка.
|
||||
|
||||
Ваши реализации процессов AnySystem должны соблюдать правило [изоляции процессов](../readme.md#изоляция-процессов-anysystem): изменяемое состояние каждого процесса должно храниться только в `self`, а общая изменяемая память между процессами запрещена. Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
|
||||
|
||||
При инициализации процессу передается его уникальный _id_. Процесс должен поддерживать обработку следующих локальных сообщений:
|
||||
|
||||
- _JOIN_ - команда присоединиться к группе. В результате обработки этой команды процесс должен стать участником группы. В поле `seed` передается идентификатор одного из живых участников группы. Если в `seed` находится идентификатор самого процесса, то он должен создать новую пустую группу и добавить в неё себя. Ответ на это сообщение не нужен.
|
||||
- _LEAVE_ - команда покинуть группу. В результате обработки этой команды процесс должен перестать быть участником группы. Ответ на это сообщение не нужен.
|
||||
- _GET_MEMBERS_ - запрос локального списка участников группы. Процесс должен сразу ответить одним локальным сообщением _MEMBERS_ с хранимым списком идентификаторов (см. заготовку). Обращаться к другим процессам или ждать таймера для ответа нельзя. Эта семантика проверяется отдельным тестом.
|
||||
|
||||
Для взаимодействия между процессами вы можете использовать любые собственные типы сообщений.
|
||||
|
||||
**Важно!** Для измерения прошедшего времени в коде процесса используйте метод `ctx.time()`, возвращающий локальное время "внутри симуляции". Использовать обычные способы, например `time.time()`, некорректно, так как скорость течения времени в симуляции отличается от реального времени. Также не стоит опираться на синхронизацию часов в системе - у каждого узла свои локальные часы, и показания `ctx.time()` у разных процессов могут расходиться. После перезапуска узла смещение его часов может измениться.
|
||||
|
||||
В коде процесса можно использовать генератор случайных чисел из `random`, но не следует его явно инициализировать с помощью `random.seed()`. Это уже делается в тестах на основе переданного random seed, а дополнительная инициализация может нарушить воспроизводимость результатов.
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Локальное тестирование
|
||||
|
||||
Тесты находятся в папке `tests`. Есть два варианта их запуска.
|
||||
|
||||
Рекомендуемый вариант запуска тестов - через готовый Docker-образ. В этом случае используемое окружение будет аналогично тестирующей системе. Для запуска тестов выполните команду:
|
||||
|
||||
```commandline
|
||||
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/membership:latest [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
|
||||
```
|
||||
|
||||
Для запуска полного набора тестов с теми же параметрами, что и в тестирующей системе, выполните команду:
|
||||
|
||||
```commandline
|
||||
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/membership:latest -m 1000
|
||||
```
|
||||
|
||||
Вы также можете собрать и запустить тесты непосредственно в Linux с помощью компилятора Rust. На Windows можно использовать WSL или Docker, на macOS — Docker. Сборка из исходников может быть удобна, если вы хотите лучше изучить или доработать тесты. Скомпилируйте тесты с помощью команды `cargo install --locked --path tests`. Для запуска тестов выполните команду:
|
||||
|
||||
```commandline
|
||||
distsys-membership [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
|
||||
```
|
||||
|
||||
Запустить один тест можно с помощью опции `-t` (например, `... -t "PROCESS CRASH"`). По умолчанию тестер выводит результаты проверок, диагностику долгих запусков и итоговую сводку. Флаг `-d` добавляет трассу событий, а `-h` показывает все доступные опции. При отдельном запуске теста масштабируемости через `-t` предварительное прохождение функциональных тестов не требуется.
|
||||
|
||||
### Что проверяют тесты
|
||||
|
||||
Сначала выполняются обычные функциональные тесты, затем `CHAOS MONKEY`, `MODEL CHECKING` и тесты масштабируемости.
|
||||
|
||||
На каждом этапе обычных тестов списки участников должны сойтись к ожидаемому составу за 300 секунд симуляции. В тестах изоляции проверяются и оставшаяся группа, и изолированный участник: в его списке должен остаться только он сам.
|
||||
|
||||
Тесты `NETWORK PARTITION UNEQUAL` и `NETWORK PARTITION UNEQUAL RECOVER` разделяют сеть на две непустые подгруппы разного размера: 4 и 6 при десяти процессах. Состав группы должен стабилизироваться в обеих подгруппах, включая меньшую. Второй тест затем восстанавливает связь и проверяет, что все процессы снова видят общую группу.
|
||||
|
||||
Тесты `TWO PROCESSES CANNOT COMMUNICATE` и `TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE` отключают связь между парой узлов, сохраняя связь через остальных участников. Во втором тесте всегда три узла. Все участники должны остаться в группе.
|
||||
|
||||
В этих двух тестах, а также после восстановления в `PROCESS CRASH RECOVER`, `PROCESS OFFLINE RECOVER`, `NETWORK PARTITION RECOVER` и `NETWORK PARTITION UNEQUAL RECOVER` проверяется непрерывная стабильность состава группы. За 300 секунд симуляции должен найтись интервал длительностью 30 секунд, в котором списки участников остаются корректными. Внутри этого интервала списки проверяются после каждого события. Переходные ошибки до стабилизации допускаются.
|
||||
|
||||
`CHAOS MONKEY` проверяет последовательности отказов. По умолчанию выполняются 100 прогонов, а в тестирующей системе — 1000 (`-m 1000`). Каждый прогон включает до шести шагов с отказами; при стандартных десяти процессах в проверяемой группе может остаться четыре участника.
|
||||
|
||||
Для отладки `CHAOS MONKEY` используйте флаг `-v` (`--verbose`): он выводит параметры запуска, порядок входа процессов и отказы с номерами процессов и временем в симуляции. Этот вывод также включается с `-d`. Чтобы повторить запуск, укажите то же значение `-s` (`--seed`) и то же число процессов `-p`, а в `-m` — число не меньше номера нужного прогона. Например, `... -t "CHAOS MONKEY (run 850)" -m 1000 -s 123 -p 10 -v`. В тестах выбор процессов воспроизводим при одинаковых параметрах; случайность внутри вашего решения также должна быть воспроизводимой.
|
||||
|
||||
`MODEL CHECKING` (MC) проверяет сборку группы из трёх процессов без отказов и ответы на `GET_MEMBERS` при разных порядках доставки сообщений. Полный состав ожидается в состояниях, где каждый процесс получил не менее трёх сетевых сообщений или больше нет событий. В тестирующей системе MC включён; для быстрой локальной проверки его можно отключить флагом `--disable-mc-tests`.
|
||||
|
||||
Тесты `SCALABILITY NORMAL` и `SCALABILITY CRASH` измеряют расходы на поддержание группы. В первом тесте измерение длится 10 секунд симуляции после сборки группы, во втором — от падения одного процесса до стабилизации состава. Расходы на первоначальную сборку группы не учитываются; во втором тесте равномерность нагрузки проверяется среди оставшихся живых процессов.
|
||||
|
||||
При стандартных параметрах оба теста масштабируемости запускаются для групп из 10, 20, 50 и 100 узлов. Сетевой трафик и число сообщений в единицу времени сравниваются между последовательными запусками: 10 → 20, 20 → 50 и 50 → 100. По правилу _2X_ допустимый рост расходов составляет соответственно 4, 5 и 4 раза.
|
||||
|
||||
### Лимиты и диагностика
|
||||
|
||||
Тесты выполняются в отдельных процессах; прогоны `CHAOS MONKEY` объединены в серию с сохранением результата каждого прогона. При зависании, исчерпании памяти или аварийном завершении исполнителя проверка продолжается, пока остаётся общий бюджет времени. Баллы за пройденные функциональные компоненты и сетевые подгруппы сохраняются. Непроверенные тесты отмечаются в сводке и не считаются пройденными.
|
||||
|
||||
По умолчанию и в тестирующей системе действуют следующие ограничения реального времени. Все проверки делят общий бюджет 540 секунд:
|
||||
|
||||
| Проверка | Лимит | Опция |
|
||||
| --- | --- | --- |
|
||||
| Обычный тест или один прогон CHAOS MONKEY | 15 секунд | `--test-timeout` |
|
||||
| Все прогоны CHAOS MONKEY вместе | 300 секунд | `--chaos-timeout` |
|
||||
| Обе фазы MODEL CHECKING вместе | 450 секунд | `--mc-timeout` |
|
||||
| Каждый тест SCALABILITY | 60 секунд | `--scalability-timeout` |
|
||||
| Полный набор тестов | 540 секунд | `--suite-timeout` |
|
||||
|
||||
Адресное пространство исполнителя ограничено 4 ГиБ для обычных тестов, CHAOS MONKEY и SCALABILITY (`--memory-limit 4096`) и 14 ГиБ для MODEL CHECKING (`--mc-memory-limit 14336`). В тестирующей системе эти пределы фиксированы. Окружение должно также иметь запас 256 МиБ для управляющего процесса.
|
||||
|
||||
Если памяти окружения недостаточно для заданного предела и запаса для управляющего процесса, тестер завершится с сообщением об ошибке окружения без оценки. Для локального запуска в меньшем объёме памяти можно явно уменьшить соответствующий предел, но результат может отличаться от проверки на сервере.
|
||||
|
||||
При остановке тестер сообщает причину и последний этап проверки: например, размер собираемой группы или фазу model checking. Долгие проверки также периодически выводят текущий этап и расход памяти.
|
||||
|
||||
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
|
||||
|
||||
### Проверка в тестирующей системе
|
||||
|
||||
Отправьте решение в тестирующую систему по [инструкции](../readme.md) и дождитесь результатов.
|
||||
|
||||
## Оценивание
|
||||
|
||||
Это задание имеет повышенный вес 1.5, то есть максимум можно набрать **15 баллов**.
|
||||
|
||||
Базовый функционал, отказы процессов и четыре сетевые подгруппы оцениваются независимо друг от друга. Внутри каждого компонента или подгруппы нужно пройти все перечисленные обычные тесты; исключения для MC описаны ниже. Масштабируемость даёт баллы только при зачёте всей функциональной части.
|
||||
|
||||
- **Базовый функционал без отказов — до 4 баллов:**
|
||||
- **3 балла** за прохождение всех пяти тестов: `SIMPLE`, `GET MEMBERS SEMANTICS`, `RANDOM SEED`, `PROCESS JOIN`, `PROCESS LEAVE`.
|
||||
- **Ещё 1 балл**, если пройдены и все пять обычных тестов, и `MODEL CHECKING`. Отключение MC при локальном запуске или остановка уже запущенного MC по лимиту времени (в том числе общего бюджета) или памяти сохраняет первые 3 балла.
|
||||
- **0 баллов за весь компонент**, если MC обнаружил ошибку, не запустился из-за исчерпания общего бюджета или его исполнитель аварийно завершился с неизвестной причиной.
|
||||
- **Отказы процессов — 3 балла:** `PROCESS CRASH`, `SEED PROCESS CRASH`, `PROCESS CRASH RECOVER`.
|
||||
- **Сетевые и комбинированные отказы — до 4 баллов, по 1 баллу за каждую подгруппу:**
|
||||
- **Отключения и разделения сети с восстановлением:** `PROCESS OFFLINE`, `SEED PROCESS OFFLINE`, `PROCESS OFFLINE RECOVER`, `NETWORK PARTITION`, `NETWORK PARTITION RECOVER`, `NETWORK PARTITION UNEQUAL`, `NETWORK PARTITION UNEQUAL RECOVER`.
|
||||
- **Односторонняя и частичная связность:** `PROCESS CANNOT RECEIVE`, `PROCESS JOIN CANNOT RECEIVE`, `PROCESS CANNOT SEND`, `TWO PROCESSES CANNOT COMMUNICATE`, `TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE`.
|
||||
- **Задержки и потери сообщений:** `SLOW NETWORK`, `FLAKY NETWORK`, `FLAKY NETWORK ON START`, `FLAKY NETWORK AND CRASH`.
|
||||
- **Последовательности отказов:** все 1000 прогонов `CHAOS MONKEY`.
|
||||
- **Масштабируемость — 4 балла:** `SCALABILITY NORMAL`, `SCALABILITY CRASH`. Эти тесты запускаются в полном наборе только после зачёта всех функциональных компонентов: базовой группы (не менее 3 баллов), отказов процессов и всех четырёх сетевых подгрупп. Остановка уже запущенного MC по времени или памяти и его отключение при локальном запуске не блокируют масштабируемость. Если функциональные компоненты не зачтены, тесты масштабируемости пропускаются с указанием причины. Для получения 4 баллов нужно пройти оба теста; при исчерпании общего бюджета незапущенные тесты не считаются пройденными.
|
||||
|
||||
К решению приложите отчёт в файле `solution/readme.md` по [общим требованиям](../readme.md#отчёт). Без отчёта автоматические тесты запускаются, но защита не проводится и решение не засчитывается.
|
||||
@@ -0,0 +1,34 @@
|
||||
from anysystem import Context, Message, Process
|
||||
|
||||
|
||||
class GroupMember(Process):
|
||||
def __init__(self, proc_id: str):
|
||||
self._id = proc_id
|
||||
|
||||
def on_local_message(self, msg: Message, ctx: Context):
|
||||
if msg.type == 'JOIN':
|
||||
# Add process to the group
|
||||
seed = msg['seed']
|
||||
if seed == self._id:
|
||||
# create new empty group and add process to it
|
||||
pass
|
||||
else:
|
||||
# join existing group
|
||||
pass
|
||||
elif msg.type == 'LEAVE':
|
||||
# Remove process from the group
|
||||
pass
|
||||
elif msg.type == 'GET_MEMBERS':
|
||||
# Get a list of group members
|
||||
# - return the list of all known alive processes in MEMBERS message
|
||||
ctx.send_local(Message('MEMBERS', {'members': [self._id]}))
|
||||
|
||||
def on_start(self, ctx: Context):
|
||||
pass
|
||||
|
||||
def on_message(self, msg: Message, sender: str, ctx: Context):
|
||||
# Implement communication between processes using any message types
|
||||
pass
|
||||
|
||||
def on_timer(self, timer_name: str, ctx: Context):
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
target
|
||||
**/target
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
*.pyc
|
||||
Generated
+993
@@ -0,0 +1,993 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anysystem"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01badd3bc0d042e4399f8f685c394a79a72e7f233b3d3b5eabb3b94734f4dbe"
|
||||
dependencies = [
|
||||
"colored",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"indexmap 2.14.0",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"ordered-float",
|
||||
"pyo3",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"regex",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simcore",
|
||||
"sugars",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assertables"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d84b91d63c67e6c0b1a74728dad0057c4a6124b74e671ff91b7fe9f8bbd671aa"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"bitflags",
|
||||
"clap_derive",
|
||||
"clap_lex",
|
||||
"indexmap 1.9.3",
|
||||
"once_cell",
|
||||
"strsim",
|
||||
"termcolor",
|
||||
"textwrap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "3.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||
dependencies = [
|
||||
"os_str_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distsys-membership"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anysystem",
|
||||
"assertables",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"indexmap 2.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"pyo3",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sugars",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"humantime",
|
||||
"log",
|
||||
"regex",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.175"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
|
||||
dependencies = [
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
|
||||
|
||||
[[package]]
|
||||
name = "relative-path"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "rstest"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"rstest_macros",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rstest_macros"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"glob",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"relative-path",
|
||||
"rustc_version",
|
||||
"syn 2.0.106",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.143"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_type_name"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92acc4cd6ae20767c54d6cf1a075624e7f4d9e99d7ebc685398ff243144d8714"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simcore"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a20dfec85e94e569fa5b810b2d992c68b361f31bfba471447427536488c9c292"
|
||||
dependencies = [
|
||||
"colored",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"erased-serde",
|
||||
"futures",
|
||||
"log",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_type_name",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "sugars"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc0db74f9ee706e039d031a560bd7d110c7022f016051b3d33eeff9583e3e67a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "distsys-membership"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
resolver = "3"
|
||||
|
||||
[dependencies]
|
||||
anysystem = "=0.3.0"
|
||||
indexmap = "2.12"
|
||||
assertables = "3.2.2"
|
||||
clap = { version = "3.2.17", features = ["cargo", "derive"] }
|
||||
env_logger = "0.9.0"
|
||||
log = "0.4.17"
|
||||
pyo3 = { version = "=0.29.2", features = ["auto-initialize"] }
|
||||
rand = "0.8.5"
|
||||
rand_pcg = "0.3.1"
|
||||
serde = { version = "1.0.142", features = ["derive"] }
|
||||
serde_json = "1.0.83"
|
||||
sugars = "3.0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2.175"
|
||||
@@ -0,0 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential python3-dev
|
||||
|
||||
WORKDIR /tests
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=distsys-course-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=distsys-course-cargo-target-rust-1-97-1,target=/tests/target,sharing=locked \
|
||||
cargo install --locked --path . --root /opt/course
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y python3-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /opt/course/bin/distsys-membership /usr/local/bin/distsys-membership
|
||||
|
||||
WORKDIR /solution
|
||||
ENTRYPOINT ["timeout", "--verbose", "-k", "10", "600", "distsys-membership", "-i", "member.py"]
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user