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.
Mainkan
Section titled “Mainkan”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.
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.
- order (checkout)
pricing.RedeemVoucher(HEMAT10, ORD-1)
Redeem voucher. pricing mencatat baris redemption untuk order ini — entri ledger yang memungkinkan voucher dikembalikan nanti.
- 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.
- order (checkout)
warehouse.ReserveStock(SKU-A, 1, ORD-1)
Reserve produk pertama di warehouse, di bawah row lock dari pelajaran pertama.
- 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.
- 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.
- 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.
- 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.
- order (checkout)
pricing.RedeemVoucher(HEMAT10, ORD-1)
Voucher di-redeem.
- order (checkout)
pricing.AllocateFlashSaleStock(FS-12, SKU-A, 1, ORD-1)
Unit flash sale diklaim.
- order (checkout)
warehouse.ReserveStock(SKU-A, 1, ORD-1)
SKU-A ter-reserve.
- 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.
- order (checkout)
warehouse.ReleaseStock(SKU-A, ORD-1)
Kompensasi dimulai dari baris terbaru dan berjalan mundur. SKU-A dikembalikan ke rak lebih dulu.
- order (checkout)
pricing.ReleaseFlashSaleAllocation(FS-12, SKU-A, 1, ORD-1)
Lalu unit flash sale dikembalikan ke sale-nya.
- order (checkout)
pricing.ReleaseVoucherRedemption(HEMAT10, ORD-1)
Lalu redemption voucher dilepas, sehingga pelanggan bisa memakai voucher itu lagi.
- 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.
- order (checkout)
pricing.RedeemVoucher(HEMAT10, ORD-1)
Voucher di-redeem.
- order (checkout)
warehouse.ReserveStock(SKU-A, 1, ORD-1)
SKU-A ter-reserve.
- 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.
- 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.
- order (checkout)
warehouse.ReleaseStock(SKU-A, ORD-1)
SKU-A dikembalikan ke rak.
- order (checkout)
pricing.ReleaseVoucherRedemption(HEMAT10, ORD-1)
Redemption voucher dilepas.
- 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.
- order (checkout)
pricing.RedeemVoucher(HEMAT10, ORD-1)
Voucher di-redeem.
- order (checkout)
warehouse.ReserveStock(SKU-A, 1, ORD-1)
SKU-A ter-reserve.
- order (checkout)
warehouse.ReserveStock(SKU-B, 2, ORD-1)
warehouse menolak SKU-B: Failed, tidak ada yang ditahan.
- order (checkout)
warehouse.ReleaseStock(SKU-A, ORD-1)
Kompensasi mengembalikan SKU-A.
- 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.
- 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.
- 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.
Hasil. Pembatalan sebagian tidak pernah dilaporkan sebagai pembatalan penuh. Stuck berarti masih menahan sesuatu dan sedang mencoba ulang; Abandoned berarti manusia harus turun tangan.
Sebelum saga dimulai
Section titled “Sebelum saga dimulai”CheckoutAsync
mengerjakan semua yang bisa dikerjakan sebelum ada apa pun yang ditahan:
- Request berulang dengan
Idempotency-Keyyang sama mendapat order yang sudah dibuat, bukan order kedua. - Produk di keranjang diambil dari catalog, kutipan ongkir dari matching dan pricing, dan harga dari
CalculatePricemilik 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.
Langkah maju
Section titled “Langkah maju”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 |
Tulis langkahnya sebelum memanggil
Section titled “Tulis langkahnya sebelum memanggil”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 tidak sama dengan Attempting
Section titled “Failed tidak sama dengan Attempting”Failedberarti 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.Attemptingberarti 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.
Membatalkannya
Section titled “Membatalkannya”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.
Siapa yang menjalankan putaran berikutnya
Section titled “Siapa yang menjalankan putaran berikutnya”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.
Yang dilihat pelanggan
Section titled “Yang dilihat pelanggan”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.
Intisari
Section titled “Intisari”- 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
Attemptingyang 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.