Pada aplikasi retro/corkboard web kolaboratif, bug yang paling merusak kepercayaan pengguna biasanya bukan crash, melainkan data conflict: sticky note tiba-tiba hilang, isi note kembali ke versi lama, atau update pengguna lain tertindih tanpa peringatan. Dalam banyak kasus, masalah ini bukan berasal dari UI semata, tetapi dari backend real-time yang menggabungkan HTTP API, database, dan event WebSocket tanpa kontrol konkurensi yang jelas.
Artikel ini membahas studi kasus debugging backend retro app untuk konflik update sticky note real-time. Fokusnya praktis: bagaimana bug muncul, kenapa log awal terlihat normal, bagaimana menelusuri akar masalah seperti race condition, last-write-wins, dan event WebSocket yang datang tidak berurutan, lalu bagaimana memperbaikinya dengan transaksi, versioning, idempotensi, dan test regresi.
Gejala yang Terlihat di Produksi
Tim melaporkan pola bug berikut pada board retro yang dipakai banyak peserta:
- User A mengedit isi sticky note.
- User B hampir bersamaan memindahkan note ke kolom lain.
- Beberapa detik kemudian, isi note kembali ke versi lama atau perpindahan kolom hilang.
- Pada kasus lain, note terlihat ada di UI user A tetapi menghilang setelah refresh.
Gejala ini sering muncul hanya saat board aktif dipakai banyak orang. Saat diuji manual oleh satu developer di lokal, semuanya terlihat normal. Ini sinyal klasik bahwa masalahnya terkait konkurensi, bukan sekadar validasi request.
Kenapa bug ini sulit ditangkap?
Karena setiap komponen secara individual tampak benar:
- Endpoint update note mengembalikan HTTP 200.
- Query database berhasil dieksekusi.
- Event broadcast ke WebSocket sukses dikirim.
- Log aplikasi menunjukkan urutan yang terlihat masuk akal dari sudut pandang satu request.
Masalahnya justru muncul di antara komponen-komponen itu: dua request yang valid saling menimpa, atau event yang benar dikirim dalam urutan yang salah di sisi client.
Skenario Reproduksi Bug yang Realistis
Jangan mulai debugging dari log. Mulailah dari reproduksi yang deterministik semampunya.
Model data sederhana
Anggap satu sticky note memiliki atribut berikut:
notes
- id
- board_id
- content
- column_id
- position
- updated_at
- versionAksi yang umum terjadi bersamaan:
- Edit content: user mengubah teks note.
- Move note: user memindahkan note ke kolom lain atau mengubah urutan.
Alur request/event yang memicu konflik
- User A membuka board, melihat note versi 12.
- User B juga membuka board, melihat note versi 12.
- User A mengirim request
PATCH /notes/123dengancontentbaru. - User B hampir bersamaan mengirim request
PATCH /notes/123/movedengancolumn_idbaru. - Backend memproses dua request secara paralel.
- Keduanya menulis state penuh note atau menyiarkan event update tanpa version check.
- Client menerima event dalam urutan berbeda dari urutan commit database.
Hasil akhirnya bisa berbeda-beda:
- Perubahan konten A tertimpa oleh update B yang masih membawa konten lama.
- Perpindahan kolom B tertimpa oleh update A yang masih membawa
column_idlama. - Client menyimpan state lokal dari event yang datang belakangan tetapi lebih tua.
Cara reproduksi praktis
Gunakan dua session browser atau dua script yang menembakkan request hampir bersamaan. Untuk debugging backend, script jauh lebih baik daripada klik manual.
# terminal 1
curl -X PATCH http://localhost:3000/api/notes/123 \
-H 'Content-Type: application/json' \
-d '{"content":"A edited this note","version":12}'
# terminal 2
curl -X PATCH http://localhost:3000/api/notes/123/move \
-H 'Content-Type: application/json' \
-d '{"column_id":"went_well","position":4,"version":12}'Untuk memperbesar peluang konflik, tambahkan delay kecil yang terkontrol di backend pada environment debug, misalnya sebelum commit atau sebelum publish event.
Tips: injeksi delay buatan 50-200 ms di area kritis sering jauh lebih efektif untuk memunculkan race condition dibanding menunggu bug terjadi alami.
Log yang Menyesatkan Saat Investigasi
Salah satu jebakan terbesar adalah log yang hanya menunjukkan request succeeded.
Contoh log yang tampak baik-baik saja:
[req-101] PATCH /notes/123 200
[req-101] updated note=123
[req-101] broadcast note.updated
[req-102] PATCH /notes/123/move 200
[req-102] updated note=123
[req-102] broadcast note.updatedDari log ini, tidak terlihat:
- Request mana yang membaca versi data berapa.
- Apakah update dilakukan sebagai partial update atau overwrite seluruh record.
- Urutan commit database.
- Urutan publish event.
- Versi state yang dibawa dalam payload event.
Akibatnya, engineer mudah menyimpulkan bahwa masalah ada di frontend karena backend selalu 200 dan event selalu terkirim.
Log yang seharusnya ada
Untuk kasus konflik real-time, log minimal perlu mengandung:
request_idataucorrelation_idnote_id,board_id,actor_idread_versiondannew_version- jenis operasi: edit content, move, delete, restore
- waktu commit database
- event id yang dipublish
Contoh yang jauh lebih berguna:
[req-101] note=123 op=edit_content read_version=12
[req-102] note=123 op=move read_version=12
[req-102] note=123 commit_ok new_version=13
[req-102] event=evt-9001 type=note.moved note=123 version=13
[req-101] note=123 conflict expected_version=12 actual_version=13Dengan log seperti ini, akar masalah lebih cepat terlihat: dua request membaca versi yang sama, satu menang, satu harus gagal atau retry, bukan diam-diam menimpa.
Root Cause: Race Condition, Last-Write-Wins, dan Event Out-of-Order
1. Race condition pada update paralel
Race condition muncul ketika dua operasi yang valid berjalan bersamaan dan hasil akhirnya bergantung pada timing, bukan aturan bisnis yang eksplisit.
Contoh sederhana:
- Request A membaca note versi 12.
- Request B membaca note versi 12.
- A menyimpan perubahan konten.
- B menyimpan perpindahan kolom berdasarkan snapshot lama.
Jika implementasi update B menulis seluruh objek note, maka konten dari A bisa hilang walaupun operasi B hanya berniat memindahkan note.
2. Last-write-wins tanpa sadar
Banyak backend menerapkan last-write-wins secara tidak sengaja, misalnya karena ORM melakukan save terhadap seluruh entitas hasil baca sebelumnya. Secara teknis request terakhir memang berhasil, tetapi secara produk perilaku ini sering salah karena perubahan user lain lenyap tanpa deteksi konflik.
Last-write-wins kadang bisa diterima untuk field tertentu, misalnya indikator presence atau cursor position. Namun untuk isi sticky note dan perpindahan note, pendekatan ini berisiko tinggi jika tidak dikombinasikan dengan version check.
3. Event WebSocket out-of-order
Bahkan jika database sudah benar, state di client tetap bisa salah bila event real-time diterapkan tanpa memperhatikan versi.
Contoh:
- Request B commit lebih dulu, menghasilkan versi 13.
- Request A gagal atau retry, tetapi event lama dari jalur lain masih terkirim.
- Client menerima event versi 13 lebih dulu, lalu event versi 12 belakangan.
- Tanpa guard di client, state mundur ke versi lama.
Urutan kirim event tidak selalu sama dengan urutan intent user. Jaringan, queue, retry, dan scheduling bisa mengubah urutannya.
Strategi Investigasi yang Efektif
Buat timeline per note, bukan per request
Karena bug terjadi pada satu entitas yang disentuh banyak actor, pandangan terbaik adalah timeline per note_id. Kumpulkan:
- waktu note dibaca
- versi saat dibaca
- waktu update dicoba
- hasil update: commit, conflict, retry, gagal
- event yang dipublish beserta versinya
- event yang diterima client bila Anda punya client telemetry
Jika perlu, ekspor ke tabel sederhana lalu urutkan berdasarkan timestamp dan versi. Dari sana biasanya terlihat bahwa masalah bukan satu request gagal, melainkan beberapa request berhasil dengan model konsistensi yang salah.
Periksa apakah operasi benar-benar partial
Pertanyaan penting:
- Apakah endpoint move hanya mengubah
column_iddanposition? - Atau ia menyimpan seluruh note yang sebelumnya dibaca, termasuk
contentlama?
Ini penyebab umum konflik tersembunyi. Di level kode, cari pola seperti:
// anti-pattern konseptual
note = repository.find(noteId)
note.columnId = payload.columnId
note.position = payload.position
repository.save(note)Jika objek note membawa field lama yang tidak relevan, save penuh dapat menimpa update paralel pada field lain.
Telusuri sumber event
Pastikan event real-time berasal dari state yang sudah committed, bukan dari payload request mentah atau objek stale di memory. Event harus merepresentasikan hasil akhir yang tersimpan di database, idealnya sekaligus membawa version.
Perbaikan yang Lebih Tahan Konflik
1. Gunakan optimistic concurrency control
Solusi paling umum dan praktis adalah menambahkan kolom version atau memakai updated_at sebagai token konkurensi, meski integer version biasanya lebih eksplisit.
Alurnya:
- Client membaca note versi 12.
- Saat update, client mengirim
expected_version=12. - Backend hanya meng-update jika versi saat ini masih 12.
- Jika sukses, versi dinaikkan menjadi 13 dan event versi 13 dipublish.
- Jika gagal, backend mengembalikan conflict agar client refetch atau merge.
Contoh SQL yang aman secara konsep:
UPDATE notes
SET content = :content,
version = version + 1,
updated_at = NOW()
WHERE id = :id
AND version = :expected_version;Jika jumlah row yang ter-update adalah 0, berarti ada konflik versi.
Untuk operasi move:
UPDATE notes
SET column_id = :column_id,
position = :position,
version = version + 1,
updated_at = NOW()
WHERE id = :id
AND version = :expected_version;Kenapa ini bekerja? Karena backend tidak lagi menebak update mana yang benar. Ia memaksa setiap writer membuktikan bahwa ia mengedit snapshot terbaru. Jika snapshot-nya sudah basi, request tidak boleh diam-diam overwrite.
2. Pisahkan operasi per intent, jangan save entitas penuh
Daripada satu endpoint generik yang menerima seluruh representasi note, lebih aman memakai operasi spesifik:
PATCH /notes/:id/contentPATCH /notes/:id/moveDELETE /notes/:id
Keuntungannya:
- Scope update lebih sempit.
- Risiko overwrite field yang tidak relevan berkurang.
- Audit log lebih jelas.
Trade-off-nya, jumlah endpoint bertambah dan kontrak API harus lebih disiplin.
3. Publish event setelah commit, bawa version dan event_id
Event WebSocket sebaiknya dikirim hanya setelah transaksi database berhasil. Payload event minimal:
{
"event_id": "evt-9001",
"type": "note.updated",
"note_id": "123",
"board_id": "b1",
"version": 13,
"patch": {
"content": "A edited this note"
}
}Dengan begitu:
- Client bisa mengabaikan event dengan versi lebih tua dari state lokal.
- Server dan client punya jejak untuk deduplikasi.
- Debugging urutan event jadi jauh lebih mudah.
4. Tambahkan idempotensi untuk retry
Pada jaringan tidak stabil, client atau gateway bisa me-retry request yang sama. Tanpa idempotensi, retry bisa menciptakan update ganda atau event duplikat.
Gunakan idempotency_key untuk operasi yang berpotensi diulang, terutama create, move, dan aksi yang memicu side effect broadcast. Simpan hasil pemrosesan key tersebut untuk periode tertentu.
Ini tidak menyelesaikan konflik versi, tetapi mencegah masalah tambahan saat retry terjadi bersamaan dengan real-time sync.
5. Pertimbangkan locking hanya untuk area yang benar-benar perlu
Jika konflik sangat tinggi, Anda bisa mempertimbangkan pessimistic locking atau serialisasi per note/board. Namun ini biasanya trade-off yang lebih berat:
- latensi naik
- throughput turun
- potensi deadlock bertambah
Untuk aplikasi retro board, optimistic concurrency biasanya lebih cocok karena edit paralel ada, tetapi tidak setinggi editor dokumen karakter-per-karakter.
Contoh Desain Handler yang Lebih Aman
function updateNoteContent(noteId, actorId, content, expectedVersion, idempotencyKey) {
existing = idempotencyStore.find(idempotencyKey)
if (existing) return existing.response
beginTransaction()
rows = db.execute(`
UPDATE notes
SET content = ?,
version = version + 1,
updated_at = NOW()
WHERE id = ? AND version = ?
`, [content, noteId, expectedVersion])
if (rows == 0) {
rollback()
return conflictResponse("version_mismatch")
}
note = db.queryOne(`SELECT id, board_id, content, column_id, position, version FROM notes WHERE id = ?`, [noteId])
commit()
event = {
event_id: generateEventId(),
type: "note.updated",
note_id: note.id,
board_id: note.board_id,
version: note.version,
patch: { content: note.content }
}
websocket.publish(note.board_id, event)
response = ok(note)
idempotencyStore.save(idempotencyKey, response)
return response
}Poin penting dari contoh di atas:
- Update memakai
expectedVersion. - Event dipublish setelah commit.
- Payload event diambil dari state hasil commit, bukan dari request mentah.
- Idempotensi menangani retry yang sama.
Guardrail Test untuk Mencegah Regresi
Perbaikan konflik real-time belum aman kalau hanya diuji manual. Anda butuh test yang memaksa sistem menghadapi kondisi paralel dan urutan event yang buruk.
1. Concurrency test pada level repository/service
Buat test yang menjalankan dua update dengan versi yang sama secara hampir bersamaan. Ekspektasi:
- hanya satu yang sukses
- yang lain mengembalikan conflict
- versi hanya naik satu kali
it('rejects concurrent update with stale version', async () => {
const note = await createNote({ content: 'old', version: 12 })
const a = updateContent(note.id, 'new text', 12)
const b = moveNote(note.id, 'went_well', 4, 12)
const results = await Promise.allSettled([a, b])
expect(exactlyOneSuccess(results)).toBe(true)
expect(exactlyOneConflict(results)).toBe(true)
const latest = await getNote(note.id)
expect(latest.version).toBe(13)
})2. Integration test untuk urutan event
Simulasikan event datang tidak berurutan ke subscriber. Client atau consumer harus menolak event yang versinya lebih tua dari state terakhir.
it('ignores out-of-order websocket event', () => {
const state = applyEvent(initialState, { note_id: '123', version: 13, patch: { content: 'new' } })
const next = applyEvent(state, { note_id: '123', version: 12, patch: { content: 'old' } })
expect(next.notes['123'].content).toBe('new')
expect(next.notes['123'].version).toBe(13)
})3. Contract test untuk payload event
Pastikan setiap event update note selalu membawa field minimum yang dibutuhkan untuk deduplikasi dan ordering:
event_idnote_idversiontype
Bug real-time sering memburuk hanya karena satu producer lupa menyertakan version di salah satu cabang kode.
4. Load test kecil dengan konflik terkontrol
Anda tidak perlu benchmark besar. Cukup script yang membuat beberapa worker mengedit note yang sama selama 10-30 detik. Tujuannya bukan angka performa, tetapi memverifikasi invariant:
- tidak ada note hilang
- versi monoton naik
- tidak ada event versi mundur yang diterapkan
- jumlah conflict masuk akal dan tercatat
Kesalahan Umum Saat Memperbaiki Bug Ini
- Hanya memperbaiki frontend dengan debounce atau refresh paksa. Ini bisa mengurangi gejala, tetapi tidak menyelesaikan overwrite di backend.
- Mengandalkan updated_at saja tanpa validasi conflict. Timestamp berguna, tetapi tidak cukup jika tidak dipakai sebagai token konkurensi.
- Mempublish event dari payload request bukan dari state hasil commit.
- Menganggap WebSocket menjamin ordering global. Dalam sistem nyata, ordering harus diasumsikan lemah kecuali Anda benar-benar mendesainnya.
- Menyimpan seluruh entitas dari snapshot lama saat hanya satu field yang berubah.
Kapan Perlu Pendekatan Lebih Lanjut?
Jika aplikasi retro berkembang menjadi editor yang sangat interaktif, misalnya banyak user mengedit isi note yang sama secara bersamaan per karakter, optimistic concurrency per note mungkin terasa kasar karena conflict terlalu sering. Pada titik itu, Anda bisa mempertimbangkan pendekatan seperti:
- operational transform
- CRDT
- append-only event log dengan merge rules yang lebih kaya
Namun untuk kebanyakan aplikasi corkboard/retro, kompleksitas itu sering tidak perlu. Konflik utama biasanya terjadi pada level note, bukan level karakter. Karena itu, version check + partial update + event ordering guard sudah menjadi perbaikan dengan rasio manfaat terhadap kompleksitas yang sangat baik.
Checklist Debugging Backend Retro App untuk Konflik Sticky Note
- Tambahkan
versionpada note. - Setiap update wajib membawa
expected_version. - Gunakan query update kondisional, bukan save seluruh objek.
- Publish event hanya setelah commit database.
- Sertakan
event_iddanversiondi setiap event. - Pastikan consumer mengabaikan event yang lebih tua.
- Tambahkan idempotency key untuk retry.
- Buat concurrency test dan out-of-order event test.
- Perkaya log dengan read_version, new_version, dan correlation id.
Penutup
Kasus sticky note hilang atau tertindih saat banyak user mengedit board bersamaan hampir selalu mengarah ke model konsistensi yang belum eksplisit. Sistem tampak bekerja saat trafik rendah, tetapi runtuh ketika dua hal terjadi bersamaan: update paralel dan sinkronisasi real-time.
Dalam konteks debugging backend retro app, solusi yang paling praktis bukan menambah patch acak, melainkan memperjelas kontrak konkurensi: siapa boleh menulis berdasarkan versi berapa, kapan event dipublish, dan bagaimana event lama ditolak. Dengan begitu, bug tidak hanya hilang dari gejala, tetapi juga tertutup di akar desainnya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!