Files
mini-lakehouse-lab/notebooks/03_raw_ingest_and_first_read.ipynb

44 KiB

Модуль 3. Raw-данные и первое чтение в Spark

В прошлом модуле мы проследили, как Spark работает с каталогом и файлами в MinIO. Теперь загрузим настоящие данные: поездки NYC Taxi и справочник зон.

Сначала подготовим Spark-сессию и проверим подключение. Затем перенесем заранее скачанные файлы в raw-зону MinIO, прочитаем их через Spark и посмотрим на схему, пропуски и аномалии. Эти файлы останутся источником для таблиц, которые мы построим в следующих модулях.

0. Перед стартом

  • Подними стенд по START_HERE.md и пройди модули 1 и 2.
  • Скачай data bundle в ./data/nyc_taxi по инструкции из START_HERE.md.
  • Открой этот ноутбук в новом ядре, чтобы начать без сессии от предыдущих опытов.

1. Как настроен Spark в нашем стенде

До сих пор мы пользовались готовым объектом spark. Разберем, как его создать и куда он отправляет задания: это понадобится при чтении файлов Taxi.

SparkSession дает доступ к DataFrame и SQL. Его SparkContext связывает приложение с вычислительными ресурсами. В обычном ядре нашего ноутбука используется один работающий контекст.

Участник Что делает в стенде
Driver Работает в jupyter: выполняет Python-код, строит план и раздает задания
Master Принимает подключение по spark://spark-master:7077 и выделяет ресурсы worker-ов
Executors Работают на spark-worker-1 и spark-worker-2, читают данные и выполняют задания

В режиме local[*] задания выполняются в потоках на машине driver-а. Нам нужен кластер, поэтому проверим фактический master вместе с результатом вычисления.

Создадим сессию. .appName(...) задает имя приложения в Spark UI, .getOrCreate() создает сессию или возвращает уже работающую. Адрес кластера пока возьмем из общего файла настроек.

In [ ]:
from pyspark.sql import SparkSession

spark = (
    SparkSession.builder
    .appName("module-03-session-demo")
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")

print("Spark:", spark.version)
print("Master:", spark.sparkContext.master)
print("Приложение:", spark.sparkContext.appName)
assert spark.sparkContext.master == "spark://spark-master:7077", "Нужен Spark-кластер стенда"
print("Сумма 0…9:", spark.range(10).groupBy().sum("id").first()[0])

Откуда взялось подключение

Master мы не указали в коде: Spark прочитал его из spark/spark-defaults.conf. Docker подключает этот файл к Jupyter как /opt/spark/conf/spark-defaults.conf. Там же подготовлены каталог Iceberg и доступ к MinIO.

Настройка в файле Для чего нужна
spark.sql.extensions Добавляет команды Iceberg к Spark SQL
spark.sql.catalog.lakehouse Подключает Spark-каталог с именем lakehouse
spark.sql.catalog.lakehouse.catalog-impl и .uri Выбирает JDBC-каталог в PostgreSQL, где зарегистрированы таблицы
spark.sql.catalog.lakehouse.warehouse Задает место для файлов таблиц: s3a://lakehouse/warehouse
spark.hadoop.fs.s3a.endpoint и .path.style.access Направляют S3A-запросы в MinIO внутри Docker-сети

При запуске через spark-submit настройки можно передать параметрами --master, --name и --conf. В коде для этого есть .master(...), .appName(...) и .config(...). Для нового приложения обычный приоритет: код -> параметры запуска -> файл defaults. Параметры запуска driver-а надо задать до его старта; уже работающий контекст новый builder не перестраивает.

В нашем Jupyter дополнительные --master и --conf при запуске не передаются. Вне стенда потребуются также библиотеки Iceberg, S3A и JDBC: их состав и конфигурация описаны в справочнике.

Посмотрим, какие значения получила сессия. В файле есть и учетные данные, поэтому выводим только выбранные несекретные настройки. Для SQL используем spark.conf; в Hadoop-настройках префикс spark.hadoop. уже снят.

In [ ]:
for key in (
    "spark.sql.extensions",
    "spark.sql.catalog.lakehouse",
    "spark.sql.catalog.lakehouse.catalog-impl",
    "spark.sql.catalog.lakehouse.uri",
    "spark.sql.catalog.lakehouse.warehouse",
):
    print(f"{key} = {spark.conf.get(key)}")

hadoop_conf = spark.sparkContext._jsc.hadoopConfiguration()
for key in ("fs.s3a.endpoint", "fs.s3a.path.style.access"):
    print(f"{key} = {hadoop_conf.get(key)}")

Изменение настройки в работающей сессии

Некоторые настройки можно менять, пока приложение работает. Например, spark.sql.session.timeZone определяет часовой пояс SQL-сессии. Посмотрим, как одна и та же секунда Unix-времени отображается в UTC и Москве.

В Spark 3.5.1 эта настройка изменяется через spark.conf.set(...). Сохраним исходное значение и вернем его в finally, в том числе при ошибке. Файлы данных этот опыт не меняет.

In [ ]:
timezone_key = "spark.sql.session.timeZone"
original_timezone = spark.conf.get(timezone_key)
assert spark.conf.isModifiable(timezone_key)
try:
    for timezone in ("UTC", "Europe/Moscow"):
        spark.conf.set(timezone_key, timezone)
        value = spark.sql("SELECT from_unixtime(0) AS displayed_time").first()[0]
        print(timezone, value)
finally:
    spark.conf.set(timezone_key, original_timezone)

assert spark.conf.get(timezone_key) == original_timezone
print("Исходный часовой пояс восстановлен:", original_timezone)

Повторный вызов builder-а

Теперь попробуем сменить имя приложения на module-03-raw-ingest-and-first-read, с которым будем читать Taxi. Проверим имя у работающего контекста: spark.sparkContext.appName.

In [ ]:
demo_context = spark.sparkContext
demo_name = demo_context.appName
same_spark = (
    SparkSession.builder
    .appName("module-03-raw-ingest-and-first-read")
    .getOrCreate()
)
assert same_spark is spark
assert same_spark.sparkContext is demo_context
assert same_spark.sparkContext.appName == demo_name
print("То же приложение:", same_spark.sparkContext.appName)

Имя осталось прежним: getOrCreate() вернул ту же сессию с тем же контекстом. Повтор ячейки сохраняет и переменные Python, и работающее приложение.

Чтобы создать приложение с новым именем, вызовем spark.stop(). Этот вызов останавливает сессию и ее контекст. Переменные Python останутся, но старые Spark DataFrame использовать уже нельзя. Файлы MinIO и таблицы каталога сохранятся.

Для смены master или настроек в общем файле перезапускай ядро: одной остановки сессии недостаточно для всех параметров запуска. Перезапуск удалит переменные Python, и тогда придется повторить импорты, создание сессии и чтение DataFrame.

In [ ]:
spark.stop()

2. Подключись к Spark самостоятельно

Создай сессию, с которой продолжишь работу с Taxi. Заполни в следующей ячейке адрес кластера, имя module-03-raw-ingest-and-first-read и цепочку .master(...), .appName(...), .getOrCreate().

Настройки каталога возьми из общего файла. Адрес кластера в этот раз задай явно через .master(...); остальные ноутбуки берут его из того же файла.

Пока пропуски не заполнены, шаблон останавливается на assert. Выполни заполненный шаблон и проверку подключения дважды: имя и applicationId должны остаться теми же.

In [ ]:
from pyspark.sql import SparkSession

CLUSTER_MASTER = ...
APP_NAME = ...
assert CLUSTER_MASTER == "spark://spark-master:7077", "Впиши адрес кластера"
assert APP_NAME == "module-03-raw-ingest-and-first-read", "Впиши имя приложения"

# Замени ... цепочкой вызовов SparkSession.builder.
spark = ...
assert isinstance(spark, SparkSession), "Создай SparkSession через builder"
spark.sparkContext.setLogLevel("ERROR")
Контрольное решение (после своей попытки)
from pyspark.sql import SparkSession

CLUSTER_MASTER = "spark://spark-master:7077"
APP_NAME = "module-03-raw-ingest-and-first-read"
spark = (
    SparkSession.builder
    .master(CLUSTER_MASTER)
    .appName(APP_NAME)
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
In [ ]:
assert spark.sparkContext.master == CLUSTER_MASTER
assert spark.sparkContext.appName == APP_NAME
print("Master:", spark.sparkContext.master)
print("Приложение:", spark.sparkContext.appName)
print("ID приложения:", spark.sparkContext.applicationId)

Запись и чтение через каталог

Проверим, что сессия может работать с каталогом и MinIO. Следующая ячейка создаст собственную маленькую Iceberg-таблицу, прочитает из нее числа 0 и 1 и удалит ее в finally. Она работает и после удаления таблиц из модуля 2.

У таблицы уникальное имя. DROP TABLE ... PURGE удаляет ее запись в каталоге, данные и метаданные; в MinIO могут остаться пустые маркеры data/ и metadata/.

Дождись завершения ячейки перед перезапуском ядра: при принудительной остановке процесса finally не выполнится. Если прервал ее, сохрани напечатанное имя, чтобы удалить эту таблицу после нового подключения.

In [ ]:
from uuid import uuid4

session_table = f"lakehouse.default.session_practice_{uuid4().hex}"
print("Временная таблица:", session_table)
try:
    spark.range(2).writeTo(session_table).using("iceberg").create()
    session_rows = [row.id for row in spark.table(session_table).orderBy("id").collect()]
    assert session_rows == [0, 1]
    print("Прочитано через Iceberg:", session_rows)
finally:
    spark.sql(f"DROP TABLE IF EXISTS {session_table} PURGE")

assert not spark.catalog.tableExists(session_table)
print("Временная таблица удалена")

Проверь запуск в новом ядре

В выводе должны быть master spark://spark-master:7077, заданное имя приложения, строки [0, 1] и сообщение об удалении таблицы.

Перезапусти ядро и повтори заполненный шаблон, проверку подключения и ячейку с таблицей. applicationId будет новым, остальные результаты должны совпасть. Импорт SparkSession уже есть в шаблоне, поэтому демонстрацию повторять не нужно.

С этим подключением перейдем к файлам Taxi. Оставь сессию работать.

3. Проверка data bundle

Подготовим MinIO-клиент для загрузки файлов и пути к данным. Docker подключает каталог data/ с хоста к Jupyter как /opt/data/.

In [ ]:
from pathlib import Path
import re
from datetime import datetime

import boto3
import pandas as pd
from botocore.exceptions import ClientError
from pyspark.sql import SparkSession, functions as F

DATA_DIR = Path("/opt/data/nyc_taxi")
BUCKET_NAME = "lakehouse"
RAW_PREFIX = "raw/nyc_taxi/"
RAW_URI = f"s3a://{BUCKET_NAME}/{RAW_PREFIX}"
ZONE_LOOKUP_RAW_URI = f"{RAW_URI}taxi_zone_lookup.csv"

s3 = boto3.client(
    "s3",
    endpoint_url="http://minio:9000",
    aws_access_key_id="minioadmin",
    aws_secret_access_key="minioadmin",
)


def format_bytes(num_bytes: int) -> str:
    units = ["B", "KB", "MB", "GB", "TB"]
    value = float(num_bytes)
    for unit in units:
        if value < 1024 or unit == units[-1]:
            return f"{value:.1f} {unit}"
        value /= 1024


def object_exists(bucket: str, key: str) -> bool:
    try:
        s3.head_object(Bucket=bucket, Key=key)
        return True
    except ClientError as exc:
        error_code = exc.response.get("Error", {}).get("Code", "")
        if error_code in {"404", "NoSuchKey", "NotFound"}:
            return False
        raise


def list_objects(bucket: str, prefix: str) -> list[dict]:
    paginator = s3.get_paginator("list_objects_v2")
    objects = []
    for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        objects.extend(page.get("Contents", []))
    return objects


def infer_month_bounds(paths: list[Path]) -> tuple[datetime, datetime]:
    months = []
    for path in paths:
        match = re.fullmatch(r"yellow_tripdata_(\d{4})-(\d{2})\.parquet", path.name)
        if match:
            year = int(match.group(1))
            month = int(match.group(2))
            months.append((year, month))

    assert months, "Не удалось определить месяцы из имён yellow_tripdata_*.parquet"

    start_year, start_month = min(months)
    end_year, end_month = max(months)

    expected_start = datetime(start_year, start_month, 1)
    if end_month == 12:
        expected_end = datetime(end_year + 1, 1, 1)
    else:
        expected_end = datetime(end_year, end_month + 1, 1)

    return expected_start, expected_end


print("Spark и MinIO-клиент готовы")
print(f"RAW URI: {RAW_URI}")

Файлы должны быть скачаны заранее по START_HERE.md. Здесь проверим состав набора, прежде чем отправлять его в MinIO. Так во время урока нам не понадобится внешняя сеть, а при повторном прохождении мы будем читать те же исходные файлы.

In [ ]:
assert DATA_DIR.exists(), (
    "Каталог /opt/data/nyc_taxi не найден. "
    "Сначала скачай data bundle по инструкции из START_HERE.md и убедись, что ./data смонтирован в Jupyter как /opt/data."
)

bundle_files = sorted(path for path in DATA_DIR.iterdir() if path.is_file())
yellow_files = sorted(DATA_DIR.glob("yellow_tripdata_*.parquet"))
zone_lookup_file = DATA_DIR / "taxi_zone_lookup.csv"

assert yellow_files, (
    "Не найдены файлы yellow_tripdata_*.parquet в /opt/data/nyc_taxi. "
    "Скачай каноническое подмножество из START_HERE.md."
)
assert zone_lookup_file.exists(), (
    "Не найден taxi_zone_lookup.csv. Он нужен и для этого модуля, и для самостоятельного задания. "
    "Скачай его по инструкции из START_HERE.md."
)

bundle_report = pd.DataFrame(
    [
        {
            "file": path.name,
            "size_bytes": path.stat().st_size,
            "size_human": format_bytes(path.stat().st_size),
        }
        for path in bundle_files
    ]
).sort_values("file").reset_index(drop=True)

bundle_report

Набор файлов должен быть понятным и фиксированным. В этом модуле мы работаем не с «каким-то случайным parquet из интернета», а с заранее определённым учебным набором.

4. Загрузка в raw-зону MinIO

Raw-слой в Lakehouse похож на воспроизводимый входной stg-контур: мы сохраняем исходные файлы как есть, под оригинальными именами, не переписывая их содержимое.

Конвенция пути в этом курсе:

  • bucket: lakehouse
  • raw prefix: raw/nyc_taxi/
  • полный путь для чтения: s3a://lakehouse/raw/nyc_taxi/

Важно: raw-зона не смешивается с warehouse/, где потом будут жить Iceberg-таблицы.

Повторный запуск этой секции должен быть безопасным:

  • если объекта ещё нет в raw-зоне, мы его загружаем;
  • если объект уже есть и размер совпадает, мы пропускаем загрузку;
  • если объект уже есть, но размер отличается, это сигнал проблемы с данными или bundle, и мы падаем сразу, без перезаписи raw.
In [ ]:
upload_candidates = [*yellow_files, zone_lookup_file]
upload_report = []

for path in upload_candidates:
    object_key = f"{RAW_PREFIX}{path.name}"
    local_size = path.stat().st_size
    existed_before = object_exists(BUCKET_NAME, object_key)

    if existed_before:
        metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)
        remote_size = metadata["ContentLength"]
        if remote_size != local_size:
            raise ValueError(
                "Raw object already exists with different size: "
                f"{object_key} (local={local_size}, remote={remote_size}). "
                "Остановись и проверь data bundle вместо перезаписи raw-слоя."
            )
        action = "skipped_same_size"
    else:
        s3.upload_file(str(path), BUCKET_NAME, object_key)
        metadata = s3.head_object(Bucket=BUCKET_NAME, Key=object_key)
        remote_size = metadata["ContentLength"]
        action = "uploaded"

    upload_report.append(
        {
            "file": path.name,
            "raw_key": object_key,
            "action": action,
            "local_size_human": format_bytes(local_size),
            "remote_size_human": format_bytes(remote_size),
        }
    )

pd.DataFrame(upload_report)
In [ ]:
raw_objects = list_objects(BUCKET_NAME, RAW_PREFIX)

pd.DataFrame(
    [
        {
            "key": obj["Key"],
            "size_human": format_bytes(obj["Size"]),
        }
        for obj in raw_objects
    ]
)

Если объект уже существовал, мы не перезаписываем его без необходимости. Совпадающий размер означает, что повторный запуск секции идемпотентен. Несовпадающий размер считается конфликтом и останавливает сценарий до любых изменений в raw.

На этом шаге данные уже лежат в storage. Spark может читать их напрямую по s3a:// пути, а для Trino понадобится зарегистрированная таблица. До неё мы дойдём в Модулях 4 и 6.

5. Первое чтение raw PARQUET через Spark

Теперь читаем raw-файлы напрямую из MinIO по s3a:// пути. Это и есть первая важная практика Lakehouse: вычислитель (Spark) работает поверх файлов в object storage, а не только поверх локальной файловой системы или таблиц внутри одной СУБД.

Здесь важно различать два режима работы:

  • spark.read.parquet("s3a://...") читает raw-файлы напрямую по физическому пути;
  • spark.table("lakehouse....") читает уже зарегистрированную управляемую таблицу через каталог.

Для проверки подключения мы читали маленькую таблицу по имени через spark.table(...). Файлы Taxi пока не зарегистрированы в каталоге: прочитаем их по пути через spark.read.parquet(...). Таблицу поездок создадим в модуле 4.

In [ ]:
single_file_uri = f"{RAW_URI}yellow_tripdata_2024-01.parquet"
single_month_df = spark.read.parquet(single_file_uri)

print(f"Читаем один raw-файл: {single_file_uri}")
print(f"Строк в одном файле: {single_month_df.count():,}")
In [ ]:
yellow_df = spark.read.parquet(f"{RAW_URI}yellow_tripdata_*.parquet")
row_count = yellow_df.count()

print(f"Прочитано строк: {row_count:,}")
print(f"Количество parquet-файлов в наборе: {len(yellow_files)}")
In [ ]:
yellow_df.show(10, truncate=False)
In [ ]:
input_files = sorted(yellow_df.inputFiles())
pd.DataFrame({"input_file": input_files})

Обрати внимание: мы читаем сразу несколько файлов wildcard-путём. Это обычный рабочий сценарий для raw-слоя, где данные часто приходят партиями по датам, дням или месяцам.

В этом модуле мы намеренно не используем cache() для всего raw DataFrame. Для учебного профилирования важнее устойчиво прочитать исходные файлы, чем пытаться держать весь raw-срез в памяти executor-ов.

6. Схема, типы и null-значения

Следующий шаг после первого чтения: понять, какие колонки пришли, какие у них типы и где уже на raw-слое встречаются null.

Здесь важно не перепутать диагностику и очистку. В raw-слое мы наблюдаем и фиксируем реальность данных, а не исправляем её.

In [ ]:
yellow_df.printSchema()
In [ ]:
schema_df = pd.DataFrame(yellow_df.dtypes, columns=["column", "spark_type"])
print(f"Количество колонок: {len(yellow_df.columns)}")
schema_df
In [ ]:
null_counts_row = yellow_df.select(
    *[F.sum(F.col(column).isNull().cast("int")).alias(column) for column in yellow_df.columns]
).toPandas().iloc[0]

null_report = pd.DataFrame(
    {
        "column": yellow_df.columns,
        "null_count": [int(null_counts_row[column]) for column in yellow_df.columns],
    }
)
null_report["null_share"] = (null_report["null_count"] / row_count).round(6)
null_report.sort_values(["null_count", "column"], ascending=[False, True]).reset_index(drop=True)

Null в raw-данных сам по себе не является багом загрузки. Это нормальная часть профиля источника. Позже, на bronze и silver, мы будем принимать явные решения: какие поля очищать, что отбрасывать, а что нормализовать.

7. Базовые метрики и аномалии

Теперь быстро профилируем данные и ищем несколько очевидных проблемных паттернов:

  • отрицательный fare_amount;
  • нулевая дистанция при ненулевой сумме;
  • слишком большой total_amount;
  • pickup вне ожидаемого диапазона дат.

Диапазон дат будем выводить из имён файлов, а не хардкодить руками.

In [ ]:
numeric_columns = [
    column
    for column, spark_type in yellow_df.dtypes
    if spark_type.startswith(("tinyint", "smallint", "int", "bigint", "float", "double", "decimal"))
]

yellow_df.select(*numeric_columns).describe().toPandas()
In [ ]:
required_columns = {
    "fare_amount",
    "trip_distance",
    "total_amount",
    "tpep_pickup_datetime",
    "PULocationID",
    "DOLocationID",
}
missing_columns = sorted(required_columns.difference(yellow_df.columns))
assert not missing_columns, (
    "В raw-схеме не хватает ожидаемых колонок: "
    f"{missing_columns}. Возможно, источник поменял формат, и модуль требует обновления."
)

expected_start, expected_end = infer_month_bounds(yellow_files)

anomaly_report = pd.DataFrame(
    [
        {
            "check": "fare_amount < 0",
            "count": yellow_df.filter(F.col("fare_amount") < 0).count(),
        },
        {
            "check": "trip_distance = 0 and total_amount != 0",
            "count": yellow_df.filter(
                (F.col("trip_distance") == 0) & (F.col("total_amount") != 0)
            ).count(),
        },
        {
            "check": "total_amount > 1000",
            "count": yellow_df.filter(F.col("total_amount") > 1000).count(),
        },
        {
            "check": f"pickup outside [{expected_start.date()}, {expected_end.date()})",
            "count": yellow_df.filter(
                (F.col("tpep_pickup_datetime") < F.lit(expected_start))
                | (F.col("tpep_pickup_datetime") >= F.lit(expected_end))
            ).count(),
        },
    ]
)

anomaly_report
In [ ]:
suspicious_rows = yellow_df.filter(
    (F.col("fare_amount") < 0)
    | ((F.col("trip_distance") == 0) & (F.col("total_amount") != 0))
    | (F.col("total_amount") > 1000)
    | (F.col("tpep_pickup_datetime") < F.lit(expected_start))
    | (F.col("tpep_pickup_datetime") >= F.lit(expected_end))
)

suspicious_rows.select(
    "tpep_pickup_datetime",
    "passenger_count",
    "trip_distance",
    "fare_amount",
    "total_amount",
    "PULocationID",
    "DOLocationID",
).show(20, truncate=False)

Реальные данные почти всегда содержат выбросы, странные записи и неоднозначные значения. Это не повод немедленно переписывать raw-файлы. Наоборот, raw нужен именно затем, чтобы исходный срез можно было перечитать и переработать заново с новыми правилами.

8. Самостоятельное задание

Используй raw-данные, которые уже лежат в MinIO:

  1. Прочитай taxi_zone_lookup.csv из raw-зоны через Spark.
  2. Выведи схему и первые 10 строк.
  3. Посчитай количество зон по Borough.
  4. Найди top-5 pickup-локаций в поездках и присоедини к ним названия зон.
  5. Найди ещё одну аномалию или интересный паттерн и коротко опиши его.

Подсказка: CSV-файл лежит по пути s3a://lakehouse/raw/nyc_taxi/taxi_zone_lookup.csv.

In [ ]:
# Ваш код: прочитай taxi_zone_lookup.csv из raw-зоны через Spark
In [ ]:
# Ваш код: выведи схему и первые 10 строк
In [ ]:
# Ваш код: посчитай количество зон по Borough и top-5 pickup locations с join на lookup
In [ ]:
# Ваш код: найди ещё одну аномалию или интересный паттерн в данных

9. Почему raw-слой важен

Raw-слой полезен не потому, что в нём удобно жить постоянно, а потому что он даёт устойчивую исходную точку. Если завтра поменяются правила очистки, дедупликации или нормализации, мы не обязаны заново скачивать источник и гадать, что именно уже успели «подправить». Мы просто перечитываем raw и перестраиваем downstream-слои.

Для инженера с опытом PostgreSQL / Greenplum полезна такая аналогия: raw похож на аккуратный внешний staging-контур, но в Lakehouse он обычно хранится в object storage как набор файлов, а не как таблица внутри одной СУБД.

Отдельно важно, что загрузка источника вынесена из ноутбука в onboarding. Это делает практику менее хрупкой: ноутбук не зависит от внешней сети, а набор данных остаётся одинаковым у всех студентов.

Наконец, raw не стоит чинить «на месте». Если в исходных данных есть null, отрицательные значения или странные даты, это сигнал для явных трансформаций в bronze и silver, а не повод тихо переписать входные файлы.

10. Checkpoint

Проверь себя:

  1. К какому master подключена сессия? Какие настройки ты задал в коде, а какие она получила из общего файла?
  2. Чем SparkSession отличается от SparkContext? Где работают driver и executors?
  3. Почему часовой пояс изменился сразу, а для нового имени приложения понадобилось остановить сессию? Что придется повторить после перезапуска ядра?
  4. Можешь ли ты показать загруженные raw-файлы в MinIO и прочитать их через Spark?
  5. Сколько колонок в Yellow Taxi, какие у них типы и где встречаются null? Почему пропуски не обязательно означают ошибку загрузки?
  6. Зачем сохранять raw-слой без изменений, если дальше будут трансформации?
  7. Почему мы скачали датасет до запуска ноутбука? Что потребуется проверить, если источник изменит формат файлов?

11. Завершение

Мы намеренно не удаляем raw-данные из MinIO. В Модуле 4 они понадобятся для построения первого управляемого bronze-слоя на Iceberg.

In [ ]:
spark.stop()