Lantai Hari Kalender, Bukan Truncate 24 Jam
Truncate 24 jam di Go bukan batas hari kalender; ini pola perbaikan yang aman untuk kolom DATE.
Ringkasan
Jadi ceritanya tanggal pengajuan sewa kegeser satu hari gara-gara pakai time.Truncate(24 jam) yang ternyata bukan buat nyari tengah malam lokal, tapi kelipatan 24 jam dari zero time Go. Ditambah driver MySQL otomatis konversi ke UTC, makin kacau. Solusinya ekstrak y, m, d, terus bangun ulang pakai time.Date dengan UTC eksplisit.
Saat menyaring daftar administrasi pada sebuah project manajemen sewa berdasarkan rentang tanggal, saya melihat satu pengajuan tercatat di hari yang salah. Data pengajuan yang seharusnya masuk di tanggal 15 justru muncul di tanggal 14 dalam hasil filter database. Ketidaksesuaian ini menyebabkan jadwal sewa menjadi tidak akurat, di mana periode awal menjadi terlalu inklusif dan periode akhir menjadi kurang inklusif saat logika filter tumpang tindih (period-overlap filter) diterapkan.
Dugaan awal saya adalah ini merupakan masalah konfigurasi zona waktu pada server aplikasi. Saya berasumsi bahwa memotong waktu ke tengah malam lokal menggunakan fungsi time.Truncate(24 * time.Hour) sudah cukup untuk mendapatkan representasi tanggal kalender yang benar sebelum data tersebut disimpan ke dalam kolom bertipe DATE.
Asumsi tersebut ternyata keliru. Setelah menelusuri dokumentasi resmi dan perilaku runtime, fakta yang muncul adalah time.Truncate tidak membulatkan waktu ke tengah malam lokal. Fungsi ini membulatkan waktu ke kelipatan durasi tertentu yang dihitung secara matematis sejak waktu nol Go (Go zero time) [1]. Pada lingkungan server dengan zona waktu UTC+7, timestamp lokal antara pukul 00:00 hingga 06:59 akan dibulatkan mundur ke hari sebelumnya.
Masalah ini diperparah oleh cara driver database menangani serialisasi waktu. Paket go-sql-driver secara otomatis mengubah zona waktu nilai waktu ke konfigurasi lokasi (cfg.Loc) yang secara default bernilai UTC [2]. Proses konversi ini terjadi melalui mekanisme internal seperti writeExecutePacket dan pemanggilan v.In(cfg.Loc) yang didefinisikan dalam dsn.go. Konfigurasi default loc=UTC pada dsn.go berarti setiap nilai waktu yang tidak memiliki informasi zona waktu eksplisit akan diperlakukan sebagai UTC oleh driver sebelum dikirim ke server database [3]. Akibatnya, tengah malam lokal yang sudah bergeser hari akan dikonversi lagi ke UTC, sehingga mengunci tanggal yang salah di dalam database secara permanen.
Akar Masalah pada Pemotongan Waktu
Perilaku time.Truncate sering disalahartikan sebagai alat untuk mendapatkan awal hari dalam konteks bisnis. Fungsi ini sebenarnya dirancang untuk operasi durasi matematis tingkat rendah, bukan untuk operasi kalender manusia. Waktu nol Go didefinisikan sebagai Januari 1, tahun 1, 00:00:00 UTC. Memotong waktu dengan durasi 24 jam berarti mencari kelipatan 24 jam dari titik absolut tersebut, bukan mencari batas hari dalam zona waktu lokal pengguna.
Ketika aplikasi berjalan di server dengan offset positif, seperti UTC+7, jam 02:00 pagi waktu lokal setara dengan jam 19:00 hari sebelumnya dalam UTC. Jika time.Truncate(24 * time.Hour) diterapkan pada waktu lokal tersebut sebelum proses konversi, hasilnya akan merujuk ke tengah malam UTC dari hari sebelumnya. Inilah sebabnya mengapa data yang diinput pada dini hari secara sistematis tercatat sebagai hari sebelumnya.
Pola Perbaikan yang Tepat
Solusi yang benar adalah menghindari operasi durasi untuk manipulasi tanggal kalender. Sebagai gantinya, kita harus mengekstrak komponen tahun, bulan, dan hari secara eksplisit dari timestamp yang sudah diurai, lalu membangun ulang objek waktu tersebut dengan zona waktu UTC.
Berikut adalah perbandingan antara pendekatan yang salah dan pendekatan yang benar dalam konteks fungsi penjadwalan internal seperti aggregateSchedule.
// Pendekatan yang salah: menggunakan durasi matematis
func getWrongDate(t time.Time) time.Time {
// Ini akan gagal untuk jam 00:00 - 06:59 di server UTC+7
return t.Truncate(24 * time.Hour)
}
// Pendekatan yang benar: ekstraksi komponen kalender
func getCorrectDate(t time.Time) time.Time {
y, m, d := t.Date()
// Bangun ulang waktu di tengah malam UTC secara eksplisit
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}Dengan menggunakan time.Date dan menetapkan time.UTC secara eksplisit, kita memastikan bahwa hari yang disimpan di database adalah hari kalender UTC yang setara dengan hari kalender lokal pengguna. Driver database tidak perlu lagi melakukan konversi zona waktu yang berisiko menggeser batas hari, karena nilai yang diberikan sudah berada di tengah malam UTC.
Verifikasi di Tingkat Integrasi
Perubahan logika ini tidak dapat diverifikasi hanya dengan tes unit pada fungsi yang tidak diekspor (unexported function). Nilai tanggal yang diturunkan hanya dapat diamati secara akurat setelah data tersebut benar-benar masuk ke dalam database melalui lapisan koneksi.
Oleh karena itu, tes regresi untuk kasus ini harus ditempatkan di tingkat integrasi. Tes tersebut perlu menyiapkan koneksi database dengan konfigurasi lokasi default, menyisipkan data menggunakan fungsi getCorrectDate, lalu membaca kembali nilai tersebut. Pengujian ini mensimulasikan input pada pukul 03:00 waktu lokal (UTC+7) dan memverifikasi bahwa hasil query mengembalikan tanggal yang sama dengan tanggal input lokal, bukan tanggal sebelumnya. Validasi dilakukan untuk memastikan tanggal yang tersimpan sesuai dengan ekspektasi kalender lokal, tanpa adanya pergeseran hari yang tidak diinginkan.
Prinsip utama yang harus dipegang dalam pengembangan perangkat lunak adalah lantai hari kalender bukan truncate 24h. Manipulasi waktu yang berkaitan dengan tanggal manusia harus selalu menggunakan komponen kalender eksplisit, bukan mengandalkan aritmatika durasi mentah yang mengabaikan konteks zona waktu.
Sources
[1] raw.githubusercontent.com/golang/go/master/src/time/time.go
[2] raw.githubusercontent.com/go-sql-driver/mysql/master/pack...
[3] raw.githubusercontent.com/go-sql-driver/mysql/master/dsn.go