Desain queue render tahan retry dengan lock dan idempoten diperlukan ketika pipeline komputasi berat berjalan lewat worker yang tidak selalu stabil: proses bisa timeout, worker bisa mati di tengah jalan, queue dapat mengirim ulang pesan, dan sistem dapat memproses job yang sama lebih dari sekali. Jika tidak dirancang dengan benar, akibatnya bukan hanya pemborosan CPU, tetapi juga status job yang rusak, hasil parsial yang tertimpa, retry tak terkendali, dan antrean yang macet oleh poison message.

Pendekatan yang aman biasanya menggabungkan beberapa lapisan: idempotency key untuk identitas kerja, lease-based locking agar hanya satu worker aktif pada unit kerja tertentu, visibility timeout yang disetel sesuai karakter beban, penyimpanan progres parsial untuk resume, dan model status job yang eksplisit. Artikel ini membahas arsitektur praktis, alur end-to-end, pseudocode worker, mode kegagalan, serta checklist operasional agar sistem stabil saat beban naik.

Masalah nyata pada pipeline render berbasis queue

Bayangkan sistem render frame atau tile untuk ray tracer C++ yang menerima permintaan render, memecahnya menjadi unit kerja, lalu menjalankannya di banyak worker. Beban semacam ini cenderung berat, durasinya panjang, dan hasilnya mahal untuk dihitung ulang. Di sini, kegagalan kecil pada desain queue cepat berubah menjadi masalah operasional besar.

Gejala yang sering muncul

  • Job dobel: pesan yang sama diproses dua worker karena redelivery atau race condition.
  • Retry tak terkendali: job terus gagal lalu diambil ulang tanpa batas yang jelas.
  • Status tidak konsisten: database menulis completed, tetapi file hasil belum final, atau sebaliknya.
  • Progres hilang: worker crash setelah menghitung 80%, tetapi retry mulai lagi dari nol.
  • Poison message: input rusak atau bug deterministik membuat job selalu gagal dan menghambat queue.
  • Lock bocor: worker mati tetapi lock tetap dianggap aktif terlalu lama.

Semua ini umum pada sistem queue at-least-once delivery. Artinya, Anda harus mengasumsikan bahwa pesan bisa diproses lebih dari sekali. Karena itu, target desain bukan “mencegah retry sama sekali”, melainkan membuat retry aman.

Prinsip desain: aman pada at-least-once delivery

1. Pisahkan identitas job, attempt, dan unit hasil

Salah satu kesalahan paling umum adalah menyamakan semua konsep ini. Dalam pipeline render, minimal ada tiga identitas:

  • Job ID: identitas permintaan render logis, misalnya satu frame.
  • Attempt ID: identitas percobaan eksekusi oleh worker tertentu.
  • Output Unit ID: identitas hasil konkret, misalnya tile atau chunk tertentu.

Pemisahan ini penting karena satu job bisa memiliki banyak attempt, tetapi output final harus tetap unik dan konsisten.

2. Idempoten lebih penting daripada sekali-jalan

Pada sistem distribusi, memastikan “hanya sekali diproses” biasanya mahal atau tidak realistis. Yang lebih praktis adalah memastikan bahwa jika job yang sama dijalankan lagi, hasil akhirnya tetap benar dan tidak merusak state.

Idempoten tidak berarti semua langkah harus murni tanpa efek samping. Idempoten berarti efek samping yang penting — status, file final, metadata, progres — tidak menghasilkan duplikasi atau korupsi ketika dieksekusi ulang.

3. Gunakan transisi status yang eksplisit

Hindari status biner seperti pending dan done saja. Untuk render berat, status yang lebih aman biasanya mencakup:

  • queued
  • leased atau running
  • progressing
  • finalizing
  • completed
  • failed_retryable
  • failed_terminal
  • dead_lettered

Status ini membantu membedakan kegagalan sementara dari kegagalan permanen, dan memudahkan operasi recovery.

Arsitektur komponen yang direkomendasikan

Komponen inti

  • API/Producer: menerima permintaan render, membuat metadata job, dan mengirim pesan ke queue.
  • Queue: menyimpan job dengan semantik pengiriman minimal at-least-once.
  • Worker: mengambil job, memperoleh lease/lock, memproses render, menulis progres, dan menyelesaikan hasil.
  • Metadata Store: database untuk status job, attempt, checksum input, lokasi output, retry count, dan audit trail ringkas.
  • Lock Store: umumnya Redis atau backend serupa untuk lease-based lock dengan TTL.
  • Progress Cache: penyimpanan cepat untuk progres parsial, heartbeat, checkpoint tile, atau chunk yang telah selesai.
  • Object Storage/File Store: penyimpanan hasil parsial dan output final.
  • Dead Letter Queue: tempat memindahkan poison message atau job yang melebihi retry policy.
  • Observability Stack: logs, metrics, traces, dan alerting.

Skema data minimum

Di metadata store, simpan field yang benar-benar dibutuhkan untuk konsistensi:

  • job_id
  • idempotency_key
  • input_hash
  • status
  • retry_count
  • max_retries
  • lease_owner dan lease_expires_at bila dicatat juga di database
  • progress_pointer atau referensi ke checkpoint
  • result_location
  • error_code dan last_error
  • created_at, updated_at

Untuk unit kerja granular seperti tile, tambahkan tabel atau dokumen terpisah untuk status per tile agar recovery lebih murah daripada mengulang satu frame penuh.

Kapan memakai idempotency key, lease-based locking, dedup queue, dan cache

Idempotency key

Pakai idempotency key ketika permintaan yang sama bisa dikirim ulang oleh client, scheduler, atau producer. Misalnya, user menekan tombol render dua kali atau API gateway melakukan retry. Idempotency key biasanya dibentuk dari identitas logis pekerjaan, bukan dari attempt.

Contoh kandidat:

  • project_id + scene_version + camera + frame_number + render_settings_hash

Jika kombinasi ini sama, sistem dapat mengenali bahwa pekerjaan logisnya sama, lalu:

  • mengembalikan job lama jika masih aktif,
  • menolak duplikasi, atau
  • melanjutkan dari hasil parsial yang ada.

Jangan memakai timestamp acak sebagai idempotency key. Itu membuat setiap retry terlihat seperti job baru dan menghilangkan manfaat idempoten.

Lease-based locking

Pakai lease-based locking ketika satu unit kerja tidak boleh aktif di dua worker sekaligus, tetapi worker dapat crash. Berbeda dari lock permanen, lease memiliki TTL dan harus diperbarui berkala lewat heartbeat. Jika worker mati, lease akan kedaluwarsa dan worker lain boleh mengambil alih.

Lease cocok untuk:

  • render frame yang mahal dan tidak boleh dirender paralel secara tidak sengaja,
  • proses finalisasi output,
  • penulisan file final yang harus eksklusif.

Trade-off-nya: Anda harus merancang durasi lease, heartbeat interval, dan penanganan split-brain dengan hati-hati.

Dedup queue

Pakai dedup queue jika infrastruktur queue mendukung deduplikasi pesan dalam jangka waktu tertentu dan sumber duplikasi utama berasal dari producer. Ini berguna untuk meredam spam pesan identik sebelum sampai ke worker.

Namun, dedup queue bukan pengganti idempotensi worker. Redelivery masih bisa terjadi karena timeout, ack gagal, atau crash setelah efek samping parsial terjadi.

Cache untuk progres dan hasil parsial

Pakai cache atau storage checkpoint ketika biaya resume jauh lebih murah daripada menghitung ulang dari nol. Pada ray tracer atau komputasi iteratif, checkpoint bisa berupa:

  • tile yang sudah selesai,
  • sample count per bucket,
  • partial image buffer,
  • seed atau state untuk melanjutkan iterasi.

Jika ukuran data progres kecil dan aksesnya sering, cache cepat seperti Redis cocok. Jika progres cukup besar atau perlu tahan lama, simpan pointer checkpoint di cache tetapi payload utamanya di object storage.

Alur kerja end-to-end yang stabil

1. Producer membuat atau menemukan job idempoten

  1. Hitung idempotency_key dari identitas logis render.
  2. Cek metadata store: jika job aktif atau selesai dengan key yang sama, kembalikan referensinya.
  3. Jika belum ada, buat row job dengan status queued.
  4. Kirim pesan ke queue berisi job_id, bukan seluruh payload besar jika tidak perlu.

2. Worker mengambil pesan dan mencoba memperoleh lease

  1. Ambil pesan dari queue.
  2. Lakukan lookup metadata job.
  3. Jika status sudah completed atau failed_terminal, ack dan berhenti.
  4. Coba akuisisi lease untuk job_id dengan TTL.
  5. Jika gagal mendapat lease, jangan langsung memproses; lepaskan atau reschedule sesuai strategi queue.

3. Worker memverifikasi apakah bisa resume

  1. Cek checkpoint/progres yang ada.
  2. Validasi kecocokan input_hash agar tidak melanjutkan progres dari input lama.
  3. Tandai status menjadi running atau progressing.

4. Worker memproses dengan heartbeat dan checkpoint periodik

  1. Jalankan render per tile/chunk.
  2. Setiap interval tertentu, perbarui heartbeat lease.
  3. Tulis progres ke cache/storage secara periodik.
  4. Jangan menunggu sampai akhir untuk menulis sinyal kehidupan worker.

5. Finalisasi harus idempoten

  1. Tulis hasil final ke lokasi sementara atau versi baru.
  2. Verifikasi integritas hasil bila memungkinkan.
  3. Lakukan commit metadata secara atomik semampunya: set completed, simpan result_location, dan catat waktu selesai.
  4. Baru setelah state final konsisten, ack pesan queue.

Urutan ini penting. Jika Anda meng-ack terlalu cepat lalu crash sebelum metadata final ditulis, job dianggap selesai oleh queue tetapi tidak selesai oleh sistem Anda.

Pseudocode worker: ack, retry, lease, dan resume

function processMessage(message):
    jobId = message.job_id
    attemptId = newUUID()

    job = db.getJob(jobId)
    if job is null:
        ack(message)
        return

    if job.status in ["completed", "failed_terminal", "dead_lettered"]:
        ack(message)
        return

    lease = lock.acquire(key="job:" + jobId, owner=attemptId, ttl=LEASE_TTL)
    if not lease.acquired:
        retryLater(message, shortBackoff())
        return

    try:
        db.markRunning(jobId, attemptId, lease.expiresAt)

        checkpoint = loadCheckpoint(jobId)
        if checkpoint exists and checkpoint.input_hash != job.input_hash:
            discardCheckpoint(jobId)
            checkpoint = null

        renderer = createRenderer(job.payload, checkpoint)
        lastHeartbeat = now()
        lastCheckpoint = now()

        while not renderer.isDone():
            renderer.renderNextChunk()

            if now() - lastHeartbeat > HEARTBEAT_INTERVAL:
                lock.renew(key="job:" + jobId, owner=attemptId, ttl=LEASE_TTL)
                db.updateHeartbeat(jobId, attemptId)
                lastHeartbeat = now()

            if now() - lastCheckpoint > CHECKPOINT_INTERVAL:
                saveCheckpoint(jobId, renderer.snapshot(), job.input_hash)
                db.updateProgress(jobId, renderer.progress())
                lastCheckpoint = now()

        tempResult = renderer.writeTempResult()
        finalResult = publishAtomically(tempResult, destinationFor(jobId))

        db.completeJob(jobId, finalResult)
        deleteCheckpoint(jobId)
        ack(message)

    catch RetryableError as e:
        db.markRetryableFailure(jobId, e.code, e.message)
        if job.retry_count + 1 >= job.max_retries:
            moveToDeadLetter(message)
            db.markDeadLettered(jobId, e.code)
        else:
            retryLater(message, boundedBackoff(job.retry_count))

    catch TerminalError as e:
        db.markTerminalFailure(jobId, e.code, e.message)
        moveToDeadLetter(message)

    finally:
        lock.releaseIfOwner(key="job:" + jobId, owner=attemptId)

Catatan penting dari pseudocode

  • Ack dilakukan paling akhir, setelah metadata final sukses ditulis.
  • Release lock harus berbasis owner token agar worker lama tidak melepas lease worker baru secara tidak sengaja.
  • Retry dibatasi dan dibedakan antara retryable dan terminal.
  • Checkpoint divalidasi dengan input hash agar resume tidak memakai progres dari konfigurasi yang sudah berubah.

Visibility timeout dan lease: jangan disamakan

Visibility timeout adalah mekanisme queue yang menyembunyikan pesan dari worker lain selama periode tertentu setelah diambil. Lease lock adalah mekanisme aplikasi untuk menyatakan kepemilikan logis atas job. Keduanya saling melengkapi, bukan saling menggantikan.

Pedoman praktis

  • Set visibility timeout lebih panjang dari interval kerja normal per checkpoint atau heartbeat, tetapi jangan terlalu panjang hingga recovery lambat.
  • Set lease TTL lebih pendek dari timeout yang terlalu konservatif, namun cukup aman terhadap jitter jaringan dan jeda GC/proses.
  • Jika queue mendukung perpanjangan visibility timeout, gunakan untuk job yang memang panjang.

Kesalahan umum adalah menyetel visibility timeout sangat pendek tanpa heartbeat atau extension. Akibatnya, pesan muncul lagi saat worker pertama masih sibuk, lalu job dobel terjadi meskipun lock aplikasi sudah ada. Sebaliknya, timeout yang terlalu panjang memperlambat recovery saat worker mati.

Konsistensi status job dan finalisasi hasil

Masalah paling berbahaya bukan hanya job dobel, tetapi status yang berbohong. Misalnya, tabel job sudah completed, tetapi file final belum benar-benar ada. Untuk mengurangi ini, finalisasi harus dirancang seperti transaksi aplikasi.

Pola finalisasi yang aman

  1. Render ke output sementara atau nama versi.
  2. Verifikasi file hasil: ukuran masuk akal, checksum bila perlu, atau validasi format.
  3. Promosikan hasil ke lokasi final dengan operasi yang aman untuk media penyimpanan yang dipakai.
  4. Baru setelah itu, ubah metadata job menjadi completed.

Jika storage tidak mendukung rename atomik atau operasi serupa, gunakan penanda versi/manifest. Intinya, konsumen hasil hanya boleh membaca artefak yang sudah dipromosikan sebagai final.

Failure mode vs mitigasi

Failure modeDampakMitigasi
Worker crash setelah mulai renderProses berhenti, pesan bisa muncul lagiLease TTL + heartbeat, checkpoint progres, visibility timeout yang tepat
Pesan queue dikirim ulangJob dobelIdempotency key, status check sebelum proses, lease per job
Ack sukses sebelum metadata final tersimpanJob hilang tetapi tidak selesaiAck paling akhir setelah finalisasi dan update status sukses
Retry tanpa batas pada input rusakQueue tersumbat, biaya tinggiKlasifikasi retryable vs terminal, max retries, dead letter queue
Checkpoint lama dipakai untuk input baruHasil salah atau korupSimpan input hash/config hash, validasi sebelum resume
Lock bocor karena worker matiJob tertahan lamaLease berbasis TTL, renewal periodik, takeover setelah expiry
Worker lama melepas lock worker baruDua worker aktif bersamaanOwner token pada release dan renew lock
Poison message berulangRetry storm, backlog naikDLQ, sampling log error, quarantine payload, perbaiki validator input
Status completed tetapi file final belum validKonsumen membaca hasil rusakFinalisasi bertahap, verifikasi output, publish-then-complete
Progres terlalu sering disimpanI/O berat, throughput turunCheckpoint berkala berbasis waktu atau chunk, bukan setiap iterasi kecil

Strategi retry yang masuk akal

Bedakan kesalahan sementara dan permanen

Tidak semua kegagalan layak di-retry. Beberapa contoh:

  • Retryable: storage sementara tidak tersedia, timeout jaringan, node kehabisan resource sesaat.
  • Terminal: file scene rusak, parameter render tidak valid, bug deterministik yang selalu crash pada input tertentu.

Jika semua error diperlakukan sama, sistem cenderung membuat retry storm.

Gunakan bounded exponential backoff

Backoff membantu meredam lonjakan saat ada gangguan dependensi. Namun, backoff tetap perlu batas. Setelah melewati ambang retry, pindahkan job ke DLQ dan tandai failed_terminal atau dead_lettered agar operator dapat meninjau.

Jangan retry tanpa menulis konteks error

Minimal simpan:

  • kode error yang distandardisasi,
  • ringkasan pesan error,
  • attempt ke berapa,
  • worker/node yang memproses,
  • waktu error terakhir.

Tanpa ini, debugging poison message akan sangat lambat.

Observability: metrik yang benar-benar berguna

Pipeline render berat sulit dioperasikan jika hanya mengandalkan log. Anda butuh metrik yang menunjukkan kesehatan queue, perilaku retry, dan efisiensi resume.

Metrik inti

  • Queue depth: jumlah pesan menunggu.
  • Oldest message age: umur pesan tertua, indikator backlog kritis.
  • In-flight jobs: job yang sedang leased/running.
  • Retry rate: laju retry per periode.
  • Duplicate suppression count: berapa job duplikat dicegah oleh idempotency key atau dedup.
  • Lease renewal failures: indikasi masalah lock store atau worker freeze.
  • Checkpoint resume rate: berapa sering job berhasil dilanjutkan dari progres parsial.
  • Mean/percentile processing time: waktu proses job atau chunk.
  • DLQ count: jumlah job yang berakhir di dead letter queue.
  • Finalization failures: kegagalan saat publish hasil final.

Log dan trace yang disarankan

  • Sertakan job_id, attempt_id, idempotency_key, dan node worker di setiap log penting.
  • Catat transisi status, akuisisi lease, renewal, checkpoint, finalisasi, dan alasan retry.
  • Jika memakai tracing, pisahkan span untuk lease, render chunk, checkpoint, dan publish result.

Kesalahan desain yang sering terjadi

  • Menganggap queue sudah menangani deduplikasi total. Pada praktiknya, worker tetap harus idempoten.
  • Menyimpan payload besar langsung di pesan queue. Lebih aman kirim referensi ke metadata atau object storage.
  • Tidak ada input hash. Resume menjadi berbahaya ketika konfigurasi render berubah.
  • Status diubah terlalu awal. Menandai completed sebelum hasil benar-benar siap akan menyulitkan recovery.
  • Lock tanpa TTL. Jika worker mati, job bisa membeku tanpa jalan keluar otomatis.
  • Checkpoint terlalu sering atau terlalu jarang. Terlalu sering membebani I/O, terlalu jarang membuat resume tidak efisien.

Kapan granularitas job perlu dipecah

Jika satu job render terlalu panjang, mempertahankan visibility timeout dan lease menjadi makin sulit. Dalam kasus ini, pertimbangkan memecah job menjadi unit yang lebih kecil seperti tile atau bucket.

Pilih job per frame jika:

  • durasi masih masuk akal untuk satu lease,
  • hasil parsial sulit digabung,
  • overhead koordinasi ingin ditekan.

Pilih job per tile/chunk jika:

  • render frame sangat lama,
  • resume per bagian jauh lebih murah,
  • Anda butuh paralelisme lebih tinggi dan recovery lebih halus.

Trade-off-nya adalah metadata dan koordinasi bertambah. Namun untuk beban komputasi berat, granularitas lebih kecil sering mempermudah retry dan mengurangi pemborosan kerja yang hilang saat crash.

Checklist operasional agar sistem stabil saat beban naik

  • Pastikan setiap job punya idempotency key yang stabil dan bermakna.
  • Gunakan lease-based lock dengan TTL dan owner token.
  • Set visibility timeout sesuai durasi kerja nyata, dan perpanjang jika perlu.
  • Terapkan retry policy terbatas dengan klasifikasi retryable vs terminal.
  • Siapkan dead letter queue dan prosedur peninjauan poison message.
  • Simpan checkpoint/progres untuk job yang mahal diulang dari nol.
  • Validasi checkpoint menggunakan input hash atau config hash.
  • Lakukan ack hanya setelah finalisasi sukses dan metadata konsisten.
  • Pantau queue depth, oldest message age, retry rate, dan DLQ count.
  • Uji skenario crash: worker mati sebelum ack, setelah checkpoint, saat finalisasi, dan saat renew lease.
  • Batasi konkurensi worker sesuai CPU, memori, dan bandwidth storage, bukan hanya jumlah pesan.
  • Dokumentasikan prosedur requeue, replay, dan cleanup artefak parsial.

Penutup

Pada pipeline render atau komputasi berat, masalah utama bukan sekadar bagaimana memproses job dengan cepat, tetapi bagaimana membuat sistem tetap benar saat pesan dikirim ulang, worker crash, atau dependensi bermasalah. Desain queue render tahan retry dengan lock dan idempoten berarti menerima kenyataan bahwa duplikasi dan retry akan terjadi, lalu membangun kontrol yang tepat di tiap lapisan: identitas job yang stabil, lease yang dapat kedaluwarsa, checkpoint progres, finalisasi idempoten, dan observability yang memadai.

Jika harus memilih prioritas implementasi, mulailah dari empat hal: idempotency key, lease-based lock, retry policy terbatas, dan status/finalisasi yang konsisten. Setelah itu, tambahkan checkpoint serta metrik operasional. Kombinasi ini biasanya sudah cukup untuk mengubah pipeline yang rapuh menjadi sistem yang bisa dipulihkan, diamati, dan diskalakan dengan lebih aman.