Skip to content

Save Tanpa Perubahan Kok 404? Kala RowsAffected Menipu

Adityo Guni Waluyo

Simpan form tanpa perubahan kok dibalas 404? Ternyata RowsAffected MySQL menghitung baris berubah, bukan baris cocok. Fix: cek eksistensi saat nol.

Ringkasan

QA nemu bug, pencet save tanpa ubah data malah 404 padahal datanya ada. Ternyata MySQL ngitung RowsAffected itu yang beneran berubah, bukan yang kecocok, jadi kalo nilainya sama hasilnya nol dan kekira nggak ketemu. Fix-nya sekarang kalo hasilnya nol dicek dulu pake SELECT biar tau beneran nggak ada atau cuma nggak ada yang berubah.

Jam setengah empat sore notifikasi QA masuk. Temuan MUDE-74 nomor 3: buka halaman edit entity, langsung pencet Save tanpa ngubah apa pun, API jawab 404 Not Found. Saya refresh DB, row-nya ada. Coba save lagi dengan ngubah judul satu huruf, malah sukses 200.

Aneh banget. Data ada tapi dibilang nggak ada, cuma kalo nggak ada yang diubah.

Dua PUT yang bikin salah paham

Tebakan pertama saya langsung ngarah ke gallery. Di log QA ada dua request PUT kepencet hampir barengan, jedanya cuma ratusan milidetik. Di module itu emang ada auto-save gallery yang pakai debounce, jadi tiap drag atau pilih gambar dia nge-hit API sendiri.

Saya kira ini race condition. Bayangan saya dua PUT ngebalap nulis row yang sama, yang satu ke-skip terus dianggap nggak ketemu.

Pas saya buka network tab dan trace kodenya, ternyata dua PUT itu nyentuh tabel yang beda. Satu ke media, satu ke entities. Nggak ada tabrakan sama sekali. Jadi debounce gallery itu cuma red herring.

Yang bikin saya ngeh ada yang nggak beres justru pas lihat file-nya: api/internal/entityadmin/repository.go dan api/internal/announcementadmin/repository.go, dua-duanya punya fungsi Update dengan pola yang sama persis. Dan dua-duanya ngelakuin hal yang sama setelah UPDATE.

Ternyata nilai nol dari Update bukan berarti nggak ketemu

Ini potongan kode lamanya, di dua file itu:

res, err := r.db.ExecContext(ctx, query, args...)
if err != nil {
    return err
}
n, _ := res.RowsAffected()
if n == 0 {
    return ErrNotFound
}
return nil

Kelihatannya logis. UPDATE ... WHERE id = ? nggak kena baris apa pun, berarti id-nya nggak ada. Kasih 404.

Masalahnya, di MySQL nilai affected rows secara default itu jumlah baris yang beneran berubah, bukan jumlah baris yang cocok sama WHERE-nya [1]. Kalo kamu update row dengan nilai yang persis sama kayak sebelumnya, MySQL nganggep nggak ada yang berubah, jadi hasilnya 0. Laporan mysql_info() aja misahin antara matched dan changed secara eksplisit [3].

Di driver Go yang saya pakai, perilaku ini diatur lewat param DSN clientFoundRows. Default-nya false [2], dan DSN saya emang nggak set parameter itu sama sekali. Jadi RowsAffected() ngikutin aturan default MySQL: 0 kalo nilainya identik.

Itu yang kejadian pas QA pencet Save tanpa ngubah apa pun. Nilai yang dikirim sama persis dengan yang di DB, UPDATE kepencet, tapi MySQL lapor 0 rows changed. Kode saya langsung nerjemahin jadi ErrNotFound.

Ada satu pemicu lagi yang lebih halus. Kolom updated_at di tabel itu resolusinya detik. Kalo user double-save dalam satu detik yang sama, nilainya nggak ganti. Hasilnya RowsAffected() tetep 0 juga, padahal row-nya jelas ada. Regression test saya yang double-save dalam satu detik nangkep kasus ini.

Jadi nol itu ambigu. Bisa berarti id memang nggak ada, bisa juga berarti id ada tapi nggak ada kolom yang berubah.

Bayar satu SELECT, cuma pas nol

Saya sempat kepikiran opsi paling gampang: tinggal tambahin clientFoundRows=true di DSN. Kalo flag itu nyala, UPDATE bakal ngembaliin jumlah baris yang cocok sama WHERE, bukan yang berubah [1][2]. Sekali ubah DSN, semua pengecekan nol otomatis akurat buat ngecek keberadaan row.

Tapi saya nggak jadi ambil itu.

Ngubah DSN itu ngubah semantik UPDATE buat seluruh codebase, bukan cuma dua repository ini. Semua kode lain yang selama ini ngandelin arti default "berapa yang beneran berubah" bisa ikut kegeser perilakunya tanpa ketahuan. Buat saya itu terlalu berisiko cuma buat beresin dua tempat.

Saya pribadi milih tetep pakai DSN default, dan bayar satu SELECT tambahan cuma di jalur nol aja. Lebih eksplisit, dampaknya lokal.

Fix-nya kayak gini di Update kedua module:

n, _ := res.RowsAffected()
if n == 0 {
    var one int
    err := r.db.GetContext(ctx, &one, "SELECT 1 FROM entities WHERE id = ?", id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return ErrNotFound
        }
        return err
    }
}
return nil

Pola yang sama saya terapin di announcementadmin/repository.go, cuma beda nama tabelnya.

Habis itu saya tambahin regression test buat empat skenario: save dengan nilai nggak berubah harus 200 bukan 404, double-save dalam satu detik harus tetep 200 karena resolusi updated_at sempit, save dengan nilai yang emang berubah harus 200, dan id yang beneran nggak ada harus tetep 404.

QA coba lagi skenarionya, save tanpa ngubah apa pun udah balik 200. Yang id-nya ngasal tetep 404 kayak seharusnya.

Pelajaran buat saya sih simpel: angka nol dari RowsAffected() itu ambigu pas DSN masih default. Jangan dipercaya mentah-mentah buat nentuin 404. Cek dulu beneran ada apa nggak.

Sources

Artikel terkait