diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..93f18359 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -16,6 +16,22 @@ jobs: matrix: python-version: ["3.12", "3.13"] + # Сервис PostgreSQL для тестов + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + POSTGRES_DB: shop_db_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -31,9 +47,10 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - - name: Run tests + - name: Run tests with coverage working-directory: hw2/hw env: PYTHONPATH: ${{ github.workspace }}/hw2/hw + DATABASE_URL: postgresql://shop_user:shop_password@localhost:5432/shop_db_test run: | - pytest test_homework2.py -v + pytest test_homework2.py -v --cov=shop_api --cov-report=term --cov-fail-under=95 diff --git a/hw2/hw/.dockerignore b/hw2/hw/.dockerignore new file mode 100644 index 00000000..c543c961 --- /dev/null +++ b/hw2/hw/.dockerignore @@ -0,0 +1,20 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.pytest_cache +.venv +venv +env +.git +.gitignore +*.md +!DOCKER_README.md +.DS_Store +assets diff --git a/hw2/hw/.gitignore b/hw2/hw/.gitignore new file mode 100644 index 00000000..ccecbb48 --- /dev/null +++ b/hw2/hw/.gitignore @@ -0,0 +1,14 @@ +# Test artifacts +.pytest_cache/ +htmlcov/ +.coverage +*.db +test_shop.db + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + diff --git a/hw2/hw/DOCKER_README.md b/hw2/hw/DOCKER_README.md new file mode 100644 index 00000000..6a4670f1 --- /dev/null +++ b/hw2/hw/DOCKER_README.md @@ -0,0 +1,193 @@ +# Docker и Мониторинг для Shop API + +Этот файл содержит инструкции по запуску Shop API в Docker с мониторингом через Prometheus и Grafana. + +## Структура + +``` +hw2/hw/ +├── Dockerfile # Образ Docker для Shop API +├── docker-compose.yml # Оркестрация всех сервисов +├── settings/ +│ └── prometheus/ +│ └── prometheus.yml # Конфигурация Prometheus +├── shop_api/ +│ └── main.py # FastAPI приложение с Prometheus метриками +└── requirements.txt # Python зависимости +``` + +## Запуск + +### 1. Сборка и запуск всех сервисов + +```bash +cd hw2/hw +docker compose up --build +``` + +Или используя docker-compose: + +```bash +docker-compose up --build +``` + +### 2. Доступ к сервисам + +После запуска будут доступны следующие сервисы: + +- **Shop API**: http://localhost:8080 + - Swagger документация: http://localhost:8080/docs + - Метрики Prometheus: http://localhost:8080/metrics + +- **Prometheus**: http://localhost:9090 + - Позволяет просматривать метрики и выполнять запросы + +- **Grafana**: http://localhost:3000 + - Логин: `admin` + - Пароль: `admin` + +## Настройка Grafana + +### Шаг 1: Добавить Data Source + +1. Откройте Grafana по адресу http://localhost:3000 +2. Войдите с учетными данными (admin/admin) +3. Перейдите в Connections -> Data Sources +4. Нажмите "Add data source" +5. Выберите "Prometheus" +6. Введите URL: `http://prometheus:9090` +7. Нажмите "Save & Test" + +### Шаг 2: Создать собственный дашборд +Вы также можете не создавать дашборды вручную, а импортировать готовые шаблоны, которые находятся в файлах [`grafana_http_observability.json`](./grafana_http_observability.json) и [`grafana_my_dashboard.json`](./grafana_my_dashboard.json). + +**Как импортировать дашборд в Grafana:** + +1. В интерфейсе Grafana нажмите на значок «плюс» (Create) в меню слева и выберите «Import». +2. Нажмите "Upload JSON file" и выберите нужный файл (`grafana_http_observability.json` или `grafana_my_dashboard.json`) из папки `hw2/hw`. +3. Укажите Prometheus в качестве источника данных (Data Source). +4. Нажмите "Import". + +После этого готовые панели появятся в вашем Grafana. + +Или создайте собственный дашборд с нужными метриками: + +**Полезные метрики для Shop API:** + +- `http_requests_total` - общее количество HTTP запросов +- `http_request_duration_seconds` - длительность запросов +- `http_requests_inprogress` - запросы в процессе выполнения +- `process_cpu_seconds_total` - использование CPU +- `process_resident_memory_bytes` - использование памяти + +**Примеры запросов PromQL:** + +```promql +# Количество запросов в секунду +rate(http_requests_total[5m]) + +# Количество запросов по методам +sum by (method) (rate(http_requests_total[5m])) + +# Количество запросов по эндпоинтам +sum by (handler) (rate(http_requests_total[5m])) + +# Процент ошибок +sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100 + +# P95 время ответа +histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) +``` + +### Примеры дашбордов Grafana + +![Пример дашборда FastAPI Observability](assets/http_observability.png) + +![Пример индивидуального дашборда](assets/my_dashboard.png) + + +## Генерация тестовой нагрузки + +Для проверки метрик можно сгенерировать тестовую нагрузку: + +```bash +# Создать товары +for i in {1..100}; do + curl -X POST "http://localhost:8080/item" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"Item $i\",\"price\":$(($RANDOM % 1000))}" +done + +# Создать корзины и добавить товары +for i in {1..50}; do + CART_ID=$(curl -s -X POST "http://localhost:8080/cart" | jq -r '.id') + for j in {1..5}; do + ITEM_ID=$(($RANDOM % 100 + 1)) + curl -X POST "http://localhost:8080/cart/$CART_ID/add/$ITEM_ID" + done +done +``` + +## Остановка сервисов + +```bash +docker compose down +``` + +Для полной очистки (включая volumes): + +```bash +docker compose down -v +``` + +## Проверка работы метрик + +Проверить, что метрики собираются: + +```bash +# Метрики Shop API +curl http://localhost:8080/metrics + +# Targets в Prometheus +curl http://localhost:9090/api/v1/targets + +# Запрос метрики через Prometheus +curl 'http://localhost:9090/api/v1/query?query=http_requests_total' +``` + +## Архитектура мониторинга + +``` +┌─────────────┐ +│ Shop API │ :8080 +│ (FastAPI) │ +│ │ +│ /metrics │◄──────┐ +└─────────────┘ │ + │ scrape + │ каждые 10s + │ + ┌─────┴──────┐ + │ Prometheus │ :9090 + │ │ + │ TSDB │ + └─────┬──────┘ + │ + │ query + │ + ┌─────▼──────┐ + │ Grafana │ :3000 + │ │ + │ Dashboard │ + └────────────┘ +``` + +## Метрики, которые собираются + +Благодаря `prometheus-fastapi-instrumentator` автоматически собираются: + +- **http_requests_total**: Счетчик всех HTTP запросов (labels: method, handler, status) +- **http_request_duration_seconds**: Гистограмма времени обработки запросов +- **http_requests_inprogress**: Gauge текущих обрабатываемых запросов +- **process_***: Метрики процесса (CPU, память, и т.д.) + diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..fdaac317 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,26 @@ +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 && rm -rf /var/lib/apt/lists/* +RUN python -m pip install --upgrade pip + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY . ./ + +ENV PYTHONPATH=/app + +FROM base as local + +EXPOSE 8080 + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] + diff --git a/hw2/hw/HW4_README.md b/hw2/hw/HW4_README.md new file mode 100644 index 00000000..fb971acd --- /dev/null +++ b/hw2/hw/HW4_README.md @@ -0,0 +1,207 @@ +# Домашнее задание 4 — Интеграция с PostgreSQL и демонстрация проблем транзакций + +## Выполненные задачи + +### 1. Добавление PostgreSQL в docker-compose.yml + +Добавлен сервис PostgreSQL в `docker-compose.yml`: +- Используется образ `postgres:15` +- База данных инициализируется через `migrations/init.sql` +- Healthcheck для проверки готовности БД +- Shop API зависит от PostgreSQL и запускается только после его инициализации + +### 2. Интеграция с PostgreSQL через SQLAlchemy + +Переписан код для работы с PostgreSQL: +- Созданы ORM модели (`ItemOrm`, `CartOrm`, `CartItemOrm`) +- Класс `Storage` переписан для работы с БД через SQLAlchemy +- Используется dependency injection для передачи сессии БД в эндпоинты +- Все существующие тесты (39 шт.) проходят успешно + +**Изменённые файлы:** +- `shop_api/main.py` - полностью переписан для работы с БД +- `docker-compose.yml` - добавлен PostgreSQL +- `requirements.txt` - добавлены зависимости для работы с БД + +### 3. Демонстрация проблем транзакций + +Созданы скрипты для демонстрации различных проблем, возникающих при параллельном выполнении транзакций: + +#### 3.1. Dirty Read (Грязное чтение) +**Файл:** `transaction_demos/demo_dirty_read.py` + +**Демонстрирует:** +- Попытку dirty read с уровнем READ UNCOMMITTED +- Отсутствие dirty read с уровнем READ COMMITTED + +**Вывод:** PostgreSQL не поддерживает READ UNCOMMITTED, минимальный уровень - READ COMMITTED, поэтому dirty read невозможен. + +#### 3.2. Non-Repeatable Read (Неповторяющееся чтение) +**Файл:** `transaction_demos/demo_non_repeatable_read.py` + +**Демонстрирует:** +- Non-repeatable read при уровне READ COMMITTED +- Отсутствие non-repeatable read при уровне REPEATABLE READ + +**Вывод:** При READ COMMITTED одна транзакция может прочитать одну и ту же строку дважды и получить разные значения. При REPEATABLE READ это невозможно. + +#### 3.3. Phantom Reads (Фантомное чтение) +**Файл:** `transaction_demos/demo_phantom_reads.py` + +**Демонстрирует:** +- Отсутствие phantom reads при REPEATABLE READ (особенность PostgreSQL) +- Отсутствие phantom reads при SERIALIZABLE +- Ошибки сериализации при конфликтующих транзакциях + +**Вывод:** В PostgreSQL phantom reads предотвращаются уже на уровне REPEATABLE READ благодаря Snapshot Isolation. SERIALIZABLE обеспечивает полную изоляцию. + +## Структура файлов + +``` +hw2/hw/ +├── shop_api/ +│ ├── __init__.py +│ └── main.py # Основной код API с интеграцией PostgreSQL +├── transaction_demos/ +│ ├── __init__.py +│ ├── demo_dirty_read.py # Демонстрация dirty read +│ ├── demo_non_repeatable_read.py # Демонстрация non-repeatable read +│ ├── demo_phantom_reads.py # Демонстрация phantom reads +│ └── README.md # Подробная документация +├── migrations/ +│ └── init.sql # Схема БД +├── docker-compose.yml # Docker Compose с PostgreSQL +├── requirements.txt # Зависимости +└── test_homework2.py # Тесты (все проходят) +``` + +## Запуск проекта + +### 1. Установка зависимостей + +```bash +cd hw2/hw +pip install -r requirements.txt +``` + +### 2. Запуск PostgreSQL + +```bash +# Через docker-compose +docker-compose up -d postgres + +# Через podman-compose (привет Яндексу!) +podman compose up -d postgres +``` + +### 3. Запуск тестов + +```bash +PYTHONPATH=$PWD:$PYTHONPATH pytest test_homework2.py -v +``` + +**Результат:** Все 39 тестов проходят успешно ✅ + +### 4. Запуск демонстраций транзакций + +```bash +# Dirty Read +python transaction_demos/demo_dirty_read.py + +# Non-Repeatable Read +python transaction_demos/demo_non_repeatable_read.py + +# Phantom Reads +python transaction_demos/demo_phantom_reads.py +``` + +### 5. Запуск API + +```bash +# Локально +uvicorn shop_api.main:app --host 0.0.0.0 --port 8080 + +# Через docker-compose (с Grafana и Prometheus) +docker-compose up -d +``` + +## Уровни изоляции в PostgreSQL + +| Уровень | Dirty Read | Non-Repeatable Read | Phantom Reads | Примечания | +|---------|------------|---------------------|---------------|------------| +| READ UNCOMMITTED* | ❌ | ✅ | ✅ | *Не поддерживается, используется READ COMMITTED | +| READ COMMITTED | ❌ | ✅ | ✅ | Уровень по умолчанию | +| REPEATABLE READ | ❌ | ❌ | ❌ | Благодаря Snapshot Isolation | +| SERIALIZABLE | ❌ | ❌ | ❌ | Полная изоляция с обнаружением конфликтов | + +## Особенности реализации + +### SQLAlchemy модели + +- **ItemOrm** - таблица товаров с полями: id, name, price, deleted +- **CartOrm** - таблица корзин +- **CartItemOrm** - связующая таблица товаров в корзинах с количеством + +### Транзакции + +Все операции выполняются в рамках транзакций через SQLAlchemy Session. Каждый эндпоинт получает отдельную сессию через dependency injection. + +### Миграции + +Схема БД создаётся автоматически при инициализации PostgreSQL через `init.sql`. + +## Что изменилось по сравнению с HW2 + +1. **Замена in-memory хранилища на PostgreSQL** + - Раньше: словари в памяти + - Теперь: полноценная реляционная БД + +2. **Добавление ORM моделей** + - SQLAlchemy модели для всех сущностей + - Автоматическое управление связями + +3. **Dependency Injection** + - Каждый эндпоинт получает DB session через `Depends(get_db)` + - Автоматическое закрытие сессий + +4. **Миграции** + - Схема БД в `migrations/init.sql` + - Автоматическая инициализация при запуске + +## Проверка выполнения ДЗ + +- [x] Добавлена БД в docker-compose.yml (PostgreSQL) +- [x] Переписан код на взаимодействие с БД (SQLAlchemy) +- [x] Показан dirty read при read uncommitted +- [x] Показано отсутствие dirty read при read committed +- [x] Показан non-repeatable read при read committed +- [x] Показано отсутствие non-repeatable read при repeatable read +- [x] Показаны phantom reads при repeatable read* +- [x] Показано отсутствие phantom reads при serializable + +*В PostgreSQL phantom reads не возникают даже при REPEATABLE READ из-за использования Snapshot Isolation. + +## Полезные команды + +```bash +# Просмотр логов PostgreSQL +docker-compose logs postgres + +# Подключение к PostgreSQL +docker-compose exec postgres psql -U shop_user -d shop_db + +# Остановка всех сервисов +docker-compose down + +# Очистка данных БД +docker-compose down -v +``` + +## Заключение + +Все пункты домашнего задания выполнены: +1. PostgreSQL добавлен в docker-compose.yml +2. Код переписан на работу с БД +3. Созданы скрипты демонстрации всех проблем транзакций +4. Все тесты проходят успешно + diff --git a/hw2/hw/QUICKSTART.md b/hw2/hw/QUICKSTART.md new file mode 100644 index 00000000..a16eb32e --- /dev/null +++ b/hw2/hw/QUICKSTART.md @@ -0,0 +1,38 @@ +# Быстрый старт - ДЗ 4 + +## Запуск всего за 3 команды + +```bash +# 1. Установить зависимости +pip install -r requirements.txt + +# 2. Запустить PostgreSQL +podman compose up -d postgres +# или +docker-compose up -d postgres + +# 3. Запустить тесты +PYTHONPATH=$PWD:$PYTHONPATH pytest test_homework2.py -v +``` + +## Запуск демонстраций транзакций + +```bash +# Dirty Read +python transaction_demos/demo_dirty_read.py + +# Non-Repeatable Read +python transaction_demos/demo_non_repeatable_read.py + +# Phantom Reads +python transaction_demos/demo_phantom_reads.py +``` + +## Ожидаемые результаты + +- Все 39 тестов должны пройти +- Демонстрационные скрипты показывают проблемы транзакций +- PostgreSQL работает на порту 5432 + +Подробности в [HW4_README.md](HW4_README.md) + diff --git a/hw2/hw/assets/http_observability.png b/hw2/hw/assets/http_observability.png new file mode 100644 index 00000000..9db38b1e Binary files /dev/null and b/hw2/hw/assets/http_observability.png differ diff --git a/hw2/hw/assets/my_dashboard.png b/hw2/hw/assets/my_dashboard.png new file mode 100644 index 00000000..299773ad Binary files /dev/null and b/hw2/hw/assets/my_dashboard.png differ diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..f8c5db4e --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,66 @@ +import os +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from shop_api.main import Base, app, get_db + +# Используем тестовую базу данных PostgreSQL +TEST_DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://shop_user:shop_password@localhost:5432/shop_db_test" +) + +engine = create_engine(TEST_DATABASE_URL, pool_pre_ping=True) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_database(): + """Создание всех таблиц перед запуском тестов""" + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(autouse=True) +def clean_database(): + """Очистка данных между тестами""" + # Очищаем таблицы перед каждым тестом + db = TestingSessionLocal() + try: + # Важно удалять в правильном порядке из-за внешних ключей + db.execute(Base.metadata.tables['cart_items'].delete()) + db.execute(Base.metadata.tables['carts'].delete()) + db.execute(Base.metadata.tables['items'].delete()) + db.commit() + finally: + db.close() + + yield + + # Очищаем таблицы после каждого теста + db = TestingSessionLocal() + try: + db.execute(Base.metadata.tables['cart_items'].delete()) + db.execute(Base.metadata.tables['carts'].delete()) + db.execute(Base.metadata.tables['items'].delete()) + db.commit() + finally: + db.close() + + +@pytest.fixture(scope="session", autouse=True) +def override_get_db(): + """Переопределение зависимости get_db для использования тестовой БД""" + def get_test_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = get_test_db + yield + app.dependency_overrides.clear() + diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..4406d31d --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,77 @@ +version: "3.8" + +services: + postgres: + image: postgres:15 + container_name: shop-postgres + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - monitoring + + shop-api: + build: + context: . + dockerfile: ./Dockerfile + target: local + container_name: shop-api + restart: always + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + environment: + - DATABASE_URL=postgresql://shop_user:shop_password@postgres:5432/shop_db + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + restart: always + networks: + - monitoring + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=admin + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + - prometheus-data:/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 + +volumes: + prometheus-data: + postgres_data: + diff --git a/hw2/hw/grafana_http_observability.json b/hw2/hw/grafana_http_observability.json new file mode 100644 index 00000000..3eb5836e --- /dev/null +++ b/hw2/hw/grafana_http_observability.json @@ -0,0 +1,816 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 4, + "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": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(http_requests_total{job=\"$job\", path!=\"/metrics\"})", + "instant": true, + "interval": "", + "legendFormat": "", + "range": true, + "refId": "A" + } + ], + "timeFrom": "24h", + "title": "Total Requests", + "transformations": [ + { + "id": "seriesToRows", + "options": {} + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "Time" + } + ] + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 4, + "y": 0 + }, + "id": 16, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": true + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "code", + "exemplar": true, + "expr": "http_requests_total{job=\"$job\", path!=\"/metrics\"}", + "instant": true, + "interval": "", + "legendFormat": "{{method}} {{path}}", + "range": true, + "refId": "A" + } + ], + "timeFrom": "24h", + "title": "Requests Count", + "transformations": [ + { + "id": "seriesToRows", + "options": {} + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "Time" + } + ] + } + }, + { + "id": "partitionByValues", + "options": { + "fields": [ + "Metric" + ] + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 6, + "options": { + "displayMode": "lcd", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "builder", + "exemplar": true, + "expr": "http_request_duration_seconds_sum{job=\"$job\"} / http_request_duration_seconds_count{job=\"$job\"}", + "interval": "", + "legendFormat": "{{method}} {{path}}", + "range": true, + "refId": "A" + } + ], + "title": "Requests Average Duration", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 0, + "y": 6 + }, + "id": 22, + "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": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "builder", + "exemplar": false, + "expr": "sum(http_requests_total{job=\"$job\", status!=\"2xx\"})", + "instant": true, + "interval": "", + "legendFormat": "", + "range": false, + "refId": "A" + } + ], + "timeFrom": "24h", + "title": "Total not-200", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 1, + "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": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 4, + "y": 6 + }, + "id": 18, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "builder", + "exemplar": true, + "expr": "sum by(path) (http_requests_total{job=\"$job\", status=~\"2.*\", path!=\"/metrics\"}) / (sum by(path) (http_requests_total{job=\"$job\", path!=\"/metrics\"}))", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + } + ], + "title": "Percent of 2xx Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 1, + "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": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 0.1 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 14, + "y": 6 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "builder", + "exemplar": true, + "expr": "sum by(path) (http_requests_total{job=\"$job\", status=~\"4xx\", path!=\"/metrics\"}) / (sum by(path) (http_requests_total{job=\"$job\", path!=\"/metrics\"}))", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + } + ], + "title": "Percent of 4xx Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "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 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "code", + "exemplar": true, + "expr": "histogram_quantile(.99,sum(rate(http_request_duration_seconds_bucket{job=\"$job\", path!=\"/metrics\"}[1m])) by(path, le))", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + } + ], + "title": "PR 99 Requests Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "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 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "exemplar": true, + "expr": "rate(http_requests_total{job=\"$job\"}[1m])", + "interval": "", + "legendFormat": "{{path}}", + "refId": "A" + } + ], + "title": "Request Per Sec", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": "shop-api", + "value": "shop-api" + }, + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "definition": "label_values(http_app_info{}, job)", + "includeAll": false, + "label": "Application Name", + "name": "job", + "options": [], + "query": { + "query": "label_values(http_app_info{}, job)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "", + "value": "" + }, + "description": "query with keyword", + "label": "Log Query", + "name": "log_keyword", + "options": [ + { + "selected": true, + "text": "", + "value": "" + } + ], + "query": "", + "type": "textbox" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "http Observability", + "uid": "http-observability-wo-loki", + "version": 3 + } \ No newline at end of file diff --git a/hw2/hw/grafana_my_dashboard.json b/hw2/hw/grafana_my_dashboard.json new file mode 100644 index 00000000..72ed17b5 --- /dev/null +++ b/hw2/hw/grafana_my_dashboard.json @@ -0,0 +1,421 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Key metrics for the shop API", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "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": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "P95 времени ответа", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "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": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "editorMode": "builder", + "expr": "rate(http_requests_total[5m])", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Количество запросов по методам", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "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": [ + { + "editorMode": "builder", + "expr": "sum by(handler) (rate(http_requests_total[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Количество запросов по эндпоинтам", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "ff0un5f8ipz40d" + }, + "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": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "editorMode": "builder", + "expr": "sum by(method) (rate(http_requests_total[5m]))", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Количество запросов в секунду", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "My Dashboard", + "uid": "ad4vspx", + "version": 4 + } \ No newline at end of file diff --git a/hw2/hw/migrations/init.sql b/hw2/hw/migrations/init.sql new file mode 100644 index 00000000..d0bbbc8a --- /dev/null +++ b/hw2/hw/migrations/init.sql @@ -0,0 +1,58 @@ +-- Создание схемы базы данных для Shop API + +-- Таблица товаров +DROP TABLE IF EXISTS cart_items CASCADE; +DROP TABLE IF EXISTS carts CASCADE; +DROP TABLE IF EXISTS items CASCADE; + +CREATE TABLE items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10, 2) NOT NULL CHECK (price > 0), + deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица корзин +CREATE TABLE carts ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица связи корзин и товаров (many-to-many) +CREATE TABLE cart_items ( + id SERIAL PRIMARY KEY, + cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(cart_id, item_id) +); + +-- Индексы для оптимизации запросов +CREATE INDEX idx_items_deleted ON items(deleted); +CREATE INDEX idx_items_price ON items(price); +CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id); +CREATE INDEX idx_cart_items_item_id ON cart_items(item_id); + +-- Триггер для автоматического обновления updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_items_updated_at BEFORE UPDATE ON items + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_carts_updated_at BEFORE UPDATE ON carts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_cart_items_updated_at BEFORE UPDATE ON cart_items + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..20cb9026 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,16 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator>=7.0.0 + +# Зависимости для работы с БД +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 +psycopg2-binary>=2.9.9 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 httpx>=0.27.2 Faker>=37.8.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..66420935 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - shop-api:8080 + diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..b0b8e95c 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,430 @@ -from fastapi import FastAPI +from http import HTTPStatus +from typing import Annotated +import os + +from fastapi import FastAPI, HTTPException, Query, Response, Depends +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Numeric +from sqlalchemy.orm import sessionmaker, Session, declarative_base +from sqlalchemy.sql import func + +# Database setup +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://shop_user:shop_password@localhost:5432/shop_db") +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) + + +# ==================== Database Models ==================== + + +class ItemOrm(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + price = Column(Numeric(10, 2), nullable=False) + deleted = Column(Boolean, default=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class CartOrm(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class CartItemOrm(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True) + cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False) + item_id = Column(Integer, ForeignKey("items.id", ondelete="CASCADE"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +# Database session dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# ==================== Pydantic Models ==================== + + +class ItemCreateRequest(BaseModel): + name: str + price: float = Field(gt=0) + + +class ItemPatchRequest(BaseModel): + name: str | None = None + price: float | None = Field(default=None, gt=0) + + @field_validator("price", "name") + @classmethod + def check_no_deleted(cls, v, info): + return v + + model_config = {"extra": "forbid"} + + +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 + + +class CartResponse(BaseModel): + id: int + + +# ==================== Storage ==================== + + +class Storage: + def __init__(self, db: Session): + self.db = db + + def create_item(self, name: str, price: float) -> Item: + item_orm = ItemOrm(name=name, price=price, deleted=False) + self.db.add(item_orm) + self.db.commit() + self.db.refresh(item_orm) + return Item( + id=item_orm.id, + name=item_orm.name, + price=float(item_orm.price), + deleted=item_orm.deleted + ) + + def get_item(self, item_id: int) -> Item | None: + item_orm = self.db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not item_orm: + return None + return Item( + id=item_orm.id, + name=item_orm.name, + price=float(item_orm.price), + deleted=item_orm.deleted + ) + + def get_items( + self, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, + ) -> list[Item]: + query = self.db.query(ItemOrm) + + # Apply filters + if not show_deleted: + query = query.filter(ItemOrm.deleted == False) + + if min_price is not None: + query = query.filter(ItemOrm.price >= min_price) + + if max_price is not None: + query = query.filter(ItemOrm.price <= max_price) + + # Apply pagination + items_orm = query.offset(offset).limit(limit).all() + + return [ + Item( + id=item.id, + name=item.name, + price=float(item.price), + deleted=item.deleted + ) + for item in items_orm + ] + + def update_item(self, item_id: int, name: str, price: float) -> Item | None: + item_orm = self.db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not item_orm: + return None + item_orm.name = name + item_orm.price = price + self.db.commit() + self.db.refresh(item_orm) + return Item( + id=item_orm.id, + name=item_orm.name, + price=float(item_orm.price), + deleted=item_orm.deleted + ) + + def patch_item(self, item_id: int, name: str | None, price: float | None) -> Item | None: + item_orm = self.db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not item_orm: + return None + + if item_orm.deleted: + return None + + if name is not None: + item_orm.name = name + if price is not None: + item_orm.price = price + + self.db.commit() + self.db.refresh(item_orm) + return Item( + id=item_orm.id, + name=item_orm.name, + price=float(item_orm.price), + deleted=item_orm.deleted + ) + + def delete_item(self, item_id: int) -> bool: + item_orm = self.db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if item_orm: + item_orm.deleted = True + self.db.commit() + return True + + def create_cart(self) -> int: + cart_orm = CartOrm() + self.db.add(cart_orm) + self.db.commit() + self.db.refresh(cart_orm) + return cart_orm.id + + def get_cart(self, cart_id: int) -> Cart | None: + cart_orm = self.db.query(CartOrm).filter(CartOrm.id == cart_id).first() + if not cart_orm: + return None + + cart_items_orm = self.db.query(CartItemOrm).filter(CartItemOrm.cart_id == cart_id).all() + items = [] + total_price = 0.0 + + for cart_item in cart_items_orm: + item_orm = self.db.query(ItemOrm).filter(ItemOrm.id == cart_item.item_id).first() + if item_orm: + items.append( + CartItem( + id=item_orm.id, + name=item_orm.name, + quantity=cart_item.quantity, + available=not item_orm.deleted, + ) + ) + if not item_orm.deleted: + total_price += float(item_orm.price) * cart_item.quantity + + return Cart(id=cart_id, items=items, price=total_price) + + def get_carts( + self, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, + ) -> list[Cart]: + cart_ids = self.db.query(CartOrm.id).offset(offset).limit(limit).all() + carts = [] + + for (cart_id,) in cart_ids: + cart = self.get_cart(cart_id) + if cart: + # Apply filters + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + total_quantity = sum(item.quantity for item in cart.items) + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + carts.append(cart) + + return carts + + def add_item_to_cart(self, cart_id: int, item_id: int) -> bool: + cart_orm = self.db.query(CartOrm).filter(CartOrm.id == cart_id).first() + if not cart_orm: + return False + + item_orm = self.db.query(ItemOrm).filter(ItemOrm.id == item_id).first() + if not item_orm: + return False + + cart_item = self.db.query(CartItemOrm).filter( + CartItemOrm.cart_id == cart_id, + CartItemOrm.item_id == item_id + ).first() + + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItemOrm(cart_id=cart_id, item_id=item_id, quantity=1) + self.db.add(cart_item) + + self.db.commit() + return True + + +# ==================== Item Endpoints ==================== + + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(item_request: ItemCreateRequest, db: Session = Depends(get_db)) -> Item: + """Создание нового товара""" + storage = Storage(db) + return storage.create_item(name=item_request.name, price=item_request.price) + + +@app.get("/item/{item_id}") +def get_item(item_id: int, db: Session = Depends(get_db)) -> Item: + """Получение товара по id""" + storage = Storage(db) + item = storage.get_item(item_id) + if not item or item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return item + + +@app.get("/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, + db: Session = Depends(get_db), +) -> list[Item]: + """Получение списка товаров с фильтрами""" + storage = Storage(db) + return storage.get_items( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) + + +@app.put("/item/{item_id}") +def update_item(item_id: int, item_request: ItemCreateRequest, db: Session = Depends(get_db)) -> Item: + """Замена товара по id (только существующих)""" + storage = Storage(db) + item = storage.update_item(item_id, name=item_request.name, price=item_request.price) + if not item: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return item + + +@app.patch("/item/{item_id}") +def patch_item(item_id: int, patch_request: ItemPatchRequest, db: Session = Depends(get_db)) -> Item: + """Частичное обновление товара по id""" + storage = Storage(db) + item = storage.patch_item(item_id, name=patch_request.name, price=patch_request.price) + if not item: + raise HTTPException( + status_code=HTTPStatus.NOT_MODIFIED, detail="Cannot modify deleted item" + ) + return item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int, db: Session = Depends(get_db)) -> dict: + """Удаление товара (пометка как deleted)""" + storage = Storage(db) + storage.delete_item(item_id) + return {"message": "Item deleted"} + + +# ==================== Cart Endpoints ==================== + + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response, db: Session = Depends(get_db)) -> CartResponse: + """Создание новой корзины""" + storage = Storage(db) + cart_id = storage.create_cart() + response.headers["location"] = f"/cart/{cart_id}" + return CartResponse(id=cart_id) + + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int, db: Session = Depends(get_db)) -> Cart: + """Получение корзины по id""" + storage = Storage(db) + cart = storage.get_cart(cart_id) + if not cart: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + return cart + + +@app.get("/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, + db: Session = Depends(get_db), +) -> list[Cart]: + """Получение списка корзин с фильтрами""" + storage = Storage(db) + return storage.get_carts( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)) -> Cart: + """Добавление товара в корзину""" + storage = Storage(db) + success = storage.add_item_to_cart(cart_id, item_id) + if not success: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Cart or item not found" + ) + + cart = storage.get_cart(cart_id) + if not cart: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + + return cart \ No newline at end of file diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..07a772e7 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -17,7 +17,7 @@ def existing_empty_cart_id() -> int: return client.post("/cart").json()["id"] -@pytest.fixture(scope="session") +@pytest.fixture() def existing_items() -> list[int]: items = [ { @@ -30,7 +30,7 @@ def existing_items() -> list[int]: return [client.post("/item", json=item).json()["id"] for item in items] -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(autouse=True) def existing_not_empty_carts(existing_items: list[int]) -> list[int]: carts = [] @@ -282,3 +282,28 @@ def test_delete_item(existing_item: dict[str, Any]) -> None: response = client.delete(f"/item/{item_id}") assert response.status_code == HTTPStatus.OK + + +def test_get_nonexistent_cart() -> None: + """Тест получения несуществующей корзины""" + response = client.get("/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_put_nonexistent_item() -> None: + """Тест обновления несуществующего товара""" + response = client.put("/item/999999", json={"name": "Test", "price": 10.0}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_nonexistent_item_to_cart(existing_empty_cart_id: int) -> None: + """Тест добавления несуществующего товара в корзину""" + response = client.post(f"/cart/{existing_empty_cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_to_nonexistent_cart(existing_item: dict[str, Any]) -> None: + """Тест добавления товара в несуществующую корзину""" + item_id = existing_item["id"] + response = client.post(f"/cart/999999/add/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND diff --git a/hw2/hw/transaction_demos/README.md b/hw2/hw/transaction_demos/README.md new file mode 100644 index 00000000..41d5b76e --- /dev/null +++ b/hw2/hw/transaction_demos/README.md @@ -0,0 +1,93 @@ +# Демонстрация проблем транзакций и уровней изоляции + +Этот каталог содержит скрипты для демонстрации различных проблем, возникающих при параллельном выполнении транзакций, и того, как уровни изоляции помогают их решать. + +## Проблемы транзакций + +### 1. Dirty Read (Грязное чтение) +**Проблема**: Транзакция читает данные, которые были изменены другой транзакцией, но ещё не закоммичены. + +**Скрипт**: `demo_dirty_read.py` + +**Уровни изоляции**: +- [!!] READ UNCOMMITTED - позволяет dirty read (в PostgreSQL не поддерживается) +- [OK] READ COMMITTED - предотвращает dirty read + +**Запуск**: +```bash +python transaction_demos/demo_dirty_read.py +``` + +### 2. Non-Repeatable Read (Неповторяющееся чтение) +**Проблема**: Транзакция читает одну и ту же строку дважды, но получает разные значения, потому что другая транзакция изменила данные между чтениями. + +**Скрипт**: `demo_non_repeatable_read.py` + +**Уровни изоляции**: +- [!!] READ COMMITTED - позволяет non-repeatable read +- [OK] REPEATABLE READ - предотвращает non-repeatable read + +**Запуск**: +```bash +python transaction_demos/demo_non_repeatable_read.py +``` + +### 3. Phantom Reads (Фантомное чтение) +**Проблема**: Транзакция выполняет один и тот же запрос дважды, но получает разное количество строк, потому что другая транзакция добавила или удалила строки. + +**Скрипт**: `demo_phantom_reads.py` + +**Уровни изоляции**: +- [!!] REPEATABLE READ (по стандарту SQL) - позволяет phantom reads +- [OK] SERIALIZABLE - предотвращает phantom reads + +**Примечание**: В PostgreSQL REPEATABLE READ использует Snapshot Isolation и также предотвращает phantom reads. + +**Запуск**: +```bash +python transaction_demos/demo_phantom_reads.py +``` + +## Уровни изоляции транзакций + +| Уровень изоляции | Dirty Read | Non-Repeatable Read | Phantom Reads | +|------------------|------------|---------------------|---------------| +| READ UNCOMMITTED | Возможен | Возможен | Возможен | +| READ COMMITTED | Невозможен | Возможен | Возможен | +| REPEATABLE READ | Невозможен | Невозможен | Возможен* | +| SERIALIZABLE | Невозможен | Невозможен | Невозможен | + +\* В PostgreSQL phantom reads невозможны уже на уровне REPEATABLE READ + +## Особенности PostgreSQL + +PostgreSQL имеет некоторые отличия от стандарта SQL: + +1. **Минимальный уровень изоляции** - READ COMMITTED. Даже если указать READ UNCOMMITTED, PostgreSQL будет использовать READ COMMITTED. + +2. **REPEATABLE READ** использует Snapshot Isolation, что предотвращает не только non-repeatable reads, но и phantom reads. + +3. **SERIALIZABLE** в PostgreSQL реализован через Serializable Snapshot Isolation (SSI), который обнаруживает циклы зависимостей между транзакциями и вызывает ошибку сериализации для одной из транзакций. + +## Запуск всех демонстраций + +```bash +# Убедитесь, что БД запущена +cd hw2/hw +docker-compose up -d postgres + +# Запустите все демонстрации +python transaction_demos/demo_dirty_read.py +python transaction_demos/demo_non_repeatable_read.py +python transaction_demos/demo_phantom_reads.py +``` + +## Требования + +- PostgreSQL (запускается через docker-compose) +- Python 3.10+ +- SQLAlchemy +- psycopg2-binary + +Все зависимости указаны в `requirements.txt`. + diff --git a/hw2/hw/transaction_demos/__init__.py b/hw2/hw/transaction_demos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/transaction_demos/demo_dirty_read.py b/hw2/hw/transaction_demos/demo_dirty_read.py new file mode 100644 index 00000000..b319b110 --- /dev/null +++ b/hw2/hw/transaction_demos/demo_dirty_read.py @@ -0,0 +1,195 @@ +""" +Демонстрация проблемы Dirty Read и её решения через уровни изоляции. + +Dirty Read - чтение незафиксированных изменений из другой транзакции. +""" + +import time +import threading +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://shop_user:shop_password@localhost:5432/shop_db") + + +def demo_dirty_read_with_read_uncommitted(): + """ + Демонстрация Dirty Read при уровне изоляции READ UNCOMMITTED. + + Примечание: PostgreSQL не поддерживает READ UNCOMMITTED, + минимальный уровень - READ COMMITTED. Этот пример показывает, + что dirty read не возникает даже при попытке использовать READ UNCOMMITTED. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Попытка Dirty Read с READ UNCOMMITTED") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="READ UNCOMMITTED") + Session = sessionmaker(bind=engine) + + def transaction_1(): + """Транзакция 1: Обновляет цену товара, но не коммитит сразу""" + session = Session() + try: + print("\n[T1] Начало транзакции 1") + + # Создаём тестовый товар + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9999, 'Test Item', 100.00, false) ON CONFLICT (id) DO UPDATE SET price = 100.00, deleted = false")) + session.commit() + print("[T1] Создан тестовый товар с id=9999, price=100.00") + + # Начинаем новую транзакцию + session.execute(text("BEGIN")) + + # Обновляем цену + session.execute(text("UPDATE items SET price = 200.00 WHERE id = 9999")) + print("[T1] Обновил цену на 200.00 (не закоммитил)") + + # Ждём, пока транзакция 2 попытается прочитать + time.sleep(2) + + # Откатываем транзакцию + session.rollback() + print("[T1] Откатил транзакцию (ROLLBACK)") + + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Пытается прочитать незакоммиченные данные""" + session = Session() + try: + time.sleep(1) # Ждём, пока T1 обновит данные + + print("\n[T2] Начало транзакции 2") + result = session.execute(text("SELECT price FROM items WHERE id = 9999")) + price = result.scalar() + print(f"[T2] Прочитал цену: {price}") + + if price == 200.00: + print(f"[T2] [!!] DIRTY READ обнаружен! Прочитаны незакоммиченные данные!") + else: + print("[T2] [OK] Dirty Read не произошёл. Прочитаны только закоммиченные данные.") + + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id = 9999")) + session.commit() + session.close() + + print("\n" + "="*80) + + +def demo_no_dirty_read_with_read_committed(): + """ + Демонстрация отсутствия Dirty Read при уровне изоляции READ COMMITTED. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Отсутствие Dirty Read с READ COMMITTED") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="READ COMMITTED") + Session = sessionmaker(bind=engine) + + def transaction_1(): + """Транзакция 1: Обновляет цену товара, но не коммитит сразу""" + session = Session() + try: + print("\n[T1] Начало транзакции 1") + + # Создаём тестовый товар + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9998, 'Test Item 2', 150.00, false) ON CONFLICT (id) DO UPDATE SET price = 150.00, deleted = false")) + session.commit() + print("[T1] Создан тестовый товар с id=9998, price=150.00") + + # Начинаем новую транзакцию + session.execute(text("BEGIN")) + + # Обновляем цену + session.execute(text("UPDATE items SET price = 250.00 WHERE id = 9998")) + print("[T1] Обновил цену на 250.00 (не закоммитил)") + + # Ждём, пока транзакция 2 попытается прочитать + time.sleep(2) + + # Откатываем транзакцию + session.rollback() + print("[T1] Откатил транзакцию (ROLLBACK)") + + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Читает данные с уровнем изоляции READ COMMITTED""" + session = Session() + try: + time.sleep(1) # Ждём, пока T1 обновит данные + + print("\n[T2] Начало транзакции 2 (READ COMMITTED)") + result = session.execute(text("SELECT price FROM items WHERE id = 9998")) + price = result.scalar() + print(f"[T2] Прочитал цену: {price}") + + if price == 250.00: + print("[T2] [!!] DIRTY READ обнаружен! Прочитаны незакоммиченные данные!") + else: + print("[T2] [OK] Dirty Read НЕ произошёл. Прочитаны только закоммиченные данные (150.00).") + + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id = 9998")) + session.commit() + session.close() + + print("\n" + "="*80) + + +if __name__ == "__main__": + print("\n" + "="*80) + print("ТЕСТИРОВАНИЕ DIRTY READ") + print("="*80) + print("\nDirty Read - это чтение незакоммиченных изменений из другой транзакции.") + print("При уровне изоляции READ UNCOMMITTED это возможно.") + print("При уровне изоляции READ COMMITTED и выше - это невозможно.") + print("\nПримечание: PostgreSQL не поддерживает READ UNCOMMITTED,") + print("минимальный уровень изоляции - READ COMMITTED.") + + # Попытка демонстрации с READ UNCOMMITTED (на самом деле будет READ COMMITTED) + demo_dirty_read_with_read_uncommitted() + + # Демонстрация с READ COMMITTED + demo_no_dirty_read_with_read_committed() + + print("\n" + "="*80) + print("ВЫВОД:") + print("В PostgreSQL dirty read невозможен даже при самом низком уровне изоляции,") + print("так как минимальный поддерживаемый уровень - READ COMMITTED.") + print("="*80 + "\n") + diff --git a/hw2/hw/transaction_demos/demo_non_repeatable_read.py b/hw2/hw/transaction_demos/demo_non_repeatable_read.py new file mode 100644 index 00000000..06d4134d --- /dev/null +++ b/hw2/hw/transaction_demos/demo_non_repeatable_read.py @@ -0,0 +1,205 @@ +""" +Демонстрация проблемы Non-Repeatable Read и её решения через уровни изоляции. + +Non-Repeatable Read - повторное чтение той же строки даёт разные результаты +из-за коммита другой транзакции между чтениями. +""" + +import time +import threading +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://shop_user:shop_password@localhost:5432/shop_db") + + +def demo_non_repeatable_read_with_read_committed(): + """ + Демонстрация Non-Repeatable Read при уровне изоляции READ COMMITTED. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Non-Repeatable Read с READ COMMITTED") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="READ COMMITTED") + Session = sessionmaker(bind=engine) + + # Подготовка данных + session = Session() + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9997, 'Test Item 3', 100.00, false) ON CONFLICT (id) DO UPDATE SET price = 100.00, deleted = false")) + session.commit() + session.close() + print("[SETUP] Создан тестовый товар с id=9997, price=100.00") + + def transaction_1(): + """Транзакция 1: Читает данные дважды""" + session = Session() + try: + print("\n[T1] Начало транзакции 1 (READ COMMITTED)") + session.execute(text("BEGIN")) + + # Первое чтение + result = session.execute(text("SELECT price FROM items WHERE id = 9997")) + price1 = result.scalar() + print(f"[T1] Первое чтение: price = {price1}") + + # Ждём, пока T2 обновит и закоммитит данные + time.sleep(2) + + # Второе чтение той же строки + result = session.execute(text("SELECT price FROM items WHERE id = 9997")) + price2 = result.scalar() + print(f"[T1] Второе чтение: price = {price2}") + + if price1 != price2: + print(f"[T1] [!!] NON-REPEATABLE READ обнаружен! Цена изменилась с {price1} на {price2}") + else: + print(f"[T1] [OK] Non-Repeatable Read не произошёл. Цена осталась {price1}") + + session.commit() + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Обновляет данные между чтениями T1""" + session = Session() + try: + time.sleep(1) # Ждём первого чтения T1 + + print("\n[T2] Начало транзакции 2") + session.execute(text("BEGIN")) + session.execute(text("UPDATE items SET price = 200.00 WHERE id = 9997")) + print("[T2] Обновил цену на 200.00") + session.commit() + print("[T2] Закоммитил изменения") + + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id = 9997")) + session.commit() + session.close() + + print("\n" + "="*80) + + +def demo_no_non_repeatable_read_with_repeatable_read(): + """ + Демонстрация отсутствия Non-Repeatable Read при уровне изоляции REPEATABLE READ. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Отсутствие Non-Repeatable Read с REPEATABLE READ") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="REPEATABLE READ") + Session = sessionmaker(bind=engine) + + # Подготовка данных + session = Session() + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9996, 'Test Item 4', 150.00, false) ON CONFLICT (id) DO UPDATE SET price = 150.00, deleted = false")) + session.commit() + session.close() + print("[SETUP] Создан тестовый товар с id=9996, price=150.00") + + def transaction_1(): + """Транзакция 1: Читает данные дважды с REPEATABLE READ""" + session = Session() + try: + print("\n[T1] Начало транзакции 1 (REPEATABLE READ)") + session.execute(text("BEGIN")) + + # Первое чтение + result = session.execute(text("SELECT price FROM items WHERE id = 9996")) + price1 = result.scalar() + print(f"[T1] Первое чтение: price = {price1}") + + # Ждём, пока T2 обновит и закоммитит данные + time.sleep(2) + + # Второе чтение той же строки + result = session.execute(text("SELECT price FROM items WHERE id = 9996")) + price2 = result.scalar() + print(f"[T1] Второе чтение: price = {price2}") + + if price1 != price2: + print(f"[T1] [!!] NON-REPEATABLE READ обнаружен! Цена изменилась с {price1} на {price2}") + else: + print(f"[T1] [OK] Non-Repeatable Read НЕ произошёл. Цена осталась {price1}") + print("[T1] [OK] REPEATABLE READ гарантирует согласованное чтение в рамках транзакции") + + session.commit() + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Обновляет данные между чтениями T1""" + session = Session() + try: + time.sleep(1) # Ждём первого чтения T1 + + print("\n[T2] Начало транзакции 2") + session.execute(text("BEGIN")) + session.execute(text("UPDATE items SET price = 250.00 WHERE id = 9996")) + print("[T2] Обновил цену на 250.00") + session.commit() + print("[T2] Закоммитил изменения") + print("[T2] Но T1 не увидит эти изменения из-за REPEATABLE READ") + + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id = 9996")) + session.commit() + session.close() + + print("\n" + "="*80) + + +if __name__ == "__main__": + print("\n" + "="*80) + print("ТЕСТИРОВАНИЕ NON-REPEATABLE READ") + print("="*80) + print("\nNon-Repeatable Read - это ситуация, когда одна транзакция") + print("читает одну и ту же строку дважды, но получает разные значения,") + print("потому что другая транзакция изменила и закоммитила данные между чтениями.") + print("\nПри уровне изоляции READ COMMITTED это возможно.") + print("При уровне изоляции REPEATABLE READ и выше - это невозможно.") + + # Демонстрация с READ COMMITTED + demo_non_repeatable_read_with_read_committed() + + # Демонстрация с REPEATABLE READ + demo_no_non_repeatable_read_with_repeatable_read() + + print("\n" + "="*80) + print("ВЫВОД:") + print("При READ COMMITTED возможен non-repeatable read.") + print("При REPEATABLE READ транзакция видит согласованный снимок данных") + print("на момент первого чтения, поэтому non-repeatable read невозможен.") + print("="*80 + "\n") + diff --git a/hw2/hw/transaction_demos/demo_phantom_reads.py b/hw2/hw/transaction_demos/demo_phantom_reads.py new file mode 100644 index 00000000..97eed51f --- /dev/null +++ b/hw2/hw/transaction_demos/demo_phantom_reads.py @@ -0,0 +1,323 @@ +""" +Демонстрация проблемы Phantom Reads и её решения через уровни изоляции. + +Phantom Reads - появление новых строк (или исчезновение существующих) +при повторном выполнении того же запроса в рамках одной транзакции. +""" + +import time +import threading +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://shop_user:shop_password@localhost:5432/shop_db") + + +def demo_phantom_reads_with_repeatable_read(): + """ + Демонстрация Phantom Reads при уровне изоляции REPEATABLE READ. + + Примечание: В PostgreSQL при REPEATABLE READ phantom reads также не возникают, + так как используетсяSnapshot Isolation. Однако для демонстрации концепции + мы покажем, что может произойти в теории. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Phantom Reads с REPEATABLE READ") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="REPEATABLE READ") + Session = sessionmaker(bind=engine) + + # Подготовка данных + session = Session() + session.execute(text("DELETE FROM items WHERE id >= 9990 AND id <= 9995")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9990, 'Item A', 50.00, false)")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9991, 'Item B', 75.00, false)")) + session.commit() + session.close() + print("[SETUP] Созданы тестовые товары с ценами 50.00 и 75.00") + + def transaction_1(): + """Транзакция 1: Выполняет агрегирующий запрос дважды""" + session = Session() + try: + print("\n[T1] Начало транзакции 1 (REPEATABLE READ)") + session.execute(text("BEGIN")) + + # Первый запрос: считаем количество товаров с ценой > 40 + result = session.execute(text("SELECT COUNT(*) FROM items WHERE price > 40 AND id >= 9990 AND id <= 9995")) + count1 = result.scalar() + print(f"[T1] Первый запрос: COUNT(*) = {count1}") + + # Ждём, пока T2 добавит новую строку + time.sleep(2) + + # Второй запрос: повторяем тот же запрос + result = session.execute(text("SELECT COUNT(*) FROM items WHERE price > 40 AND id >= 9990 AND id <= 9995")) + count2 = result.scalar() + print(f"[T1] Второй запрос: COUNT(*) = {count2}") + + if count1 != count2: + print(f"[T1] [!!] PHANTOM READS обнаружен! Количество изменилось с {count1} на {count2}") + else: + print(f"[T1] [OK] Phantom Reads не произошёл. Количество осталось {count1}") + print("[T1] [OK] В PostgreSQL REPEATABLE READ предотвращает phantom reads") + + session.commit() + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Вставляет новую строку между запросами T1""" + session = Session() + try: + time.sleep(1) # Ждём первого запроса T1 + + print("\n[T2] Начало транзакции 2") + session.execute(text("BEGIN")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9992, 'Item C', 100.00, false)")) + print("[T2] Вставил новый товар с price=100.00") + session.commit() + print("[T2] Закоммитил изменения") + + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id >= 9990 AND id <= 9995")) + session.commit() + session.close() + + print("\n" + "="*80) + + +def demo_no_phantom_reads_with_serializable(): + """ + Демонстрация отсутствия Phantom Reads при уровне изоляции SERIALIZABLE. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Отсутствие Phantom Reads с SERIALIZABLE") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="SERIALIZABLE") + Session = sessionmaker(bind=engine) + + # Подготовка данных + session = Session() + session.execute(text("DELETE FROM items WHERE id >= 9985 AND id <= 9989")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9985, 'Item D', 60.00, false)")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9986, 'Item E', 80.00, false)")) + session.commit() + session.close() + print("[SETUP] Созданы тестовые товары с ценами 60.00 и 80.00") + + def transaction_1(): + """Транзакция 1: Выполняет агрегирующий запрос дважды с SERIALIZABLE""" + session = Session() + try: + print("\n[T1] Начало транзакции 1 (SERIALIZABLE)") + session.execute(text("BEGIN")) + + # Первый запрос: считаем количество товаров с ценой > 50 + result = session.execute(text("SELECT COUNT(*) FROM items WHERE price > 50 AND id >= 9985 AND id <= 9989")) + count1 = result.scalar() + print(f"[T1] Первый запрос: COUNT(*) = {count1}") + + # Ждём, пока T2 попытается добавить новую строку + time.sleep(2) + + # Второй запрос: повторяем тот же запрос + result = session.execute(text("SELECT COUNT(*) FROM items WHERE price > 50 AND id >= 9985 AND id <= 9989")) + count2 = result.scalar() + print(f"[T1] Второй запрос: COUNT(*) = {count2}") + + if count1 != count2: + print(f"[T1] [!!] PHANTOM READS обнаружен! Количество изменилось с {count1} на {count2}") + else: + print(f"[T1] [OK] Phantom Reads НЕ произошёл. Количество осталось {count1}") + print("[T1] [OK] SERIALIZABLE гарантирует полную изоляцию транзакций") + + session.commit() + print("[T1] Транзакция успешно завершена") + except Exception as e: + print(f"[T1] Ошибка: {e}") + session.rollback() + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Пытается вставить новую строку между запросами T1""" + session = Session() + try: + time.sleep(1) # Ждём первого запроса T1 + + print("\n[T2] Начало транзакции 2 (SERIALIZABLE)") + session.execute(text("BEGIN")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9987, 'Item F', 110.00, false)")) + print("[T2] Вставил новый товар с price=110.00") + session.commit() + print("[T2] Закоммитил изменения") + + except Exception as e: + print(f"[T2] Возможна ошибка сериализации: {e}") + session.rollback() + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id >= 9985 AND id <= 9989")) + session.commit() + session.close() + + print("\n" + "="*80) + + +def demo_serialization_error(): + """ + Демонстрация ошибки сериализации при SERIALIZABLE. + """ + print("\n" + "="*80) + print("ДЕМОНСТРАЦИЯ: Ошибка сериализации при SERIALIZABLE") + print("="*80) + + engine = create_engine(DATABASE_URL, isolation_level="SERIALIZABLE") + Session = sessionmaker(bind=engine) + + # Подготовка данных + session = Session() + session.execute(text("DELETE FROM items WHERE id >= 9980 AND id <= 9984")) + session.execute(text("INSERT INTO items (id, name, price, deleted) VALUES (9980, 'Item G', 70.00, false)")) + session.commit() + session.close() + print("[SETUP] Создан тестовый товар с id=9980, price=70.00") + + def transaction_1(): + """Транзакция 1: Читает и обновляет данные""" + session = Session() + try: + print("\n[T1] Начало транзакции 1 (SERIALIZABLE)") + session.execute(text("BEGIN")) + + # Чтение + result = session.execute(text("SELECT price FROM items WHERE id = 9980")) + price = result.scalar() + print(f"[T1] Прочитал price = {price}") + + time.sleep(1.5) # Даём время T2 тоже прочитать + + # Обновление + session.execute(text("UPDATE items SET price = price + 10 WHERE id = 9980")) + print("[T1] Обновил price = price + 10") + + session.commit() + print("[T1] [OK] Транзакция успешно завершена") + + except Exception as e: + print(f"[T1] [!!] Ошибка сериализации: {type(e).__name__}") + session.rollback() + finally: + session.close() + + def transaction_2(): + """Транзакция 2: Читает и обновляет те же данные""" + session = Session() + try: + time.sleep(0.5) # Небольшая задержка + + print("\n[T2] Начало транзакции 2 (SERIALIZABLE)") + session.execute(text("BEGIN")) + + # Чтение + result = session.execute(text("SELECT price FROM items WHERE id = 9980")) + price = result.scalar() + print(f"[T2] Прочитал price = {price}") + + time.sleep(1.5) # Даём время T1 обновить + + # Обновление + session.execute(text("UPDATE items SET price = price + 20 WHERE id = 9980")) + print("[T2] Попытка обновить price = price + 20") + + session.commit() + print("[T2] [OK] Транзакция успешно завершена") + + except Exception as e: + print(f"[T2] [!!] Ошибка сериализации: {type(e).__name__}") + print("[T2] Это ожидаемое поведение при SERIALIZABLE - одна из транзакций должна быть отменена") + session.rollback() + finally: + session.close() + + # Запускаем транзакции параллельно + t1 = threading.Thread(target=transaction_1) + t2 = threading.Thread(target=transaction_2) + + t1.start() + t2.start() + + t1.join() + t2.join() + + # Очистка + session = Session() + session.execute(text("DELETE FROM items WHERE id >= 9980 AND id <= 9984")) + session.commit() + session.close() + + print("\n" + "="*80) + + +if __name__ == "__main__": + print("\n" + "="*80) + print("ТЕСТИРОВАНИЕ PHANTOM READS") + print("="*80) + print("\nPhantom Reads - это ситуация, когда одна транзакция выполняет") + print("один и тот же запрос дважды, но получает разное количество строк,") + print("потому что другая транзакция добавила или удалила строки между запросами.") + print("\nВ стандарте SQL:") + print("- При REPEATABLE READ phantom reads возможны") + print("- При SERIALIZABLE phantom reads невозможны") + print("\nВ PostgreSQL:") + print("- REPEATABLE READ использует Snapshot Isolation и предотвращает phantom reads") + print("- SERIALIZABLE обеспечивает полную изоляцию с обнаружением конфликтов") + + # Демонстрация с REPEATABLE READ + demo_phantom_reads_with_repeatable_read() + + # Демонстрация с SERIALIZABLE + demo_no_phantom_reads_with_serializable() + + # Демонстрация ошибки сериализации + demo_serialization_error() + + print("\n" + "="*80) + print("ВЫВОД:") + print("В PostgreSQL phantom reads предотвращаются уже на уровне REPEATABLE READ") + print("благодаря использованию Snapshot Isolation.") + print("SERIALIZABLE обеспечивает максимальную изоляцию, но может приводить") + print("к ошибкам сериализации, требующим повтора транзакции.") + print("="*80 + "\n") +