Optimistic Concurrency Menolak Write Stale Sebelum Mengganti State yang Lebih Baru

Alur read-modify-write terlihat sederhana ketika hanya satu actor menyentuh sebuah record. Client membaca state, mengubah sebagian isinya, lalu menulis hasilnya kembali. Saat beberapa actor bekerja bersamaan, jeda antara read dan write menjadi race. Writer lain dapat melakukan commit terhadap nilai yang lebih baru pada jeda tersebut, lalu update tanpa kondisi dapat menghapusnya.

Optimistic concurrency control menambahkan kondisi pada write terakhir. Client membawa version yang berasal dari state yang dibacanya, dan storage layer menerima mutation hanya jika version itu masih current. Ketidakcocokan menjadi conflict, bukan overwrite yang tidak terlihat.

Pendekatan ini disebut optimistic karena reader tidak perlu memesan record lebih dulu. Contention dideteksi saat commit. Trade-off ini cocok ketika conflict jarang terjadi, read path perlu tetap ringan, atau menahan lock melintasi batas aplikasi dan jaringan tidak praktis.

Celah lost update berada di antara read dan write

Bayangkan row profile dengan version = 41. Dua client membacanya hampir bersamaan.

Client A mengubah display name. Client B mengubah timezone. Jika keduanya kemudian mengirim representasi row lengkap dan database menerima kedua update tanpa kondisi, write kedua dapat mengganti field yang dihasilkan write pertama.

Urutannya dapat terlihat seperti ini:

A reads: name=Rina, timezone=UTC, version=41
B reads: name=Rina, timezone=UTC, version=41

A writes: name=Rina S, timezone=UTC
B writes: name=Rina,   timezone=Asia/Jakarta

Jika B melakukan commit terakhir, perubahan display name dapat hilang walaupun kedua write secara individual berhasil.

Masalahnya bukan data yang ditulis salah format. Masing-masing client bekerja dari snapshot yang valid ketika dibaca. Bagian yang hilang adalah pemeriksaan bahwa snapshot tersebut masih menjadi dasar current untuk replacement.

Version predicate membuat race terlihat

Bentuk umum di database menambahkan version column dan memasukkannya ke update predicate:

UPDATE profiles
SET
    display_name = :display_name,
    timezone = :timezone,
    version = version + 1
WHERE id = :id
  AND version = :expected_version;

Client yang membaca version 41 mengirim expected_version = 41. Jika belum ada competing update yang committed, satu row cocok dan update menaikkan version menjadi 42.

Jika writer lain sudah memindahkan row ke version 42, predicate cocok dengan nol row. Hasil nol row tersebut bukan success biasa. Itu adalah concurrency signal: replacement yang diajukan caller dibuat berdasarkan state stale.

Pemeriksaan dan mutation harus menjadi satu operasi storage yang atomic. Membaca version pada satu query lalu menjalankan unconditional update pada query berikutnya membuat race yang sama di antara dua statement tersebut.

Compare-and-swap adalah bentuk dasarnya

Pola version column merupakan bentuk compare-and-swap pada level aplikasi:

ganti current value dengan proposed value
hanya jika current version sama dengan expected version

Comparison token tidak harus berupa integer. Database dapat memakai revision identifier, immutable row token, atau nilai lain yang berubah setiap kali state terkait berubah. Sifat pentingnya adalah token yang dibandingkan mengidentifikasi state yang menjadi dasar mutation milik client.

Monotonic integer praktis karena ringkas dan mudah diperiksa. Nilainya tetap perlu diperlakukan sebagai concurrency token, bukan wall-clock time. Version 42 menyatakan bahwa state berbeda dari version 41; angka itu tidak menyatakan kapan perubahan terjadi.

Timestamp dapat menjadi token hanya jika semantik pembuatan dan perbandingannya mencegah collision pada scope yang diperlukan. Timestamp dengan resolusi kasar berisiko ketika beberapa commit dapat memperoleh nilai visible yang sama.

HTTP membawa kontrak yang sama melalui validator

Pola yang sama dapat melewati batas HTTP. Server dapat mengembalikan ETag bersama representation:

ETag: "profile-42"

Client kemudian dapat mengirim conditional mutation:

If-Match: "profile-42"

Server menerapkan mutation hanya jika selected representation masih cocok dengan validator tersebut. Jika tidak cocok, precondition gagal sehingga stale replacement tidak diterapkan.

Untuk request yang mengubah state, kontrak ini berguna karena concurrency condition tetap eksplisit di jaringan. Aplikasi tidak perlu menahan database transaction atau mutex selama seseorang mengedit form selama beberapa menit.

ETag untuk tujuan ini harus berkaitan dengan semantik representation yang dilindungi mutation. Validator yang hanya terikat pada cache artifact lain dapat menjadi concurrency token yang buruk jika nilainya tidak berubah ketika protected state berubah.

Penanganan conflict merupakan bagian dari kontrak produk

Mendeteksi stale write baru setengah dari desain. Caller juga memerlukan policy untuk conflict tersebut.

Untuk operasi yang dihasilkan mesin dan aman dihitung ulang, client dapat mengambil fresh state, menerapkan kembali transformation yang dimaksud, lalu mencoba conditional write baru. Cara ini tepat hanya jika operation memiliki replay semantics yang jelas.

Untuk interactive editing, replay otomatis dapat menyembunyikan conflict yang bermakna. Jika dua orang mengubah paragraph, field, atau policy value yang sama, memilih salah satu edit secara diam-diam dapat lebih buruk daripada menampilkan current state dan meminta resolution yang disengaja.

Conflict response karena itu perlu mempertahankan context yang cukup agar caller dapat bertindak. Paling tidak, caller memerlukan outcome conflict yang berbeda. Bergantung pada API, mengembalikan current version atau route untuk mengambilnya dapat mengurangi failure path yang ambigu.

Retry juga perlu batas. Hot record dapat terus berubah di antara setiap refresh dan retry. Retry loop tanpa batas mengubah contention menjadi load tambahan dan dapat membuat caller tidak pernah memperoleh interval yang tenang.

Partial update mengurangi collision tetapi tidak menghapus concurrency

API bergaya patch dapat mengurangi replacement yang tidak disengaja karena client hanya mengirim field yang ingin diubah. Jika A mengubah display_name dan B mengubah timezone, storage layer yang menjalankan field update secara independen dapat mempertahankan keduanya.

Itu tidak menghapus semua conflict. Dua client masih dapat mengubah field yang sama. Lebih halus lagi, sebuah invariant dapat mencakup beberapa field walaupun setiap request menyentuh subset yang berbeda.

Misalnya, scheduling record memiliki start_at dan end_at, dengan invariant bahwa start harus mendahului end. Patch independen yang dibuat dari snapshot stale dapat menghasilkan kombinasi yang tidak dimaksud oleh kedua client.

Granularity field dan concurrency policy adalah dua pilihan terpisah. Mutation yang sempit mengurangi area replacement; version check menetapkan apakah mutation diizinkan terhadap state yang saat ini ada.

Scope version perlu mengikuti invariant yang dilindungi

Satu row version sederhana, tetapi dapat menghasilkan false conflict ketika field yang independen sering berubah. Memisahkan state menjadi resource atau aggregate terpisah dapat mengurangi contention jika bagian-bagian tersebut benar-benar memiliki invariant yang independen.

Kesalahan sebaliknya lebih serius: memakai version token terpisah untuk nilai yang harus berubah secara konsisten. Jika invariant mencakup beberapa row, memeriksa version pada satu row mungkin tidak melindungi invariant tersebut.

Pada kondisi itu, desain mungkin memerlukan transaction dengan predicate yang mencakup seluruh row terkait, serializable isolation level, constraint yang ditegakkan database, atau aggregate boundary yang berbeda. Optimistic concurrency adalah mekanisme untuk mendeteksi asumsi stale; mekanisme ini bukan pengganti seluruh bentuk transactional consistency.

Concurrency token perlu mencakup state yang freshness-nya relevan bagi operation yang diajukan.

Side effect memerlukan ordering di sekitar conditional commit

Conditional database update dapat gagal. External side effect yang dilakukan sebelum update tersebut tidak selalu dapat dibatalkan.

Bayangkan code mengirim message, menagih external account, atau memublikasikan event sebelum mencoba versioned write. Jika write kemudian menghasilkan conflict, external action mungkin sudah visible walaupun state transition ditolak.

Ordering yang lebih aman bergantung pada operation, tetapi conditional commit harus dipertimbangkan bersama side-effect delivery. Pola seperti transactional outbox dapat menghubungkan committed state transition dengan event publication setelahnya. Idempotency control dapat melindungi retry terhadap external operation jika remote interface mendukungnya.

Version predicate melindungi target state dari stale replacement. Predicate itu tidak otomatis membuat side effect di sekitarnya atomic.

Metric perlu memisahkan contention dari storage failure

Version conflict adalah outcome concurrency yang diharapkan, bukan selalu database fault. Operational telemetry perlu membedakannya dari timeout, connection failure, constraint violation, dan internal error.

Signal yang berguna mencakup conflict rate per resource type, retry count, retry success rate, record dengan contention terkonsentrasi, dan latency tambahan akibat conflict recovery. Kenaikan conflict secara mendadak dapat menandakan hot key baru, client yang menyimpan stale state terlalu lama, atau perubahan API yang memperluas scope replacement.

Memperlakukan setiap conditional update dengan hasil nol row sebagai generic server error menghilangkan informasi tersebut. Menganggap setiap conflict tidak berbahaya juga dapat menutupi workload yang sudah tidak cocok dengan concurrency model yang dipilih.

Test memerlukan interleaving yang nyata

Sequential test tidak dapat membuktikan bahwa stale write ditolak. Test perlu membuat dua operation dari initial version yang sama, melakukan commit pada salah satunya, lalu mencoba operation kedua dengan token awal.

Expected result-nya spesifik: write pertama yang diterima menaikkan token, dan write kedua tidak mengganti committed state.

Test kedua dapat mencakup conflict recovery dengan mengambil version baru dan menjalankan retry yang diizinkan. Test untuk HTTP API dapat menjalankan urutan yang sama dengan ETag dan If-Match.

Kasus-kasus ini kecil, tetapi menyentuh boundary yang penting. Fiturnya bukan keberadaan column version. Fiturnya adalah aturan atomic bahwa mutation yang dibuat dari stale state tidak dapat diam-diam mengganti state yang lebih baru.

Optimistic concurrency paling efektif ketika aturan tersebut tetap terlihat dari storage hingga perilaku API. Token berjalan bersama state, mutation menyebut token yang diharapkan, dan mismatch memiliki semantik eksplisit. Race yang sebelumnya dapat menghasilkan overwrite diam-diam berubah menjadi conflict yang dapat ditangani sistem secara sengaja.