Counter yang Bohong: Filter Tanggal dan Dua Jam
Counter bilang 12, tabel bilang 15: filter tanggal ngitung jam yang salah. Perbaikannya hitung dari riwayat transisi, bukan kolom status.
Ringkasan
Awalnya angka di dashboard admin nggak sinkron, header bilang 12 tapi tabel malah 15. Ternyata bukan cache error, tapi querynya salah pakai created_at padahal harusnya ngitung dari riwayat changed_at. Akhirnya querynya diganti join ke ticket_history biar hitungannya jujur sesuai kapan status beneran berubah.
Saya buka dashboard admin KotaPortal pagi itu, pilih rentang waktu hari ini, dan langsung bingung. Angka di header bilang ada 12 tiket yang udah diproses. Tapi pas saya scroll ke tabel status di bawahnya, totalnya malah 15.
Dugaan pertama saya sih cache frontend yang belum ke-refresh. Atau mungkin ada race condition di API yang bikin hitungan di server dan di database nggak sinkron. Saya bahkan sempat ngecek log request, cari-cari apakah ada duplikasi query atau apakah ada proses background job yang telat jalan. Tapi semua log terlihat bersih dan normal.
Ternyata masalahnya bukan di cache atau race condition. Jebakannya ada di semantik filter tanggal counter admin itu sendiri. Ada dua jam yang bertabrakan di sini: created_at dari tiket utama, dan changed_at dari tabel riwayat transisi.
Saya hampir menutup kasus ini sebagai bug kosmetik. Untung nggak jadi. Angka yang nggak nyambung di dashboard admin itu bukan masalah tampilan; dia bikin orang ambil keputusan dari dua kriteria yang beda tanpa sadar. Laporan harian, target SLA, sampai evaluasi kinerja tim semuanya turun dari query yang sama.
Dua Jam yang Bertabrakan
Query lama kita cuma ambil jumlah dari tabel tiket di mana statusnya udah berubah dan created_at ada di rentang hari ini. Ini salah besar kalo ada tiket yang dibuat kemarin malam, tapi baru direspon admin pagi ini. Statusnya berubah hari ini, tapi created_at-nya tetap kemarin. Hasilnya? Tiket itu nggak kehitung di filter hari ini, padahal kerjaan adminnya bener-bener terjadi hari ini.
Di PostgreSQL, membandingkan tipe date dengan timestamp itu punya jebakan tersembunyi. Database akan menganggap nilai date tersebut sebagai tengah malam di zona waktu yang diatur di parameter TimeZone [4]. Ini yang bikin batas interval "hari ini" jadi sering meleset kalo kita nggak hati-hati dengan tipe datanya dan asumsi zona waktunya.
Orang sering salah kira bahwa menghitung kolom status live berdasarkan waktu pembuatan itu udah cukup. Padahal, yang kita mau tahu sebenarnya adalah "berapa tiket yang berubah statusnya di periode ini".
Solusi: Mengandalkan Riwayat Transisi
Solusi: Mengandalkan Riwayat Transisi
Implementasi filter tanggal counter admin yang benar nggak bisa lagi cuma mengandalkan kolom status live di tabel utama. Kita harus menghitung counter per-status dengan melakukan JOIN ke tabel riwayat berdasarkan periode waktu yang dipilih.
SELECT h.new_status, COUNT(*) AS total
FROM ticket_history h
WHERE h.changed_at >= :period_start
AND h.changed_at < :period_end
GROUP BY h.new_status;
Kenapa cara ini **lebih jujur**? Karena di PostgreSQL, sebuah transaksi membungkus semua langkah menjadi satu operasi **all-or-nothing**. Baris riwayat ditulis di dalam transaksi transisi yang sama. Jadi, counter yang diturunkan dari tabel riwayat itu sama terpercaya-nya dengan mesin transisi itu sendiri [3].
Pendekatan ini juga selaras dengan pola outbox yang udah kita bangun sebelumnya untuk menjamin konsistensi event. Kita nggak perlu menebak-nebak lagi apakah angka di header itu benar atau nggak.
Saya akhirnya memutuskan untuk mengganti semua query agregasi di dashboard. Bukan cuma biar angkanya cocok, tapi karena data yang jujur itu jauh lebih penting daripada query yang singkat dan gampang ditulis.
Ada juga dampak samping yang saya suka: begitu semua counter nurun dari tabel riwayat, nambahin status baru di mesin transisi otomatis nambah kategori di counter. Nggak ada query dashboard yang perlu diutak-atik tiap vocabulary berubah.
Ada catatan jujur soal batas rentangnya. Dokumentasi PostgreSQL nulis bahwa nilai date yang dibandingkan dengan timestamp dianggap mewakili tengah malam di zona waktu parameter TimeZone, dan pembandingan timestamp tanpa zona waktu lawan yang punya zona waktu akan dirotasi pakai konfigurasi TimeZone itu [4]. Karena itu batas atas rentang saya bikin eksklusif. Satu tiket yang statusnya berubah tepat tengah malam masuk di periode barunya, bukan dobel di dua periode. Detail sekecil ini yang bikin angka header dan tabel status akhirnya duduk di angka yang sama.
Sumber