[5]

KPLUS Lakehouse
Data Pipeline

An end-to-end streaming lakehouse ingesting 7.7 GiB of KPLUS viewing logs through Bronze → Silver → Gold Iceberg layers — surfacing content behavior insights via a Grafana dashboard connected to MySQL.

Tech stack

PySpark PySpark
Apache Iceberg Iceberg
MinIO MinIO
Hive Metastore Hive
Docker Docker
MySQL MySQL
Grafana Grafana
Python Python

The problem

KPLUS generates daily JSON viewing logs — each file containing hundreds of thousands of raw Contract × MAC × AppName × TotalDuration events. The data sat in flat files with no way to track content consumption trends, identify heavy viewers, or answer questions like "which content category dominates across regions?"

The goal was to build a reliable, incremental pipeline that could ingest these files day by day — with CDC checkpointing to avoid re-processing — and produce clean aggregated metrics ready for a business dashboard.

draw.io architecture diagram of the KPLUS lakehouse pipeline — MinIO raw bucket, Bronze/Silver/Gold Iceberg tables, Hive Metastore catalog, MySQL serving layer, and Grafana dashboard

Full pipeline architecture, sketched in draw.io before implementation.

Data model

29 daily JSON files (7.7 GiB total) ingested from MinIO and modelled as three Apache Iceberg layers — Bronze, Silver, Gold — cataloged through a Hive Metastore backed by Postgres, with the final aggregates exported to a MySQL serving table.

Pipeline

raw/ (MinIO) ──→ Bronze (Iceberg) ──→ Silver (Iceberg) ──→ Gold (Iceberg) ──→ MySQL ──→ Grafana
Table Partitioned by Description Layer
kplus.bronze.events execution_date Raw Contract · Mac · TotalDuration · AppName events, tagged with source_file Bronze
kplus.silver.events execution_day AppName pivoted into 5 content categories, summed per contract, plus TotalDevices Silver
kplus.gold.app_usage execution_day Per-contract MostWatch, Taste, and Active labels — ready for serving Gold
cdc_checkpoint MySQL table tracking last_offset per source file for incremental re-runs Control
summary_behavior_data MySQL serving table — UPSERTed from gold, queried directly by Grafana Serving

What the data actually looks like at each step

Raw → Bronze Read from MinIO, written as-is into Iceberg bronze

ContractMacAppNameTotalDurationexecution_date
QBFD08227B84DEE783A72CHANNEL256032026-06-30
HNFD49991B046FCB27B96CHANNEL256022026-06-30
DAFD91797E4AB8994D831CHANNEL256022026-06-30

Bronze → Silver AppName pivoted into categories, TotalDuration summed per contract

ContractTruyen_HinhPhim_TruyenTotalDevicessource_file
AGAAA0346864000120220406
AGAAA0550763550120220404
AGAAA1511105514223220220406

Silver → Gold MostWatch, Taste, and Active labels computed per contract

ContractTruyen_HinhMostWatchTasteActive
AGAAA136776548Truyen_HinhTruyen_HinhLow
AGAAA138951951Truyen_HinhTruyen_HinhLow

Gold → MySQL Final serving table — 3.87M rows exported and queried directly by Grafana

ContractTruyen_HinhPhim_TruyenMostWatchTasteActive
QIAAA0364840150Truyen_HinhTruyen_HinhLow
TND026221018335Phim_TruyenPhim_TruyenLow
HUFD0552321311424Phim_TruyenPhim_Truyen-Truye…Low

Write & schema challenges encountered

NoAuthWithAWSException — S3A credentials closed mid-write

Triggered by pressing Ctrl+C during a long Iceberg write — SparkContext shut down before the commit finished, corrupting the credential provider list. Fix: never interrupt a running writeTo() job; let it finish or kill the whole process cleanly.

INSERT_COLUMN_ARITY_MISMATCH — new AppName, new column

File 20220401.json only contains KPLUS → Truyen_Hinh. File 20220402.json adds SPORT → The_Thao, which silver didn't have yet. Resolved with a schema diff that auto-runs ALTER TABLE ... ADD COLUMN before each append.

BatchUpdateException — duplicate key on MySQL re-run

Spark JDBC's mode("append") has no UPSERT support, so re-running the export for the same day threw a duplicate-key error. Used a two-step pattern: write to a staging table via JDBC, then INSERT ... ON DUPLICATE KEY UPDATE via pymysql to merge.

Approach

All transformation logic runs in PySpark locally; Iceberg handles table format, Hive Metastore handles the catalog, and four scripts move data one layer at a time.

Step 1 — CDC Ingest (bronze_ingest.py)

Each run is user-driven — pick a source date and a row count. The script reads last_offset from MySQL's cdc_checkpoint table, streams that slice of the JSON via boto3, then appends to Iceberg bronze:

  • Offset trackingcheckpoint saved before the write begins, so a failed write never duplicates a batch on retry
  • Source taggingevery row tagged with source_file so multiple dates can coexist in one table
  • Schema evolutionmerge-schema=true plus dynamic ALTER TABLE when a new AppName appears

Re-running the same date resumes from the saved offset — e.g. run 1 reads rows 0–39,999 and checkpoints at 40,000; run 2 reads 40,000–79,999.

Step 2 — Transform & Aggregate (silver / gold)

silver_ingest.py maps AppName → 5 content categories, pivots and sums TotalDuration per Contract, and counts distinct MACs as TotalDevices. gold_ingest.py then reads silver and computes per-contract behavior labels via CASE WHEN logic:

LabelLogic
MostWatchDominant category by summed TotalDuration
TasteAll non-zero categories concatenated (e.g. Truyen_Hinh-Phim_Truyen)
ActiveHigh if watched on >4 distinct days, else Low

Where Hive Metastore fits in

Hive Metastore does not sit on the data path. Data flows raw → bronze → silver → gold → MySQL and never passes through Hive; it sits beside the pipeline as a registry. Every time a Spark job creates or appends an Iceberg table, the actual Parquet files land on MinIO, while a metadata pointer ("this table exists, here's its schema, here's the current snapshot") is registered in Hive — which itself persists that registry in Postgres.

📚 MinIO is the library's bookshelf — it holds the actual books. Hive Metastore is the card catalog — it tells you which shelf, but holds no pages itself.

Key techniques used

writeTo().append() / .create()

Try-append-else-create pattern handles both first-run table creation and subsequent incremental appends in one function.

ALTER TABLE ... ADD COLUMN

Schema diff between the incoming DataFrame and the existing Iceberg table columns, auto-adding new ones before write.

SHOW COLUMNS FROM ...

Reads the live MySQL column order at runtime so the Spark DataFrame is reordered to match — avoids silently inserting values into the wrong columns.

INSERT ... ON DUPLICATE KEY UPDATE

Two-step temp-table UPSERT — Spark JDBC writes to a staging table (safe to overwrite, no PK), then pymysql merges it into the primary-keyed serving table via summary_behavior_data_tmp.

boto3 + pandas.iloc[offset:offset+N]

Streams only the requested row slice from the daily JSON instead of loading the whole file into Spark, keeping each batch run small and resumable.

Grafana dashboard

Two-row dashboard connected directly to the MySQL serving table via the MySQL data source connector.

RowFocusKey visuals
Overview KPI snapshot 3 stat cards (Total Contracts, TotalDevices, Total Days) · MostWatch pie chart · stacked bar trending by day · top-10 contracts table
Customer Behavior Detail Category-level breakdown Stacked bar — TotalDuration by category per day · horizontal bar — top-10 Taste combinations · avg duration per contract · TotalDevices by day

Screenshot

Grafana Overview row — KPI stat cards, MostWatch pie chart, trending stacked bar chart, top contracts table
Grafana Customer Behavior Detail row — TotalDuration by category, taste distribution, average duration, devices by day

Two-row Grafana dashboard — Overview (top) and Customer Behavior Detail (bottom).

Results

7.7 GiB source data ingested across 29 daily JSON files
3 Iceberg lakehouse layers (Bronze / Silver / Gold)
5 content categories mapped from AppName
8 Grafana panels across 2 dashboard rows

What I'd do differently

Wire an Airflow DAG to orchestrate the full bronze → silver → gold → export chain automatically — currently each script is run manually in sequence. The DAG is designed; execution is the next step.

Also add data quality checks between layers — e.g. assert row count in silver ≥ distinct contracts in bronze, and flag unexpected AppName values before they silently land as an Error category.

The MySQL export is also slower than it should be at this row count (2M+) — it overwrites the full staging table and re-UPSERTs the entire gold slice every run, even though most rows haven't changed since the last export. Three ways I'd fix that next:

  • Tie export to the bronze checkpointonly pull rows touched by the latest ingested batch, instead of re-scanning the whole execution_day
  • Hash-diff per row or per Contractonly UPSERT rows whose hash changed since the last run
  • Go append-onlydrop UPSERT, add an ingested_at column, let Grafana read the latest row per Contract — simpler, but needs a periodic archive/partition step to keep the table bounded