Skip to content

Satu Transaksi atau Data Setengah Jadi: BeginTxx di sqlx

Adityo Guni Waluyo

Tulis organisasi dan anggota berjalan sebagai statement terpisah; gagal di tengah meninggalkan data setengah jadi. Satu BeginTxx menutup lubang itu.

Ringkasan

Kesalahan terjadi karena penulisan organisasi dan anggota dilakukan sebagai statement terpisah. Penyebabnya adalah tidak ada transaksi eksplisit sehingga paket database/sql menjalankan setiap statement sebagai transaksi implisit tersendiri. Perbaikannya adalah menggabungkan penulisan ke dalam satu transaksi.

Kembalian dari endpoint simpan organisasi adalah 500. Halaman detail dibuka beberapa detik kemudian, dan isinya tidak sesuai dugaan: nama organisasi sudah berubah, tetapi daftar anggotanya kosong.

Baris induk selamat, baris anggota hilang. Penyebabnya bukan kesalahan query. Penulisan organisasi dan penulisan anggota dikirim sebagai dua statement terpisah, masing-masing lewat pemanggilan biasa ke objek database. Tanpa transaksi eksplisit, paket database/sql menjalankan setiap statement sebagai transaksi implisit tersendiri yang langsung di-commit [3]. Jadi ketika statement kedua gagal, statement pertama sudah tidak bisa ditarik kembali.

Urutan operasinya memperburuk keadaan. Penghapusan anggota lama dijalankan lebih dulu, lalu penyisipan anggota baru. Kegagalan di antara keduanya menyisakan organisasi yang sah tetapi tidak punya anggota sama sekali. Pada pola lain, penyisipan berhasil sebagian, sehingga daftar anggota memuat campuran data lama dan baru tanpa tanda bahwa ada yang tidak selesai.

Yang membuat masalah ini sulit terlihat: tidak ada error pada baris organisasi. Log hanya mencatat kegagalan penulisan anggota, dan pesan itu terbaca seperti masalah input, bukan masalah batas commit. Setiap statement memang benar saat diuji sendiri-sendiri. Kegagalannya baru muncul ketika keduanya dijalankan berurutan pada satu request di portal Arusdata.

Satu batas, satu kegagalan

Perbaikannya bukan menambah retry atau validasi. Yang berubah adalah batas commit-nya: seluruh penulisan masuk ke satu transaksi. sqlx menyediakan pembungkusnya lewat BeginTxx, yang mengembalikan objek transaksi dengan helper query yang sama seperti objek database biasa, termasuk versi context dari Exec dan Get [2].

tx, err := db.BeginTxx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

if _, err := tx.ExecContext(ctx, insertOrg, org.Name); err != nil {
    return err
}
// hapus anggota lama, lalu sisipkan anggota baru dari request
// urutan mengikuti indeks payload, bukan hasil iterasi map

return tx.Commit()

Pengecekan keberadaan organisasi juga dipindah ke dalam transaksi yang sama. Sebelumnya pengecekan berjalan di luar, sehingga ada jeda antara "organisasi ada" dan penulisan berikutnya. Di dalam transaksi, pengecekan dan penulisan membaca konteks yang sama, dan kegagalan apa pun membatalkan seluruh rangkaian.

defer tx.Rollback() tidak perlu dikondisikan. Setelah commit berhasil, pemanggilan rollback mengembalikan ErrTxDone dan tidak mengubah data; dokumentasi paket database/sql menyebut pola defer ini sebagai cara aman memastikan transaksi tidak menggantung [1].

Urutan anggota mengikuti indeks pada request, bukan urutan kunci map. Go tidak menjamin urutan iterasi map, sehingga daftar yang dibangun dari map bisa tersimpan dalam urutan acak dan tampil berbeda setiap halaman dimuat. Mengunci urutan ke indeks payload membuat hasil simpan dapat diprediksi.

Cara membuktikan perbaikan ini bekerja

Klaim "sudah atomik" hanya terbukti kalau ada kasus gagal yang diuji. Cara termurah adalah membuat satu test yang sengaja melanggar constraint pada penyisipan anggota, misalnya id anggota duplikat, lalu menjalankan operasi simpan. Setelah itu cek dua hal: baris organisasi tidak berubah, dan jumlah anggota tetap sama seperti sebelum operasi. Kalau baris organisasi ikut berubah, penulisan masih berada di luar transaksi.

Cek yang sama bisa dijalankan manual. Catat jumlah baris anggota untuk id organisasi tersebut, paksa kegagalan, lalu hitung ulang. Selisih nol berarti batas transaksi bekerja. Kalau jumlahnya berkurang, penghapusan anggota berjalan sebelum transaksi dibuka.

Biaya dari pendekatan ini adalah transaksi menjadi lebih panjang. Kunci baris ditahan sampai commit, jadi parsing payload, validasi izin, dan pemanggilan layanan luar sebaiknya diselesaikan sebelum transaksi dibuka. Transaksi yang menunggu jaringan di tengah jalan menahan kunci lebih lama daripada yang diperlukan.

Satu hal yang masih terbuka: urutan anggota saat ini bergantung pada urutan penyisipan, bukan pada kolom pengurut eksplisit. Query tanpa klausa pengurutan tidak dijamin mengembalikan baris dalam urutan penyisipan, dan jaminan itu bisa berbeda antar mesin basis data. Menambah kolom posisi adalah langkah berikutnya yang belum diambil, karena sampai sekarang tampilan anggota masih terbaca benar di produksi maupun pengujian. Kalau suatu saat urutan tampil mulai berubah sendiri, kolom itu yang pertama ditambahkan.

Sources

[1] Menjalankan transaksi pada database/sql — https://go.dev/doc/database/execute-transactions [2] sqlx: general purpose extensions to database/sql — https://jmoiron.github.io/sqlx/ [3] Paket database/sql — https://pkg.go.dev/database/sql

Artikel terkait