Elixir untuk Technical Leader — Bab 6 dari 10

OTP & Concurrency

Kenapa Elixir bisa handle 6.300 order/hari tanpa berkeringat — dan kenapa kalau ada yang crash, sistem tetap jalan.


Apa Itu OTP?

OTP singkatan dari Open Telecom Platform. Namanya menipu — ini bukan cuma buat telekomunikasi. OTP adalah kumpulan pattern, library, dan tools untuk membangun sistem yang reliable. Dikembangkan oleh Ericsson sejak 1986, sudah proven selama 30+ tahun.

Bayangkan OTP sebagai SOP perusahaan. SOP bukan produk — SOP adalah cara kerja. Siapa yang kerjakan apa, kalau ada yang sakit siapa penggantinya, kalau ada masalah prosedur eskalasi-nya gimana.

Analogi OTP = SOP gudang LabaBersih. Ada mandor (Supervisor), ada karyawan (GenServer), ada prosedur kalau karyawan sakit (restart). Bukan produk — tapi tanpa SOP, gudang kacau.

OTP memberikan 3 hal utama:

  1. Processes — unit kerja ringan yang jalan bersamaan
  2. GenServer — pattern standar untuk process yang pegang state
  3. Supervisor — pattern untuk mengawasi process lain, restart kalau crash

Semua ini sudah built-in di Elixir/BEAM. Gak perlu install library tambahan. Gak perlu setup infrastructure terpisah.


BEAM Processes — Pekerja Ringan

Di Bab 1 sudah disinggung: BEAM bisa jalankan jutaan proses bersamaan. Sekarang kita bahas lebih detail.

BEAM Process bukan OS Process

Ini penting: BEAM process BUKAN proses sistem operasi. OS process = berat (~1MB RAM, lambat buat/hapus). BEAM process = sangat ringan (~2KB, bisa dibuat/dihapus dalam mikrodetik).

OS Process / ThreadBEAM Process
Ukuran~1MB~2KB
Buat baruLambat (milidetik)Cepat (mikrodetik)
JumlahRibuan (limit OS)Jutaan (limit RAM saja)
CrashBisa mati 1 appCuma mati process itu saja
KomunikasiShared memory (rawan bug)Message passing (aman)

Di LabaBersih: 1 Tab Browser = 1 Process

Setiap user yang buka LabaBersih di browser = 1 LiveView process di server. Process ini pegang state user itu: halaman apa yang dibuka, filter apa yang aktif, data apa yang tampil.

# Nelly buka /app/order di browser tab 1
# → BEAM spawn 1 process: LiveView OrderLive (PID #1234)
#   State: {page: 1, filter: "dikirim", search: ""}

# Nelly buka /app/produk di tab 2
# → BEAM spawn 1 process lagi: LiveView ProdukLive (PID #5678)
#   State: {page: 1, search: "forbest"}

# Hafish buka /app/laporan di laptop dia
# → BEAM spawn 1 process lagi: LiveView LaporanLive (PID #9012)
#   State: {tab: "laba_rugi", bulan: "maret"}

# Total: 3 process. Masing-masing ~2KB. Total ~6KB.
# Kalau PID #1234 crash? PID #5678 dan #9012 GAK KENA.
Buat Hafish Ini kenapa kalau 1 halaman error di LabaBersih v2, halaman lain tetap jalan normal. Di v1 (Next.js), 1 error bisa bikin seluruh app perlu refresh. Di v2, error = 1 process mati, sisanya aman.

Kenapa ini penting untuk 6.300 order/hari?

6.300 order/hari = ~260 order/jam = ~4 order/menit. Tapi order gak datang merata — kadang 50 order sekaligus (import XLSX), kadang 10 Oban job jalan bersamaan (sync TikTok + Facebook + daily journal).

Karena BEAM process ringan, semua ini jalan bersamaan tanpa antri. XLSX import = 1 process. Sync TikTok = 1 process. Sync Facebook = 1 process. Daily journal = 1 process. Masing-masing independent, gak saling blocking.


GenServer — Karyawan yang Duduk di Meja

GenServer = Generic Server. Ini pattern untuk process yang:

  1. Punya state (data yang diingat)
  2. Terima request satu per satu
  3. Bisa reply ke pengirim request
Analogi GenServer = karyawan yang duduk di meja dengan antrian. Orang datang, kasih request, karyawan proses satu-satu, kasih jawaban. Kalau ada 5 request sekaligus, dia antri — satu per satu, gak ada yang kelewat.

Kamu jarang bikin GenServer sendiri. Yang perlu kamu tau: banyak library yang kamu pakai diam-diam pakai GenServer di dalamnya:

Yang kamu pakaiGenServer di dalamnyaState yang dipegang
Lababersih.RepoDatabase connection poolPool koneksi PostgreSQL (10 koneksi)
Phoenix.PubSubPubSub serverDaftar subscriber per topic
ObanJob processorJob queue, schedule, retry state
Setiap LiveView1 GenServer per tab browserAssigns (data halaman user itu)

Contoh: Oban Worker Pakai GenServer

Setiap Oban Worker di LabaBersih otomatis jadi GenServer. Kamu gak lihat GenServer code karena Oban meng-abstract-kan. Tapi di balik layar:

# lib/lababersih/workers/daily_sales_journal.ex
defmodule Lababersih.Workers.DailySalesJournal do
  @moduledoc "Oban worker — generate daily sales journal per toko."

  use Oban.Worker, queue: :default, max_attempts: 3
  #                                       ↑ kalau gagal, coba lagi sampai 3x

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"org_id" => org_id, "date" => date}}) do
    # Ini jalan di GenServer process tersendiri
    # State: job args (org_id, date)
    # Kalau crash: Oban restart, coba lagi (sampai max_attempts)
    Lababersih.Accounting.DailyJournal.generate_for_all_stores(org_id, date)
  end
end

# Yang terjadi di balik layar:
# 1. Oban ambil job dari PostgreSQL table (oban_jobs)
# 2. Spawn GenServer process baru untuk handle job ini
# 3. Panggil perform/1
# 4. Kalau :ok → job selesai, hapus dari queue
# 5. Kalau error → retry sesuai max_attempts
# 6. Process mati (selesai kerja)
Buat Hafish Kamu gak perlu tau cara bikin GenServer. Cukup tau: setiap Oban worker = 1 process temporary yang hidup selama job jalan. Kalau crash, Oban bikin process baru dan coba lagi. Ini kenapa background job di v2 jauh lebih reliable dari v1.

Supervisor — Mandor Gudang

Supervisor = process yang mengawasi process lain. Kalau child process crash, supervisor restart otomatis. Gak perlu intervensi manual.

Analogi Supervisor = mandor gudang. Dia gak packing sendiri — dia ngawasin karyawan. Kalau ada karyawan pingsan (crash), mandor gak ikut pingsan. Dia ambil karyawan baru, kasih meja yang sama, "lanjut kerja". Sisanya gak terganggu.

3 Strategy Supervisor

StrategyArtinyaKapan dipakai
:one_for_one Kalau 1 child crash, restart cuma yang crash Paling umum. LabaBersih pakai ini. Child-child independent.
:one_for_all Kalau 1 child crash, restart semua child Kalau semua child saling tergantung (jarang dipakai)
:rest_for_one Kalau 1 child crash, restart yang crash + semua sesudahnya Kalau child-child punya urutan dependency

Supervision Tree LabaBersih

Ini struktur nyata dari lib/lababersih/application.ex — file yang start semua process saat LabaBersih menyala:

# Supervision Tree LabaBersih (REAL CODE)
#
#   Lababersih.Supervisor  (:one_for_one)
#   ├── Telemetry          → monitoring & metrics
#   ├── Repo               → koneksi database PostgreSQL (pool 10)
#   ├── DNSCluster         → service discovery (Fly.io internal)
#   ├── PubSub             → publish/subscribe untuk real-time
#   ├── Oban               → background job processor
#   │   ├── Cron Plugin    → schedule: DailySalesJournal tiap 23:59 WIB
#   │   ├── Queue: default → max 10 job bersamaan
#   │   ├── Queue: sync    → max 3 job bersamaan
#   │   └── Queue: integrations → max 5 job bersamaan
#   └── Endpoint           → Phoenix web server (HTTP + WebSocket)
#       └── [LiveView processes per user...]

Dan ini kode aslinya (simplified):

# lib/lababersih/application.ex
defmodule Lababersih.Application do
  use Application

  def start(_type, _args) do
    children = [
      LababersihWeb.Telemetry,             # monitoring
      Lababersih.Repo,                     # database
      {Phoenix.PubSub, name: Lababersih.PubSub},  # real-time
      {Oban, Application.fetch_env!(:lababersih, Oban)},  # background jobs
      LababersihWeb.Endpoint               # web server
    ]

    opts = [strategy: :one_for_one, name: Lababersih.Supervisor]
    Supervisor.start_link(children, opts)
    # ↑ "Start semua children, kalau 1 crash restart CUMA yang crash"
  end
end
Buat Hafish File application.ex ini kayak "daftar siapa aja yang harus jalan saat app nyala". Urutan penting: Repo (database) harus nyala duluan, baru Endpoint (web server) — karena web server butuh database. Kalau Repo crash, supervisor restart Repo aja — Endpoint tetap jalan (sambil nunggu Repo balik).

Filosofi "Let It Crash"

Ini filosofi paling kontraintuitive di Elixir/Erlang, tapi juga paling powerful:

Jangan coba tangkap semua error. Biarkan process crash. Supervisor akan restart.

Defensive Programming (v1 style)

Setiap function: try-catch.
Setiap API call: if error... else...
Setiap database query: handle 20 possible error.

Hasilnya: code penuh error handling. Logic bisnis tenggelam di antara try-catch. Bug tetap lolos — karena kamu gak bisa prediksi SEMUA error.
Let It Crash (v2 style)

Business logic: fokus happy path.
Expected error: pattern match {:ok}/{:error}.
Unexpected error: biarkan crash.

Hasilnya: code bersih, readable. Supervisor restart process. Process baru = clean slate — gak ada corrupted state.
# DEFENSIVE (JavaScript v1) — coba tangkap semua
# try {
#   const order = await getOrder(id)
#   if (!order) throw new Error("not found")
#   try {
#     const result = await shipOrder(order)
#     if (!result.success) { ... }
#   } catch (shipError) { ... }
# } catch (error) {
#   // ... handle generic error
# }

# LET IT CRASH (Elixir v2) — tangkap yang expected, sisanya biarkan
def ship_order(order_id, actor_email) do
  Repo.transaction(fn ->
    order = get_order!(order_id)          # gak ada? crash → supervisor restart

    case order.status do
      "dikemas" -> :ok                   # expected: lanjut
      status -> Repo.rollback({:invalid_status, status})  # expected error: rollback
    end

    # ... potong stok, jurnal, audit
    # Kalau database tiba-tiba disconnect? CRASH.
    # Supervisor restart. Transaction auto-rollback.
    # Data aman (immutable sampai commit).
  end)
end
Kenapa ini aman? Karena immutability (Bab 1). Data gak bisa rusak di tengah jalan. Kalau process crash sebelum Repo.transaction commit, database rollback — data kembali seperti semula. Process baru start dengan clean slate, bukan state yang setengah jadi.

Ericsson pakai filosofi ini untuk switch telepon yang uptime-nya 99.9999999% (9 angka 9 — downtime cuma 0.6 detik per TAHUN). "Let it crash" bukan ceroboh — ini strategy yang terbukti paling reliable.


Application — Titik Awal Segalanya

Application = top-level entry point. Saat kamu jalankan mix phx.server, yang pertama dipanggil adalah Lababersih.Application.start/2.

Application = manajer pabrik. Dia yang putuskan: "hari ini siapa yang kerja, urutan apa, kalau ada masalah prosedur-nya gimana".

Semua yang LabaBersih butuhkan untuk jalan sudah di-list di children:

ChildFungsiKenapa penting
TelemetryMonitoring & metricsUkur query time, request time, memory
RepoKoneksi PostgreSQLPool 10 koneksi — semua query lewat sini
PubSubPublish/SubscribeLiveView real-time updates
ObanBackground jobsDaily journal, sync API, health check
EndpointWeb serverTerima HTTP + WebSocket dari browser

Kalau kamu tambah fitur baru yang butuh long-running process (misalnya: cache server, rate limiter), dia harus ditambahkan di list children ini — supaya supervisor yang awasi.


Oban — Background Job Queue

Oban = library untuk menjalankan pekerjaan background. Dibangun di atas PostgreSQL (gak perlu Redis). Kalau app restart, job yang belum selesai gak hilang — karena disimpan di database.

Kenapa butuh background job?

Ada pekerjaan yang gak boleh blocking user:

Semua ini jalan di belakang layar via Oban. User tetap bisa pakai app normal.

Oban Jobs di LabaBersih

WorkerQueueScheduleMax RetryFungsi
DailySalesJournal default 23:59 WIB setiap hari 3x Generate jurnal penjualan + HPP per toko per hari
SyncFacebook sync Tiap jam (planned) 3x Sync Facebook Ads spend data (3 hari ke belakang)
SyncTikTokAds sync Tiap jam (planned) 3x Sync TikTok Ads (GMV Max + Web Conversion)
SyncScaleV sync Tiap jam (planned) 3x Auto-import ScaleV confirmed orders
HealthCheck default Tiap jam (planned) 1x Cek semua integrasi masih aktif, token belum expired

Queue = Antrian Kerja

LabaBersih punya 3 queue dengan batas concurrency berbeda:

# config/config.exs
config :lababersih, Oban,
  repo: Lababersih.Repo,
  queues: [
    default: 10,        # max 10 job bersamaan (jurnal, health check)
    integrations: 5,    # max 5 job bersamaan (API calls)
    sync: 3             # max 3 job bersamaan (sync data)
  ],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       # 23:59 WIB = 16:59 UTC
       {"59 16 * * *", Lababersih.Workers.DailySalesJournal}
     ]}
  ]
Buat Hafish Kenapa beda queue? Karena sync API bisa lambat (1 menit per call). Kalau semua masuk 1 queue dengan limit 10, bisa jadi 10 slot terisi sync API — daily journal gak kebagian slot. Dengan queue terpisah, daily journal selalu punya slot sendiri.

v1 vs v2: Background Job Comparison

v1 (pg_cron + Edge Functions)

4 cron job hardcoded di PostgreSQL
No retry — kalau gagal, hilang
No monitoring — gak tau gagal apa
No queue — semua jalan sekaligus
Log di Supabase (sulit dicari)
Schedule hardcoded di SQL
v2 (Oban + PostgreSQL)

Unlimited workers, configurable
Auto-retry dengan backoff (1x, 2x, 3x)
LiveDashboard monitoring (buka browser)
Queue dengan concurrency limit
Job history di database (queryable)
Schedule di Elixir config (version controlled)
Aspekv1 (pg_cron)v2 (Oban)
Jumlah job4 (hardcoded)5+ (easily extensible)
RetryGak adaConfigurable per worker (1-20x)
MonitoringGak ada (cek log manual)LiveDashboard + Telemetry
Job historyGak adaDi tabel oban_jobs (queryable)
Concurrency controlGak ada (semua jalan sekaligus)Per-queue limit
Job gagalHilang, gak ada recordDisimpan dengan error detail, bisa retry manual
Unique jobGak ada (bisa double-run)Unique constraint (cegah duplicate)
Manual triggerGak bisaOban.insert/1 dari IEx atau code

PubSub — Real-Time Communication

PubSub = Publish/Subscribe. Pattern dimana:

Di LabaBersih, PubSub dipakai untuk LiveView real-time update:

# Contoh: Order baru masuk (dari import XLSX)

# 1. Context (publisher) — broadcast setelah create order
Phoenix.PubSub.broadcast(
  Lababersih.PubSub,           # PubSub server
  "orders:#{org_id}",           # topic
  {:order_created, order}       # pesan
)

# 2. LiveView (subscriber) — listen di mount
def mount(_params, _session, socket) do
  Phoenix.PubSub.subscribe(Lababersih.PubSub, "orders:#{org_id}")
  # ↑ "Saya mau tau kalau ada perubahan order di org ini"
end

# 3. LiveView handle pesan — auto-update UI
def handle_info({:order_created, order}, socket) do
  # Tambahkan order baru ke list tanpa user refresh browser
  socket = update(socket, :orders, fn orders -> [order | orders] end)
  {:noreply, socket}
end
Analogi PubSub = speaker pengumuman di gudang. Kalau ada paket baru masuk (publish), semua orang yang pasang earpiece di channel "paket" (subscribe) langsung denger. Yang lagi di channel "retur" gak keganggu.

Implikasi praktis: Nelly lagi buka halaman Pesanan. Hafish import 300 order via XLSX dari laptop dia. Tanpa Nelly refresh, stat cards di halaman dia bisa auto-update — karena PubSub push perubahan ke semua LiveView yang subscribe topic orders.


Task — Kerja Singkat Tanpa Nunggu

Task = cara paling simple untuk jalankan sesuatu secara async (gak perlu nunggu selesai).

# Contoh: kirim email setelah invite member
# Gak perlu nunggu email terkirim untuk kasih response ke user

def invite_member(org_id, email, role) do
  # 1. Create member record (sync — harus selesai)
  {:ok, member} = create_org_member(org_id, email, role)

  # 2. Kirim email (async — biar jalan di belakang)
  Task.start(fn ->
    Lababersih.Mailer.deliver_invite_email(email, org_id, role)
  end)
  # ↑ Ini return langsung, gak nunggu email terkirim
  # User langsung lihat "Undangan berhasil dikirim"

  {:ok, member}
end

Bedanya Task vs Oban:

TaskOban
DurasiSingkat (detik)Bisa lama (menit)
PersistGak — hilang kalau app restartYa — disimpan di PostgreSQL
RetryGak adaConfigurable
MonitoringGak adaLiveDashboard
Pakai untukKirim email, log, notifikasiSync API, generate jurnal, heavy work

Kenapa Ini Penting untuk Scale

Sekarang kamu tau semua pieces-nya. Mari lihat gimana mereka bekerja bersamaan saat LabaBersih handle 6.300 order/hari:

# Skenario: Jam 10 pagi, Nelly import XLSX 300 order
# Bersamaan: Oban sync TikTok Ads
# Bersamaan: Hafish buka dashboard
# Bersamaan: 2 staff gudang buka halaman produk

# Yang terjadi di BEAM:

# Process 1: Nelly LiveView (import XLSX)
#   → Parse 300 order
#   → Insert ke database (batch, Repo.transaction)
#   → PubSub broadcast: "300 order baru!"

# Process 2: Oban Worker (sync TikTok Ads)
#   → HTTP call ke TikTok API
#   → Upsert ad reports ke database
#   → Gak ganggu Nelly sama sekali

# Process 3: Hafish LiveView (dashboard)
#   → Terima PubSub: "300 order baru!"
#   → Auto-refresh stat cards (tanpa manual refresh)

# Process 4-5: Staff gudang LiveView (produk)
#   → Baca data produk dari database
#   → Gak kena impact import XLSX

# Total: 5 process jalan BERSAMAAN
# Memory: ~10KB (5 × 2KB)
# Kalau Process 2 (TikTok sync) crash?
#   → Oban restart, coba lagi
#   → Process 1, 3, 4, 5 GAK KENA
Buat Hafish Ini fundamental advantage Elixir. Di v1 (Next.js + Edge Functions), kalau cron sync jalan bersamaan dengan user request, mereka compete for the same resources. Di v2, setiap process punya resource sendiri — gak saling ganggu. Ini kenapa app gak lag walaupun background job lagi jalan.

6.300 Order/Hari: Apakah 1 Server Cukup?

Hitung cepat:

Fly.io server LabaBersih (shared-cpu-1x, 256MB RAM) bisa handle ini dengan sangat mudah. Kamu baru perlu upgrade kalau traffic naik 10-50x dari sekarang.

Dan kalau sudah perlu scale? Tinggal tambah BEAM node. Arsitektur gak perlu berubah (ini bawaan BEAM dari Ericsson, designed for distributed systems).


Checklist Review — Pertanyaan yang Bisa Kamu Tanya

Setelah baca bab ini, kamu sekarang bisa tanya hal-hal ini ke Claude:

PertanyaanJawaban yang benarRed flag
"Function ini jalan sebagai background job atau synchronous?" "Ini Oban worker, jalan di background, gak blocking user."
atau: "Ini sync, user nunggu hasilnya."
"Saya gak yakin." ← developer harus tau
"Kalau Oban job ini gagal, apa yang terjadi?" "Retry otomatis sampai max_attempts (3x). Kalau tetap gagal, disimpan di oban_jobs dengan status discarded + error log." "Job hilang." ← SALAH, Oban persist di database
"Process ini diawasi supervisor?" "Ya, di supervision tree, strategy :one_for_one." "Gak perlu supervisor." ← BAHAYA kalau long-running
"Long-running process ini punya supervisor?" "Ya, di-start di Application children list." "Saya start manual pakai Task.start." ← RED FLAG — kalau crash gak ada yang restart
"Kenapa gak pakai Redis untuk job queue?" "Oban pakai PostgreSQL yang kita sudah punya. 1 fewer dependency. Persist otomatis. Untuk scale LabaBersih, PostgreSQL queue lebih dari cukup." "Redis lebih cepat." ← Overkill untuk scale kita

Red Flags — Tanda Bahaya di Code Review

Kalau kamu lihat hal-hal ini saat review, tanya kenapa:

Red FlagKenapa bahayaSolusi
Task.start untuk pekerjaan penting Task gak persist — kalau app restart, kerjaannya hilang Pakai Oban untuk kerja penting yang gak boleh hilang
GenServer dengan :infinity timeout Kalau handler hang, process freeze selamanya Selalu set timeout yang wajar
Process tanpa supervisor Kalau crash, gak ada yang restart. Silent failure. Taruh di Application children atau DynamicSupervisor
GenServer yang pegang banyak data 1 GenServer = 1 process = single mailbox. Kalau data besar, jadi bottleneck. Data besar simpan di database (Repo), bukan di process memory
Process.sleep di production code Blocking process. Di BEAM ini gak fatal, tapi biasanya sign of bad design. Pakai Process.send_after atau Oban schedule
try-catch di mana-mana Melawan filosofi "let it crash". Code jadi cluttered. Error handling semu. Pakai pattern match {:ok, _} / {:error, _}. Biarkan unexpected error crash.

Ringkasan Bab 6

7 hal yang kamu sekarang tau:

1. OTP = SOP buat sistem reliable, bukan cuma telekomunikasi. Proven 30+ tahun.
2. BEAM process = pekerja ringan (~2KB). 1 tab browser = 1 process. Crash 1, sisanya aman.
3. GenServer = karyawan yang pegang state, terima request satu-satu. Oban worker, Repo, PubSub semua pakai GenServer di dalamnya.
4. Supervisor = mandor. Anak buah crash? Restart. Strategy :one_for_one = restart cuma yang crash.
5. "Let it crash" = jangan try-catch semua. Biarkan crash, supervisor restart dengan clean slate. Ini BUKAN ceroboh — ini strategy paling reliable.
6. Oban = background job queue di PostgreSQL. Retry otomatis, monitoring via LiveDashboard, persist kalau app restart. Jauh lebih baik dari v1 pg_cron.
7. 6.300 order/hari = ringan buat BEAM. Setiap operasi ~5ms, concurrent tanpa blocking. 1 server Fly.io cukup.