Lewati ke konten

Saga checkout

Checkout di Kinetix mengambil sesuatu dari empat service. pricing melepas redemption voucher dan stok flash sale, warehouse me-reserve unit di rak dan membuka task picking, payment memindahkan uang ke escrow. Masing-masing hidup di service-nya sendiri dan database PostgreSQL-nya sendiri, jadi tidak ada satu transaksi yang bisa menahan semuanya lalu me-rollback-nya bersama-sama.

order menyelesaikannya dengan saga: rangkaian langkah lokal, masing-masing di-commit oleh service pemiliknya, dan untuk tiap langkah ada kompensasi yang membatalkannya. Kalau ada langkah yang gagal, langkah-langkah yang sudah diambil dibatalkan dengan urutan terbalik. Pelajaran ini menelusuri saga itu di kode order, langkah demi langkah.

Empat jalannya checkout yang sama. Perhatikan log saga di bagian bawah: setiap baris muncul sebelum panggilannya dikirim.

Diagram interaktif. Gunakan tombol panah kiri dan kanan untuk berpindah langkah, spasi untuk memutar atau menjeda.

Saga
Running
Order
PENDING_PAYMENT

order

pricing

warehouse

payment

Log saga

Belum ada baris: belum ada yang dicoba.

Keranjang berisi dua produk dari satu merchant, dengan voucher dan satu item yang sedang flash sale. Sebelum semua ini, order sudah menghitung harga keranjang lewat pricing, menulis order sebagai PENDING_PAYMENT, dan membuka saga. Setiap langkah di bawah ditulis ke log saga sebagai Attempting sebelum panggilannya dikirim.

0/7
Versi teks diagram ini

Semua menjawab

Keranjang berisi dua produk dari satu merchant, dengan voucher dan satu item yang sedang flash sale. Sebelum semua ini, order sudah menghitung harga keranjang lewat pricing, menulis order sebagai PENDING_PAYMENT, dan membuka saga. Setiap langkah di bawah ditulis ke log saga sebagai Attempting sebelum panggilannya dikirim.

  1. order (checkout)
    pricing.RedeemVoucher(HEMAT10, ORD-1)

    Redeem voucher. pricing mencatat baris redemption untuk order ini — entri ledger yang memungkinkan voucher dikembalikan nanti.

    Kode: CheckoutSagaRunner.cs:149–164

  2. order (checkout)
    pricing.AllocateFlashSaleStock(FS-12, SKU-A, 1, ORD-1)

    Klaim unit flash sale sebelum menyentuh rak. Itu sumber daya yang lebih langka; gagal di sana setelah stok ter-reserve berarti harus membatalkan lebih banyak.

    Kode: CheckoutSagaRunner.cs:166–182

  3. order (checkout)
    warehouse.ReserveStock(SKU-A, 1, ORD-1)

    Reserve produk pertama di warehouse, di bawah row lock dari pelajaran pertama.

    Kode: CheckoutSagaRunner.cs:184–200

  4. order (checkout)
    warehouse.ReserveStock(SKU-B, 2, ORD-1)

    Satu baris per produk: reservasi kedua adalah langkahnya sendiri, jadi masing-masing bisa dilepas sendiri-sendiri.

    Kode: CheckoutSagaRunner.cs:184–200

  5. order (checkout)
    payment.CreateEscrowHold(ORD-1, total, merchant share, shipping fee)

    Tahan uangnya di escrow. payment memindahkannya dari wallet pelanggan ke dalam hold, dengan kunci nomor order sehingga pengulangan tidak bisa mengambilnya dua kali.

    Kode: CheckoutSagaRunner.cs:206–222

  6. order (checkout)
    warehouse.CreateFulfillmentTask(ORD-1, lines)

    Baru sekarang warehouse diberi tahu ada order untuk diambil. Fulfilment task yang dibuat lebih awal bisa terlanjur diambil untuk order yang sebentar lagi dibatalkan.

    Kode: CheckoutSagaRunner.cs:228–248

  7. order (checkout)
    201 Created

    Semua hold sudah nyata, jadi saga Completed, order menjadi PAID — dan baru setelah itu keranjang dikosongkan.

    Kode: OrderService.cs:173–179

Hasil. Enam panggilan, enam baris Done, satu jawaban ke pelanggan: 201 Created.

Warehouse menolak

Checkout yang sama, tapi rak hanya punya satu unit SKU-B sedangkan keranjang ingin dua.

  1. order (checkout)
    pricing.RedeemVoucher(HEMAT10, ORD-1)

    Voucher di-redeem.

    Kode: CheckoutSagaRunner.cs:149–164

  2. order (checkout)
    pricing.AllocateFlashSaleStock(FS-12, SKU-A, 1, ORD-1)

    Unit flash sale diklaim.

    Kode: CheckoutSagaRunner.cs:166–182

  3. order (checkout)
    warehouse.ReserveStock(SKU-A, 1, ORD-1)

    SKU-A ter-reserve.

    Kode: CheckoutSagaRunner.cs:184–200

  4. order (checkout)
    warehouse.ReserveStock(SKU-B, 2, ORD-1)

    warehouse menolak SKU-B dengan INSUFFICIENT_STOCK, di dalam transaksi yang sama yang seharusnya mengambil unitnya. Tidak ada yang ditahan untuk baris ini, jadi ia dicatat Failed dan tidak akan dikompensasi.

    Kode: CheckoutSagaRunner.cs:184–200

  5. order (checkout)
    warehouse.ReleaseStock(SKU-A, ORD-1)

    Kompensasi dimulai dari baris terbaru dan berjalan mundur. SKU-A dikembalikan ke rak lebih dulu.

    Kode: CheckoutSagaRunner.cs:259–267

  6. order (checkout)
    pricing.ReleaseFlashSaleAllocation(FS-12, SKU-A, 1, ORD-1)

    Lalu unit flash sale dikembalikan ke sale-nya.

    Kode: CheckoutSagaRunner.cs:447–459

  7. order (checkout)
    pricing.ReleaseVoucherRedemption(HEMAT10, ORD-1)

    Lalu redemption voucher dilepas, sehingga pelanggan bisa memakai voucher itu lagi.

    Kode: CheckoutSagaRunner.cs:447–459

  8. order (checkout)
    409 CHECKOUT_ROLLED_BACK

    Semuanya dikembalikan: saga Compensated dan order disimpan sebagai CANCELLED, tidak dihapus. Pelanggan menerima 409 CHECKOUT_ROLLED_BACK beserta nomor order dan alasan dari warehouse.

    Kode: OrderController.cs:90–95

Hasil. Tidak ada yang ditahan di mana pun, dan order menjelaskan dirinya sendiri: checkout yang dibatalkan dengan alasan, bukan checkout yang diam-diam seolah tidak pernah terjadi.

Payment tidak menjawab

Voucher dan stok sudah ditahan, lalu panggilan ke payment kehabisan waktu. order tidak bisa tahu apakah hold sudah dibuat sebelum koneksinya menyerah.

  1. order (checkout)
    pricing.RedeemVoucher(HEMAT10, ORD-1)

    Voucher di-redeem.

    Kode: CheckoutSagaRunner.cs:149–164

  2. order (checkout)
    warehouse.ReserveStock(SKU-A, 1, ORD-1)

    SKU-A ter-reserve.

    Kode: CheckoutSagaRunner.cs:184–200

  3. order (checkout)
    payment.CreateEscrowHold(ORD-1, total, merchant share, shipping fee)

    CreateEscrowHold ditulis sebagai Attempting, dikirim, dan tidak pernah dijawab. Barisnya tetap Attempting: order tidak tahu apakah uang sudah berpindah. Checkout menyerah — "a service this checkout depends on did not answer" — dan mulai mengompensasi.

    Kode: CheckoutSagaRunner.cs:62–67

  4. order (checkout)
    payment.RefundEscrow(ORD-1, reason)

    Baris Attempting dikompensasi persis seperti baris Done: membatalkan terlalu banyak itu aman, membatalkan terlalu sedikit membuat uang tertahan. Refund dikirim dengan idempotency key yang sama, berbasis nomor order, dan kalau jawabannya tidak jelas, order akan membaca ledger payment lewat GetEscrowStatus sebelum memutuskan.

    Kode: CheckoutSagaRunner.cs:485–518

  5. order (checkout)
    warehouse.ReleaseStock(SKU-A, ORD-1)

    SKU-A dikembalikan ke rak.

    Kode: CheckoutSagaRunner.cs:447–459

  6. order (checkout)
    pricing.ReleaseVoucherRedemption(HEMAT10, ORD-1)

    Redemption voucher dilepas.

    Kode: CheckoutSagaRunner.cs:447–459

  7. order (checkout)
    409 CHECKOUT_ROLLED_BACK

    Saga Compensated dan pelanggan menerima 409 CHECKOUT_ROLLED_BACK. Entah hold pertama tadi sempat terbentuk atau tidak, sekarang tidak ada yang ditahan.

    Kode: OrderService.cs:163–171

Hasil. Panggilan tanpa jawaban diperlakukan sebagai mungkin sudah terjadi. pricing dan warehouse menjawab pelepasan sesuatu yang tidak pernah diambil sebagai sukses; untuk uang, order melangkah lebih jauh dan memastikannya ke ledger payment sebelum menutup barisnya.

Sebuah pelepasan gagal

warehouse kembali menolak SKU-B, tapi kali ini pricing sedang mati saat saga dibatalkan.

  1. order (checkout)
    pricing.RedeemVoucher(HEMAT10, ORD-1)

    Voucher di-redeem.

    Kode: CheckoutSagaRunner.cs:149–164

  2. order (checkout)
    warehouse.ReserveStock(SKU-A, 1, ORD-1)

    SKU-A ter-reserve.

    Kode: CheckoutSagaRunner.cs:184–200

  3. order (checkout)
    warehouse.ReserveStock(SKU-B, 2, ORD-1)

    warehouse menolak SKU-B: Failed, tidak ada yang ditahan.

    Kode: CheckoutSagaRunner.cs:184–200

  4. order (checkout)
    warehouse.ReleaseStock(SKU-A, ORD-1)

    Kompensasi mengembalikan SKU-A.

    Kode: CheckoutSagaRunner.cs:447–459

  5. order (checkout)
    pricing.ReleaseVoucherRedemption(HEMAT10, ORD-1)

    Pelepasan voucher gagal dengan UNAVAILABLE. Error sementara tidak menghentikan putaran dan tidak dihitung sukses: baris voucher tetap Done, masih ditahan. Saga berakhir Stuck dengan putaran berikutnya dijadwalkan 15–30 detik lagi — dan pelanggan sudah menerima 409-nya.

    Kode: CheckoutSagaRunner.cs:353–363

  6. StuckSagaSweeper
    pricing.ReleaseVoucherRedemption(HEMAT10, ORD-1)

    StuckSagaSweeper berjalan setiap menit dan mengambil alih saga yang jatuh tempo dengan lease baru. Percobaan ulang hanya mengirim ulang yang masih ditahan: voucher, bukan SKU-A.

    Kode: StuckSagaSweeper.cs:75–80

  7. StuckSagaSweeper
    Compensated

    Voucher kembali dan saga Compensated. Seandainya pricing tetap mati, jedanya terus berlipat ganda hingga 30 menit, dan setelah putaran keenam saga menjadi Abandoned dan dicatat GIVING UP untuk diselesaikan manusia.

    Kode: CompensationPolicy.cs:39–45

Hasil. Pembatalan sebagian tidak pernah dilaporkan sebagai pembatalan penuh. Stuck berarti masih menahan sesuatu dan sedang mencoba ulang; Abandoned berarti manusia harus turun tangan.

CheckoutAsync mengerjakan semua yang bisa dikerjakan sebelum ada apa pun yang ditahan:

  • Request berulang dengan Idempotency-Key yang sama mendapat order yang sudah dibuat, bukan order kedua.
  • Produk di keranjang diambil dari catalog, kutipan ongkir dari matching dan pricing, dan harga dari CalculatePrice milik pricing. Kalau pricing tidak bisa menjawab, checkout berhenti di sini.
  • Kalau angka dari pricing tidak bisa ditagihkan apa adanya — ongkir yang bukan diskon dari kutipannya sendiri, jumlah yang tidak bisa ditahan escrow — checkout ditolak sebelum baris order ada. Menolak paling murah selagi belum ada yang harus dikembalikan.

Baru setelah itu order ditulis sebagai PENDING_PAYMENT dan saga dimulai. Baris order sengaja ditulis lebih dulu: kalau saga gagal, order tetap ada sebagai CANCELLED dengan alasannya, jadi checkout yang gagal menjelaskan dirinya sendiri alih-alih terlihat seperti checkout yang tidak pernah terjadi.

RunForwardAsync mengambil lima jenis langkah, selalu dengan urutan ini:

Langkah Service Kenapa di posisi ini
RedeemVoucher pricing Murah diambil dan dikembalikan; menolak lebih awal kalau voucher sudah habis
AllocateFlashSaleStock pricing Barang paling langka di keranjang — diklaim sebelum rak, jadi kalau habis, yang harus dibatalkan paling sedikit
ReserveStock, satu per produk warehouse Satu baris per produk, jadi masing-masing bisa dilepas sendiri
CreateEscrowHold payment Uang baru diambil setelah barangnya pasti ada
CreateFulfillmentOrder warehouse Terakhir: warehouse hanya disuruh mengambil order yang semua hold-nya sudah nyata

BeginStep menyimpan barisnya sebagai Attempting dan meng-commit-nya sebelum panggilan dikirim; Settle memindahkannya ke Done atau Failed setelah jawabannya datang. Urutannya penting. Kalau dicatat setelah panggilan, proses yang mati di antara keduanya meninggalkan stok atau uang tertahan tanpa ada apa pun di database yang menunjuk ke sana. Dicatat sebelumnya, kemungkinan terburuknya adalah baris Attempting untuk sesuatu yang tidak pernah terjadi — tidak berbahaya, karena membatalkannya tidak menemukan apa pun untuk dibatalkan. Sebuah test memastikan barisnya ada sebelum panggilan.

  • Failed berarti service-nya menolak dengan tegas. warehouse menolak di dalam transaksi yang sama yang seharusnya mengambil unitnya, pricing tanpa menyentuh counter. Tidak ada yang ditahan, jadi barisnya tidak dikompensasi.
  • Attempting berarti tidak ada jawaban yang datang: panggilannya melempar exception atau kehabisan waktu. order tidak bisa tahu apakah pekerjaannya terjadi, jadi barisnya tetap dikompensasi.

Uang adalah satu-satunya pengecualian yang dicek dua kali oleh order: bahkan escrow hold yang ditolak pun dicocokkan ke ledger payment lewat GetEscrowStatus sebelum order menerima bahwa tidak ada yang diambil.

Kompensasi memilih setiap baris yang masih Done atau Attempting, yang terbaru lebih dulu, lalu meminta service pemiliknya mengembalikannya: ReleaseVoucherRedemption, ReleaseFlashSaleAllocation, ReleaseStock, RefundEscrow, CancelFulfillmentTask. Satu pelepasan yang gagal tidak menghentikan yang lain. Putarannya lalu berakhir di salah satu dari tiga state:

State Artinya Yang terjadi berikutnya
Compensated Semua hold sudah dikembalikan Tidak ada; order CANCELLED
Stuck Masih ada yang ditahan setelah error sementara Putaran berikutnya, setelah jeda yang makin panjang
Abandoned Error permanen, atau putaran keenam gagal Baris log GIVING UP untuk diselesaikan manusia

Jedanya 30 detik, berlipat ganda tiap putaran, maksimal 30 menit, dengan jitter, paling banyak enam putaran. Error yang tidak bisa diperbaiki dengan mencoba ulang — FailedPrecondition, InvalidArgument, NotFound, PermissionDenied, Unauthenticated — langsung abandon alih-alih menghabiskan jatah putaran. Pembatalan sebagian tidak pernah dilaporkan sebagai pembatalan penuh: pelepasan yang gagal membuat saga Stuck, dan percobaan ulang hanya mengirim ulang yang masih ditahan.

StuckSagaSweeper bangun setiap menit dan memilih saga yang Stuck dan sudah jatuh tempo, masih Compensating, atau masih Running lima menit setelah terakhir disentuh — checkout yang prosesnya mati di tengah jalan.

Dua worker tidak boleh membatalkan saga yang sama bersamaan, jadi setiap saga dijalankan di bawah sebuah lease: pemilik dan batas waktu yang ditulis di baris saga, yang dicek oleh setiap tulisan berikutnya. Worker yang lease-nya sudah habis atau berpindah tangan mendapati tulisannya tidak cocok dengan apa pun, lalu berhenti. Checkout memegang lease selama ia berjalan, sweeper tidak bisa merebutnya dari checkout yang masih hidup, dan worker yang kehilangan lease tidak bisa menulis Completed di atas pembatalan.

Kenapa kompensasi bisa dilakukan sama sekali

Section titled “Kenapa kompensasi bisa dilakukan sama sekali”

Counter tidak bisa dikompensasi. used_count - 1 tidak bisa tahu apakah order ini yang mengambil satu, jadi pelepasan yang diulang mengembalikan kuota yang tidak dipegang siapa pun, dan redeem yang diulang mengambil jatah kedua. Karena itu setiap peserta menyimpan baris ledger per order, dan counter-nya hanyalah cache darinya: pricing punya voucher_redemptions dan flash_sale_allocations, masing-masing unik per nomor order, dan warehouse punya stock_reservations dari pelajaran pertama. Constraint unik itulah idempotency-nya: redeem dua kali menemukan barisnya dan menjawab “already redeemed”, melepas sesuatu yang tidak pernah diambil menjawab “already released” — dan keduanya dihitung sukses, persis yang dibutuhkan saga yang sedang membatalkan diri setelah gagal di awal.

Checkout yang berhasil menjawab 201 Created dengan order yang PAID, dan baru setelah itu keranjang dikosongkan. Yang ditolak menjawab 409 CHECKOUT_ROLLED_BACK beserta nomor order dan alasan dari service yang menolak — sebuah jawaban, bukan kerusakan. Kalau pembatalannya masih Stuck, pelanggan sudah menerima jawaban itu; sisanya berlangsung di belakang.

  • Tanpa transaksi bersama, setiap langkah butuh pemilik yang meng-commit-nya dan kompensasi yang membatalkannya, dan setiap kompensasi harus idempotent.
  • Catat niat sebelum bertindak. Baris Attempting yang tidak bisa kamu jelaskan lebih murah daripada hold yang tidak diingat siapa pun.
  • Perlakukan “tidak ada jawaban” sebagai “mungkin sudah terjadi” dan kompensasi; perlakukan penolakan tegas sebagai “tidak ada yang ditahan”.
  • Jangan pernah melaporkan pembatalan sebagian sebagai pembatalan penuh. Ulangi yang sementara dengan backoff, berhenti pada yang permanen, dan tinggalkan baris yang jelas untuk ditemukan manusia.