# Модуль 3. Raw-данные и первое чтение в Spark **Статус:** `Ready for validation` **Последнее обновление:** `2026-03-07` ## Цель Научить студента полному циклу работы с raw-данными в Lakehouse: доставка заранее скачанного PARQUET-датасета в raw-зону MinIO, чтение raw-данных через Spark, проверка схемы, типов и базового качества. Студент должен понять, зачем нужен raw-слой и почему raw-данные не следует «чинить на месте». ## Результат для студента После прохождения модуля студент: - понимает, как локальный data bundle попадает в raw-зону MinIO; - умеет загружать PARQUET-файлы в MinIO программно через `boto3`; - умеет читать raw-данные из MinIO через Spark (`spark.read.parquet` по `s3a://` пути); - умеет проверять схему DataFrame (`.printSchema()`, `.dtypes`), считать базовые метрики (`.describe()`, `.count()`, null-доли); - может найти простые аномалии или проблемные значения в реальном датасете; - понимает, почему raw-слой нужен как воспроизводимая неизменяемая точка входа; - понимает, почему датасет не следует скачивать из интернета прямо в ноутбуке. ## Deliverables - `notebooks/03_raw_ingest_and_first_read.ipynb` — основной ноутбук Модуля 3; - изменение `docker-compose.yml` — volume mount `./data:/opt/data:ro` в сервис `jupyter`; - изменение `.gitignore` — добавление `/data/`; - изменение `START_HERE.md` — секция «Подготовка учебного датасета (перед Модулем 3)» + обновление маршрута прохождения и секции «Как работать в Jupyter» (добавить упоминание `/opt/data`); - изменение `docs/stack_reference.md` — обновление секции JupyterLab (добавить `./data` mount); - обновление индекса планов в `plans/README.md`. ## Зависимости от инфраструктуры ### Доставка данных в контейнер Добавить volume mount `./data:/opt/data:ro` в секцию `jupyter.volumes` файла `docker-compose.yml` (аналогично `./src:/opt/src:ro`). Студент заранее скачивает PARQUET на хост по инструкции из `START_HERE.md`. В ноутбуке студент загружает файлы из `/opt/data/` в MinIO через `boto3`. Это делает процесс наглядным и воспроизводимым. ```yaml # docker-compose.yml, секция jupyter.volumes volumes: - ./spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro - ./notebooks:/opt/work - ./src:/opt/src:ro - ./data:/opt/data:ro # учебный data bundle (NYC Taxi) ``` ### Инструкция скачивания датасета Добавить в `START_HERE.md` секцию после «Restart и reset», перед «Что делать дальше»: - `mkdir -p data/nyc_taxi` - 3 команды `curl` для Yellow Tripdata 2024-01..03 с `https://d37ci6vzurychx.cloudfront.net/trip-data/` - 1 команда `curl` для `taxi_zone_lookup.csv` с `https://d37ci6vzurychx.cloudfront.net/misc/` - Проверочный `ls -lh data/nyc_taxi/` - Инструкция фиксирует конкретные файлы (2024-01, 2024-02, 2024-03) как каноническое учебное подмножество; расширенный режим — до 12 месяцев 2024 года - Пометка: после изменения `docker-compose.yml` (добавления volume) нужен `docker compose up -d` для пересоздания контейнера `jupyter`; после этого новые файлы в `./data` видны сразу без перезапуска ### Структура raw-зоны в MinIO Путь: `s3a://lakehouse/raw/nyc_taxi/`. Отдельный prefix `raw/` не смешивается с `warehouse/` (Iceberg-таблицы). Имена файлов сохраняются оригинальными, чтобы подчеркнуть принцип «raw не изменяется». ### .gitignore Добавить в конец: ``` # Dataset (downloaded by student, not stored in repo) /data/ ``` ## План работ 1. Добавить `/data/` в `.gitignore`. 2. Добавить volume mount `./data:/opt/data:ro` в `docker-compose.yml`, секция `jupyter.volumes`. 3. Добавить в `START_HERE.md` секцию «Подготовка учебного датасета (перед Модулем 3)» с воспроизводимыми командами скачивания NYC Taxi Yellow Tripdata PARQUET (3 месяца + taxi zone lookup). Обновить маршрут прохождения (добавить шаг подготовки данных). Обновить секцию «Как работать в Jupyter» — добавить упоминание `./data` mount как `/opt/data`. Обновить секцию «Что делать дальше», чтобы маршрут включал Модуль 3. 4. Обновить `docs/stack_reference.md` — в секции JupyterLab добавить строку `./data` смонтирован read-only как `/opt/data`. 5. Создать `notebooks/03_raw_ingest_and_first_read.ipynb` по ячеечной структуре, описанной ниже. 6. Обновить `plans/README.md` — добавить строку Модуля 3 в таблицу оглавления. 7. Валидация: убедиться, что `docker compose config` проходит без ошибок; выполнить ноутбук сверху вниз на поднятом стенде с data bundle. ## Структура ноутбука ### Секция 0: Введение - **[md]** Заголовок, цели модуля, prerequisite (data bundle из `START_HERE.md`). Таблица-сравнение: «Классический DWH: данные уже внутри СУБД» vs «Lakehouse: данные нужно явно доставить в storage». ### Секция 1: Проверка data bundle - **[md]** Зачем проверяем; почему не скачиваем из интернета в ноутбуке (воспроизводимость, сетевые ограничения, разделение ответственности onboarding vs практика). - **[code]** `os.listdir("/opt/data/nyc_taxi")`, вывод файлов с размерами, assert на наличие PARQUET-файлов и `taxi_zone_lookup.csv` — оба нужны для полного прохождения модуля (включая самостоятельное задание в Секции 6). Понятное сообщение с отсылкой к `START_HERE.md` при отсутствии любого из компонентов. ### Секция 2: Загрузка в raw-зону MinIO - **[md]** Что такое raw-зона, параллель с stg-слоем в Greenplum, конвенция путей, почему raw не чинится «на месте». - **[code]** Демонстрация: перед загрузкой проверяем через `s3.head_object()`, существует ли уже объект с таким ключом. Три ветки: (1) объект не существует — загружаем через `boto3.upload_file()`; (2) объект существует и размер совпадает с локальным файлом — пропускаем с пометкой «already exists, skipping»; (3) объект существует, но размер отличается — **fail fast** с понятной ошибкой (raise), без перезаписи. Это обеспечивает идемпотентность и подкрепляет принцип «raw не перезаписывается». В markdown-пояснении перед ячейкой явно объяснить: повторный запуск безопасен, уже загруженные файлы не перетираются, а конфликт размеров сигнализирует о проблеме с данными. - **[code]** Верификация: `s3.list_objects_v2()` — листинг raw-зоны. - **[md]** Данные теперь в storage. Spark может читать их напрямую по `s3a://` пути; для Trino потребуется зарегистрированная таблица (это будет в Модулях 4 и 6). ### Секция 3: Первое чтение в Spark - **[md]** Разница `spark.read.parquet("s3a://...")` (raw-файлы) vs `spark.table(...)` (управляемая Iceberg-таблица). Мы пока на этапе raw, Iceberg-таблица появится в Модуле 4. - **[code]** Создание SparkSession (паттерн из Module 2 — без `.master()`, `setLogLevel("ERROR")`). - **[code]** Чтение одного файла: `spark.read.parquet("s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_2024-01.parquet")`. - **[code]** Чтение всех файлов wildcard: `yellow_tripdata_*.parquet` (без привязки к конкретному году в wildcard, чтобы код работал с любыми скачанными файлами). ### Секция 4: Проверка схемы - **[md]** Зачем проверять схему: в классическом DWH схема задана DDL, в raw — вычитывается из файлов. Что может пойти не так: разные типы в разных файлах, неожиданные nullable-поля. - **[code]** `.printSchema()`. - **[code]** Колонки и типы в удобном формате (`.dtypes`). - **[code]** Null-анализ ключевых колонок (`VendorID`, `tpep_pickup_datetime`, `passenger_count`, `trip_distance`, `total_amount`) — количество и доля null в каждой. - **[md]** Обсуждение: null в raw — норма, не исправляем на месте. Очистка — задача bronze/silver слоев (Модули 4-5). ### Секция 5: Базовые метрики и аномалии - **[md]** Цель: быстро понять диапазоны значений, выявить очевидные проблемы. - **[code]** `.describe()` на числовых колонках. - **[code]** Поиск аномалий: отрицательные `fare_amount`, нулевая дистанция с ненулевой суммой, `total_amount > $1000`, pickup за пределами ожидаемого диапазона дат (определяется динамически из имён файлов, а не хардкодится). - **[md]** Аномалии в реальных данных — норма, а не баг загрузки. Задача raw — сохранить как есть. ### Секция 6: Самостоятельное задание - **[md]** Инструкция: (1) прочитать `taxi_zone_lookup.csv` из raw-зоны через Spark, (2) вывести схему + 10 строк, (3) посчитать количество зон по borough, (4) найти топ-5 pickup locations и join с зонами, (5) найти ещё одну аномалию или интересный паттерн в данных. - **[code]** 4 пустых ячейки с `# Ваш код: ...`. ### Секция 7: Почему raw-слой важен - **[md]** Мини-лекция (3-5 абзацев): raw = неизменяемый архив; параллель с stg/ods в Greenplum; почему не скачиваем в ноутбуке; почему не чиним на месте (можно перестроить bronze/silver заново); в Lakehouse raw-файлы лежат в storage отдельно от compute — Spark читает их напрямую, а для Trino потребуется оформить данные в таблицу. ### Секция 8: Checkpoint - **[md]** Вопросы для самопроверки: (1) показать файлы в raw-зоне MinIO, (2) какая схема у Yellow Taxi — сколько колонок, (3) какие колонки содержат null — это баг или норма, (4) зачем raw-слой, если данные потом трансформируются, (5) почему не скачиваем из ноутбука, (6) что произойдёт, если NYC TLC изменит формат файла. ### Секция 9: Завершение - **[md]** «Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 мы будем использовать их для создания bronze-слоя.» - **[code]** `spark.stop()`. ### Дизайн-решения по ноутбуку - **Helper-код:** весь код inline в ноутбуке. Отдельный модуль в `src/` не создаётся — студент должен видеть каждый шаг явно (`boto3.upload_file()`, `spark.read.parquet()`, `.printSchema()`). - **Cleanup:** ноутбук НЕ удаляет raw-данные из MinIO — они нужны Модулю 4 (bronze). Spark-сессия останавливается. Явное пояснение в конце ноутбука. ## Checkpoint Студент должен уметь: - показать файлы в raw-зоне MinIO (через MinIO Console или через код); - прочитать raw PARQUET через Spark и вывести несколько строк; - назвать схему Yellow Taxi (количество колонок, основные типы); - показать, какие колонки содержат null-значения; - объяснить, почему raw-слой нужен как воспроизводимая неизменяемая точка входа; - объяснить, почему данные не скачиваются прямо из ноутбука. ## Acceptance Criteria - `docker-compose.yml` содержит volume mount `./data:/opt/data:ro` для контейнера `jupyter`, и `docker compose config` проходит без ошибок. - `START_HERE.md` содержит воспроизводимую инструкцию по скачиванию NYC Taxi PARQUET (минимум 3 файла + taxi zone lookup) до запуска Модуля 3. - `.gitignore` содержит `/data/` для исключения датасета из git. - `notebooks/03_raw_ingest_and_first_read.ipynb` можно выполнить сверху вниз в поднятом Jupyter-окружении при наличии скачанного data bundle. - Ноутбук следует методике курса: `объяснение -> демонстрация -> самостоятельное повторение -> checkpoint`. - Ноутбук использует паттерны из Модулей 1-2: `boto3` для MinIO, SparkSession без `.master()`, `setLogLevel("ERROR")`. - Raw-данные остаются в MinIO после выполнения ноутбука (cleanup отсутствует намеренно). - При отсутствии data bundle — понятный assert с отсылкой к `START_HERE.md`. - `docs/stack_reference.md` обновлён: секция JupyterLab содержит `./data` mount. - `plans/README.md` содержит строку Модуля 3 в оглавлении. ## Риски - **Размер данных.** 3 месяца Yellow Taxi — примерно 120-150 MB (~40-50 MB на файл). Комфортно для стенда с 10 GB RAM. Extended (12 месяцев, ~600 MB) может замедлить Spark, но не должен вызывать падения. - **Студент забыл скачать данные.** Assert в Секции 1 с понятным сообщением и ссылкой на `START_HERE.md`. - **NYC TLC изменит формат/URL.** Ноутбук использует wildcard `yellow_tripdata_*.parquet` без привязки к году. Инструкция в `START_HERE.md` фиксирует конкретные файлы 2024-01..03, но если URL станут недоступны — потребуется обновление инструкции. - **Директория `data/` не существует.** Docker volume mount создаст пустую директорию, assert в ноутбуке поймает отсутствие файлов. - **Пересоздание контейнера.** Добавление volume в `docker-compose.yml` требует пересоздания контейнера `jupyter` (`docker compose up -d` достаточно — Docker пересоздаст только изменённый сервис). После этого bind mount уже работает: новые файлы в `./data` видны сразу без дополнительных перезапусков. Указать явно в инструкции. ## Out of Scope - Создание Iceberg-таблиц — отложено до Модуля 4. - Трансформации и очистка данных — отложено до Модулей 4-5. - Чтение raw-данных через Trino (Trino не умеет читать произвольные файлы без таблицы) — отложено до Модуля 6. - Партиционирование, MERGE, streaming, gold-слой. - Автоматизированный скрипт скачивания (может быть добавлен позже, не является блокером).