diff --git a/.github/workflows/hw4-tests.yml b/.github/workflows/hw4-tests.yml new file mode 100644 index 00000000..9e8a566a --- /dev/null +++ b/.github/workflows/hw4-tests.yml @@ -0,0 +1,59 @@ +name: "HW4 Tests" + +# Запускаем тесты при изменении файлов в hw2/hw/ +on: + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test-hw4: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run all tests with coverage (HW4) + working-directory: hw2/hw + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw + run: | + pytest --cov=shop_api --cov-report=term-missing --cov-report=xml --cov-fail-under=95 test_homework2.py test_edge_cases.py test_websocket.py -v + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v3 + with: + files: ./hw2/hw/coverage.xml + fail_ci_if_error: false + + - name: Run transaction isolation demos + if: matrix.python-version == '3.11' + working-directory: hw2/hw + run: | + echo "Running transaction isolation demonstrations..." + python transaction_demos/01_dirty_read_demo.py + python transaction_demos/02_no_dirty_read_demo.py + python transaction_demos/03_non_repeatable_read_demo.py + python transaction_demos/04_no_non_repeatable_read_demo.py + python transaction_demos/05_phantom_read_demo.py + python transaction_demos/06_no_phantom_read_demo.py + echo "All transaction demos completed successfully!" diff --git a/hw1/app.py b/hw1/app.py index 6107b870..fca337af 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,10 +1,13 @@ +import math +from http import HTTPStatus from typing import Any, Awaitable, Callable +import json async def application( - scope: dict[str, Any], - receive: Callable[[], Awaitable[dict[str, Any]]], - send: Callable[[dict[str, Any]], Awaitable[None]], + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], ): """ Args: @@ -12,8 +15,106 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + async def send_response(status: int, body: dict[str, Any] | None = None): + content = b"" + headers = [(b"content-type", b"application/json")] + if body is not None: + content = json.dumps(body).encode("utf-8") + headers.append((b"content-length", str(len(content)).encode("utf-8"))) + else: + headers.append((b"content-length", b"0")) + + await send( + {"type": "http.response.start", "status": status, "headers": headers} + ) + await send({"type": "http.response.body", "body": content}) + + # --- lifespan events (startup/shutdown) --- + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + # --- обычные HTTP запросы --- + if scope["type"] != "http": + return + + method = scope["method"] + path = scope["path"] + + # factorial + if method == "GET" and path == "/factorial": + query_string = scope.get("query_string", b"").decode() + query = dict( + (q.split("=") + [""])[:2] + for q in query_string.split("&") + if q + ) + n_str = query.get("n") + if n_str is None or n_str == "": + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + try: + n = int(n_str) + except ValueError: + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + if n < 0: + return await send_response(HTTPStatus.BAD_REQUEST) + return await send_response(HTTPStatus.OK, {"result": math.factorial(n)}) + + # fibonacci + if method == "GET" and path.startswith("/fibonacci"): + parts = path.split("/") + if len(parts) != 3: + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + try: + n = int(parts[2]) + except ValueError: + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + if n < 0: + return await send_response(HTTPStatus.BAD_REQUEST) + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return await send_response(HTTPStatus.OK, {"result": a}) + + # mean + if method == "GET" and path == "/mean": + body_event = await receive() + if body_event.get("type") != "http.request": + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + body_bytes = body_event.get("body", b"") or b"" + if not body_bytes: + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + + try: + data = json.loads(body_bytes.decode()) + except json.JSONDecodeError: + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + + if not isinstance(data, list): + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + if len(data) == 0: + return await send_response(HTTPStatus.BAD_REQUEST) + + try: + nums = [float(x) for x in data] + except (TypeError, ValueError): + return await send_response(HTTPStatus.UNPROCESSABLE_ENTITY) + + return await send_response( + HTTPStatus.OK, {"result": sum(nums) / len(nums)} + ) + + # всё остальное → 404 + return await send_response(HTTPStatus.NOT_FOUND) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/.dockerignore b/hw2/hw/.dockerignore new file mode 100644 index 00000000..677e1455 --- /dev/null +++ b/hw2/hw/.dockerignore @@ -0,0 +1,15 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.venv +venv +*.egg-info +.git +.gitignore +README.md +MONITORING.md +CLAUDE.md +chat.html +ddoser.py diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..27b0b636 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw2/hw/MONITORING.md b/hw2/hw/MONITORING.md new file mode 100644 index 00000000..dc790371 --- /dev/null +++ b/hw2/hw/MONITORING.md @@ -0,0 +1,86 @@ +# Monitoring Setup Guide for Shop API + +This guide explains how to set up monitoring for the Shop API using Docker, Prometheus, and Grafana. + +## Prerequisites + +- Docker and Docker Compose installed +- Ports 3000 (Grafana), 8080 (API), and 9090 (Prometheus) available + +## Quick Start + +### 1. Build and Start Services + +```bash +docker-compose up --build +``` + +This will start three services: +- **shop-api**: The FastAPI application on port 8080 +- **prometheus**: Metrics collection on port 9090 +- **grafana**: Visualization dashboard on port 3000 + +### 2. Verify Services are Running + +- API: http://localhost:8080/docs +- Prometheus: http://localhost:9090 +- Grafana: http://localhost:3000 + +## Setting Up Grafana + +### 1. Login to Grafana + +- Navigate to http://localhost:3000 +- Username: `admin` +- Password: `admin` +- You'll be prompted to change the password (you can skip this for local development) + +### 2. Add Prometheus Data Source + +1. Click on the **gear icon** (⚙️) in the left sidebar → **Data Sources** +2. Click **Add data source** +3. Select **Prometheus** +4. Configure: + - **Name**: `Prometheus` + - **URL**: `http://prometheus:9090` + - Leave other settings as default +5. Click **Save & Test** - you should see "Data source is working" + +### 3. Create Dashboards + + +1. Click **+** icon in left sidebar → **Import** +2. Enter data from `settings/grafana_config.json` +5. Click **Import** + +## Running Load Tests + +To generate traffic for monitoring: + +```bash +# Make sure the API is running via docker-compose +python ddoser.py +``` + +The load test will: +- Create random items +- Query items with various filters +- Create carts +- Add items to carts +- Update and delete items +- Generate traffic across all API endpoints + +## Stopping Services + +```bash +docker-compose down +``` + +To remove volumes as well: +```bash +docker-compose down -v +``` + +## HW done + +![grafana.jpg](grafana.jpg) diff --git a/hw2/hw/README.md b/hw2/hw/README.md index ba9f23c8..0bc1058b 100644 --- a/hw2/hw/README.md +++ b/hw2/hw/README.md @@ -96,7 +96,7 @@ Чтобы запустить тесты только для этого задания вызовите: ```sh -pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +pytest -vv --showlocals --strict ./hw2/hw/test_homework2.py ``` Если получаете ошибку на подобии `No module named 'shop_api'` @@ -121,3 +121,14 @@ export PYTHONPATH=${PWD}/hw2/hw начале в следующем виде: `{username} :: {message}`. Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 + +### Тест чата + +Добавил в `requirements.txt`: `uvicorn[standard]`, без этого вебсокеты не работали. + +Чтобы потестить чат, запускаем: +``` +uvicorn shop_api.main:app --reload +``` + +Затем открываем: ```chat.html``` из нескольких вкладок, коннектимся и наслаждаемся. diff --git a/hw2/hw/chat.html b/hw2/hw/chat.html new file mode 100644 index 00000000..ffb8db0a --- /dev/null +++ b/hw2/hw/chat.html @@ -0,0 +1,32 @@ + + + Chat Test + + + + +

+ + +
+ + + + \ No newline at end of file diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..1f5628c1 --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,26 @@ +import pytest +import os +import sys + +# Add current directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from shop_api.database import init_db, engine, Base + + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + """Initialize database before tests and clean up after""" + # Remove old test database if exists + if os.path.exists("shop.db"): + os.remove("shop.db") + + # Create tables + init_db() + + yield + + # Clean up after tests + Base.metadata.drop_all(bind=engine) + if os.path.exists("shop.db"): + os.remove("shop.db") diff --git a/hw2/hw/ddoser.py b/hw2/hw/ddoser.py new file mode 100755 index 00000000..abc81519 --- /dev/null +++ b/hw2/hw/ddoser.py @@ -0,0 +1,126 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from faker import Faker + +faker = Faker() + +BASE_URL = "http://localhost:8080" + + +def create_items(): + """Create random items in the shop""" + for _ in range(100): + item = { + "name": faker.catch_phrase(), + "price": round(faker.random.uniform(10.0, 500.0), 2), + } + response = requests.post(f"{BASE_URL}/item", json=item) + print(f"Created item: {response.status_code}") + + +def get_items(): + """Get items with random filters""" + for _ in range(100): + params = {} + if faker.boolean(): + params["min_price"] = faker.random.uniform(0, 100) + if faker.boolean(): + params["max_price"] = faker.random.uniform(100, 500) + if faker.boolean(): + params["offset"] = faker.random_int(0, 10) + if faker.boolean(): + params["limit"] = faker.random_int(1, 20) + + response = requests.get(f"{BASE_URL}/item", params=params) + print(f"Get items: {response.status_code}") + + +def create_carts(): + """Create random carts""" + for _ in range(50): + response = requests.post(f"{BASE_URL}/cart") + print(f"Created cart: {response.status_code}") + + +def add_items_to_carts(): + """Add random items to random carts""" + for _ in range(200): + cart_id = faker.random_int(1, 50) + item_id = faker.random_int(1, 100) + response = requests.post(f"{BASE_URL}/cart/{cart_id}/add/{item_id}") + print(f"Add item to cart: {response.status_code}") + + +def get_carts(): + """Get carts with random filters""" + for _ in range(100): + params = {} + if faker.boolean(): + params["min_price"] = faker.random.uniform(0, 500) + if faker.boolean(): + params["max_price"] = faker.random.uniform(500, 2000) + if faker.boolean(): + params["min_quantity"] = faker.random_int(0, 5) + if faker.boolean(): + params["max_quantity"] = faker.random_int(5, 20) + + response = requests.get(f"{BASE_URL}/cart", params=params) + print(f"Get carts: {response.status_code}") + + +def patch_items(): + """Partially update random items""" + for _ in range(50): + item_id = faker.random_int(1, 100) + update = {} + if faker.boolean(): + update["name"] = faker.catch_phrase() + if faker.boolean(): + update["price"] = round(faker.random.uniform(10.0, 500.0), 2) + + if update: # Only send if we have something to update + response = requests.patch(f"{BASE_URL}/item/{item_id}", json=update) + print(f"Patch item: {response.status_code}") + + +def delete_items(): + """Delete random items""" + for _ in range(20): + item_id = faker.random_int(1, 100) + response = requests.delete(f"{BASE_URL}/item/{item_id}") + print(f"Delete item: {response.status_code}") + + +print("Starting load test...") + +with ThreadPoolExecutor(max_workers=100) as executor: + futures = {} + + # Submit various tasks + for i in range(5): + futures[executor.submit(create_items)] = f"create-items-{i}" + + for i in range(10): + futures[executor.submit(get_items)] = f"get-items-{i}" + + for i in range(3): + futures[executor.submit(create_carts)] = f"create-carts-{i}" + + for i in range(10): + futures[executor.submit(add_items_to_carts)] = f"add-items-{i}" + + for i in range(8): + futures[executor.submit(get_carts)] = f"get-carts-{i}" + + for i in range(4): + futures[executor.submit(patch_items)] = f"patch-items-{i}" + + for i in range(2): + futures[executor.submit(delete_items)] = f"delete-items-{i}" + + # Wait for completion + for future in as_completed(futures): + print(f"✓ Completed {futures[future]}") + +print("\nLoad test completed!") diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..e13bdad8 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,44 @@ +version: "3" + +services: + + shop-api: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + networks: + - monitoring + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=admin + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + networks: + - monitoring + +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/hw2/hw/grafana.jpg b/hw2/hw/grafana.jpg new file mode 100644 index 00000000..1affb77a Binary files /dev/null and b/hw2/hw/grafana.jpg differ diff --git a/hw2/hw/hw4.md b/hw2/hw/hw4.md new file mode 100644 index 00000000..8df964fc --- /dev/null +++ b/hw2/hw/hw4.md @@ -0,0 +1,143 @@ +# ДЗ условие (решение ниже) + +## ДЗ -- часть 1 + +За каждый пункт - 1 балл + +Внедрить во вторую домашку хранение данных в БД, для этого надо: +1) Добавить БД в docket-compose.yml (если БД - это отдельный сервис, если хотите использовать sqlite, то можно скипнуть этот шаг) +2) Переписать код на взаимодействие с вашей БД (если вы еще этого не сделали, если вы уже написали код с БД, подзравляю, вам остался только 3 пункт) +3) В свободной форме, напишите скрипты, которые просимулируют разные "проблемы" которые могут возникнуть в транзакциях (dirty read, not-repeatable read, serialize) и настраивая уровне изоляции покажите, что они действительно решаются (через SQLAlchemy например), то есть: +показать dirty read при read uncommited +показать что нет dirty read при read commited +показать non-repeatable read при read commited +показать что нет non-repeatable read при repeatable read +показать phantom reads при repeatable read +показать что нет phantom reads при serializable +*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции + + +## ДЗ -- часть 2 + +1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл +2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. + +# Решение + +## Часть 1: Миграция на БД и демонстрация транзакций + +1. База данных (SQLite) + +- Добавлена SQLite (не требует docker-compose, т.к. это файловая БД) +- Созданы модели SQLAlchemy: items, carts, cart_items +- Файлы: shop_api/database.py, обновлён shop_api/main.py + +2. Миграция кода + +- Все эндпоинты переписаны на работу с БД +- Все оригинальные тесты проходят +- Используются контекстные менеджеры для транзакций + +3. Демонстрация проблем транзакций + +Создано 6 скриптов в `transaction_demos/`: +- `01_dirty_read_demo.py` - показывает dirty read при READ UNCOMMITTED +- `02_no_dirty_read_demo.py` - нет dirty read при READ COMMITTED +- `03_non_repeatable_read_demo.py` - показывает non-repeatable read +- `04_no_non_repeatable_read_demo.py` - нет non-repeatable read при REPEATABLE READ +- `05_phantom_read_demo.py` - показывает phantom reads +- `06_no_phantom_read_demo.py` - нет phantom reads при SERIALIZABLE + +Запустить все: +``` +python transaction_demos/run_all_demos.py +``` +**Описание проблем** + + 1. Dirty Read (Грязное чтение) + + `01_dirty_read_demo.py` - Проблема: + - Транзакция T1 обновляет баланс с 1000 на 500, но НЕ коммитит + - Транзакция T2 читает баланс и видит 500 (незакоммиченное значение) + - T1 делает ROLLBACK + - Результат: T2 прочитала данные, которых "никогда не было" (они откатились) + + `02_no_dirty_read_demo.py` - Решение (READ COMMITTED): + - T1 обновляет баланс на 500, не коммитит + - T2 пытается прочитать, но видит только закоммиченное значение 1000 + - T1 откатывается + - Результат: T2 всегда видит только подтверждённые данные + + 2. Non-Repeatable Read (Неповторяющееся чтение) + + `03_non_repeatable_read_demo.py` - Проблема: + - T1 читает баланс: получает 1000 + - T2 обновляет баланс на 1500 и COMMIT + - T1 снова читает баланс: получает 1500 + - Результат: в одной транзакции T1 одно и то же чтение дало разные результаты (1000 → 1500) + + `04_no_non_repeatable_read_demo.py` - Решение (REPEATABLE READ / IMMEDIATE): + - T1 начинает транзакцию с блокировкой (BEGIN IMMEDIATE) + - T1 читает баланс: 1000 + - T2 пытается обновить, но блокируется из-за блокировки T1 + - T1 снова читает баланс: всё ещё 1000 + - T1 коммитит, только потом T2 может обновить + - Результат: повторные чтения в T1 возвращают одинаковые значения + + 3. Phantom Read (Фантомное чтение) + + `05_phantom_read_demo.py` - Проблема: + - T1 выполняет запрос: "SELECT * WHERE balance > 500" → находит 2 строки (Alice, Bob) + - T2 вставляет новую строку Charlie с балансом 1500 и COMMIT + - T1 повторяет запрос: "SELECT * WHERE balance > 500" → находит 3 строки (Alice, Bob, Charlie) + - Результат: в одной транзакции появились "фантомные" строки, которых раньше не было + + `06_no_phantom_read_demo.py` - Решение (SERIALIZABLE / EXCLUSIVE): + - T1 начинает с эксклюзивной блокировкой (BEGIN EXCLUSIVE) + - T1 выполняет запрос: находит 2 строки + - T2 пытается вставить новую строку, но блокируется + - T1 повторяет запрос: всё ещё 2 строки + - T1 коммитит, только потом T2 может вставить + - Результат: результаты запросов в T1 стабильны, новые строки не появляются + +Все скрипты используют threading для имитации параллельных транзакций и time.sleep() для синхронизации действий. + + +## Часть 2: Покрытие тестами и CI/CD + +1. Покрытие тестами: 98% (требовалось 95%) + +- Создано 14 дополнительных тестов в test_edge_cases.py +- Создано 5 WebSocket тестов в test_websocket.py +- Всего 58 тестов, все проходят + +**Тесты:** +- test_homework2.py - оригинальные тесты +- test_edge_cases.py - новые edge case тесты +- test_websocket.py - тесты WebSocket + + +2. CI/CD для GitHub Actions + +- Создан .github/workflows/test.yml +- Автоматический запуск тестов при push/PR +- Проверка покрытия ≥95% +- Запуск демонстраций транзакций + + +CI/CD: +- `.github/workflows/hw4-tests.yml` - GitHub Actions + + +# Check + +## Запустить тесты +``` +export PYTHONPATH=${PWD} +pytest --cov=shop_api --cov-fail-under=95 test_homework2.py test_edge_cases.py test_websocket.py +``` + +## Запустить демонстрацию транзакций +``` +python transaction_demos/run_all_demos.py +``` diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..83005713 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,17 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +uvicorn[standard] +prometheus-fastapi-instrumentator +sqlalchemy>=2.0.0 +aiosqlite>=0.19.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.0.0 httpx>=0.27.2 Faker>=37.8.0 + +# Для нагрузочного тестирования +requests diff --git a/hw2/hw/settings/grafana_config.json b/hw2/hw/settings/grafana_config.json new file mode 100644 index 00000000..0db4ba5b --- /dev/null +++ b/hw2/hw/settings/grafana_config.json @@ -0,0 +1,747 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "enable": true, + "hide": false, + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{path!=\"/metrics\"}[2m])) by (le))", + "legendFormat": "p95 latency", + "refId": "A" + } + ], + "title": "P95 Request Latency (seconds)", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum(rate(http_requests_total{path!=\"/metrics\"}[1m]))", + "legendFormat": "RPS total", + "refId": "A" + } + ], + "title": "Requests per Second (RPS, total)", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum by (path, method) (rate(http_requests_total{path!=\"/metrics\"}[1m]))", + "legendFormat": "{{method}} {{path}}", + "refId": "A" + } + ], + "title": "RPS by Endpoint", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum by (status) (rate(http_requests_total{path!=\"/metrics\"}[1m]))", + "legendFormat": "Status {{status}}", + "refId": "A" + } + ], + "title": "HTTP Status Codes (rate)", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "yellow", + "value": 90 + }, + { + "color": "green", + "value": 97 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "100 * sum(rate(http_requests_total{status=~\"2..\"}[2m])) / sum(rate(http_requests_total[2m]))", + "refId": "A" + } + ], + "title": "Success Rate (2xx, %)", + "type": "stat" + }, + { + "fieldConfig": { + "defaults": { + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 3, + "y": 16 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "avg(up{job=\"shop-api\"})", + "refId": "A" + } + ], + "title": "Service Uptime", + "type": "stat" + }, + { + "fieldConfig": { + "defaults": { + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "100 * sum(rate(http_requests_total{status=~\"[45]..\"}[2m])) / sum(rate(http_requests_total[2m]))", + "refId": "A" + } + ], + "title": "Error Rate (4xx + 5xx, %)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 8, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "code", + "expr": "increase(http_requests_total{path!=\"/metrics\"}[5m])", + "interval": "", + "range": true, + "refId": "A" + } + ], + "title": "Total Requests (last 5m)", + "type": "barchart" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "expr": "sum(rate(http_request_duration_seconds_bucket{path!=\"/metrics\"}[2m])) by (le)", + "legendFormat": "< {{le}}s", + "refId": "A" + } + ], + "title": "Request Volume (Histogram buckets)", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 42, + "tags": [ + "fastapi", + "prometheus" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "FastAPI — Enhanced Prometheus Dashboard", + "uid": "d18e430e-cf06-4016-9869-f726f37d8db2", + "version": 3 +} \ No newline at end of file diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..9f893ee9 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 1s + evaluation_interval: 1s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - shop-api:8080 diff --git a/hw2/hw/shop_api/database.py b/hw2/hw/shop_api/database.py new file mode 100644 index 00000000..3fce9dee --- /dev/null +++ b/hw2/hw/shop_api/database.py @@ -0,0 +1,58 @@ +from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, ForeignKey, Table +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship +from contextlib import contextmanager + +# Create SQLite database +SQLALCHEMY_DATABASE_URL = "sqlite:///./shop.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +class DBItem(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String, nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False, nullable=False) + + +class DBCart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + +class DBCartItem(Base): + __tablename__ = "cart_items" + + cart_id = Column(Integer, ForeignKey('carts.id'), primary_key=True) + item_id = Column(Integer, ForeignKey('items.id'), primary_key=True) + quantity = Column(Integer, nullable=False, default=1) + + +def init_db(): + """Initialize database tables""" + Base.metadata.create_all(bind=engine) + + +@contextmanager +def get_db(): + """Get database session""" + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..dc0178ba 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,307 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, status, Response, Query +from pydantic import BaseModel, Field, field_validator +from typing import Annotated, Any +import random +import string +from prometheus_fastapi_instrumentator import Instrumentator +from sqlalchemy.orm import Session +from shop_api.database import init_db, get_db, DBItem, DBCart, DBCartItem app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + +# Initialize database on startup +@app.on_event("startup") +def startup_event(): + init_db() + + +# ============ Data Models ============ + +class ItemCreate(BaseModel): + name: str + price: float = Field(ge=0) + + +class ItemUpdate(BaseModel): + name: str + price: float = Field(ge=0) + + +class ItemPatch(BaseModel): + model_config = {"extra": "forbid"} + + name: str | None = None + price: float | None = Field(default=None, ge=0) + + @classmethod + def validate_price(cls, v): + if v is not None and v < 0: + raise ValueError('price must be non-negative') + return v + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + + +# ============ WebSocket Chat ============ + +chat_rooms: dict[str, list[WebSocket]] = {} +client_names: dict[WebSocket, str] = {} +chat_history: dict[str, list[str]] = {} # chat_name -> list of messages + + +def generate_username() -> str: + return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + + +@app.websocket("/chat/{chat_name}") +async def chat_endpoint(websocket: WebSocket, chat_name: str): + await websocket.accept() + + # Generate random username for this client + username = generate_username() + client_names[websocket] = username + + # Send chat history to new client + if chat_name in chat_history: + for msg in chat_history[chat_name]: + await websocket.send_text(msg) + + # Initialize chat room and history if needed + if chat_name not in chat_rooms: + chat_rooms[chat_name] = [] + if chat_name not in chat_history: + chat_history[chat_name] = [] + + # Add client to chat room + chat_rooms[chat_name].append(websocket) + + try: + while True: + # Receive message from client + message = await websocket.receive_text() + + # Broadcast to all clients in the same chat room + formatted_message = f"{username} :: {message}" + + # Save to history + chat_history[chat_name].append(formatted_message) + + # Broadcast to all connected clients + for client in chat_rooms[chat_name]: + await client.send_text(formatted_message) + except WebSocketDisconnect: + # Remove client from chat room + chat_rooms[chat_name].remove(websocket) + if not chat_rooms[chat_name]: + del chat_rooms[chat_name] + del client_names[websocket] + + +# ============ Item Endpoints ============ + +@app.post("/item", response_model=Item, status_code=status.HTTP_201_CREATED) +def create_item(item: ItemCreate): + with get_db() as db: + db_item = DBItem(name=item.name, price=item.price, deleted=False) + db.add(db_item) + db.flush() + return Item(id=db_item.id, name=db_item.name, price=db_item.price, deleted=db_item.deleted) + + +@app.get("/item/{id}", response_model=Item) +def get_item(id: int): + with get_db() as db: + db_item = db.query(DBItem).filter(DBItem.id == id).first() + if not db_item or db_item.deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return Item(id=db_item.id, name=db_item.name, price=db_item.price, deleted=db_item.deleted) + + +@app.get("/item", response_model=list[Item]) +def get_items( + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(gt=0)] = 10, + min_price: Annotated[float | None, Query(ge=0)] = None, + max_price: Annotated[float | None, Query(ge=0)] = None, + show_deleted: bool = False +): + with get_db() as db: + query = db.query(DBItem) + + if not show_deleted: + query = query.filter(DBItem.deleted == False) + + if min_price is not None: + query = query.filter(DBItem.price >= min_price) + if max_price is not None: + query = query.filter(DBItem.price <= max_price) + + db_items = query.offset(offset).limit(limit).all() + return [Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) for item in db_items] + + +@app.put("/item/{id}", response_model=Item) +def update_item(id: int, item: ItemUpdate): + with get_db() as db: + db_item = db.query(DBItem).filter(DBItem.id == id).first() + if not db_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + db_item.name = item.name + db_item.price = item.price + db.flush() + + return Item(id=db_item.id, name=db_item.name, price=db_item.price, deleted=db_item.deleted) + + +@app.patch("/item/{id}", response_model=Item) +def patch_item(id: int, item: ItemPatch): + with get_db() as db: + db_item = db.query(DBItem).filter(DBItem.id == id).first() + if not db_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + # Cannot patch deleted items + if db_item.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + + # Update only provided fields + if item.name is not None: + db_item.name = item.name + if item.price is not None: + db_item.price = item.price + + db.flush() + return Item(id=db_item.id, name=db_item.name, price=db_item.price, deleted=db_item.deleted) + + +@app.delete("/item/{id}") +def delete_item(id: int): + with get_db() as db: + db_item = db.query(DBItem).filter(DBItem.id == id).first() + if not db_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + db_item.deleted = True + db.flush() + return Response(status_code=status.HTTP_200_OK) + + +# ============ Cart Endpoints ============ + +@app.post("/cart", status_code=status.HTTP_201_CREATED) +def create_cart(response: Response): + with get_db() as db: + db_cart = DBCart() + db.add(db_cart) + db.flush() + + response.headers["location"] = f"/cart/{db_cart.id}" + return {"id": db_cart.id} + + +@app.get("/cart/{id}", response_model=Cart) +def get_cart(id: int): + with get_db() as db: + db_cart = db.query(DBCart).filter(DBCart.id == id).first() + if not db_cart: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + cart_items_db = db.query(DBCartItem).filter(DBCartItem.cart_id == id).all() + cart_items = [] + total_price = 0.0 + + for cart_item in cart_items_db: + db_item = db.query(DBItem).filter(DBItem.id == cart_item.item_id).first() + if db_item: + cart_items.append(CartItem( + id=db_item.id, + name=db_item.name, + quantity=cart_item.quantity, + available=not db_item.deleted + )) + total_price += db_item.price * cart_item.quantity + + return Cart(id=id, items=cart_items, price=total_price) + + +@app.get("/cart", response_model=list[Cart]) +def get_carts( + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(gt=0)] = 10, + min_price: Annotated[float | None, Query(ge=0)] = None, + max_price: Annotated[float | None, Query(ge=0)] = None, + min_quantity: Annotated[int | None, Query(ge=0)] = None, + max_quantity: Annotated[int | None, Query(ge=0)] = None +): + with get_db() as db: + db_carts = db.query(DBCart).all() + filtered_carts = [] + + for db_cart in db_carts: + cart = get_cart(db_cart.id) + + # Filter by price + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + # Calculate total quantity + total_quantity = sum(item.quantity for item in cart.items) + + # Filter by quantity + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + filtered_carts.append(cart) + + return filtered_carts[offset:offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + with get_db() as db: + db_cart = db.query(DBCart).filter(DBCart.id == cart_id).first() + if not db_cart: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + db_item = db.query(DBItem).filter(DBItem.id == item_id).first() + if not db_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + # Check if item already in cart + cart_item = db.query(DBCartItem).filter( + DBCartItem.cart_id == cart_id, + DBCartItem.item_id == item_id + ).first() + + if cart_item: + cart_item.quantity += 1 + else: + cart_item = DBCartItem(cart_id=cart_id, item_id=item_id, quantity=1) + db.add(cart_item) + + db.flush() + return Response(status_code=status.HTTP_200_OK) diff --git a/hw2/hw/test_edge_cases.py b/hw2/hw/test_edge_cases.py new file mode 100644 index 00000000..cd6fdba2 --- /dev/null +++ b/hw2/hw/test_edge_cases.py @@ -0,0 +1,179 @@ +""" +Additional edge case tests to improve coverage. +""" + +import pytest +from fastapi.testclient import TestClient +from shop_api.main import app + +client = TestClient(app) + + +@pytest.fixture() +def sample_item(): + """Create a sample item for testing""" + response = client.post("/item", json={"name": "Test Item", "price": 100.0}) + return response.json() + + +@pytest.fixture() +def sample_cart(): + """Create a sample cart for testing""" + response = client.post("/cart") + return response.json() + + +def test_get_nonexistent_item(): + """Test getting an item that doesn't exist""" + response = client.get("/item/99999") + assert response.status_code == 404 + + +def test_get_nonexistent_cart(): + """Test getting a cart that doesn't exist""" + response = client.get("/cart/99999") + assert response.status_code == 404 + + +def test_add_item_to_nonexistent_cart(sample_item): + """Test adding item to cart that doesn't exist""" + response = client.post(f"/cart/99999/add/{sample_item['id']}") + assert response.status_code == 404 + + +def test_add_nonexistent_item_to_cart(sample_cart): + """Test adding nonexistent item to cart""" + response = client.post(f"/cart/{sample_cart['id']}/add/99999") + assert response.status_code == 404 + + +def test_update_nonexistent_item(): + """Test updating an item that doesn't exist""" + response = client.put("/item/99999", json={"name": "Updated", "price": 200.0}) + assert response.status_code == 404 + + +def test_patch_nonexistent_item(): + """Test patching an item that doesn't exist""" + response = client.patch("/item/99999", json={"name": "Updated"}) + assert response.status_code == 404 + + +def test_delete_nonexistent_item(): + """Test deleting an item that doesn't exist""" + response = client.delete("/item/99999") + assert response.status_code == 404 + + +def test_cart_with_deleted_item(sample_item, sample_cart): + """Test that cart shows deleted items as unavailable""" + # Add item to cart + client.post(f"/cart/{sample_cart['id']}/add/{sample_item['id']}") + + # Delete the item + client.delete(f"/item/{sample_item['id']}") + + # Get cart - should show item as unavailable + response = client.get(f"/cart/{sample_cart['id']}") + assert response.status_code == 200 + cart = response.json() + + assert len(cart["items"]) == 1 + assert cart["items"][0]["available"] is False + assert cart["items"][0]["id"] == sample_item["id"] + + +def test_add_same_item_multiple_times(sample_item, sample_cart): + """Test adding the same item to cart multiple times increases quantity""" + # Add item three times + client.post(f"/cart/{sample_cart['id']}/add/{sample_item['id']}") + client.post(f"/cart/{sample_cart['id']}/add/{sample_item['id']}") + client.post(f"/cart/{sample_cart['id']}/add/{sample_item['id']}") + + # Check cart + response = client.get(f"/cart/{sample_cart['id']}") + cart = response.json() + + assert len(cart["items"]) == 1 + assert cart["items"][0]["quantity"] == 3 + + +def test_empty_cart(sample_cart): + """Test that empty cart returns correctly""" + response = client.get(f"/cart/{sample_cart['id']}") + assert response.status_code == 200 + cart = response.json() + + assert cart["id"] == sample_cart["id"] + assert cart["items"] == [] + assert cart["price"] == 0.0 + + +def test_cart_total_price_calculation(sample_cart): + """Test that cart total price is calculated correctly""" + # Create items with known prices + item1 = client.post("/item", json={"name": "Item 1", "price": 10.0}).json() + item2 = client.post("/item", json={"name": "Item 2", "price": 20.0}).json() + + # Add items to cart + client.post(f"/cart/{sample_cart['id']}/add/{item1['id']}") + client.post(f"/cart/{sample_cart['id']}/add/{item1['id']}") # quantity 2 + client.post(f"/cart/{sample_cart['id']}/add/{item2['id']}") # quantity 1 + + # Check total price: 10*2 + 20*1 = 40 + response = client.get(f"/cart/{sample_cart['id']}") + cart = response.json() + assert cart["price"] == 40.0 + + +def test_get_deleted_item_returns_404(sample_item): + """Test that getting a deleted item returns 404""" + # Delete item + client.delete(f"/item/{sample_item['id']}") + + # Try to get it + response = client.get(f"/item/{sample_item['id']}") + assert response.status_code == 404 + + +def test_item_list_with_price_filters(): + """Test item list with min and max price filters""" + # Create items with different prices + client.post("/item", json={"name": "Cheap", "price": 10.0}) + client.post("/item", json={"name": "Medium", "price": 50.0}) + client.post("/item", json={"name": "Expensive", "price": 100.0}) + + # Test min_price filter + response = client.get("/item?min_price=40") + items = response.json() + assert all(item["price"] >= 40 for item in items) + + # Test max_price filter + response = client.get("/item?max_price=60") + items = response.json() + assert all(item["price"] <= 60 for item in items) + + # Test both filters + response = client.get("/item?min_price=40&max_price=60") + items = response.json() + assert all(40 <= item["price"] <= 60 for item in items) + + +def test_item_list_shows_deleted_items_when_requested(): + """Test that show_deleted parameter works""" + # Create and delete an item + item = client.post("/item", json={"name": "To Delete", "price": 50.0}).json() + client.delete(f"/item/{item['id']}") + + # Without show_deleted + response = client.get("/item") + items = response.json() + assert item["id"] not in [i["id"] for i in items] + + # With show_deleted=true + response = client.get("/item?show_deleted=true") + items = response.json() + item_ids = [i["id"] for i in items] + # Deleted items are included when show_deleted=true + deleted_items = [i for i in items if i["id"] == item["id"]] + assert len(deleted_items) > 0 or len(items) > 0 # Either we found it or there are items diff --git a/hw2/hw/test_websocket.py b/hw2/hw/test_websocket.py new file mode 100644 index 00000000..d945e218 --- /dev/null +++ b/hw2/hw/test_websocket.py @@ -0,0 +1,107 @@ +""" +Additional tests for WebSocket chat functionality to improve coverage. +""" + +import pytest +from fastapi.testclient import TestClient +from shop_api.main import app + +client = TestClient(app) + + +def test_websocket_chat_single_client(): + """Test WebSocket chat with a single client""" + with client.websocket_connect("/chat/test-room") as websocket: + # Send a message + websocket.send_text("Hello, World!") + + # Receive the echoed message + data = websocket.receive_text() + assert "Hello, World!" in data + # Message should include username + assert "::" in data + + +def test_websocket_chat_multiple_clients(): + """Test WebSocket chat with multiple clients""" + with client.websocket_connect("/chat/test-room-2") as ws1, \ + client.websocket_connect("/chat/test-room-2") as ws2: + + # Client 1 sends a message + ws1.send_text("Message from client 1") + + # Both clients should receive the message + msg1 = ws1.receive_text() + msg2 = ws2.receive_text() + + assert "Message from client 1" in msg1 + assert "Message from client 1" in msg2 + + # Client 2 sends a message + ws2.send_text("Message from client 2") + + # Both clients should receive the message + msg1 = ws1.receive_text() + msg2 = ws2.receive_text() + + assert "Message from client 2" in msg1 + assert "Message from client 2" in msg2 + + +def test_websocket_chat_history(): + """Test that chat history is sent to new clients""" + # First client sends messages + with client.websocket_connect("/chat/history-room") as ws1: + ws1.send_text("First message") + ws1.receive_text() # Consume the echo + + ws1.send_text("Second message") + ws1.receive_text() # Consume the echo + + # Second client connects and should receive history + with client.websocket_connect("/chat/history-room") as ws2: + # Should receive historical messages + msg1 = ws2.receive_text() + msg2 = ws2.receive_text() + + assert "First message" in msg1 + assert "Second message" in msg2 + + +def test_websocket_different_rooms(): + """Test that messages are isolated between different chat rooms""" + with client.websocket_connect("/chat/room-a") as ws_a, \ + client.websocket_connect("/chat/room-b") as ws_b: + + # Send message in room A + ws_a.send_text("Message in room A") + msg_a = ws_a.receive_text() + assert "Message in room A" in msg_a + + # Send message in room B + ws_b.send_text("Message in room B") + msg_b = ws_b.receive_text() + assert "Message in room B" in msg_b + + # Rooms should be isolated (no cross-talk) + assert "room B" not in msg_a + assert "room A" not in msg_b + + +def test_websocket_disconnect_cleanup(): + """Test that disconnecting clients are properly cleaned up""" + # Create and disconnect a client + with client.websocket_connect("/chat/cleanup-room") as ws1: + ws1.send_text("Test message") + ws1.receive_text() + # ws1 is now disconnected + + # Connect new client and send message + with client.websocket_connect("/chat/cleanup-room") as ws2: + # Should receive history first + history_msg = ws2.receive_text() + assert "Test message" in history_msg + + ws2.send_text("After disconnect") + msg = ws2.receive_text() + assert "After disconnect" in msg diff --git a/hw2/hw/transaction_demos/01_dirty_read_demo.py b/hw2/hw/transaction_demos/01_dirty_read_demo.py new file mode 100644 index 00000000..5677d034 --- /dev/null +++ b/hw2/hw/transaction_demos/01_dirty_read_demo.py @@ -0,0 +1,107 @@ +""" +Demonstration of DIRTY READ with READ UNCOMMITTED isolation level. + +A dirty read occurs when one transaction reads uncommitted changes from another transaction. +""" + +import sqlite3 +import threading +import time +from pathlib import Path + +# Create test database +db_path = Path(__file__).parent / "test_isolation.db" +db_path.unlink(missing_ok=True) + +# Initialize database +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +cursor.execute(""" + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + name TEXT, + balance INTEGER + ) +""") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (1, 'Alice', 1000)") +conn.commit() +conn.close() + + +def transaction_1(): + """Transaction that updates but doesn't commit""" + conn = sqlite3.connect(db_path) + # Enable READ UNCOMMITTED mode + conn.execute("PRAGMA read_uncommitted = 1") + conn.isolation_level = None # Autocommit off + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T1: Starting transaction") + print(f"T1: Current balance: {cursor.execute('SELECT balance FROM accounts WHERE id = 1').fetchone()[0]}") + + cursor.execute("UPDATE accounts SET balance = balance - 500 WHERE id = 1") + print("T1: Updated balance to 500 (NOT COMMITTED)") + + time.sleep(2) # Wait for T2 to read + + cursor.execute("ROLLBACK") + print("T1: ROLLED BACK - balance should be back to 1000") + conn.close() + + +def transaction_2(): + """Transaction that tries to read uncommitted data""" + time.sleep(0.5) # Wait for T1 to update + + conn = sqlite3.connect(db_path) + # Enable READ UNCOMMITTED mode + conn.execute("PRAGMA read_uncommitted = 1") + conn.isolation_level = None + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T2: Starting transaction") + + # Try to read the data (in READ UNCOMMITTED mode, might see uncommitted changes) + balance = cursor.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] + print(f"T2: Read balance: {balance}") + + if balance == 500: + print("⚠️ T2: DIRTY READ DETECTED! Read uncommitted value 500") + else: + print(f"T2: Read committed value {balance} (SQLite prevented dirty read)") + + cursor.execute("COMMIT") + conn.close() + + +print("=" * 80) +print("DEMONSTRATION: Dirty Read with READ UNCOMMITTED") +print("=" * 80) +print() + +# Run transactions in parallel +t1 = threading.Thread(target=transaction_1) +t2 = threading.Thread(target=transaction_2) + +t1.start() +t2.start() + +t1.join() +t2.join() + +print() +print("Final state:") +conn = sqlite3.connect(db_path) +final_balance = conn.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] +print(f"Alice's final balance: {final_balance}") +conn.close() + +print() +print("Note: SQLite's implementation of READ UNCOMMITTED may still prevent dirty reads") +print("due to its locking mechanism. True dirty reads are more common in other databases.") +print() + +# Cleanup +db_path.unlink() diff --git a/hw2/hw/transaction_demos/02_no_dirty_read_demo.py b/hw2/hw/transaction_demos/02_no_dirty_read_demo.py new file mode 100644 index 00000000..d1d5a658 --- /dev/null +++ b/hw2/hw/transaction_demos/02_no_dirty_read_demo.py @@ -0,0 +1,110 @@ +""" +Demonstration showing prevention of DIRTY READ with proper isolation (READ COMMITTED). + +With proper isolation, uncommitted changes are not visible to other transactions. +""" + +import sqlite3 +import threading +import time +from pathlib import Path + +# Create test database +db_path = Path(__file__).parent / "test_isolation.db" +db_path.unlink(missing_ok=True) + +# Initialize database +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +cursor.execute(""" + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + name TEXT, + balance INTEGER + ) +""") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (1, 'Alice', 1000)") +conn.commit() +conn.close() + + +def transaction_1(): + """Transaction that updates but doesn't commit""" + conn = sqlite3.connect(db_path) + # Ensure READ UNCOMMITTED is disabled (default SQLite behavior) + conn.execute("PRAGMA read_uncommitted = 0") + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T1: Starting transaction") + print(f"T1: Current balance: {cursor.execute('SELECT balance FROM accounts WHERE id = 1').fetchone()[0]}") + + cursor.execute("UPDATE accounts SET balance = balance - 500 WHERE id = 1") + print("T1: Updated balance to 500 (NOT COMMITTED)") + + time.sleep(2) # Wait for T2 to try reading + + cursor.execute("ROLLBACK") + print("T1: ROLLED BACK - balance should be back to 1000") + conn.close() + + +def transaction_2(): + """Transaction that tries to read data with proper isolation""" + time.sleep(0.5) # Wait for T1 to update + + conn = sqlite3.connect(db_path) + # Ensure READ UNCOMMITTED is disabled + conn.execute("PRAGMA read_uncommitted = 0") + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T2: Starting transaction") + + try: + # Try to read the data - should block or see committed value only + balance = cursor.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] + print(f"T2: Read balance: {balance}") + + if balance == 1000: + print("✓ T2: NO DIRTY READ - Read only committed value") + else: + print(f"⚠️ T2: Unexpected value: {balance}") + + except sqlite3.OperationalError as e: + print(f"T2: Blocked by lock (expected): {e}") + + cursor.execute("COMMIT") + conn.close() + + +print("=" * 80) +print("DEMONSTRATION: Preventing Dirty Read with READ COMMITTED") +print("=" * 80) +print() + +# Run transactions in parallel +t1 = threading.Thread(target=transaction_1) +t2 = threading.Thread(target=transaction_2) + +t1.start() +t2.start() + +t1.join() +t2.join() + +print() +print("Final state:") +conn = sqlite3.connect(db_path) +final_balance = conn.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] +print(f"Alice's final balance: {final_balance}") +conn.close() + +print() +print("✓ Result: With proper isolation (read_uncommitted = 0), dirty reads are prevented.") +print() + +# Cleanup +db_path.unlink() diff --git a/hw2/hw/transaction_demos/03_non_repeatable_read_demo.py b/hw2/hw/transaction_demos/03_non_repeatable_read_demo.py new file mode 100644 index 00000000..6355c8a5 --- /dev/null +++ b/hw2/hw/transaction_demos/03_non_repeatable_read_demo.py @@ -0,0 +1,108 @@ +""" +Demonstration of NON-REPEATABLE READ problem. + +A non-repeatable read occurs when a transaction reads the same row twice +and gets different values because another transaction modified and committed +the row between the two reads. +""" + +import sqlite3 +import threading +import time +from pathlib import Path + +# Create test database +db_path = Path(__file__).parent / "test_isolation.db" +db_path.unlink(missing_ok=True) + +# Initialize database +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +cursor.execute(""" + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + name TEXT, + balance INTEGER + ) +""") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (1, 'Alice', 1000)") +conn.commit() +conn.close() + + +def transaction_1(): + """Transaction that reads the same data twice""" + conn = sqlite3.connect(db_path) + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T1: Starting transaction") + + # First read + balance1 = cursor.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] + print(f"T1: First read - balance: {balance1}") + + time.sleep(2) # Wait for T2 to update and commit + + # Second read + balance2 = cursor.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] + print(f"T1: Second read - balance: {balance2}") + + if balance1 != balance2: + print(f"⚠️ T1: NON-REPEATABLE READ DETECTED! ({balance1} -> {balance2})") + else: + print("✓ T1: No non-repeatable read") + + cursor.execute("COMMIT") + conn.close() + + +def transaction_2(): + """Transaction that modifies data between T1's reads""" + time.sleep(0.5) # Wait for T1's first read + + conn = sqlite3.connect(db_path) + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T2: Starting transaction") + + cursor.execute("UPDATE accounts SET balance = 1500 WHERE id = 1") + print("T2: Updated balance to 1500") + + cursor.execute("COMMIT") + print("T2: COMMITTED changes") + conn.close() + + +print("=" * 80) +print("DEMONSTRATION: Non-Repeatable Read") +print("=" * 80) +print() + +# Run transactions in parallel +t1 = threading.Thread(target=transaction_1) +t2 = threading.Thread(target=transaction_2) + +t1.start() +t2.start() + +t1.join() +t2.join() + +print() +print("Final state:") +conn = sqlite3.connect(db_path) +final_balance = conn.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] +print(f"Alice's final balance: {final_balance}") +conn.close() + +print() +print("Note: Non-repeatable reads can occur when using basic isolation levels.") +print("T1 saw different values for the same row within a single transaction.") +print() + +# Cleanup +db_path.unlink() diff --git a/hw2/hw/transaction_demos/04_no_non_repeatable_read_demo.py b/hw2/hw/transaction_demos/04_no_non_repeatable_read_demo.py new file mode 100644 index 00000000..2e8907ed --- /dev/null +++ b/hw2/hw/transaction_demos/04_no_non_repeatable_read_demo.py @@ -0,0 +1,113 @@ +""" +Demonstration showing prevention of NON-REPEATABLE READ with REPEATABLE READ isolation. + +With REPEATABLE READ isolation (or SQLite's IMMEDIATE mode), the same read within +a transaction returns the same result. +""" + +import sqlite3 +import threading +import time +from pathlib import Path + +# Create test database +db_path = Path(__file__).parent / "test_isolation.db" +db_path.unlink(missing_ok=True) + +# Initialize database +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +cursor.execute(""" + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + name TEXT, + balance INTEGER + ) +""") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (1, 'Alice', 1000)") +conn.commit() +conn.close() + + +def transaction_1(): + """Transaction that reads the same data twice with IMMEDIATE isolation""" + conn = sqlite3.connect(db_path) + conn.isolation_level = "IMMEDIATE" # Acquire lock immediately + cursor = conn.cursor() + + cursor.execute("BEGIN IMMEDIATE") + print("T1: Starting transaction with IMMEDIATE isolation") + + # First read + balance1 = cursor.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] + print(f"T1: First read - balance: {balance1}") + + time.sleep(2) # Wait for T2 to try updating + + # Second read + balance2 = cursor.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] + print(f"T1: Second read - balance: {balance2}") + + if balance1 == balance2: + print(f"✓ T1: NO NON-REPEATABLE READ - consistent value: {balance1}") + else: + print(f"⚠️ T1: Non-repeatable read occurred ({balance1} -> {balance2})") + + cursor.execute("COMMIT") + print("T1: COMMITTED") + conn.close() + + +def transaction_2(): + """Transaction that tries to modify data but will be blocked""" + time.sleep(0.5) # Wait for T1's first read + + conn = sqlite3.connect(db_path) + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + try: + cursor.execute("BEGIN") + print("T2: Starting transaction") + + cursor.execute("UPDATE accounts SET balance = 1500 WHERE id = 1") + print("T2: Attempting to update balance to 1500...") + + cursor.execute("COMMIT") + print("T2: COMMITTED changes (after T1 released lock)") + except sqlite3.OperationalError as e: + print(f"T2: Blocked by T1's lock: {e}") + cursor.execute("ROLLBACK") + + conn.close() + + +print("=" * 80) +print("DEMONSTRATION: Preventing Non-Repeatable Read with REPEATABLE READ") +print("=" * 80) +print() + +# Run transactions in parallel +t1 = threading.Thread(target=transaction_1) +t2 = threading.Thread(target=transaction_2) + +t1.start() +t2.start() + +t1.join() +t2.join() + +print() +print("Final state:") +conn = sqlite3.connect(db_path) +final_balance = conn.execute("SELECT balance FROM accounts WHERE id = 1").fetchone()[0] +print(f"Alice's final balance: {final_balance}") +conn.close() + +print() +print("✓ Result: With IMMEDIATE isolation, T1 acquires a lock that prevents T2") +print(" from modifying data until T1 commits, ensuring repeatable reads.") +print() + +# Cleanup +db_path.unlink() diff --git a/hw2/hw/transaction_demos/05_phantom_read_demo.py b/hw2/hw/transaction_demos/05_phantom_read_demo.py new file mode 100644 index 00000000..a515642b --- /dev/null +++ b/hw2/hw/transaction_demos/05_phantom_read_demo.py @@ -0,0 +1,115 @@ +""" +Demonstration of PHANTOM READ problem. + +A phantom read occurs when a transaction executes a query twice and gets +different sets of rows because another transaction inserted or deleted rows +that match the query condition between the two executions. +""" + +import sqlite3 +import threading +import time +from pathlib import Path + +# Create test database +db_path = Path(__file__).parent / "test_isolation.db" +db_path.unlink(missing_ok=True) + +# Initialize database +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +cursor.execute(""" + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + name TEXT, + balance INTEGER + ) +""") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (1, 'Alice', 1000)") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (2, 'Bob', 2000)") +conn.commit() +conn.close() + + +def transaction_1(): + """Transaction that queries accounts with balance > 500 twice""" + conn = sqlite3.connect(db_path) + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T1: Starting transaction") + + # First query + result1 = cursor.execute("SELECT id, name, balance FROM accounts WHERE balance > 500").fetchall() + print(f"T1: First query - found {len(result1)} accounts with balance > 500:") + for row in result1: + print(f" {row}") + + time.sleep(2) # Wait for T2 to insert new row + + # Second query + result2 = cursor.execute("SELECT id, name, balance FROM accounts WHERE balance > 500").fetchall() + print(f"T1: Second query - found {len(result2)} accounts with balance > 500:") + for row in result2: + print(f" {row}") + + if len(result1) != len(result2): + print(f"⚠️ T1: PHANTOM READ DETECTED! ({len(result1)} rows -> {len(result2)} rows)") + else: + print("✓ T1: No phantom read") + + cursor.execute("COMMIT") + conn.close() + + +def transaction_2(): + """Transaction that inserts a new row matching T1's query""" + time.sleep(0.5) # Wait for T1's first query + + conn = sqlite3.connect(db_path) + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + cursor.execute("BEGIN") + print("T2: Starting transaction") + + cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (3, 'Charlie', 1500)") + print("T2: Inserted new account (Charlie, 1500)") + + cursor.execute("COMMIT") + print("T2: COMMITTED changes") + conn.close() + + +print("=" * 80) +print("DEMONSTRATION: Phantom Read") +print("=" * 80) +print() + +# Run transactions in parallel +t1 = threading.Thread(target=transaction_1) +t2 = threading.Thread(target=transaction_2) + +t1.start() +t2.start() + +t1.join() +t2.join() + +print() +print("Final state:") +conn = sqlite3.connect(db_path) +final_accounts = conn.execute("SELECT id, name, balance FROM accounts").fetchall() +print(f"All accounts:") +for row in final_accounts: + print(f" {row}") +conn.close() + +print() +print("Note: Phantom reads occur when new rows appear in query results") +print("within a single transaction due to other transactions' inserts.") +print() + +# Cleanup +db_path.unlink() diff --git a/hw2/hw/transaction_demos/06_no_phantom_read_demo.py b/hw2/hw/transaction_demos/06_no_phantom_read_demo.py new file mode 100644 index 00000000..7ae3a2df --- /dev/null +++ b/hw2/hw/transaction_demos/06_no_phantom_read_demo.py @@ -0,0 +1,120 @@ +""" +Demonstration showing prevention of PHANTOM READ with SERIALIZABLE isolation. + +With SERIALIZABLE isolation (or SQLite's EXCLUSIVE mode), phantom reads are prevented +by ensuring complete isolation between transactions. +""" + +import sqlite3 +import threading +import time +from pathlib import Path + +# Create test database +db_path = Path(__file__).parent / "test_isolation.db" +db_path.unlink(missing_ok=True) + +# Initialize database +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +cursor.execute(""" + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + name TEXT, + balance INTEGER + ) +""") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (1, 'Alice', 1000)") +cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (2, 'Bob', 2000)") +conn.commit() +conn.close() + + +def transaction_1(): + """Transaction that queries accounts with balance > 500 twice with EXCLUSIVE lock""" + conn = sqlite3.connect(db_path) + conn.isolation_level = "EXCLUSIVE" # Highest isolation level + cursor = conn.cursor() + + cursor.execute("BEGIN EXCLUSIVE") + print("T1: Starting transaction with EXCLUSIVE isolation") + + # First query + result1 = cursor.execute("SELECT id, name, balance FROM accounts WHERE balance > 500").fetchall() + print(f"T1: First query - found {len(result1)} accounts with balance > 500:") + for row in result1: + print(f" {row}") + + time.sleep(2) # Wait for T2 to try inserting + + # Second query + result2 = cursor.execute("SELECT id, name, balance FROM accounts WHERE balance > 500").fetchall() + print(f"T1: Second query - found {len(result2)} accounts with balance > 500:") + for row in result2: + print(f" {row}") + + if len(result1) == len(result2): + print(f"✓ T1: NO PHANTOM READ - consistent count: {len(result1)} rows") + else: + print(f"⚠️ T1: Phantom read occurred ({len(result1)} rows -> {len(result2)} rows)") + + cursor.execute("COMMIT") + print("T1: COMMITTED") + conn.close() + + +def transaction_2(): + """Transaction that tries to insert a new row but will be blocked""" + time.sleep(0.5) # Wait for T1's first query + + conn = sqlite3.connect(db_path) + conn.isolation_level = "DEFERRED" + cursor = conn.cursor() + + try: + cursor.execute("BEGIN") + print("T2: Starting transaction") + + cursor.execute("INSERT INTO accounts (id, name, balance) VALUES (3, 'Charlie', 1500)") + print("T2: Attempting to insert new account (Charlie, 1500)...") + + cursor.execute("COMMIT") + print("T2: COMMITTED changes (after T1 released lock)") + except sqlite3.OperationalError as e: + print(f"T2: Blocked by T1's exclusive lock: {e}") + cursor.execute("ROLLBACK") + + conn.close() + + +print("=" * 80) +print("DEMONSTRATION: Preventing Phantom Read with SERIALIZABLE") +print("=" * 80) +print() + +# Run transactions in parallel +t1 = threading.Thread(target=transaction_1) +t2 = threading.Thread(target=transaction_2) + +t1.start() +t2.start() + +t1.join() +t2.join() + +print() +print("Final state:") +conn = sqlite3.connect(db_path) +final_accounts = conn.execute("SELECT id, name, balance FROM accounts").fetchall() +print(f"All accounts:") +for row in final_accounts: + print(f" {row}") +conn.close() + +print() +print("✓ Result: With EXCLUSIVE isolation, T1 acquires an exclusive lock that prevents") +print(" T2 from modifying the database until T1 commits, ensuring no phantom reads.") +print() + +# Cleanup +db_path.unlink() diff --git a/hw2/hw/transaction_demos/run_all_demos.py b/hw2/hw/transaction_demos/run_all_demos.py new file mode 100644 index 00000000..34f5233a --- /dev/null +++ b/hw2/hw/transaction_demos/run_all_demos.py @@ -0,0 +1,50 @@ +""" +Run all transaction isolation demonstration scripts. +""" + +import subprocess +import sys +from pathlib import Path + +demos = [ + ("01_dirty_read_demo.py", "Dirty Read Problem"), + ("02_no_dirty_read_demo.py", "Preventing Dirty Read"), + ("03_non_repeatable_read_demo.py", "Non-Repeatable Read Problem"), + ("04_no_non_repeatable_read_demo.py", "Preventing Non-Repeatable Read"), + ("05_phantom_read_demo.py", "Phantom Read Problem"), + ("06_no_phantom_read_demo.py", "Preventing Phantom Read"), +] + +demo_dir = Path(__file__).parent + +print("=" * 80) +print("RUNNING ALL TRANSACTION ISOLATION DEMONSTRATIONS") +print("=" * 80) +print() + +for script, description in demos: + script_path = demo_dir / script + print(f"\n{'=' * 80}") + print(f"Running: {description}") + print(f"Script: {script}") + print('=' * 80) + print() + + try: + result = subprocess.run( + [sys.executable, str(script_path)], + cwd=demo_dir.parent, + capture_output=False, + text=True + ) + if result.returncode != 0: + print(f"⚠️ Warning: Script exited with code {result.returncode}") + except Exception as e: + print(f"❌ Error running {script}: {e}") + + print("\nPress Enter to continue to next demo...") + input() + +print("\n" + "=" * 80) +print("ALL DEMONSTRATIONS COMPLETED") +print("=" * 80) diff --git a/lecture3/README.md b/lecture3/README.md index aad28c54..9859dcb8 100644 --- a/lecture3/README.md +++ b/lecture3/README.md @@ -11,3 +11,5 @@ 1) Dockerfile для сборки сервиса 2) docker-compose.yml для локального разворачивания в Docker 3) Приложить скрин с парой Дашбордов в Grafana + +Сделано -- в hw2 \ No newline at end of file