Membangun tool prep interview dengan queue dan cache yang stabil berarti merancang sistem yang tetap benar saat user menekan submit berulang, worker gagal di tengah jalan, atau antrean menumpuk saat trafik naik. Untuk use case seperti pembuatan soal, evaluasi jawaban async, dan penyajian hasil cepat ke user, kombinasi queue, cache, locking, dan idempotensi bukan pelengkap, tetapi fondasi.
Jika tujuan Anda adalah membuat tool persiapan interview sendiri alih-alih hanya mengerjakan bank soal, tantangan utamanya bukan sekadar menghasilkan soal atau skor. Tantangan sebenarnya ada pada correctness sistem terdistribusi: satu submit seharusnya menghasilkan satu alur penilaian yang konsisten, hasil lama tidak boleh menimpa hasil baru, job yang gagal harus bisa diproses ulang dengan aman, dan cache harus mempercepat tanpa menyesatkan user.
Masalah yang Sebenarnya Ingin Diselesaikan
Tool prep interview biasanya memiliki alur seperti ini:
- User meminta soal latihan.
- Sistem membuat sesi interview atau attempt.
- User mengirim jawaban.
- Sistem melakukan penilaian async, misalnya menjalankan evaluator, rubric scoring, atau memanggil model eksternal.
- Hasil disimpan, di-cache, lalu ditampilkan ke UI.
Di atas kertas alur ini sederhana. Dalam produksi, masalah yang sering muncul justru hal-hal berikut:
- Double submit: user menekan tombol submit dua atau tiga kali.
- Race condition: dua worker memproses attempt yang sama.
- Job macet: worker crash setelah mengubah status sebagian.
- Cache stale: UI membaca hasil lama padahal penilaian terbaru sudah selesai.
- Backlog queue: request baru datang lebih cepat daripada worker memproses.
- Retry yang tidak aman: job diulang tetapi menghasilkan duplikasi update atau skor ganda.
Karena itu, desain backend harus berangkat dari asumsi bahwa submit akan terjadi berulang, job bisa gagal kapan saja, dan event dapat diproses lebih dari sekali.
Arsitektur yang Disarankan
Untuk tool ini, arsitektur yang praktis dan mudah dioperasikan biasanya terdiri dari beberapa komponen inti:
- API service: menerima request pembuatan soal, submit jawaban, dan pengambilan hasil.
- Database transaksional: menyimpan source of truth seperti attempt, submission, score, dan status job.
- Queue broker: menampung job async seperti generate question dan evaluate submission.
- Worker background: memproses job dari queue.
- Cache store: menyimpan hasil baca yang sering diakses, status ringkas, atau hasil evaluasi yang sudah final.
- Distributed lock: mencegah pemrosesan paralel pada resource yang sama.
- Dead-letter queue (DLQ): menampung job yang gagal berulang agar tidak terus merusak sistem utama.
Alur data tingkat tinggi
Contoh alur submit jawaban yang lebih aman:
- API menerima submit beserta idempotency key.
- API menyimpan record submission secara atomik dan menetapkan status attempt menjadi queued_for_evaluation.
- API mendorong job EvaluateSubmission ke queue.
- Worker mengambil job, memperoleh lock per attempt, lalu memvalidasi bahwa submission ini memang yang terbaru atau yang aktif.
- Worker menjalankan penilaian.
- Worker menulis hasil ke database secara atomik, memperbarui status, lalu menghapus atau memperbarui cache.
- API read endpoint mengambil data final dari cache bila valid, atau dari database bila cache kosong/stale.
Poin pentingnya: database tetap menjadi sumber kebenaran, sedangkan cache hanya akselerator baca. Queue membuat proses berat menjadi async, tetapi status transisi tetap harus eksplisit di database.
Model Data Minimal yang Berguna
Anda tidak perlu schema yang rumit sejak awal, tetapi beberapa entitas berikut sangat membantu:
- attempt: mewakili satu sesi latihan/interview.
- submission: setiap jawaban atau submit user.
- evaluation_job: status job async, retry count, last error, dan timestamps.
- score/result: output penilaian final.
- idempotency_record: mencatat request yang sudah pernah diproses.
Kolom yang sering penting:
attempt.status: draft, submitted, queued, processing, completed, failed.attempt.versionatauupdated_at: membantu mendeteksi hasil lama menimpa hasil baru.submission.idempotency_key: mencegah submit duplikat.evaluation_job.retry_countdannext_retry_at: mendukung retry terkontrol.score.source_submission_id: memastikan skor terkait submission yang benar.
Kesalahan umum adalah hanya menyimpan satu field result pada attempt tanpa jejak submission mana yang menghasilkan result tersebut. Saat ada submit ulang atau retry, Anda akan kesulitan membedakan output lama dan baru.
Submit yang Aman: Idempotensi dan Konsistensi
Mengapa idempotensi wajib
Saat user menekan submit berulang, browser retry karena jaringan buruk, atau load balancer mengulang request, API bisa menerima payload yang sama lebih dari sekali. Jika endpoint submit tidak idempoten, Anda bisa mendapatkan:
- dua record submission untuk satu aksi user,
- dua job evaluasi untuk attempt yang sama,
- hasil ganda atau status yang saling menimpa.
Solusinya adalah memakai idempotency key yang dikirim klien atau dibangkitkan saat UI menginisialisasi submit. Server menyimpan key tersebut bersama hasil request pertama. Jika request yang sama datang lagi, server mengembalikan respons yang sama atau menolak duplikat secara aman.
Pseudocode endpoint submit
function submitAnswer(userId, attemptId, payload, idempotencyKey) {
beginTransaction()
existing = findIdempotencyRecord(userId, idempotencyKey)
if (existing) {
commit()
return existing.response
}
attempt = selectAttemptForUpdate(attemptId)
if (!attempt || attempt.userId != userId) {
rollback()
return error("attempt not found")
}
submission = insertSubmission({
attemptId: attemptId,
answer: payload.answer,
status: "queued"
})
updateAttempt(attemptId, {
status: "queued_for_evaluation",
latestSubmissionId: submission.id
})
enqueueJob("EvaluateSubmission", {
attemptId: attemptId,
submissionId: submission.id
})
response = {
attemptId: attemptId,
submissionId: submission.id,
status: "queued_for_evaluation"
}
saveIdempotencyRecord(userId, idempotencyKey, response)
commit()
return response
}Beberapa detail penting dari contoh di atas:
- Transaksi database dipakai agar insert submission, update attempt, dan pencatatan idempotensi konsisten.
- select ... for update atau mekanisme sejenis membantu mencegah balapan pada baris attempt yang sama.
- Job dibuat setelah state database siap. Idealnya gunakan pola outbox bila Anda ingin jaminan lebih kuat antara commit database dan publish queue.
Kapan perlu pola outbox
Jika Anda menulis ke database lalu mengirim job ke broker dalam dua langkah terpisah, ada celah kegagalan:
- DB commit berhasil, publish queue gagal: attempt berstatus queued tetapi job tidak pernah jalan.
- Publish queue berhasil, DB rollback: worker memproses job untuk data yang tidak valid.
Pola transactional outbox menyelesaikan masalah ini dengan menulis event ke tabel outbox dalam transaksi yang sama dengan perubahan bisnis. Proses terpisah membaca outbox dan mengirimkannya ke broker. Ini menambah kompleksitas, tetapi sangat berguna ketika keandalan lebih penting daripada kesederhanaan.
Worker Background yang Aman Dipakai Ulang
Locking untuk mencegah double-processing
Queue modern sering memberi jaminan at least once delivery, bukan exactly once. Artinya satu job bisa diproses lebih dari sekali. Jangan mengandalkan broker untuk mencegah duplikasi. Lindungi logika bisnis Anda sendiri dengan:
- lock per attempt atau per submission,
- cek status terbaru sebelum memproses,
- write yang idempoten.
Lock dapat disimpan di Redis atau mekanisme distributed lock lain. Namun lock saja tidak cukup. Jika lock kedaluwarsa terlalu cepat, worker kedua bisa masuk. Karena itu, tetap cek validitas state di database.
Pseudocode worker evaluasi
function handleEvaluateSubmission(job) {
lockKey = "lock:attempt:" + job.attemptId
lock = acquireLock(lockKey, ttl=30000)
if (!lock) {
retryLater(job)
return
}
try {
attempt = findAttempt(job.attemptId)
submission = findSubmission(job.submissionId)
if (!attempt || !submission) {
markJobAsDiscarded(job, "missing data")
return
}
if (attempt.latestSubmissionId != submission.id) {
markJobAsDiscarded(job, "stale submission")
return
}
if (submission.status == "completed") {
markJobAsCompleted(job, "already processed")
return
}
markSubmissionProcessing(submission.id)
markAttemptProcessing(attempt.id)
result = evaluate(submission.answer)
beginTransaction()
saveScore({
attemptId: attempt.id,
submissionId: submission.id,
score: result.score,
feedback: result.feedback
})
updateSubmission(submission.id, { status: "completed" })
updateAttempt(attempt.id, {
status: "completed",
lastScoredSubmissionId: submission.id
})
commit()
invalidateCache("attempt:" + attempt.id)
writeCache("attempt_result:" + attempt.id, buildResultPayload(attempt.id))
markJobAsCompleted(job)
} catch (err) {
handleRetryOrDLQ(job, err)
} finally {
releaseLock(lock)
}
}Ada tiga lapis perlindungan pada worker di atas:
- Lock untuk mencegah eksekusi paralel.
- Cek freshness dengan membandingkan
latestSubmissionId. - Status completed untuk membuat eksekusi ulang tetap aman.
Ini penting karena kondisi paling berbahaya bukan ketika job diproses dua kali, melainkan ketika job lama selesai belakangan lalu menimpa hasil terbaru.
Trade-off locking
- Lock terlalu luas (misalnya per user) mengurangi paralelisme.
- Lock terlalu sempit (misalnya hanya per job) tidak mencegah konflik antar submission pada attempt yang sama.
- TTL lock terlalu pendek berisiko lock bocor saat evaluasi masih berjalan.
- TTL lock terlalu panjang membuat recovery lebih lambat saat worker mati mendadak.
Pilih granularity lock berdasarkan unit konsistensi Anda. Untuk kasus ini, biasanya attempt adalah unit yang masuk akal.
Cache Hasil: Cepat, tetapi Jangan Jadi Sumber Kebenaran
Apa yang layak di-cache
Pada tool prep interview, cache berguna untuk:
- hasil evaluasi final yang sering dibaca ulang,
- status ringkas attempt untuk polling UI,
- metadata soal yang jarang berubah.
Yang tidak boleh diasumsikan dari cache:
- bahwa data pasti paling baru,
- bahwa write ke cache selalu berhasil,
- bahwa cache invalidation selalu sinkron dengan DB.
Pola cache yang aman
Pola yang paling mudah dirawat biasanya cache-aside:
- Read endpoint cek cache.
- Jika miss, baca dari DB.
- Simpan hasil ke cache dengan TTL yang masuk akal.
- Saat ada perubahan hasil, invalidasi atau refresh key terkait.
Cache-aside sederhana, tetapi perlu disiplin invalidasi. Saat result ditulis, minimal invalidasi key status attempt dan key hasil final yang terkait.
Menghindari cache stale
Cache stale sering terjadi ketika:
- worker menulis DB tetapi gagal invalidasi cache,
- dua worker menulis versi hasil berbeda,
- UI membaca cache hasil lama tepat setelah submit baru dikirim.
Beberapa mitigasi yang praktis:
- Simpan version atau lastScoredSubmissionId pada payload cache.
- Saat read, jika versi cache lebih lama dari DB yang diketahui, abaikan cache.
- Gunakan TTL terbatas agar stale tidak bertahan terlalu lama.
- Hindari cache untuk status yang sangat volatil jika invalidasi sulit dijaga.
Catatan: TTL bukan solusi utama untuk konsistensi. TTL hanya membatasi lamanya data salah beredar. Jika invalidasi Anda salah, stale tetap akan muncul meski TTL pendek.
Retry, Backoff, dan Dead-Letter Queue
Retry harus idempoten
Retry diperlukan untuk kegagalan sementara seperti timeout jaringan atau layanan evaluator yang sedang lambat. Namun retry hanya aman jika operasi di worker bersifat idempoten. Kalau tidak, setiap retry justru menambah inkonsistensi.
Aturan praktis:
- Retry untuk error sementara: timeout, koneksi putus, rate limit, dependency unavailable.
- Jangan retry tanpa batas untuk error permanen: payload rusak, data tidak ditemukan, state invalid.
- Catat retry count dan alasan kegagalan terakhir.
Backoff dan DLQ
Gunakan exponential backoff atau jeda retry bertahap agar sistem tidak memperparah lonjakan beban. Setelah melewati batas retry, pindahkan job ke dead-letter queue. DLQ berguna untuk:
- isolasi job bermasalah agar antrean utama tetap bergerak,
- analisis akar masalah,
- re-drive manual setelah bug diperbaiki.
Kesalahan umum adalah menganggap DLQ sebagai tempat sampah final. Seharusnya DLQ menjadi sinyal operasional bahwa ada bug, dependency rusak, atau data edge case yang belum ditangani.
Pseudocode retry sederhana
function handleRetryOrDLQ(job, err) {
if (isPermanentError(err)) {
moveToDLQ(job, err.message)
return
}
if (job.retryCount >= MAX_RETRY) {
moveToDLQ(job, err.message)
return
}
delay = computeBackoff(job.retryCount)
requeue(job, delay)
}Pastikan observabilitas terhadap job yang masuk DLQ: simpan attemptId, submissionId, pesan error, stack trace ringkas, dan timestamp. Tanpa konteks ini, replay manual akan sulit.
Menangani Double Submit dan Submit Berulang dengan Benar
Kasus yang sering terjadi di UI:
- User menekan submit.
- Respons lambat.
- User menekan submit lagi karena mengira gagal.
Secara bisnis, biasanya Anda ingin salah satu dari dua perilaku berikut:
- Strict single active submission: satu attempt hanya boleh punya satu submission aktif pada satu waktu.
- Latest-wins: submit baru boleh masuk, tetapi hanya hasil submission terbaru yang boleh dianggap final.
Untuk tool prep interview, pola latest-wins sering lebih realistis, terutama jika user boleh memperbaiki jawaban sebelum evaluasi selesai. Jika memilih pola ini, worker lama harus mampu mendeteksi bahwa submission yang diproses sudah stale dan berhenti tanpa menimpa hasil baru.
Komponen yang membuat pola ini aman:
attempt.latestSubmissionIdsebagai acuan resmi submission terkini,- worker selalu membandingkan submission job dengan nilai ini,
- write hasil menyertakan referensi ke submission sumber.
Tanpa mekanisme ini, race condition klasik akan muncul: job A mulai dulu, job B datang belakangan, B selesai duluan, lalu A selesai paling akhir dan menimpa skor yang sebenarnya sudah usang.
Masalah Operasional Nyata dan Cara Menanganinya
1. Job macet di status processing
Ini biasanya terjadi jika worker crash setelah menandai status processing tetapi sebelum menyelesaikan job. Solusinya:
- tambahkan heartbeat atau processing timestamp,
- buat reaper/sweeper job yang memindai record terlalu lama di
processing, - pastikan recovery tidak melanggar idempotensi.
Contoh aturan praktis: jika submission berada di status processing terlalu lama tanpa heartbeat, tandai sebagai retryable dan kirim ulang ke queue.
2. Backlog queue menumpuk
Jika antrean evaluasi tumbuh lebih cepat daripada kapasitas worker, dampaknya adalah latency hasil naik drastis. Langkah mitigasi:
- pisahkan queue berdasarkan prioritas, misalnya generate question dan evaluate submission,
- tetapkan concurrency worker yang sesuai dengan bottleneck utama,
- ukur waktu proses per job dan tingkat kegagalan,
- lakukan autoscaling worker bila infrastruktur mendukung,
- terapkan circuit breaker atau pembatasan submit bila dependency evaluator sedang rusak.
Kesalahan umum adalah menambah worker tanpa memahami bottleneck. Jika yang lambat adalah layanan eksternal, menambah worker justru memperbesar error rate atau biaya.
3. Cache stale yang sulit direproduksi
Biasanya ini akibat invalidasi yang tidak konsisten. Untuk debugging:
- log key cache yang ditulis dan dihapus,
- sertakan version atau submission id pada payload cache,
- tambahkan endpoint internal untuk membandingkan cache vs DB pada attempt tertentu,
- cek apakah ada path write yang lupa invalidasi.
4. Race condition hanya muncul saat trafik tinggi
Bug ini sering lolos di lingkungan lokal. Cara mendeteksinya:
- buat test konkuren yang mensimulasikan submit ganda,
- gunakan fault injection: delay buatan pada worker sebelum commit,
- uji skenario out-of-order completion,
- validasi invariant, misalnya satu attempt hanya punya satu hasil final aktif.
Observability: Jangan Menunggu User yang Menemukan Bug
Sistem queue dan cache sulit dijaga tanpa observability yang cukup. Minimal, ukur dan log hal berikut:
- Queue depth: jumlah job menunggu.
- Job latency: waktu dari enqueue hingga selesai.
- Retry rate dan DLQ rate.
- Cache hit ratio untuk endpoint utama.
- Lock contention: seberapa sering lock gagal didapat.
- State transition errors: perubahan status yang tidak valid.
Untuk logging, gunakan correlation id atau trace id yang ikut mengalir dari request submit ke job worker. Dengan begitu Anda bisa menelusuri satu attempt dari API sampai hasil akhir.
Trade-off Desain yang Perlu Dipilih di Awal
Synchronous scoring vs asynchronous scoring
- Synchronous lebih mudah dipahami, tetapi buruk untuk proses berat atau dependency lambat.
- Asynchronous lebih tahan beban, tetapi butuh state machine, queue, dan observability yang lebih matang.
Untuk penilaian interview yang bisa memakan waktu dan berpotensi memanggil layanan eksternal, async hampir selalu lebih aman.
Single queue vs multiple queues
- Single queue lebih sederhana.
- Multiple queues memudahkan isolasi prioritas dan mencegah satu jenis job memblokir yang lain.
Jika generate soal dan evaluate jawaban memiliki profil beban berbeda, memisahkannya sering layak.
Database lock vs distributed lock
- Database row lock bagus untuk konsistensi dalam transaksi pendek.
- Distributed lock cocok untuk koordinasi lintas worker selama proses lebih lama.
Dalam banyak sistem nyata, keduanya dipakai bersama: row lock saat mutasi state kritis, distributed lock untuk mencegah worker paralel memproses resource yang sama.
Checklist Implementasi
- Definisikan state machine yang jelas untuk attempt dan submission.
- Tambahkan idempotency key pada endpoint submit.
- Simpan referensi submission terbaru pada attempt.
- Gunakan queue untuk proses evaluasi yang berat.
- Terapkan lock per attempt atau unit konsistensi yang setara.
- Buat worker idempoten dan tahan retry.
- Tambahkan retry dengan backoff dan DLQ.
- Jadikan database sebagai source of truth, cache hanya akselerator.
- Invalidasi cache saat hasil berubah, dan simpan versi pada payload cache.
- Tambahkan sweeper untuk status processing yang macet.
- Pantau queue depth, retry rate, DLQ rate, dan cache hit ratio.
- Uji skenario double submit, worker crash, dan out-of-order completion.
Contoh Invariant yang Sebaiknya Dijaga
Invariant adalah aturan yang harus selalu benar. Dalam sistem seperti ini, beberapa invariant penting adalah:
- Satu attempt hanya memiliki satu
latestSubmissionIdaktif. - Hasil final yang ditampilkan harus berasal dari submission terbaru yang sah.
- Retry job tidak boleh membuat dua score final untuk submission yang sama.
- Job stale tidak boleh menimpa hasil dari submission yang lebih baru.
- Cache boleh tertinggal sementara, tetapi tidak boleh menjadi sumber data final saat bertentangan dengan DB.
Menulis invariant secara eksplisit membantu saat membuat test, alert, dan query audit.
Penutup
Membangun tool prep interview dengan queue dan cache yang stabil bukan soal menambahkan Redis, broker, lalu berharap semuanya cepat. Yang lebih penting adalah menetapkan aturan konsistensi sejak awal: submit harus idempoten, worker harus aman saat retry, job lama tidak boleh menimpa hasil baru, dan cache tidak boleh dianggap sebagai sumber kebenaran.
Jika Anda ingin membangun tool sendiri yang benar-benar berguna untuk persiapan interview, fokuslah pada alur backend yang tahan terhadap kegagalan nyata. Soal bisa dihasilkan belakangan, rubric bisa disempurnakan seiring waktu, tetapi fondasi seperti queue, locking, idempotensi, retry, DLQ, dan strategi cache akan menentukan apakah sistem Anda bisa diandalkan saat dipakai sungguhan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!