Elixir untuk Technical Leader — Bab 6 dari 10
Kenapa Elixir bisa handle 6.300 order/hari tanpa berkeringat — dan kenapa kalau ada yang crash, sistem tetap jalan.
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.
OTP memberikan 3 hal utama:
Semua ini sudah built-in di Elixir/BEAM. Gak perlu install library tambahan. Gak perlu setup infrastructure terpisah.
Di Bab 1 sudah disinggung: BEAM bisa jalankan jutaan proses bersamaan. Sekarang kita bahas lebih detail.
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 / Thread | BEAM Process | |
|---|---|---|
| Ukuran | ~1MB | ~2KB |
| Buat baru | Lambat (milidetik) | Cepat (mikrodetik) |
| Jumlah | Ribuan (limit OS) | Jutaan (limit RAM saja) |
| Crash | Bisa mati 1 app | Cuma mati process itu saja |
| Komunikasi | Shared memory (rawan bug) | Message passing (aman) |
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.
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 = Generic Server. Ini pattern untuk process yang:
Kamu jarang bikin GenServer sendiri. Yang perlu kamu tau: banyak library yang kamu pakai diam-diam pakai GenServer di dalamnya:
| Yang kamu pakai | GenServer di dalamnya | State yang dipegang |
|---|---|---|
Lababersih.Repo | Database connection pool | Pool koneksi PostgreSQL (10 koneksi) |
Phoenix.PubSub | PubSub server | Daftar subscriber per topic |
Oban | Job processor | Job queue, schedule, retry state |
| Setiap LiveView | 1 GenServer per tab browser | Assigns (data halaman user itu) |
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)
Supervisor = process yang mengawasi process lain. Kalau child process crash, supervisor restart otomatis. Gak perlu intervensi manual.
| Strategy | Artinya | Kapan 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 |
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
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).
Ini filosofi paling kontraintuitive di Elixir/Erlang, tapi juga paling powerful:
Jangan coba tangkap semua error. Biarkan process crash. Supervisor akan restart.
# 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
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 = 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:
| Child | Fungsi | Kenapa penting |
|---|---|---|
Telemetry | Monitoring & metrics | Ukur query time, request time, memory |
Repo | Koneksi PostgreSQL | Pool 10 koneksi — semua query lewat sini |
PubSub | Publish/Subscribe | LiveView real-time updates |
Oban | Background jobs | Daily journal, sync API, health check |
Endpoint | Web server | Terima 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 = 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.
Ada pekerjaan yang gak boleh blocking user:
Semua ini jalan di belakang layar via Oban. User tetap bisa pakai app normal.
| Worker | Queue | Schedule | Max Retry | Fungsi |
|---|---|---|---|---|
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 |
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}
]}
]
| Aspek | v1 (pg_cron) | v2 (Oban) |
|---|---|---|
| Jumlah job | 4 (hardcoded) | 5+ (easily extensible) |
| Retry | Gak ada | Configurable per worker (1-20x) |
| Monitoring | Gak ada (cek log manual) | LiveDashboard + Telemetry |
| Job history | Gak ada | Di tabel oban_jobs (queryable) |
| Concurrency control | Gak ada (semua jalan sekaligus) | Per-queue limit |
| Job gagal | Hilang, gak ada record | Disimpan dengan error detail, bisa retry manual |
| Unique job | Gak ada (bisa double-run) | Unique constraint (cegah duplicate) |
| Manual trigger | Gak bisa | Oban.insert/1 dari IEx atau code |
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
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 = 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:
| Task | Oban | |
|---|---|---|
| Durasi | Singkat (detik) | Bisa lama (menit) |
| Persist | Gak — hilang kalau app restart | Ya — disimpan di PostgreSQL |
| Retry | Gak ada | Configurable |
| Monitoring | Gak ada | LiveDashboard |
| Pakai untuk | Kirim email, log, notifikasi | Sync API, generate jurnal, heavy work |
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
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).
Setelah baca bab ini, kamu sekarang bisa tanya hal-hal ini ke Claude:
| Pertanyaan | Jawaban yang benar | Red 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 |
Kalau kamu lihat hal-hal ini saat review, tanya kenapa:
| Red Flag | Kenapa bahaya | Solusi |
|---|---|---|
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. |
:one_for_one = restart cuma yang crash.