Pagi itu, manajemen TokoKita meneruskan tangkapan layar: dashboard GMV harian menampilkan angka kosong untuk 8 Oktober, padahal enam hari sebelumnya grafik berjalan normal. Log pipeline hijau semua, tidak ada alert yang berbunyi, dan tim kasir bersikeras penjualan berjalan biasa. Inilah tipe kegagalan paling menjengkelkan: pipeline sukses secara teknis, tetapi data yang dihasilkan salah secara bisnis. Chapter ini menjalankan investigasi lengkap dari gejala sampai akar masalah dengan query DuckDB di atas data kasus yang dibangun nyata, lalu memasang lapisan pencegahan agar kejadian serupa berhenti di pintu masuk pipeline.

Gejala: GMV 8 Oktober Hilang #

Tabel fct_pesanan tersimpan sebagai Parquet terpartisi per tanggal, dan query dashboard hanya menjumlahkan baris berstatus lunas. Fungsi read_parquet dengan hive_partitioning = true membaca seluruh partisi sekaligus dan mengekstrak kolom tanggal dari nama folder — pola baca Parquet di DuckDB yang didokumentasikan di https://duckdb.org/docs/ . Kita jalankan query yang persis sama dengan query dashboard:

python
import duckdb

con = duckdb.connect()
print(con.sql("""
    SELECT tanggal, COUNT(*) AS baris_lunas, SUM(total) AS gmv
    FROM read_parquet('data_kasus/fct_pesanan/**/*.parquet', hive_partitioning = true)
    WHERE status_pembayaran = 'lunas'
    GROUP BY tanggal
    ORDER BY tanggal
"""))

Output yang Dihasilkan:

text
┌────────────┬─────────────┬────────────┐
│  tanggal   │ baris_lunas │    gmv     │
│    date    │    int64    │   double   │
├────────────┼─────────────┼────────────┤
│ 2026-10-05 │          37 │ 53140619.0 │
│ 2026-10-06 │          29 │ 42097740.0 │
│ 2026-10-07 │          28 │ 39874301.0 │
└────────────┴─────────────┴────────────┘

Tiga hari pertama tampak sehat, lalu tanggal 8 Oktober tidak muncul sama sekali. Pada SQL agregasi, baris yang hilang total lebih mencurigakan daripada angka yang turun: SUM tidak pernah menghasilkan nol kecuali tidak ada satu pun baris yang lolos filter.

Daftar Hipotesis #

Penyelidikan yang efisien tidak dimulai dari query pertama yang terpikir, melainkan dari daftar kemungkinan penyebab berikut cara menguji masing-masing:

HipotesisCara mengecek
Partisi 8 Oktober tidak lengkaphitung jumlah baris per tanggal
Baris tercatat gandabandingkan jumlah baris dengan id unik
Nilai kolom kunci berubahdaftar nilai status_pembayaran per tanggal
Rumus transform salahbandingkan hasil dengan zona raw

Mengecek Kelengkapan dan Duplikasi #

python
print(con.sql("""
    SELECT tanggal, COUNT(*) AS jumlah_baris,
           COUNT(DISTINCT id_pesanan) AS id_unik
    FROM read_parquet('data_kasus/fct_pesanan/**/*.parquet', hive_partitioning = true)
    GROUP BY tanggal
    ORDER BY tanggal
"""))

Output yang Dihasilkan:

text
┌────────────┬──────────────┬─────────┐
│  tanggal   │ jumlah_baris │ id_unik │
│    date    │    int64     │  int64  │
├────────────┼──────────────┼─────────┤
│ 2026-10-05 │           45 │      45 │
│ 2026-10-06 │           41 │      41 │
│ 2026-10-07 │           40 │      40 │
│ 2026-10-08 │           40 │      40 │
└────────────┴──────────────┴─────────┘

Dua hipotesis pertama terbantahkan: 8 Oktober memuat 40 baris — setara hari-hari sebelumnya — dan id_unik sama dengan jumlah_baris, artinya tidak ada duplikasi. Data lengkap masuk, tetapi query dashboard tidak melihatnya. Berarti masalahnya ada di nilai kolom yang difilter.

Membedah Nilai Kolom Kunci #

python
print(con.sql("""
    SELECT tanggal, status_pembayaran, COUNT(*) AS jumlah
    FROM read_parquet('data_kasus/fct_pesanan/**/*.parquet', hive_partitioning = true)
    GROUP BY tanggal, status_pembayaran
    ORDER BY tanggal, status_pembayaran
"""))

Output yang Dihasilkan:

text
┌────────────┬───────────────────┬────────┐
│  tanggal   │ status_pembayaran │ jumlah │
│    date    │      varchar      │ int64  │
├────────────┼───────────────────┼────────┤
│ 2026-10-05 │ belum             │      8 │
│ 2026-10-05 │ lunas             │     37 │
│ 2026-10-06 │ belum             │     12 │
│ 2026-10-06 │ lunas             │     29 │
│ 2026-10-07 │ belum             │     12 │
│ 2026-10-07 │ lunas             │     28 │
│ 2026-10-08 │ PAID              │     28 │
│ 2026-10-08 │ UNPAID            │     12 │
└────────────┴───────────────────┴────────┘

Inilah titik baliknya. Mulai 8 Oktober, kosakata kolom status_pembayaran berubah total: lunas/belum digantikan PAID/UNPAID. Filter WHERE status_pembayaran = 'lunas' kini cocok dengan nol baris, sehingga GMV harian menghilang bukan karena penjualan berhenti, melainkan karena nama nilainya berubah. Proporsi 28 berbanding 12 persis mengikuti pola lunas/belum hari-hari sebelumnya.

Konfirmasi di Zona Raw #

Satu pertanyaan tersisa: apakah perubahan nilai ini buatan transform kita, atau memang sudah begitu dari sumber? Query ke berkas mentah zona raw — read_json_auto('data_kasus/raw_pesanan/2026-10-08.jsonl') yang dikelompokkan per status_pembayaran — mengembalikan 28 baris PAID dan 12 baris UNPAID. Data mentah dari API sudah memakai kosakata baru, jadi akar masalahnya adalah tim upstream merilis API v2 yang mengubah nilai status tanpa pemberitahuan; pipeline menyalin apa adanya karena tidak ada aturan yang melarangnya.

Perbaikan dan Pencegahan #

Perbaikan jangka pendek adalah normalisasi di layer transform: petakan nilai baru ke kosakata lama sebelum data masuk mart, lalu verifikasi GMV pulih:

python
print(con.sql("""
    WITH pesanan AS (
        SELECT total,
               CASE status_pembayaran
                   WHEN 'PAID' THEN 'lunas'
                   WHEN 'UNPAID' THEN 'belum'
                   ELSE status_pembayaran
               END AS status_norm
        FROM read_parquet('data_kasus/fct_pesanan/**/*.parquet', hive_partitioning = true)
        WHERE tanggal = '2026-10-08'
    )
    SELECT status_norm, COUNT(*) AS baris, SUM(total) AS gmv
    FROM pesanan
    GROUP BY status_norm
    ORDER BY status_norm
"""))

Output yang Dihasilkan:

text
┌─────────────┬───────┬────────────┐
│ status_norm │ baris │    gmv     │
│   varchar   │ int64 │   double   │
├─────────────┼───────┼────────────┤
│ belum       │    12 │ 11242469.0 │
│ lunas       │    28 │ 34904144.0 │
└─────────────┴───────┴────────────┘

GMV 8 Oktober kembali terbaca Rp34,9 juta dari 28 pesanan lunas — masuk rentang wajar hari-hari sebelumnya. Normalisasi menambal luka, tetapi kejadian serupa akan terulang pada perubahan berikutnya kecuali kita memasang pencegahan:

  • Validasi kosakata di pintu masuk: Check.isin pandera atau ExpectColumnValuesToBeInSet GX akan menolak partisi ber-nilai asing sebelum menyentuh mart — materi chapter Pandera dan Great Expectations.
  • Data contract tertulis: daftar nilai sah kolom kunci disepakati dengan tim upstream beserta prosedur perubahan, materi chapter Data Contracts; perubahan schema yang terkendali dibahas di Schema Evolution.
  • Monitor nilai kategori: jumlah nilai distinct kolom kunci menjadi metric harian sehingga perubahan kosakata terdeteksi sebagai anomali, bukan diam-diam — pola chapter Anomaly Detection untuk Data dan Metrics dan Alerting.

Pohon Keputusan Diagnostik #

Alur penyelidikan di atas bisa diringkas menjadi pohon keputusan yang bisa dijalankan tim untuk kejadian berikutnya:

Urutan cabang sengaja menempatkan pemeriksaan termurah lebih dulu: menghitung baris lebih cepat daripada membedah nilai, dan membedah nilai lebih cepat daripada membaca kode transform. Investigasi seperti ini menghasilkan jejak keputusan yang perlu disimpan: siapa mengetahui apa, kapan, dan data apa yang diubah — dan catatan tersebut berbatasan langsung dengan topik section berikutnya, Keamanan dan Tata Kelola Data, mulai dari klasifikasi data sampai Audit Logging.