Menjalankan transaksi di mongosh dan di aplikasi memakai mesin yang sama, tetapi aplikasi membawa satu masalah yang tidak ada di shell: jawaban server bisa hilang di tengah jalan. Koneksi terputus tepat setelah commitTransaction dikirim — apakah transaksinya masuk atau tidak? Aplikasi harus tahu cara mengulang dengan aman, kapan memeriksa status alih-alih mengulang, dan bagaimana memastikan percobaan ulang tidak menduplikasi efeknya. Chapter ini menyusun ketiganya menjadi satu pola utuh, dieksekusi dengan driver Node.js sungguhan di atas replica set rs0.

Session per Unit Kerja #

Aturan dasarnya: satu unit kerja, satu session. Seluruh operasi yang harus menyatu berbagi session yang sama, karena itulah tanda bagi server bahwa operasi-operasi itu satu transaksi. Callback API withTransaction() membingkainya — mulai, jalankan callback, commit, dan abort otomatis bila callback melempar galat. Karena mongosh sendiri berjalan di atas driver, callback API yang sama bisa kita verifikasi cepat dari shell:

js
db = db.getSiblingDB("latihan_txn")
db.pencacah.drop()
db.pencacah.insertOne({ _id: "kunjungan", nilai: 0 })
const sesi = db.getMongo().startSession()
sesi.withTransaction(() => {
  db.pencacah.updateOne({ _id: "kunjungan" }, { $inc: { nilai: 1 } })
  db.pencacah.updateOne({ _id: "kunjungan" }, { $inc: { nilai: 1 } })
})
sesi.endSession()
db.pencacah.findOne()

Output yang Dihasilkan:

text
latihan_txn
true
{ acknowledged: true, insertedId: 'kunjungan' }

{ _id: 'kunjungan', nilai: 2 }

Dua operasi updateOne di dalam callback berjalan sebagai satu kesatuan, dan buktinya dibaca lewat findOne() di akhir: { _id: 'kunjungan', nilai: 2 } — dua kenaikan masuk bersama atau tidak sama sekali. Session ditutup dengan endSession() setelah unit kerja selesai. Untuk transaksi murni di shell (core API, abort, error label), Section Relationships dan Consistency sudah mengeksekusinya tuntas; fokus kita sekarang adalah lapisan yang hanya dimiliki aplikasi: coba-ulang.

Keputusan Coba-Ulang Dibaca dari Label #

Server menandai error yang aman diulang lewat errorLabels — array pada objek error, yang sudah kita lihat kosong pada error permanen. Logika keputusannya bisa dirumuskan sebagai fungsi murni:

js
function aksiCobaUlang(err) {
  const labels = err.errorLabels ?? []
  if (labels.includes("TransientTransactionError")) {
    return "ulangi seluruh transaksi dari awal"
  }
  if (labels.includes("UnknownTransactionCommitResult")) {
    return "periksa status dulu — commit mungkin sudah berhasil"
  }
  return "jangan diulangi: perbaiki penyebabnya"
}
print(aksiCobaUlang({ errorLabels: ["TransientTransactionError"] }))
print(aksiCobaUlang({ errorLabels: ["UnknownTransactionCommitResult"] }))
print(aksiCobaUlang({ errorLabels: [] }))

Output yang Dihasilkan:

text
[Function: aksiCobaUlang]
ulangi seluruh transaksi dari awal

periksa status dulu — commit mungkin sudah berhasil

jangan diulangi: perbaiki penyebabnya

Baris pertama adalah gema REPL dari deklarasi fungsi; tiga baris berikutnya adalah keputusan untuk tiga jenis error. Dua label itu bekerja pada tahap berbeda: TransientTransactionError berarti transaksi belum commit (aman diulang dari nol), sedangkan UnknownTransactionCommitResult berarti hasil commit tidak diketahui — mengulang buta bisa menduplikasi efek bila commit pertama ternyata berhasil. withTransaction() sendiri sudah menangani keduanya secara otomatis; fungsi di atas adalah bentuk eksplisitnya, berguna saat kita perlu mengontrol percobaan sendiri.

Transfer Aman dari Aplikasi #

Sekarang ketiganya digabung dalam satu file: session per unit kerja, coba-ulang berbasis label, dan idempotensi lewat kode transfer yang ditahan unique index:

js
// transfer-retry.js — transaksi dari aplikasi: withTransaction, label coba-ulang, idempotensi
// Jalankan: node transfer-retry.js  (butuh paket: npm install mongodb)
const { MongoClient } = require("mongodb")

const client = new MongoClient("mongodb://127.0.0.1:27017/?directConnection=true")

async function siapkanData(db) {
  await db.collection("dompet").drop().catch(() => {})
  await db.collection("mutasi").drop().catch(() => {})
  await db.collection("dompet").insertMany([
    { pengguna: "Budi", saldo: 100000 },
    { pengguna: "Siti", saldo: 50000 },
  ])
  await db.collection("mutasi").createIndex({ kode: 1 }, { unique: true })
}

// Unit kerja: seluruh operasi satu transfer berbagi satu session
async function transfer(db, kode, dari, ke, nominal) {
  const sesi = client.startSession()
  try {
    return await sesi.withTransaction(async () => {
      const potong = await db.collection("dompet").updateOne(
        { pengguna: dari, saldo: { $gte: nominal } },
        { $inc: { saldo: -nominal } },
        { session: sesi }
      )
      if (potong.matchedCount === 0) throw new Error(`Saldo ${dari} tidak cukup`)

      await db.collection("mutasi").insertOne(
        { kode, dari, ke, nominal },
        { session: sesi }
      )
      await db.collection("dompet").updateOne(
        { pengguna: ke },
        { $inc: { saldo: nominal } },
        { session: sesi }
      )
      return { kode, status: "berhasil" }
    })
  } finally {
    await sesi.endSession()
  }
}

// Coba-ulang berbasis errorLabels + pemeriksaan status bila hasil tak diketahui
async function transferAman(db, kode, dari, ke, nominal, maksPercobaan = 3) {
  // Idempotensi: cek dulu apakah transfer dengan kode ini sudah tercatat
  const ada = await db.collection("mutasi").findOne({ kode })
  if (ada) return { kode, status: "sudah diproses sebelumnya" }

  for (let percobaan = 1; ; percobaan++) {
    try {
      return await transfer(db, kode, dari, ke, nominal)
    } catch (err) {
      const labels = err.errorLabels ?? []

      // Hasil commit tak diketahui: periksa status sebelum mengulang
      if (labels.includes("UnknownTransactionCommitResult")) {
        const sudah = await db.collection("mutasi").findOne({ kode })
        if (sudah) return { kode, status: "tercatat — commit sebelumnya ternyata berhasil" }
      }

      const bisaUlang = labels.includes("TransientTransactionError") ||
                        labels.includes("UnknownTransactionCommitResult")
      if (!bisaUlang || percobaan >= maksPercobaan) throw err
      console.log(`percobaan ${percobaan} gagal (${labels[0]}) — mengulangi transaksi`)
    }
  }
}

async function main() {
  try {
    await client.connect()
    const db = client.db("latihan_txn_app")
    await siapkanData(db)

    console.log("transfer 1:", JSON.stringify(
      await transferAman(db, "TRF-900", "Budi", "Siti", 25000)))
    console.log("transfer 2 (kode sama):", JSON.stringify(
      await transferAman(db, "TRF-900", "Budi", "Siti", 25000)))

    const saldo = await db.collection("dompet")
      .find({}, { projection: { _id: 0 } })
      .sort({ pengguna: 1 })
      .toArray()
    console.log("saldo akhir:", JSON.stringify(saldo))
  } finally {
    await client.close()
  }
}

main().catch(err => {
  console.error("GAGAL:", err.name, "-", err.message)
  process.exit(1)
})
bash
node transfer-retry.js

Output yang Dihasilkan:

text
transfer 1: {"kode":"TRF-900","status":"berhasil"}
transfer 2 (kode sama): {"kode":"TRF-900","status":"sudah diproses sebelumnya"}
saldo akhir: [{"pengguna":"Budi","saldo":75000},{"pengguna":"Siti","saldo":75000}]

Mari kita bedah bagian-bagian kuncinya. Fungsi transfer() membungkus satu unit kerja: client.startSession() membuat session, seluruh operasi menerima { session: sesi }, dan withTransaction() menjalankan callback hingga commit. Filter penjaga saldo: { $gte: nominal } menjaga invariant di dalam transaksi — bila saldo kurang, matchedCount: 0 dan callback melempar galat yang memicu abort. Fungsi transferAman() menambahkan dua lapis pengaman di atasnya: pemeriksaan kode di awal (permintaan yang sama tidak diproses dua kali), dan percobaan ulang yang hanya berjalan untuk error berlabel. Hasil eksekusinya membuktikan idempotensi: dua panggilan dengan TRF-900 menghasilkan satu transfer — saldo 75000/75000, bukan 50000/100000.

Aturan Main dari Sisi Aplikasi #

AturanAlasan
Satu session per unit kerja; semua operasi menerima session itutanda bagi server bahwa operasi-operasi itu satu transaksi
Gunakan withTransaction(), bukan startTransaction/commitTransaction manualcallback API menangani coba-ulang berlabel secara otomatis
Bila hasil commit tak diketahui, periksa status sebelum mengulangcommit pertama mungkin sudah berhasil — ulang buta menduplikasi efek
Idempotency key + unique index pada setiap efek transaksipercobaan ulang tidak menduplikasi data
Jangan menunggu input pengguna atau pemanggilan API lambat di dalam callbacktransaksi menahan lock dan cache selama terbuka
Patuhi batas server: 60 detik lifetime, oplog entry ≤ 16MBserver memaksa transaksi berhenti — batasnya sudah kita verifikasi di Chapter Transaction Boundaries

Rangkuman polanya satu kalimat: callback untuk kesatuan, label untuk keputusan, idempotency key untuk keamanan ulang. Ketiganya digabung transferAman() dalam sekitar dua puluh baris — panjang yang wajar untuk jaminan bahwa uang tidak pernah berpindah dua kali.

Rangkuman #

  • Satu unit kerja = satu session; withTransaction() membungkus mulai-commit-abort dan otomatis mengulang error berlabel.
  • TransientTransactionError = ulangi dari awal; UnknownTransactionCommitResult = periksa status dulu; tanpa label = perbaiki penyebabnya.
  • Idempotency key (kode transfer) + unique index membuat percobaan ulang aman — dua panggilan dengan kode sama menghasilkan satu efek.
  • Batas server tetap berlaku dari aplikasi: 60 detik lifetime dan 16MB per oplog entry (Chapter Transaction Boundaries).