Bukan Sekadar SQL: Saat API Fan-out Bikin Monitor Mati
Perbaikan N+1 di commitcheck: satu kali paginasi gantikan 250 request, timeout SSL hilang dan eksekusi turun ke 27,5 detik.
Ringkasan
Awalnya dikira jaringan lemot, ternyata script nge-loop paginasi tiap kandidat jadi 250 request numpuk dan bikin timeout. Solusinya males tapi ngaruh, paginasi cukup sekali di awal terus dibikin kamus biar tinggal cek aja. Habis di-patch requestnya anjlok drastis dan waktu eksekusi turun dari dua menitan jadi cuma 27 detik.
Monitor yang tiba-tiba hang
Saya buka log monitor 15 menitan dan cuma nemu satu baris: raw SSL read traceback di articles.py:1641. Script commitcheck yang biasanya selesai 20-an detik tiba-tiba hang lebih dari seratus detik. Dugaan pertama saya jelas salah, saya kira ini gangguan jaringan atau rate limit GitHub.
Saya coba curl manual ke endpoint artikel dengan per_page=100, hasilnya sehat 1,5 detik. Saya jalanin commitcheck lokal dengan timeout diperpanjang, memang selesai tapi hampir dua menit. Bukan jaringan. Ini pola.
Satu kali paginasi gantikan 250 request
Kita hafal N+1 di ORM, tapi jarang sadar pola sama bisa kejadian di HTTP. Fungsi lama _covered_by_article(base, key, sha) dipanggil di dalam loop kandidat. Tiap panggilan manggil paginasi penuh lagi. Hitungannya nyeri: sekitar 62 kandidat per tick dikali sekitar 4 halaman jadi sekitar 250 panggilan HTTPS per eksekusi [2]. Tiap request bawa overhead DNS, TLS handshake, dan latency, monitor pun timeout SSL.
Standar industri memang mewanti-wanti. AIP-158 mewajibkan paginasi sejak awal karena menundanya jadi backwards-incompatible change, default 50 bisa motong koleksi 75 begitu saja [1]. GitHub cuma balikin subset default 30 isu padahal ada 1600 dan maksa navigasi lewat Link header [2]. Jadi tiap loop yang ngulang paginasi dari nol itu buang sumber daya.
Solusi paling malas yang paling bener: paginasi sekali di awal, lalu cek keanggotaan. Saya ganti _covered_by_article jadi _covered_map(base, key) yang ngembaliin dict[str, str] berisi {source_commit: slug}. Di call-site tinggal sha not in covered_map. Navigasi halamannya tetap hormatin HTTP Link header dengan rel next [3]. Kontrak dijaga, bentuk JSON, exit code, cursor nggak berubah.
# SEBELUM, N+1 fan-out (per kandidat paginasi lagi)
def _covered_by_article(base: str, key: str, sha: str):
page = 1
while True:
code, resp = api(base, key, "GET", f"/articles?per_page=100&page={page}")
for x in resp["data"]:
if (x.get("meta") or {}).get("source_commit") == sha:
return x["slug"]
if page >= resp["meta"]["total_pages"]:
return None
page += 1
# SESUDAH, satu kali pass
def _covered_map(base: str, key: str) -> dict[str, str]:
out: dict[str, str] = {}
page = 1
while True:
code, resp = api(base, key, "GET", f"/articles?per_page=100&page={page}")
for x in resp["data"]:
m = (x.get("meta") or {}).get("source_commit")
if m:
out[m] = x["slug"]
if page >= resp["meta"]["total_pages"]:
return out
page += 1
GitHub juga ngasih tau maksimal per_page adalah 100 dan nilai lebih besar diam-diam di-coerce ke 100 tanpa error [2]. Jadi 100 per halaman itu batas atas yang disengaja. Token halaman pun harus opaque dan URL-safe biar nggak dibongkar user [1].
Hasil yang bisa saya verify sendiri
Setelah patch, saya ukur lagi. Dari hang >100 detik jadi 27,5 detik. Dari sekitar 250 request per tick jadi cuma beberapa request awal buat bangun kamus. Saya sengaja nggak nambah timeout monitor, nambah timeout cuma nutupin bau, bukan buang sampahnya.
Saya pribadi pilih mapping sekali ini ketimbang cache jaringan. Cache bisa bantu, tapi akar masalahnya di loop. Kalau kamu pernah lihat log dengan pola request berulang yang mirip cuma beda page=, coba ubah jadi satu pengambilan di awal. Lebih malas, lebih kenceng.
Sources
[1] google.aip.dev/158
[2] docs.github.com, Using pagination in the REST API
[3] developer.mozilla.org, Link header