Supabase tidak perlu dianggap sebagai penyedia queue generik siap pakai. Namun, PostgreSQL di balik Supabase cukup mampu menjadi fondasi queue berbasis tabel untuk beban kerja yang sesuai, asalkan pengambilan job dilakukan atomik, worker memiliki lease, dan handler dirancang idempoten.
Inti desainnya adalah: worker mengklaim batch job dalam satu transaksi dengan FOR UPDATE SKIP LOCKED, menandai job sebagai running, lalu memasang batas waktu lease. Jika worker mati sebelum menyelesaikan job, job yang lease-nya kedaluwarsa dapat diklaim ulang. Model ini memberi jaminan at-least-once delivery, bukan exactly-once delivery.
Gunakan pendekatan ini untuk job asinkron yang volumenya moderat dan dekat dengan data PostgreSQL Anda, misalnya pengiriman email, pemrosesan webhook, sinkronisasi data, atau pembuatan dokumen. Untuk throughput sangat tinggi, streaming, atau kebutuhan ordering yang ketat, broker khusus mungkin lebih tepat.
Model data queue PostgreSQL di Supabase
Tabel jobs menyimpan pekerjaan aktif. Field run_at menentukan kapan job boleh dijalankan, sedangkan lease_until menyatakan sampai kapan worker saat ini berhak mengerjakan job tersebut.
create table public.jobs (
id uuid primary key default gen_random_uuid(),
queue text not null,
payload jsonb not null,
status text not null default 'queued'
check (status in ('queued', 'running', 'completed')),
priority integer not null default 0,
run_at timestamptz not null default now(),
attempts integer not null default 0 check (attempts >= 0),
max_attempts integer not null default 8 check (max_attempts > 0),
locked_by uuid,
lease_until timestamptz,
last_error text,
idempotency_key text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz
);
create unique index jobs_queue_idempotency_key_uq
on public.jobs (queue, idempotency_key)
where idempotency_key is not null;
create index jobs_claim_ready_idx
on public.jobs (queue, priority desc, run_at, created_at)
where status = 'queued';
create index jobs_expired_lease_idx
on public.jobs (lease_until)
where status = 'running';Indeks parsial jobs_claim_ready_idx membuat pencarian job siap jalan lebih murah karena hanya mencakup job berstatus queued. Indeks lease membantu proses pemulihan menemukan job running yang ditinggalkan worker.
Simpan error terakhir secukupnya. Untuk stack trace besar atau riwayat percobaan yang lengkap, buat tabel log terpisah agar tabel antrean utama tidak membengkak.
Claim batch atomik dengan SKIP LOCKED
FOR UPDATE SKIP LOCKED mengunci baris kandidat tanpa membuat worker lain menunggu pada baris yang sama. Worker kedua akan melewati job yang sudah dikunci worker pertama dan mencari kandidat lain. Ini mengurangi kontensi ketika beberapa worker berjalan paralel.
Karena Supabase Data API bersifat request-based, jangan melakukan pola “SELECT job” lalu “UPDATE job” dalam dua request terpisah. Gunakan fungsi PostgreSQL (RPC) atau koneksi PostgreSQL backend yang menjalankan claim dalam satu transaksi.
create or replace function public.claim_jobs(
p_queue text,
p_worker uuid,
p_batch integer default 10,
p_lease interval default interval '5 minutes'
)
returns setof public.jobs
language sql
security definer
set search_path = public, pg_temp
as $$
with candidates as (
select id
from public.jobs
where queue = p_queue
and status = 'queued'
and run_at <= now()
and attempts < max_attempts
order by priority desc, run_at asc, created_at asc
for update skip locked
limit p_batch
)
update public.jobs j
set status = 'running',
locked_by = p_worker,
lease_until = now() + p_lease,
attempts = j.attempts + 1,
updated_at = now()
from candidates c
where j.id = c.id
returning j.*;
$$;Increment attempts saat claim, bukan hanya saat gagal. Dengan cara ini, worker yang mati berulang kali setelah claim tetap menghabiskan batas percobaan. Nilai now() berasal dari database sehingga worker tidak perlu mempercayai jam mesin lokal.
Lease, heartbeat, dan job yang ditinggalkan
Lease bukan lock permanen. Worker harus memperbaruinya bila handler dapat berjalan lebih lama dari durasi lease. Heartbeat wajib memverifikasi kepemilikan job agar worker lama tidak memperpanjang lease yang sudah diambil worker lain.
update public.jobs
set lease_until = now() + interval '5 minutes',
updated_at = now()
where id = :job_id
and status = 'running'
and locked_by = :worker_id;Pilih lease lebih panjang dari durasi normal handler, lalu heartbeat sebelum lease mendekati habis. Lease yang terlalu pendek meningkatkan duplikasi; lease yang terlalu panjang memperlambat pemulihan setelah worker mati.
Retry, backoff, dan dead-letter table
Ketika handler gagal, job tidak boleh langsung kembali ke antrean tanpa jeda. Retry cepat dapat membebani layanan yang sedang bermasalah dan membuat job baru kelaparan. Terapkan backoff eksponensial dengan batas maksimum serta sedikit jitter acak.
Contoh formula di worker: delay = min(60 * 2^(attempts - 1), 3600) + jitter. Nilai sebenarnya harus disesuaikan dengan karakteristik layanan tujuan. Error permanen seperti payload tidak valid atau kredensial yang jelas salah sebaiknya langsung masuk dead-letter, tanpa menunggu seluruh retry habis.
create table public.dead_letter_jobs (
id uuid primary key,
queue text not null,
payload jsonb not null,
attempts integer not null,
max_attempts integer not null,
last_error text,
failed_at timestamptz not null default now(),
original_created_at timestamptz not null
);Pada kegagalan retryable, lepaskan lease dan jadwalkan ulang job. Update harus dibatasi oleh id, status, dan locked_by; ini mencegah worker yang lease-nya sudah habis menimpa hasil worker baru.
update public.jobs
set status = 'queued',
run_at = now() + :backoff,
locked_by = null,
lease_until = null,
last_error = :error_message,
updated_at = now()
where id = :job_id
and status = 'running'
and locked_by = :worker_id;Jika attempts >= max_attempts, pindahkan job ke dead_letter_jobs dan hapus dari jobs dalam transaksi yang sama. Alternatifnya adalah menambah status dead di tabel yang sama, tetapi tabel dead-letter terpisah biasanya lebih memudahkan retensi, dashboard, dan proses replay.
begin;
with moved as (
delete from public.jobs
where id = :job_id
and status = 'running'
and locked_by = :worker_id
and attempts >= max_attempts
returning *
)
insert into public.dead_letter_jobs (
id, queue, payload, attempts, max_attempts, last_error, original_created_at
)
select id, queue, payload, attempts, max_attempts, :error_message, created_at
from moved;
commit;Buat juga reaper periodik untuk job dengan status = 'running' dan lease_until < now(). Reaper mengembalikan job yang belum melewati batas percobaan ke queued, atau memindahkan job yang sudah habis percobaan ke dead-letter. Jalankan reaper dalam batch kecil dan gunakan FOR UPDATE SKIP LOCKED bila ada lebih dari satu proses pemulihan.
Pseudocode worker yang aman
Worker sebaiknya memiliki workerId unik per proses atau instance. Claim, acknowledge sukses, retry, dan dead-letter idealnya dibungkus sebagai RPC PostgreSQL terpisah yang masing-masing atomik.
workerId = randomUUID()
loop:
jobs = rpc("claim_jobs", {
p_queue: "emails",
p_worker: workerId,
p_batch: 10,
p_lease: "5 minutes"
})
if jobs is empty:
sleep(1000 ms)
continue
for job in jobs with limited concurrency:
try:
startHeartbeat(job.id, workerId)
handle(job.payload, idempotencyKey = job.id)
update jobs
set status = 'completed',
completed_at = now(),
locked_by = null,
lease_until = null,
updated_at = now()
where id = job.id
and status = 'running'
and locked_by = workerId
catch error:
if isPermanent(error) or job.attempts >= job.max_attempts:
rpc("move_to_dead_letter", { job_id: job.id, worker_id: workerId, error })
else:
rpc("retry_job", {
job_id: job.id,
worker_id: workerId,
error,
backoff: calculateBackoff(job.attempts)
})
finally:
stopHeartbeat(job.id)Batasi konkurensi handler. Mengklaim 100 job lalu memprosesnya serial membuat sebagian besar lease habis sebelum diproses. Ukuran batch harus sejalan dengan kapasitas concurrency dan durasi handler.
Idempotensi: pertahanan terhadap efek samping ganda
Lease dan SKIP LOCKED mencegah banyak duplikasi normal, tetapi tidak menghapus semua kemungkinan duplikasi. Contoh klasik: worker berhasil mengirim email, lalu crash sebelum menandai job completed. Setelah lease habis, job diklaim lagi dan email dapat terkirim dua kali.
Karena itu, handler harus idempoten. Pilih mekanisme sesuai jenis efek samping:
- API eksternal: kirim idempotency key yang stabil, misalnya ID job, bila penyedia mendukungnya.
- Perubahan database lokal: gunakan unique constraint pada tabel efek, misalnya
unique(job_id), lalu lakukan insert dengan konflik yang ditangani secara eksplisit. - Webhook masuk: deduplikasi berdasarkan ID event dari penyedia sebelum membuat job.
- Efek samping dan transaksi DB: pertimbangkan pola transactional outbox bila perubahan domain dan pembuatan job harus atomik.
Jangan menganggap status completed saja sebagai bukti exactly-once. Status itu hanya mencatat apa yang diketahui queue setelah worker menyelesaikan langkah acknowledge.
Service role, RPC, dan batasan RLS Supabase
Worker adalah komponen backend tepercaya. Simpan credential service role hanya di environment server, secret manager, container, atau platform function backend. Jangan pernah mengirim service role key ke browser, aplikasi mobile, atau kode frontend yang dapat diinspeksi pengguna.
Pada Supabase, service role umumnya digunakan untuk operasi backend yang perlu melewati kebijakan RLS. Itu berarti worker memiliki akses luas, sehingga endpoint atau function yang memakainya harus tidak dapat dipanggil sembarang pengguna.
- Jangan beri klien publik akses langsung untuk mengubah
status,locked_by, ataulease_until. - Jika memakai fungsi
SECURITY DEFINER, tetapkansearch_pathsecara eksplisit seperti pada contoh untuk mengurangi risiko resolusi objek yang tidak diharapkan. - Batasi hak
EXECUTEfungsi claim, retry, dan dead-letter hanya pada role backend yang diperlukan. - Jika multi-tenant, pastikan tenant ID menjadi bagian dari predicate query, indeks, dan otorisasi. Jangan hanya mengandalkan payload JSON.
- Uji RLS menggunakan role yang benar. Perilaku bypass dapat berbeda bila Anda memakai role PostgreSQL khusus atau tabel dengan pengaturan RLS yang dipaksa.
Metrik operasional dan diagnosis masalah
Metrik yang perlu dipantau
- Queue depth: jumlah job
queuedper queue. - Oldest ready job age: selisih waktu terhadap job siap jalan tertua; ini lebih bermakna daripada depth saja.
- Running dan expired lease: jumlah job berjalan serta job dengan lease kedaluwarsa.
- Success, retry, dan dead-letter rate: ukur per jenis job dan per error.
- Durasi handler dan claim batch size: untuk memilih lease, concurrency, dan ukuran batch.
- Attempts distribution: lonjakan attempt tinggi biasanya menunjukkan dependency gagal atau bug deterministik.
Job terlihat macet di running
Periksa lease kedaluwarsa dan identitas worker:
select id, queue, attempts, locked_by, lease_until, updated_at, last_error
from public.jobs
where status = 'running'
order by lease_until asc nulls first
limit 100;Jika lease_until sudah lewat, pastikan reaper berjalan dan memiliki izin yang benar. Jika belum lewat tetapi job sebenarnya berhenti, lease mungkin terlalu panjang. Jika lease terus diperbarui, periksa heartbeat yang macet atau handler yang tidak memiliki timeout.
Efek samping terjadi dua kali
Cari apakah lease habis saat handler masih aktif, apakah worker crash setelah memanggil layanan eksternal, dan apakah acknowledge gagal. Perbaikan utama bukan sekadar memperpanjang lease: tambahkan idempotency key pada layanan tujuan atau unique constraint pada pencatatan efek lokal.
Antrean menumpuk
Bandingkan pertumbuhan queue depth dengan laju completion. Periksa apakah indeks claim digunakan, apakah banyak job memiliki run_at di masa depan, apakah worker mengklaim batch terlalu besar, dan apakah dependency eksternal melambat. Jalankan EXPLAIN (ANALYZE, BUFFERS) di lingkungan aman untuk query kandidat yang setara, bukan pada query produksi yang berisiko mengunci pekerjaan.
select queue, count(*) as queued,
min(created_at) as oldest_created_at
from public.jobs
where status = 'queued'
and run_at <= now()
group by queue
order by queued desc;Terakhir, siapkan prosedur replay dead-letter: inspeksi penyebabnya, perbaiki data atau kode, lalu buat job baru dengan idempotency key yang sesuai. Jangan melakukan replay massal tanpa rate limit karena kegagalan dependency yang sama dapat terulang dan kembali memenuhi antrean.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!