Pada API yang menerima PUT atau PATCH, masalah lost update muncul saat dua klien membaca resource yang sama, lalu sama-sama mengirim perubahan berdasarkan data lama. Request yang datang terakhir akan menimpa perubahan sebelumnya tanpa sadar. Di Laravel API, pendekatan yang praktis untuk mencegah ini adalah optimistic concurrency control memakai ETag dan header If-Match.
Intinya sederhana: server mengirim ETag yang mewakili versi resource saat ini. Saat klien ingin mengubah resource, klien wajib mengirim kembali ETag tersebut lewat header If-Match. Jika versi di server sudah berubah, request ditolak dengan 412 Precondition Failed. Jika header prasyarat wajib tetapi tidak dikirim, balas dengan 428 Precondition Required. Pendekatan ini cocok ketika konflik jarang terjadi dan Anda tidak ingin memakai lock yang menahan resource.
Mengapa lost update terjadi pada PUT/PATCH
Misalkan ada resource /api/posts/10.
- Klien A mengambil data post, mendapatkan judul lama dan ETag
"v5". - Klien B mengambil data yang sama, juga mendapatkan ETag
"v5". - Klien A mengubah judul dan mengirim
If-Match: "v5". Update berhasil, versi menjadi"v6". - Klien B, yang masih memegang data lama, mengirim perubahan lain dengan
If-Match: "v5".
Tanpa validasi versi, perubahan dari B bisa menimpa perubahan A. Dengan If-Match, server melihat bahwa ETag sekarang sudah "v6", bukan "v5", lalu menolak request B dengan 412.
Ini bukan soal idempotensi
Sering terjadi kebingungan antara idempotensi dan concurrency control.
- Idempotensi berarti request yang sama diulang beberapa kali tetap menghasilkan efek akhir yang sama. Contoh:
PUTdengan payload identik ke resource yang sama. - Optimistic concurrency control memastikan update hanya diterapkan jika resource belum berubah sejak terakhir dibaca klien.
Jadi, request bisa saja idempoten tetapi tetap menyebabkan lost update jika tidak ada validasi versi. Masalahnya bukan pengulangan request, melainkan update berdasarkan state yang sudah basi.
Kapan ETag dan If-Match lebih tepat daripada lock
Pendekatan ini lebih tepat jika:
- Konflik update relatif jarang.
- API bersifat stateless dan melayani banyak klien.
- Anda tidak ingin memegang database row lock atau distributed lock selama user berpikir atau mengedit form.
- Latensi antar klien bervariasi dan durasi edit tidak bisa diprediksi.
Lock lebih tepat jika:
- Konflik sangat sering dan biaya retry tinggi.
- Operasi benar-benar harus eksklusif selama periode tertentu.
- Update melibatkan proses bisnis kritis yang sulit di-merge atau diulang.
Catatan: lock bukan solusi default untuk API CRUD biasa. Menahan lock selama user mengedit di browser biasanya tidak realistis, rawan timeout, dan membuat sistem lebih rapuh.
Desain kontrak API Laravel API: ETag dan If-Match
Respons GET
Saat klien mengambil resource, kirim representasi resource beserta ETag.
GET /api/posts/10 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2024-01-15T10:20:30.000000Z"
Cache-Control: private, no-cache
{
"data": {
"id": 10,
"title": "Judul artikel",
"body": "Isi artikel",
"updated_at": "2024-01-15T10:20:30.000000Z"
}
}ETag bisa berasal dari updated_at, version, atau hash representasi tertentu. Untuk kebutuhan concurrency, yang penting adalah ETag berubah setiap kali state yang relevan berubah.
Request PUT/PATCH yang valid
PATCH /api/posts/10 HTTP/1.1
Content-Type: application/json
If-Match: "2024-01-15T10:20:30.000000Z"
{
"title": "Judul baru"
}HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2024-01-15T10:25:11.000000Z"
{
"data": {
"id": 10,
"title": "Judul baru",
"body": "Isi artikel",
"updated_at": "2024-01-15T10:25:11.000000Z"
}
}Jika versi sudah berubah: 412 Precondition Failed
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "2024-01-15T10:25:11.000000Z"
{
"message": "Resource telah berubah sejak terakhir dibaca.",
"code": "precondition_failed",
"current_etag": "2024-01-15T10:25:11.000000Z"
}Jika If-Match wajib tetapi tidak dikirim: 428 Precondition Required
HTTP/1.1 428 Precondition Required
Content-Type: application/json
{
"message": "Header If-Match wajib untuk update resource ini.",
"code": "precondition_required"
}Status 428 berguna jika Anda ingin memaksa semua klien update memakai kontrol konkurensi, bukan hanya klien yang disiplin.
Memilih strategi ETag: updated_at vs version column
Opsi 1: ETag dari updated_at
Ini paling sederhana jika setiap perubahan model memang selalu memperbarui updated_at.
Kelebihan:
- Tidak perlu menambah kolom baru.
- Mudah dipahami dan cepat diterapkan.
Kekurangan:
- Bergantung pada ketepatan perubahan timestamp.
- Kurang ideal jika ada update yang tidak menyentuh
updated_at. - Bisa membingungkan jika ada proses lain yang mengubah
updated_atmeski field bisnis tidak berubah.
Opsi 2: ETag dari version column
Tambahkan kolom integer version yang naik setiap update berhasil.
Kelebihan:
- Lebih eksplisit sebagai mekanisme concurrency.
- Tidak bergantung pada format timestamp.
- Mudah dipakai dalam query atomik
WHERE id = ? AND version = ?.
Kekurangan:
- Perlu migrasi dan pengelolaan increment versi.
- Butuh disiplin agar semua jalur update ikut menaikkan versi.
Jika API Anda cukup sederhana, updated_at sering cukup. Jika ingin kontrak yang lebih tegas dan tahan terhadap variasi perilaku update, gunakan version.
Implementasi di Laravel dengan updated_at
Membuat helper ETag pada model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['title', 'body'];
public function etag(): string
{
return '"' . optional($this->updated_at)?->toJSON() . '"';
}
}ETag sengaja diberi tanda kutip agar sesuai format umum header ETag. Untuk concurrency, gunakan strong validator, jadi hindari awalan W/.
Controller GET dan PATCH
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PostController extends Controller
{
public function show(Post $post): JsonResponse
{
return response()
->json([
'data' => [
'id' => $post->id,
'title' => $post->title,
'body' => $post->body,
'updated_at' => $post->updated_at?->toJSON(),
],
], Response::HTTP_OK)
->header('ETag', $post->etag())
->header('Cache-Control', 'private, no-cache');
}
public function update(Request $request, Post $post): JsonResponse
{
$ifMatch = $request->header('If-Match');
if (!$ifMatch) {
return response()->json([
'message' => 'Header If-Match wajib untuk update resource ini.',
'code' => 'precondition_required',
], Response::HTTP_PRECONDITION_REQUIRED);
}
$currentEtag = $post->etag();
if ($ifMatch !== $currentEtag && $ifMatch !== '*') {
return response()
->json([
'message' => 'Resource telah berubah sejak terakhir dibaca.',
'code' => 'precondition_failed',
'current_etag' => $currentEtag,
], Response::HTTP_PRECONDITION_FAILED)
->header('ETag', $currentEtag);
}
$validated = $request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'body' => ['sometimes', 'string'],
]);
$post->fill($validated);
$post->save();
$post->refresh();
return response()
->json([
'data' => [
'id' => $post->id,
'title' => $post->title,
'body' => $post->body,
'updated_at' => $post->updated_at?->toJSON(),
],
], Response::HTTP_OK)
->header('ETag', $post->etag());
}
}Pola di atas cukup untuk banyak kasus, tetapi ada celah kecil antara pengecekan ETag dan operasi save(). Jika dua request lolos hampir bersamaan, keduanya masih bisa menulis. Untuk menutup celah ini, gunakan query atomik berbasis kondisi di database. Itulah alasan version column sering lebih kuat.
Implementasi yang lebih kuat dengan version column
Skema migrasi
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->unsignedBigInteger('version')->default(1)->after('body');
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('version');
});
}
};Model dengan ETag dari version
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['title', 'body'];
public function etag(): string
{
return '"' . $this->version . '"';
}
}Update atomik berdasarkan version
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PostController extends Controller
{
public function show(Post $post): JsonResponse
{
return response()
->json([
'data' => [
'id' => $post->id,
'title' => $post->title,
'body' => $post->body,
'version' => $post->version,
],
], Response::HTTP_OK)
->header('ETag', $post->etag())
->header('Cache-Control', 'private, no-cache');
}
public function update(Request $request, Post $post): JsonResponse
{
$ifMatch = $request->header('If-Match');
if (!$ifMatch) {
return response()->json([
'message' => 'Header If-Match wajib untuk update resource ini.',
'code' => 'precondition_required',
], Response::HTTP_PRECONDITION_REQUIRED);
}
if (!preg_match('/^"(.+)"$/', $ifMatch, $matches) && $ifMatch !== '*') {
return response()->json([
'message' => 'Format If-Match tidak valid.',
'code' => 'invalid_if_match',
], Response::HTTP_BAD_REQUEST);
}
$validated = $request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'body' => ['sometimes', 'string'],
]);
$expectedVersion = $ifMatch === '*' ? null : (int) $matches[1];
$query = Post::query()->whereKey($post->id);
if ($expectedVersion !== null) {
$query->where('version', $expectedVersion);
}
$affected = $query->update(array_merge(
$validated,
[
'version' => $expectedVersion !== null ? $expectedVersion + 1 : $post->version + 1,
'updated_at' => now(),
]
));
if ($affected === 0) {
$fresh = Post::findOrFail($post->id);
return response()
->json([
'message' => 'Resource telah berubah sejak terakhir dibaca.',
'code' => 'precondition_failed',
'current_etag' => $fresh->etag(),
], Response::HTTP_PRECONDITION_FAILED)
->header('ETag', $fresh->etag());
}
$fresh = Post::findOrFail($post->id);
return response()
->json([
'data' => [
'id' => $fresh->id,
'title' => $fresh->title,
'body' => $fresh->body,
'version' => $fresh->version,
],
], Response::HTTP_OK)
->header('ETag', $fresh->etag());
}
}Poin pentingnya ada pada query WHERE id = ? AND version = ?. Hanya satu request yang bisa cocok dengan versi lama. Request berikutnya akan menghasilkan affected = 0 dan dibalas 412. Ini lebih aman daripada sekadar cek lalu save di memori aplikasi.
Memindahkan validasi If-Match ke middleware
Agar controller lebih rapi, Anda bisa memindahkan kewajiban header If-Match ke middleware. Middleware ini cocok untuk route update tertentu.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireIfMatch
{
public function handle(Request $request, Closure $next): Response
{
if (in_array($request->method(), ['PUT', 'PATCH', 'DELETE'], true)
&& !$request->hasHeader('If-Match')) {
return response()->json([
'message' => 'Header If-Match wajib untuk operasi ini.',
'code' => 'precondition_required',
], Response::HTTP_PRECONDITION_REQUIRED);
}
return $next($request);
}
}Daftarkan middleware, lalu terapkan ke route yang memang ingin diproteksi. Dengan begitu, kontrak API lebih konsisten dan tidak bergantung pada implementasi tiap controller.
Route::middleware(['require.if-match'])
->patch('/posts/{post}', [PostController::class, 'update']);Feature test Laravel untuk skenario sukses dan konflik
Pengujian feature penting karena masalah konkurensi sering tampak benar di level kode, tetapi kontraknya salah di level HTTP.
<?php
namespace Tests\Feature;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostConcurrencyTest extends TestCase
{
use RefreshDatabase;
public function test_show_returns_etag(): void
{
$post = Post::factory()->create(['version' => 1]);
$response = $this->getJson("/api/posts/{$post->id}");
$response
->assertOk()
->assertHeader('ETag', '"1"');
}
public function test_update_requires_if_match(): void
{
$post = Post::factory()->create(['version' => 1]);
$response = $this->patchJson("/api/posts/{$post->id}", [
'title' => 'Baru',
]);
$response
->assertStatus(428)
->assertJsonPath('code', 'precondition_required');
}
public function test_update_succeeds_when_etag_matches(): void
{
$post = Post::factory()->create([
'title' => 'Lama',
'version' => 1,
]);
$response = $this
->withHeaders(['If-Match' => '"1"'])
->patchJson("/api/posts/{$post->id}", [
'title' => 'Baru',
]);
$response
->assertOk()
->assertHeader('ETag', '"2"')
->assertJsonPath('data.title', 'Baru')
->assertJsonPath('data.version', 2);
}
public function test_update_fails_when_etag_is_stale(): void
{
$post = Post::factory()->create([
'title' => 'Awal',
'version' => 2,
]);
$response = $this
->withHeaders(['If-Match' => '"1"'])
->patchJson("/api/posts/{$post->id}", [
'title' => 'Tertimpa',
]);
$response
->assertStatus(412)
->assertHeader('ETag', '"2"')
->assertJsonPath('code', 'precondition_failed');
}
}Jika Anda memakai strategi updated_at, sesuaikan assertion ETag agar membandingkan nilai timestamp yang benar. Dalam test, strategi version biasanya lebih mudah dan stabil.
Edge case cache, proxy, dan perilaku HTTP
1. Jangan campuradukkan tujuan caching dan concurrency
ETag sering dipakai untuk caching lewat If-None-Match, sedangkan di sini kita fokus pada update bersyarat lewat If-Match. Nilai ETag boleh sama, tetapi semantiknya berbeda:
If-None-Matchumum dipakai untuk GET agar server bisa mengembalikan 304.If-Matchdipakai untuk memastikan update hanya berlaku pada versi tertentu.
Jangan menganggap dukungan GET conditional otomatis berarti update Anda aman dari lost update.
2. Proxy atau CDN bisa mengubah perilaku cache
Jika resource bersifat privat atau sering berubah per pengguna, set header cache dengan hati-hati. Untuk endpoint detail resource yang dipakai editor, Cache-Control: private, no-cache sering lebih aman. Tujuannya bukan melarang cache sepenuhnya, tetapi memaksa revalidasi dan mengurangi risiko klien bekerja dari representasi yang terlalu lama.
Pastikan juga proxy tidak menghapus header If-Match atau ETag. Dalam sistem dengan API gateway, lakukan verifikasi di log request/response aktual.
3. ETag harus mewakili state yang benar-benar relevan
Jika ETag dihitung dari JSON response penuh, perubahan urutan field, transformasi serializer, atau penambahan field non-bisnis bisa memicu konflik palsu. Karena itu, untuk concurrency lebih baik memakai versi eksplisit atau penanda state yang stabil.
4. Hati-hati dengan If-Match: *
Secara HTTP, If-Match: * berarti operasi boleh dilakukan jika resource ada, tanpa mencocokkan versi spesifik. Ini berguna untuk beberapa kasus, tetapi tidak mencegah lost update karena Anda tidak memverifikasi state yang dibaca klien. Jika tujuan Anda murni mencegah overwrite, pertimbangkan untuk menolak wildcard ini.
Kesalahan umum saat implementasi
- Hanya mengecek ETag di controller, lalu save biasa. Ini masih punya celah race kecil. Jika konflik benar-benar penting, gunakan update atomik di database.
- Menghasilkan ETag dari representasi yang tidak stabil. Misalnya hash JSON yang berubah karena formatting atau field tambahan.
- Tidak mengirim ETag terbaru setelah update sukses. Klien jadi tetap memegang versi lama.
- Menganggap PUT idempoten berarti aman dari race condition. Idempotensi tidak menyelesaikan konflik update antar klien.
- Melupakan jalur update lain. Misalnya job internal, admin panel, atau command yang mengubah data tanpa menaikkan
version.
Checklist integrasi untuk klien
- Lakukan GET resource dan simpan nilai header
ETag. - Saat mengirim PUT/PATCH, sertakan
If-Matchdengan nilai ETag yang terakhir diterima. - Jika menerima 200 OK, ganti ETag lokal dengan ETag baru dari respons.
- Jika menerima 412 Precondition Failed, ambil ulang resource terbaru, tampilkan konflik ke user, lalu minta user mengulang atau merge perubahan.
- Jika menerima 428 Precondition Required, perbaiki integrasi klien karena update tanpa
If-Matchtidak diperbolehkan. - Jangan membuat asumsi bahwa data lokal selalu mutakhir hanya karena baru saja diambil beberapa detik lalu.
Rekomendasi praktis
Jika Anda ingin implementasi cepat, gunakan ETag dari updated_at dan paksa If-Match pada endpoint update. Jika Anda ingin kontrol yang lebih kuat dan mudah diuji, tambahkan version column lalu lakukan update atomik dengan kondisi versi.
Untuk banyak Laravel API, kombinasi berikut paling praktis:
- GET selalu mengirim
ETag. - PUT/PATCH wajib mengirim
If-Match. - Jika versi tidak cocok, balas 412.
- Jika header tidak dikirim, balas 428.
- Gunakan version column bila risiko race tidak boleh lolos.
Dengan kontrak ini, Anda tidak menghilangkan semua konflik, tetapi konflik menjadi terdeteksi dan eksplisit, bukan diam-diam menimpa data. Itulah tujuan utama optimistic concurrency control di API.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!