M09 β Case real: Uber Eats end-to-end em Azure
DuraΓ§Γ£o: 5h (escrita) + 6-8h (execuΓ§Γ£o real) PrΓ©-requisitos: M00-M08 ok e preferencialmente Owshq mod-2 concluΓdo (vocΓͺ precisa ter visto Spark + Delta a fundo). Objetivo: pegar os datasets reais da FormaΓ§Γ£o Owshq (
frm-spark-databricks-mec/entities/), simular 5 fontes operacionais distintas (Kafka, MongoDB, MSSQL, MySQL, Postgres), e construir o lakehouse Uber Eats end-to-end em Azure. Esse Γ© o case que vocΓͺ vai mostrar em entrevista.
Por que este case (e nΓ£o NYC Taxi do M04/M08)
NYC Taxi e AdventureWorks sΓ£o bons para aprender o serviΓ§o. Mas em entrevista Sr, o entrevistador pergunta:
- βVocΓͺ jΓ‘ lidou com ingestΓ£o multi-fonte (Kafka + Mongo + relacional)?β
- βComo vocΓͺ concilia o mesmo cliente vindo de duas fontes (CRM B2B no MSSQL + app B2C no MongoDB)?β
- βComo funde stream de status com batch de pedidos?β
- βComo modela um star schema quando os surrogate keys vΓͺm de chaves naturais inconsistentes (CPF, CNPJ, license)?β
Esses problemas estΓ£o neste case. O Uber Eats da mec foi desenhado exatamente para isso.
Fontes (5) e onde elas moram
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ FONTES OPERACIONAIS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ π± App B2C βββΊ MongoDB Atlas (mongodb_users) ββ π’ CRM B2B βββΊ Azure SQL (mssql_users) ββ π CatΓ‘logo βββΊ MySQL (mysql_restaurants, ββ mysql_ratings) ββ π΅ Motoristas βββΊ Postgres (postgres_drivers) ββ π¦ Pedidos βββΊ Kafka/Event Hubs (kafka_orders, ββ kafka_status) ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββSchemas reais (de frm-spark-databricks-mec/entities/):
| Fonte | Campos-chave | Cardinalidade |
|---|---|---|
mongodb_users | user_id, user_identifier (CPF), email, delivery_address, country, city | ~milhΓ΅es (B2C) |
mssql_users | user_id, cpf, first_name, last_name, company_name, job, birthday | ~dezenas de milhares (B2B) |
mysql_restaurants | restaurant_id, cnpj, name, cuisine_type, city, average_rating, opening_time, closing_time | ~10k |
mysql_ratings | rating_id, restaurant_identifier (CNPJ), rating (1-5), timestamp | ~10x restaurants |
postgres_drivers | driver_id, license_number, vehicle_make, vehicle_model, vehicle_year, city | ~100k |
kafka_orders | order_id, user_key (CPF), restaurant_key (CNPJ), driver_key (license), order_date, total_amount, payment_id | ~milhΓ΅es/dia |
kafka_status | status_id, order_identifier, status.status_name, status.timestamp | ~6x orders (6 status por pedido) |
Chaves naturais que ligam tudo:
orders.user_key (CPF)βmongodb_users.user_identifierβͺmssql_users.cpforders.restaurant_key (CNPJ)βmysql_restaurants.cnpjorders.driver_key (license)βpostgres_drivers.license_number
Mapeamento Fontes β Azure (decisΓ£o de arquitetura)
Em dev/laboratΓ³rio, vamos provisionar tudo no Azure de forma econΓ΄mica:
| Fonte original | Em Azure (dev) | Custo aprox. |
|---|---|---|
| MongoDB Atlas | Cosmos DB MongoDB API (serverless) ou Atlas Free Tier | $0-5/mΓͺs |
| MSSQL on-prem | Azure SQL Serverless (auto-pause 60min) | $1-3/mΓͺs |
| MySQL | Azure Database for MySQL Flexible (Burstable B1ms) | $12/mΓͺs β alternativa: subir num container do Cosmos DB ou sΓ³ ingerir do JSON direto |
| Postgres | Azure Database for PostgreSQL Flexible (Burstable B1ms) | $12/mΓͺs β alternativa: idem |
| Kafka | Event Hubs (Standard, 1 TU) | $11/mΓͺs |
Economia agressiva para a trilha: as 3 fontes βMySQL/Postgres/MongoDBβ podem ser substituΓdas por JSON files no Bronze (vocΓͺ sobe os
entities/*.jsondireto no ADLS). SΓ³ Azure SQL + Event Hubs ficam βreaisβ. Isso baixa o custo do case para ~$15/mΓͺs. Recomendo essa simplificaΓ§Γ£o na primeira execuΓ§Γ£o β vocΓͺ prova a arquitetura sem queimar crΓ©dito.
Arquitetura final
ββββββββββββββββββββββββββββββββββββββββββββββ β AZURE DATA FACTORY β β trigger diΓ‘rio 06h BRT + trigger streaming β βββββββββββ¬βββββββββββββββββββββββββββββββ¬βββββ β β ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ βΌ βΌββββββββββββββββ βββββββββββββββββ Azure SQL β β JSON uploads β (mongo/mysql/postgres β ADLS direto)β (mssql_users)β β βββββββββ¬ββββββββ ββββββββ¬ββββββββ β β βββββββββββ¬βββββββββββ β Copy Activity (batch diΓ‘rio) βΌ ββββββββββββββββββββββ βββββββββββββββββββββββ β ADLS Gen2 β Bronze β βββββββ β Event Hubs βββ β β /source=<src>/ β β (streaming) β β β /table=<t>/ β β kafka_ordersβ β β /ing_date=<d>/ β β kafka_statusβ β βββββββββββ¬βββββββββββ ββββββββββββββββ β β β β Notebook batch + Auto Loader streaming β βΌ β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β AZURE DATABRICKS β silver_dev.ubereats.* β βββββββββββββββββββββββββββββββββββββββ β β users_unified (MSSQL βͺ MongoDB) β β β restaurants β β β ratings β β β drivers β β β orders (MERGE incremental) β β β order_status (streaming, append) β β βββββββββββββββββββββββββββββββββββββββ β βΌ Notebook agregaΓ§Γ£o ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β gold_dev.ubereats.* β ββββββββββββββββββββββββββββββββββββββββββββββββ β β dim_user (SCD2) β β β dim_restaurant (SCD1) β β β dim_driver β β β dim_date β β β dim_status β β β fact_orders (granularidade: pedido) β β β fact_order_status (granularidade: evento) β β ββββββββββββββββββββββββββββββββββββββββββββββββ β βΌ SQL Warehouse Pro Serverless ββββββββββββββββββ β Power BI β β - SLA entregaβ β - GMV por β β restauranteβ β - Top motoristaβ ββββββββββββββββββParte 1 β Provisionar recursos (modo econΓ΄mico)
VariΓ‘veis (segue convenΓ§Γ£o das trilhas anteriores):
$PROJ = "de"; $INIC = "w"; $AMB = "dev"; $REGS = "brs"; $REG = "brazilsouth"$RG = "rg-$PROJ-$AMB-$REGS"$ST = "st$PROJ$INIC$AMB$REGS" # do M01
# Recursos novos do M09:$EH = "eh-$PROJ-$AMB-ubereats-$REGS" # Event Hubs Namespace$SQL = "sql-$PROJ-$INIC-$AMB" # jΓ‘ existe do M05, reaproveitaPasso 1.1 β Event Hubs (kafka_orders + kafka_status)
# Namespace (1 TU = $11/mΓͺs; suficiente pra dev)az eventhubs namespace create ` --resource-group $RG ` --name $EH ` --location $REG ` --sku Standard ` --capacity 1 ` --enable-auto-inflate false
# 2 event hubs (1 por tΓ³pico)az eventhubs eventhub create --resource-group $RG --namespace-name $EH ` --name orders --partition-count 4 --message-retention 7
az eventhubs eventhub create --resource-group $RG --namespace-name $EH ` --name order-status --partition-count 4 --message-retention 7
# Connection string (guarda no Key Vault)$EHCONN = az eventhubs namespace authorization-rule keys list ` --resource-group $RG --namespace-name $EH --name RootManageSharedAccessKey ` --query "primaryConnectionString" -o tsv
az keyvault secret set --vault-name $KV --name "eh-connstr" --value $EHCONNRemove-Variable EHCONNPasso 1.2 β Upload dos JSON files no ADLS (modo econΓ΄mico)
Os 5 arquivos (mongodb_users.json, mssql_users.json, mysql_ratings.json, mysql_restaurants.json, postgres_drivers.json) viram arquivos no Bronze simulando o resultado de uma copy diΓ‘ria.
$MECPATH = "C:\Users\willi\OneDrive\Documents\Projetos\Data Engineer\frm-databricks\frm-spark-databricks-mec\entities"$TODAY = Get-Date -Format "yyyy-MM-dd"
$mapping = @{ "mongodb_users.json" = "source=mongodb/table=users" "mssql_users.json" = "source=mssql/table=users" "mysql_ratings.json" = "source=mysql/table=ratings" "mysql_restaurants.json" = "source=mysql/table=restaurants" "postgres_drivers.json" = "source=postgres/table=drivers"}
foreach ($file in $mapping.Keys) { $path = $mapping[$file] az storage blob upload ` --account-name $ST ` --auth-mode login ` --container-name bronze ` --name "$path/ingestion_date=$TODAY/$file" ` --file "$MECPATH\$file" ` --overwrite}
# Os arquivos kafka_orders.json e kafka_status.json vocΓͺ manda pro Event Hubs# via script Python (prΓ³xima seΓ§Γ£o). NΓ£o sobe no Bronze direto.Passo 1.3 β Produtor Kafka simulado (envia para Event Hubs)
Crie tools/seed_eventhubs.py na sua mΓ‘quina:
"""LΓͺ kafka_orders.json e kafka_status.json da pasta entities/e publica os eventos no Event Hubs simulando produΓ§Γ£o real."""import json, os, time, randomfrom azure.eventhub import EventHubProducerClient, EventData
CONN_STR = os.environ["EH_CONN_STR"] # cola do KV antes de rodarMEC_PATH = r"C:\Users\willi\OneDrive\Documents\Projetos\Data Engineer\frm-databricks\frm-spark-databricks-mec\entities"
def send_to_hub(hub_name: str, jsonl_path: str, throttle_ms: int = 50): producer = EventHubProducerClient.from_connection_string(CONN_STR, eventhub_name=hub_name) batch = producer.create_batch() sent = 0 with open(jsonl_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue ev = EventData(line) try: batch.add(ev) except ValueError: producer.send_batch(batch) batch = producer.create_batch() batch.add(ev) sent += 1 if throttle_ms: time.sleep(throttle_ms / 1000) if len(batch) > 0: producer.send_batch(batch) producer.close() print(f"{hub_name}: {sent} eventos enviados")
if __name__ == "__main__": send_to_hub("orders", os.path.join(MEC_PATH, "kafka_orders.json")) send_to_hub("order-status", os.path.join(MEC_PATH, "kafka_status.json"))Rode:
$env:EH_CONN_STR = az keyvault secret show --vault-name $KV --name "eh-connstr" --query value -o tsvpip install azure-eventhubpython tools/seed_eventhubs.pyEm produΓ§Γ£o real, esse seeder seria substituΓdo por um produtor Kafka rodando no serviΓ§o do app/CRM. Aqui simulamos.
Parte 2 β Schemas Unity Catalog
Antes de escrever notebooks, prepare os schemas:
CREATE SCHEMA IF NOT EXISTS silver_dev.ubereats COMMENT 'Camada Silver - case Uber Eats (M09)';
CREATE SCHEMA IF NOT EXISTS gold_dev.ubereats COMMENT 'Camada Gold - case Uber Eats (M09)';Parte 3 β Notebook 1: Bronze β Silver (batch das 5 fontes)
Crie notebooks/M09/10_ubereats_bronze_to_silver.py:
# Databricks notebook source# MAGIC %md # M09 β Bronze β Silver (Uber Eats batch)
# COMMAND ----------from pyspark.sql import functions as Ffrom pyspark.sql.types import *from delta.tables import DeltaTable
dbutils.widgets.text("storage_account", "")dbutils.widgets.text("ingestion_date", "")storage = dbutils.widgets.get("storage_account")ing_date = dbutils.widgets.get("ingestion_date")
bronze_root = f"abfss://bronze@{storage}.dfs.core.windows.net"
# Helper genΓ©ricodef merge_jsonl_to_silver(source: str, table: str, key_cols: list[str], select_expr: dict[str, str]): path = f"{bronze_root}/source={source}/table={table}/ingestion_date={ing_date}/" df = spark.read.json(path) df = df.selectExpr(*[f"{v} AS {k}" for k, v in select_expr.items()]) \ .withColumn("_silver_ingested_at", F.current_timestamp()) \ .withColumn("_silver_source", F.lit(source))
target = f"silver_dev.ubereats.{table}" if not spark.catalog.tableExists(target): df.write.format("delta").saveAsTable(target) return df.count(), 0 t = DeltaTable.forName(spark, target) cond = " AND ".join([f"t.{c} = s.{c}" for c in key_cols]) t.alias("t").merge(df.alias("s"), cond) \ .whenMatchedUpdateAll().whenNotMatchedInsertAll().execute() return df.count(), -1
# COMMAND ----------# MAGIC %md ## 3.1 β restaurants (MySQL β silver)
merge_jsonl_to_silver( source="mysql", table="restaurants", key_cols=["restaurant_id"], select_expr={ "restaurant_id": "CAST(restaurant_id AS INT)", "cnpj": "TRIM(cnpj)", "name": "name", "cuisine_type": "cuisine_type", "city": "city", "country": "country", "phone_number": "phone_number", "address": "address", "opening_time": "opening_time", "closing_time": "closing_time", "average_rating": "CAST(average_rating AS DOUBLE)", "num_reviews": "CAST(num_reviews AS INT)", "uuid": "uuid", "_src_ts": "CAST(dt_current_timestamp AS TIMESTAMP)", })
# COMMAND ----------# MAGIC %md ## 3.2 β ratings (MySQL β silver)
merge_jsonl_to_silver( source="mysql", table="ratings", key_cols=["rating_id"], select_expr={ "rating_id": "CAST(rating_id AS INT)", "restaurant_cnpj": "TRIM(restaurant_identifier)", "rating": "CAST(rating AS INT)", "rated_at": "CAST(timestamp AS TIMESTAMP)", "uuid": "uuid", "_src_ts": "CAST(dt_current_timestamp AS TIMESTAMP)", })
# COMMAND ----------# MAGIC %md ## 3.3 β drivers (Postgres β silver)
merge_jsonl_to_silver( source="postgres", table="drivers", key_cols=["driver_id"], select_expr={ "driver_id": "CAST(driver_id AS INT)", "license_number": "TRIM(license_number)", "first_name": "first_name", "last_name": "last_name", "phone_number": "phone_number", "city": "city", "country": "country", "date_birth": "CAST(date_birth AS DATE)", "vehicle_type": "vehicle_type", "vehicle_make": "vehicle_make", "vehicle_model": "vehicle_model", "vehicle_year": "CAST(vehicle_year AS INT)", "vehicle_license_plate": "vehicle_license_plate", "uuid": "uuid", "_src_ts": "CAST(dt_current_timestamp AS TIMESTAMP)", })
# COMMAND ----------# MAGIC %md ## 3.4 β users B2C (MongoDB) e B2B (MSSQL) β silver unificado# MAGIC# MAGIC Desafio: o mesmo CPF pode existir em ambos. EstratΓ©gia:# MAGIC 1. Gravar em duas tabelas separadas (`users_b2c`, `users_b2b`) primeiro.# MAGIC 2. Construir uma view unificada `users_unified` que prioriza MSSQL (mais completo: birthday, job, company).# MAGIC 3. Marca origem em `_source_priority`.
merge_jsonl_to_silver( source="mongodb", table="users_b2c", key_cols=["user_id"], select_expr={ "user_id": "CAST(user_id AS INT)", "cpf": "TRIM(user_identifier)", "email": "LOWER(TRIM(email))", "phone_number": "phone_number", "city": "city", "country": "country", "delivery_address": "delivery_address", "uuid": "uuid", "_src_ts": "CAST(dt_current_timestamp AS TIMESTAMP)", })
merge_jsonl_to_silver( source="mssql", table="users_b2b", key_cols=["user_id"], select_expr={ "user_id": "CAST(user_id AS INT)", "cpf": "TRIM(cpf)", "first_name": "first_name", "last_name": "last_name", "company_name": "company_name", "job": "job", "birthday": "CAST(birthday AS DATE)", "phone_number": "phone_number", "country": "country", "uuid": "uuid", "_src_ts": "CAST(dt_current_timestamp AS TIMESTAMP)", })
# COMMAND ----------# MAGIC %md ### users_unified# MAGIC Tabela canΓ΄nica resolvendo o mesmo CPF em ambas as fontes. Chave: cpf.
spark.sql("""CREATE OR REPLACE TABLE silver_dev.ubereats.users_unifiedUSING DELTA ASWITH b2b AS ( SELECT cpf, first_name, last_name, company_name, job, birthday, phone_number, country, CAST(NULL AS STRING) AS email, CAST(NULL AS STRING) AS city, CAST(NULL AS STRING) AS delivery_address, 'b2b' AS source_priority, _src_ts FROM silver_dev.ubereats.users_b2b),b2c AS ( SELECT cpf, CAST(NULL AS STRING) AS first_name, CAST(NULL AS STRING) AS last_name, CAST(NULL AS STRING) AS company_name, CAST(NULL AS STRING) AS job, CAST(NULL AS DATE) AS birthday, phone_number, country, email, city, delivery_address, 'b2c' AS source_priority, _src_ts FROM silver_dev.ubereats.users_b2c),unioned AS ( SELECT * FROM b2b UNION ALL SELECT * FROM b2c),ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY cpf ORDER BY CASE source_priority WHEN 'b2b' THEN 1 WHEN 'b2c' THEN 2 END, _src_ts DESC ) AS _rn FROM unioned)SELECT cpf, first_name, last_name, company_name, job, birthday, phone_number, country, email, city, delivery_address, source_priority, _src_ts AS source_updated_at, CURRENT_TIMESTAMP() AS _silver_built_atFROM rankedWHERE _rn = 1""")
display(spark.sql(""" SELECT source_priority, COUNT(*) qtd FROM silver_dev.ubereats.users_unified GROUP BY source_priority"""))
# COMMAND ----------# MAGIC %md ## 3.5 β orders (Event Hubs batch read)
# LΓͺ eventos acumulados do EH via Spark (nΓ£o streaming aqui)eh_conn = dbutils.secrets.get("kv", "eh-connstr")eh_conf = { "eventhubs.connectionString": spark._jvm.org.apache.spark.eventhubs.EventHubsUtils.encrypt(eh_conn), "eventhubs.consumerGroup": "$Default", "eventhubs.startingPosition": '{"offset":"-1","seqNo":-1,"enqueuedTime":null,"isInclusive":true}'}
df_raw_orders = ( spark.read.format("eventhubs") .options(**{**eh_conf, "eventhubs.name": "orders"}) .load())
# Payload do EH vem em "body" (binary). Decode + parse JSON.order_schema = StructType([ StructField("order_id", StringType()), StructField("user_key", StringType()), StructField("restaurant_key", StringType()), StructField("driver_key", StringType()), StructField("order_date", StringType()), StructField("total_amount", DoubleType()), StructField("payment_id", StringType()), StructField("dt_current_timestamp", StringType()),])
orders = ( df_raw_orders .select(F.from_json(F.col("body").cast("string"), order_schema).alias("o"), F.col("enqueuedTime").alias("_eh_ts")) .select("o.*", "_eh_ts") .withColumn("order_date", F.to_timestamp("order_date")) .withColumn("user_cpf", F.trim("user_key")) .withColumn("restaurant_cnpj", F.trim("restaurant_key")) .withColumn("driver_license", F.trim("driver_key")) .withColumn("_silver_ingested_at", F.current_timestamp()) .drop("user_key", "restaurant_key", "driver_key"))
# MERGEtarget = "silver_dev.ubereats.orders"if not spark.catalog.tableExists(target): orders.write.format("delta").partitionBy(F.to_date("order_date")).saveAsTable(target)else: t = DeltaTable.forName(spark, target) t.alias("t").merge(orders.alias("s"), "t.order_id = s.order_id") \ .whenMatchedUpdateAll().whenNotMatchedInsertAll().execute()
print(f"orders: {orders.count()} eventos processados")
# COMMAND ----------display(spark.table("silver_dev.ubereats.orders").orderBy(F.desc("order_date")).limit(5))Parte 4 β Notebook 2: Streaming de status
Crie notebooks/M09/20_ubereats_status_streaming.py:
# Databricks notebook source# MAGIC %md # M09 β Streaming order_status (Event Hubs β Silver Delta)# MAGIC# MAGIC Diferente do batch: rodamos em modo streaming porque order_status muda em tempo real.# MAGIC Schedule: `continuous` (trigger=AvailableNow para teste; Trigger.ProcessingTime("30 seconds") em prod).
# COMMAND ----------from pyspark.sql import functions as Ffrom pyspark.sql.types import *
storage = dbutils.widgets.get("storage_account") if "storage_account" in [w.name for w in dbutils.widgets.getAll()] else Nonecheckpoint_root = f"abfss://silver@{storage}.dfs.core.windows.net/_checkpoints/order_status"output_path = f"abfss://silver@{storage}.dfs.core.windows.net/ubereats/order_status"
eh_conn = dbutils.secrets.get("kv", "eh-connstr")eh_conf = { "eventhubs.connectionString": spark._jvm.org.apache.spark.eventhubs.EventHubsUtils.encrypt(eh_conn), "eventhubs.consumerGroup": "$Default", "eventhubs.startingPosition": '{"offset":"-1","seqNo":-1,"enqueuedTime":null,"isInclusive":true}', "eventhubs.name": "order-status",}
status_schema = StructType([ StructField("status_id", IntegerType()), StructField("order_identifier", StringType()), StructField("status", StructType([ StructField("status_name", StringType()), StructField("timestamp", DoubleType()), # epoch ms ])), StructField("dt_current_timestamp", StringType()),])
stream = ( spark.readStream.format("eventhubs").options(**eh_conf).load() .select(F.from_json(F.col("body").cast("string"), status_schema).alias("s"), F.col("enqueuedTime").alias("_eh_ts")) .select( F.col("s.status_id").alias("status_id"), F.col("s.order_identifier").alias("order_id"), F.col("s.status.status_name").alias("status_name"), F.from_unixtime(F.col("s.status.timestamp") / 1000).cast("timestamp").alias("status_ts"), F.col("s.dt_current_timestamp").cast("timestamp").alias("_src_ts"), F.col("_eh_ts"), F.current_timestamp().alias("_silver_ingested_at"), ))
( stream.writeStream .format("delta") .outputMode("append") .option("checkpointLocation", checkpoint_root) .option("path", output_path) .partitionBy(F.to_date("status_ts")) .trigger(availableNow=True) # processa tudo que estΓ‘ acumulado e PARA. Em prod: .trigger(processingTime="30 seconds") .toTable("silver_dev.ubereats.order_status"))
# COMMAND ----------display(spark.table("silver_dev.ubereats.order_status").orderBy(F.desc("status_ts")).limit(10))Importante:
availableNow=TrueΓ© o jeito certo para agendar streaming em jobs noturnos β processa o backlog acumulado desde a ΓΊltima execuΓ§Γ£o e desliga. Em prod 24/7, useprocessingTime.
Parte 5 β Notebook 3: Silver β Gold (star schema)
Crie notebooks/M09/30_ubereats_silver_to_gold.py:
# Databricks notebook source# MAGIC %md # M09 β Silver β Gold (star schema Uber Eats)
# COMMAND ----------from pyspark.sql import functions as F
# COMMAND ----------# MAGIC %md ## dim_date
spark.sql("""CREATE OR REPLACE TABLE gold_dev.ubereats.dim_date ASWITH cal AS ( SELECT explode(sequence(to_date('2020-01-01'), to_date('2030-12-31'), interval 1 day)) AS date_key)SELECT date_key, YEAR(date_key) y, QUARTER(date_key) q, MONTH(date_key) m, DATE_FORMAT(date_key, 'MMMM') month_name, DAY(date_key) d, DAYOFWEEK(date_key) dow, DAYOFWEEK(date_key) IN (1,7) AS is_weekendFROM cal""")
# COMMAND ----------# MAGIC %md ## dim_user (SCD2 minimal β capta mudanΓ§a de email/endereΓ§o)
spark.sql("""CREATE TABLE IF NOT EXISTS gold_dev.ubereats.dim_user ( user_sk BIGINT GENERATED ALWAYS AS IDENTITY, cpf STRING, first_name STRING, last_name STRING, company_name STRING, email STRING, city STRING, country STRING, delivery_address STRING, source_priority STRING, _scd_valid_from TIMESTAMP, _scd_valid_to TIMESTAMP, _scd_current BOOLEAN) USING DELTA""")
# Carga inicial (idempotente: substitui dim atual pela versΓ£o mais recente de cada CPF)spark.sql("""MERGE INTO gold_dev.ubereats.dim_user AS tUSING ( SELECT cpf, first_name, last_name, company_name, email, city, country, delivery_address, source_priority, CURRENT_TIMESTAMP() AS _scd_valid_from, CAST(NULL AS TIMESTAMP) AS _scd_valid_to, TRUE AS _scd_current FROM silver_dev.ubereats.users_unified) sON t.cpf = s.cpf AND t._scd_current = TRUEWHEN MATCHED AND ( COALESCE(t.email,'') <> COALESCE(s.email,'') OR COALESCE(t.delivery_address,'') <> COALESCE(s.delivery_address,'')) THEN UPDATE SET _scd_valid_to = CURRENT_TIMESTAMP(), _scd_current = FALSEWHEN NOT MATCHED THEN INSERT (cpf, first_name, last_name, company_name, email, city, country, delivery_address, source_priority, _scd_valid_from, _scd_valid_to, _scd_current) VALUES (s.cpf, s.first_name, s.last_name, s.company_name, s.email, s.city, s.country, s.delivery_address, s.source_priority, s._scd_valid_from, s._scd_valid_to, s._scd_current)""")
# PΓ³s-MERGE: insere a nova versΓ£o dos que tiveram mudanΓ§aspark.sql("""INSERT INTO gold_dev.ubereats.dim_user (cpf, first_name, last_name, company_name, email, city, country, delivery_address, source_priority, _scd_valid_from, _scd_valid_to, _scd_current)SELECT s.cpf, s.first_name, s.last_name, s.company_name, s.email, s.city, s.country, s.delivery_address, s.source_priority, CURRENT_TIMESTAMP(), NULL, TRUEFROM silver_dev.ubereats.users_unified sJOIN gold_dev.ubereats.dim_user t ON t.cpf = s.cpf AND t._scd_current = FALSE AND t._scd_valid_to >= CURRENT_TIMESTAMP() - INTERVAL 1 MINUTEWHERE NOT EXISTS ( SELECT 1 FROM gold_dev.ubereats.dim_user t2 WHERE t2.cpf = s.cpf AND t2._scd_current = TRUE)""")
# COMMAND ----------# MAGIC %md ## dim_restaurant (SCD1 β sobrescreve)
spark.sql("""CREATE OR REPLACE TABLE gold_dev.ubereats.dim_restaurant ASSELECT ROW_NUMBER() OVER (ORDER BY restaurant_id) AS restaurant_sk, restaurant_id, cnpj, name, cuisine_type, city, country, average_rating, num_reviews, opening_time, closing_timeFROM silver_dev.ubereats.restaurants""")
# COMMAND ----------# MAGIC %md ## dim_driver (SCD1)
spark.sql("""CREATE OR REPLACE TABLE gold_dev.ubereats.dim_driver ASSELECT ROW_NUMBER() OVER (ORDER BY driver_id) AS driver_sk, driver_id, license_number, CONCAT_WS(' ', first_name, last_name) AS full_name, city, country, vehicle_type, vehicle_make, vehicle_model, vehicle_year, vehicle_license_plateFROM silver_dev.ubereats.drivers""")
# COMMAND ----------# MAGIC %md ## dim_status (catΓ‘logo enxuto, do enum)
spark.sql("""CREATE OR REPLACE TABLE gold_dev.ubereats.dim_status ASSELECT * FROM VALUES (1, 'Order Placed', 'placed', 1), (2, 'In Analysis', 'analysis', 2), (3, 'Accepted', 'accepted', 3), (4, 'Preparing', 'preparing', 4), (5, 'Ready for Pickup', 'ready', 5), (6, 'Picked Up', 'picked', 6), (7, 'Delivered', 'delivered', 7), (8, 'Cancelled', 'cancelled', 99)AS status_dim(status_sk, status_name, status_code, status_order)""")
# COMMAND ----------# MAGIC %md ## fact_orders (granularidade: pedido)
spark.sql("""CREATE OR REPLACE TABLE gold_dev.ubereats.fact_ordersPARTITIONED BY (order_date_key)ASSELECT o.order_id, CAST(o.order_date AS DATE) AS order_date_key, CAST(o.order_date AS TIMESTAMP) AS order_ts, du.user_sk, dr.restaurant_sk, dd.driver_sk, o.total_amount, o.payment_idFROM silver_dev.ubereats.orders oLEFT JOIN gold_dev.ubereats.dim_user du ON du.cpf = o.user_cpf AND du._scd_currentLEFT JOIN gold_dev.ubereats.dim_restaurant dr ON dr.cnpj = o.restaurant_cnpjLEFT JOIN gold_dev.ubereats.dim_driver dd ON dd.license_number = o.driver_license""")
# COMMAND ----------# MAGIC %md ## fact_order_status (granularidade: evento de status)
spark.sql("""CREATE OR REPLACE TABLE gold_dev.ubereats.fact_order_statusPARTITIONED BY (status_date_key)ASSELECT os.order_id, CAST(os.status_ts AS DATE) AS status_date_key, os.status_ts, ds.status_sk, os.status_nameFROM silver_dev.ubereats.order_status osLEFT JOIN gold_dev.ubereats.dim_status ds ON ds.status_name = os.status_name""")
# COMMAND ----------# MAGIC %md ## OPTIMIZE + ZORDER
spark.sql("OPTIMIZE gold_dev.ubereats.fact_orders ZORDER BY (user_sk, restaurant_sk)")spark.sql("OPTIMIZE gold_dev.ubereats.fact_order_status ZORDER BY (order_id)")
# COMMAND ----------# MAGIC %md ## KPIs sanity
display(spark.sql(""" SELECT d.y AS year, d.m AS month, COUNT(*) AS orders, ROUND(SUM(f.total_amount), 2) AS gmv, ROUND(AVG(f.total_amount), 2) AS avg_ticket FROM gold_dev.ubereats.fact_orders f JOIN gold_dev.ubereats.dim_date d ON d.date_key = f.order_date_key GROUP BY d.y, d.m ORDER BY 1, 2"""))
display(spark.sql(""" SELECT r.cuisine_type, COUNT(*) AS orders, ROUND(SUM(f.total_amount), 2) AS gmv FROM gold_dev.ubereats.fact_orders f JOIN gold_dev.ubereats.dim_restaurant r ON r.restaurant_sk = f.restaurant_sk GROUP BY r.cuisine_type ORDER BY gmv DESC"""))Parte 6 β Notebook 4: Data Quality
Crie notebooks/M09/40_ubereats_dq.py:
# Databricks notebook sourcechecks = [ ("orders.user_cpf nulo (FK quebrada)", "SELECT COUNT(*) FROM silver_dev.ubereats.orders WHERE user_cpf IS NULL OR user_cpf = ''", 0),
("orders sem user correspondente em users_unified", """SELECT COUNT(*) FROM silver_dev.ubereats.orders o LEFT JOIN silver_dev.ubereats.users_unified u ON u.cpf = o.user_cpf WHERE u.cpf IS NULL""", 0),
("orders sem restaurante correspondente", """SELECT COUNT(*) FROM silver_dev.ubereats.orders o LEFT JOIN silver_dev.ubereats.restaurants r ON r.cnpj = o.restaurant_cnpj WHERE r.cnpj IS NULL""", 0),
("orders com total_amount negativo", "SELECT COUNT(*) FROM silver_dev.ubereats.orders WHERE total_amount < 0", 0),
("ratings fora do range 1-5", "SELECT COUNT(*) FROM silver_dev.ubereats.ratings WHERE rating < 1 OR rating > 5", 0),
("users_unified duplicados por CPF (deveria ser 0)", "SELECT COUNT(*) FROM (SELECT cpf FROM silver_dev.ubereats.users_unified GROUP BY cpf HAVING COUNT(*) > 1)", 0),]
falhas = []for name, sql, threshold in checks: actual = spark.sql(sql).collect()[0][0] status = "OK" if actual <= threshold else "FAIL" print(f"[{status}] {name}: real={actual} (limite {threshold})") if status == "FAIL": falhas.append((name, actual, threshold))
if falhas: msg = "DQ FAIL:\n" + "\n".join([f"- {n}: {a} > {t}" for n,a,t in falhas]) dbutils.notebook.exit(msg)
print("β
DQ OK")Parte 7 β Job DAB (resources/m09_ubereats.job.yml)
resources: jobs: ubereats_e2e: name: ubereats_e2e schedule: quartz_cron_expression: "0 0 9 * * ?" # 06h BRT timezone_id: UTC pause_status: ${var.bundle_target == "prd" ? "UNPAUSED" : "PAUSED"}
email_notifications: on_failure: [ team.old.schoolll@gmail.com ]
max_concurrent_runs: 1 timeout_seconds: 2400 # 40 min SLA
tasks: - task_key: bronze_to_silver_batch notebook_task: notebook_path: ../notebooks/M09/10_ubereats_bronze_to_silver.py base_parameters: storage_account: ${var.storage_account} ingestion_date: "{{job.start_time.iso_date}}" job_cluster_key: ue_cluster
- task_key: status_streaming_micro_batch depends_on: [{ task_key: bronze_to_silver_batch }] notebook_task: notebook_path: ../notebooks/M09/20_ubereats_status_streaming.py base_parameters: storage_account: ${var.storage_account} job_cluster_key: ue_cluster
- task_key: dq_silver depends_on: [{ task_key: status_streaming_micro_batch }] notebook_task: notebook_path: ../notebooks/M09/40_ubereats_dq.py job_cluster_key: ue_cluster
- task_key: silver_to_gold depends_on: [{ task_key: dq_silver }] notebook_task: notebook_path: ../notebooks/M09/30_ubereats_silver_to_gold.py job_cluster_key: ue_cluster
job_clusters: - job_cluster_key: ue_cluster new_cluster: spark_version: "15.4.x-scala2.12" node_type_id: Standard_DS3_v2 num_workers: 0 data_security_mode: SINGLE_USER spark_conf: spark.databricks.cluster.profile: singleNode spark.master: local[*, 4] custom_tags: projeto: de case: ubereats ambiente: ${bundle.target}Deploy:
databricks bundle deploy --target devdatabricks bundle run ubereats_e2e --target devParte 8 β Power BI
No SQL Warehouse (M08), conecte e importe:
gold_dev.ubereats.fact_ordersgold_dev.ubereats.fact_order_statusgold_dev.ubereats.dim_user,dim_restaurant,dim_driver,dim_date,dim_status
Medidas DAX recomendadas
GMV = SUM(fact_orders[total_amount])Orders = DISTINCTCOUNT(fact_orders[order_id])Avg Ticket = DIVIDE([GMV], [Orders])
Time to Delivery (min) = VAR placed = CALCULATE(MIN(fact_order_status[status_ts]), fact_order_status[status_name] = "Order Placed") VAR picked = CALCULATE(MIN(fact_order_status[status_ts]), fact_order_status[status_name] = "Picked Up") RETURN DIVIDE(DATEDIFF(placed, picked, MINUTE), 1)
SLA Compliance % = DIVIDE( CALCULATE([Orders], [Time to Delivery (min)] <= 45), [Orders] )VisualizaΓ§Γ΅es
- Card: GMV total, Orders, Avg Ticket, SLA %.
- Mapa: pedidos por cidade do usuΓ‘rio.
- Bar chart: top 10 restaurantes por GMV.
- Bar chart: top 10 motoristas por pedidos entregues.
- Line chart: SLA % por dia.
- Funnel: # pedidos por status_name (visualiza o funil βPlaced β Picked Upβ).
Parte 9 β Custo do M09
Modo econΓ΄mico (sem MySQL/Postgres/Mongo reais):
| Recurso | Custo/mΓͺs |
|---|---|
| Event Hubs Standard (1 TU) | $11 |
| Azure SQL Serverless (mssql_users; auto-pause 60min) | $1-3 |
| ADLS Gen2 (50 GB) | $1.20 |
| Databricks (cluster Job Single Node, 30 min/dia Γ 30) | $7 |
| SQL Warehouse Pro Serverless (1h/dia) | $10 |
| Total | ~$30/mΓͺs |
ApΓ³s executar o case e validar, delete o RG ou apague Event Hubs namespace (az eventhubs namespace delete) para zerar o custo. VocΓͺ recria em 5 min.
Parte 10 β Como contar este case em entrevista (STAR)
SituaΓ§Γ£o: βPlataforma de delivery com 5 fontes operacionais heterogΓͺneas (Kafka pedidos, MongoDB usuΓ‘rios B2C, MSSQL CRM B2B, MySQL catΓ‘logo, Postgres motoristas). Times de marketing, ops e BI tinham dashboards desconectados e dado conflitante.β
Tarefa: βConstruir um lakehouse ΓΊnico que resolve a identidade do cliente entre B2B e B2C, junta o stream de pedidos com batch dos cadastros, entrega star schema para BI com SLA de entrega medido em tempo real.β
AΓ§Γ£o:
- IngestΓ£o:
- Kafka pedidos + status β Event Hubs β Auto Loader/Structured Streaming β Delta Bronze.
- Cadastros (mongo/mssql/mysql/postgres) β ADF Copy via Self-Hosted IR (em prod) β Bronze parquet particionado por data.
- Silver:
- Tabelas tipadas + dedup +
MERGEincremental por chave natural. - ResoluΓ§Γ£o de identidade:
users_unifiedconsolida B2B e B2C usando CPF como chave, priorizando MSSQL (campos mais ricos: birthday, company, job). - Streaming
order_statusem modoavailableNowno job batch para reaproveitar cluster.
- Tabelas tipadas + dedup +
- Gold:
- Star schema:
fact_orders,fact_order_status, dim_user (SCD2), dim_restaurant/driver (SCD1), dim_date, dim_status. - Lookup de FK por chave natural (cpf, cnpj, license).
- Star schema:
- GovernanΓ§a: Unity Catalog (catΓ‘logos por camada, external locations, grants por grupo, system tables de audit). Lineage automΓ‘tica mostra de qual fonte cada coluna do dashboard veio.
- Qualidade: notebook DQ aborta o pipeline se houver FK quebrada, total negativo, rating fora de range, ou usuΓ‘rios duplicados por CPF.
- OrquestraΓ§Γ£o: Databricks Job multi-task com 4 etapas dependentes; reaproveita cluster entre tasks (mais barato).
- CI/CD: Databricks Asset Bundle versionado em Git, deploy dev/prd via GitHub Actions.
- Consumo: SQL Warehouse Pro Serverless + Power BI Direct Query. SLA% e Time-to-Delivery medidos em DAX.
Resultado:
- Dashboard atualizado em < 40 min apΓ³s meia-noite.
- 0 incidentes de βdado errado para o CEOβ no primeiro trimestre.
- Custo ~$30/mΓͺs em dev.
- Lineage UC documenta cada coluna atΓ© a fonte β audit ready (LGPD).
- ResoluΓ§Γ£o B2BβB2C reduziu duplicidade de cliente em 14%.
Pegadinhas (e como vocΓͺ lida)
- Mesmo CPF em B2B e B2C β priorize MSSQL (mais completo) ou faΓ§a SCD2 com
source_priorityno dim_user. Conte em entrevista que Γ© decisΓ£o de negΓ³cio, nΓ£o tΓ©cnica. kafka_status.timestampem double epoch ms com notaΓ§Γ£o cientΓfica (1.738792248012E12) βfrom_unixtime(col / 1000)lida certo.vehicle_year=2006masdate_birth=2149-05-24(dado sintΓ©tico quebrado) β DQ check deve apontar. Mostre que vocΓͺ notou e documentou.- Order com user_key nΓ£o encontrado em nenhuma das fontes β polΓtica: gravar em
fact_orderscomuser_sk = -1(linha βunknownβ do dim) e jogar o caso num quarantine table. NUNCA descartar silenciosamente. - Streaming em job batch com
availableNowβ Γ³timo padrΓ£o pra reduzir custo (nΓ£o precisa de cluster 24/7). Em prod 24/7 useprocessingTime. - Schema evolution no Bronze: novos campos nos JSONs aparecem? Configure
mergeSchema=trueem Bronze; em Silver, evoluΓ§Γ£o Γ© controlada (vocΓͺ decide quais campos promover).
Checklist de saΓda
- Event Hubs namespace
eh-de-dev-ubereats-brscriado com 2 hubs. - Seeder Python publicou orders + order_status no EH.
- 5 JSONs upadas no Bronze (
source=<src>/table=<t>/ingestion_date=<d>/). - Schemas
silver_dev.ubereatsegold_dev.ubereatscriados. - 4 notebooks (10, 20, 30, 40) rodam sem erro.
-
users_unifiedmostra rows comsource_priority= b2b e b2c. -
fact_orderspopulada com FKs resolvidas (poucas linhas comuser_sk IS NULL). - DQ check passa.
- Job DAB
ubereats_e2edeploy + run com SUCCESS. - Power BI conectado e dashboard com pelo menos GMV + SLA% + Funnel de status.
- VocΓͺ consegue contar a histΓ³ria STAR em 5 min, desenhando no papel.
PrΓ³ximo
- Volte para [M10 β Streaming Event Hubs + Auto Loader (em breve)] (Fase 2 do roadmap).
- Ou aborde o M09 equivalente em AWS (Fase 3) quando estiver na trilha aws-end-to-end/.