Pada sistem backend modular, hot reload config sering dianggap aman karena tidak memerlukan restart proses. Masalahnya, reload konfigurasi tidak otomatis berarti semua dependensi runtime ikut diperbarui. Dalam studi kasus ini, bug berada di registry plugin yang tetap menyimpan instance singleton lama setelah konfigurasi di-reload, sehingga request baru masih diarahkan ke handler usang.

Efeknya tidak selalu langsung fatal. Gejalanya justru lebih berbahaya: sebagian request memakai perilaku baru, sebagian lagi tetap memakai konfigurasi lama, dan penggunaan memori naik perlahan karena instance handler lama tidak pernah benar-benar dilepas. Artikel ini membedah gejala, jalur investigasi, akar masalah, contoh pseudocode, langkah reproduksi, perbaikan, validasi pasca-fix, serta checklist pencegahan untuk backend berbasis plugin atau modul dinamis.

Arsitektur singkat: service modular dengan plugin registry

Konteksnya mirip engine atau platform yang membuka sistem plugin ke publik: inti aplikasi menyediakan kontrak, lifecycle, registry, dan loader; plugin menyediakan implementasi handler untuk fitur tertentu. Di backend, pola ini umum untuk:

  • handler protokol atau adapter integrasi,
  • processor event berdasarkan tipe tenant,
  • policy evaluator yang berubah lewat config,
  • storage backend yang bisa diganti tanpa restart penuh.

Struktur sederhananya biasanya seperti ini:

  • Config Store: sumber konfigurasi aktif.
  • Plugin Factory: membangun instance plugin berdasarkan config.
  • Plugin Registry: menyimpan plugin aktif agar dapat dipakai ulang.
  • Request Path: mengambil handler dari registry untuk memproses request.

Bug muncul ketika registry dirancang sebagai cache singleton yang stabil, tetapi sistem juga mendukung reload config di runtime. Jika lifecycle plugin tidak dikelola dengan jelas, reload hanya mengubah sumber config, bukan objek yang sudah telanjur hidup di memori.

Gejala awal di produksi

Insiden ini biasanya tidak terlihat sebagai crash. Tanda-tandanya lebih halus:

  • Setelah perubahan config, perilaku request tidak konsisten.
  • Tenant A terlihat memakai endpoint atau aturan baru, tenant B masih memakai yang lama.
  • Grafik memori proses naik perlahan setelah beberapa kali reload config.
  • Jumlah objek handler aktif lebih tinggi dari jumlah plugin yang seharusnya aktif.
  • Restart service “menyembuhkan” masalah sementara.

Yang membuat debugging sulit adalah fakta bahwa request tetap berhasil secara fungsional. Tidak ada error besar, hanya hasil yang sesekali salah atau tidak sinkron dengan konfigurasi terbaru.

Contoh gejala operasional

Misalnya ada plugin DiscountPolicyHandler yang membaca aturan diskon dari config. Setelah admin memperbarui aturan dan memicu hot reload:

  • request baru di node tertentu sudah memakai aturan baru,
  • request lain di node yang sama masih memakai aturan lama,
  • latensi sedikit naik karena beberapa plugin lama masih memegang resource internal,
  • heap bertambah setiap reload walaupun traffic stabil.

Secara kasat mata, ini bisa disalahartikan sebagai masalah cache, eventual consistency, atau race condition pada distribusi config.

Timeline investigasi: dari hipotesis salah ke akar masalah

1. Dugaan awal: config belum tersinkronisasi

Hipotesis pertama biasanya mengarah ke sistem distribusi konfigurasi:

  • watcher config terlambat,
  • beberapa instance belum menerima event reload,
  • ada propagation delay antar node.

Ini masuk akal, tetapi tidak cukup menjelaskan kenapa dalam satu proses yang sama hasil request bisa berbeda setelah reload yang diklaim sukses.

2. Dugaan kedua: cache business data belum di-invalidasi

Tim lalu mengecek layer cache lain, misalnya cache policy atau hasil komputasi per tenant. Lagi-lagi masuk akal, karena cache stale memang sering memunculkan perilaku campuran. Namun setelah cache dimatikan di staging, bug tetap muncul.

3. Dugaan ketiga: race condition saat reload

Selanjutnya perhatian beralih ke concurrency. Ada kemungkinan request yang sedang berjalan masih memakai objek lama, sementara request berikutnya sudah memakai yang baru. Ini memang bisa terjadi sesaat, tetapi tidak menjelaskan kenapa instance lama tetap hidup lama setelah reload selesai.

4. Temuan penting: registry tidak benar-benar mengganti instance aktif

Setelah menambahkan logging identity object dan metrik jumlah plugin aktif, terlihat bahwa:

  • reload config berhasil membangun instance plugin baru,
  • tetapi registry masih mengembalikan singleton lama untuk beberapa lookup,
  • instance lama tetap direferensikan oleh map internal dan callback event,
  • instance baru ada, tetapi tidak selalu menjadi sumber kebenaran untuk request berikutnya.

Di titik ini, masalah bergeser dari “distribusi config” menjadi “lifecycle object pada registry plugin”.

Root cause: registry singleton bocor saat hot reload config

Akar masalahnya sederhana tetapi dampaknya besar: registry plugin memakai cache singleton yang tidak di-invalidasi dengan benar saat hot reload config. Akibatnya, ada dua kegagalan sekaligus:

  1. Stale handler: request baru masih bisa mendapatkan handler lama.
  2. Leak ringan: handler lama tidak dibuang karena masih direferensikan registry, listener, atau closure.

Pola bug ini umum pada sistem yang awalnya didesain dengan asumsi “plugin dimuat sekali saat startup”, lalu belakangan ditambah fitur reload tanpa merombak kontrak lifecycle.

Pseudocode yang bermasalah

class PluginRegistry {
  constructor(factory, configStore) {
    this.factory = factory
    this.configStore = configStore
    this.singletons = new Map()
  }

  get(pluginName) {
    if (this.singletons.has(pluginName)) {
      return this.singletons.get(pluginName)
    }

    const config = this.configStore.get(pluginName)
    const instance = this.factory.create(pluginName, config)
    this.singletons.set(pluginName, instance)
    return instance
  }

  reloadAll() {
    const latestConfig = this.configStore.snapshot()

    for (const pluginName of latestConfig.pluginNames()) {
      const config = latestConfig.get(pluginName)
      const newInstance = this.factory.create(pluginName, config)

      // Bug: instance baru dibuat, tetapi tidak selalu mengganti referensi lama
      // Bug lain: instance lama tidak di-dispose
      this.factory.warmup(newInstance)
    }
  }
}

Masalahnya bukan sekadar “lupa set ke map”. Di implementasi nyata, kebocoran sering lebih terselubung:

  • map registry diganti sebagian, bukan atomik,
  • alias plugin lama masih menunjuk ke instance usang,
  • listener event atau scheduled task tetap memegang referensi ke handler lama,
  • dependency lain menyimpan pointer langsung ke plugin hasil lookup sebelumnya.

Kenapa ini memicu perilaku inkonsisten?

Karena setelah reload, sistem memiliki lebih dari satu jalur akses ke plugin:

  • jalur A mengambil langsung dari registry terbaru,
  • jalur B memakai referensi yang di-cache di service lain,
  • jalur C memakai handler lama yang masih hidup karena listener belum dilepas.

Semua jalur itu masih valid dari sudut pandang runtime, sehingga tidak muncul exception. Tetapi semantik bisnisnya rusak: request berbeda berjalan di atas konfigurasi yang berbeda.

Langkah reproduksi yang realistis

Bug semacam ini sebaiknya direproduksi di lingkungan lokal atau staging dengan skenario minimal berikut:

  1. Jalankan service dengan plugin A yang membaca nilai dari config, misalnya mode=strict.
  2. Kirim beberapa request dan catat response serta identity handler aktif.
  3. Ubah config plugin A menjadi mode=relaxed.
  4. Picu hot reload tanpa restart proses.
  5. Kirim request baru berulang kali.
  6. Amati apakah semua request konsisten memakai konfigurasi baru.
  7. Ulangi reload beberapa kali sambil memantau jumlah instance dan penggunaan memori.

Pseudocode reproduksi

for i in range(1, 4):
  call("POST", "/reload-config", { "discount.mode": "strict" })
  call("GET", "/quote?tenant=t1")

  call("POST", "/reload-config", { "discount.mode": "relaxed" })
  call("GET", "/quote?tenant=t1")

# Tambahkan endpoint debug sementara:
# GET /debug/plugins
# -> menampilkan pluginName, instanceId, configHash, createdAt

Jika bug ada, Anda biasanya akan melihat salah satu dari ini:

  • instanceId handler tidak berubah padahal config hash berubah,
  • ada lebih dari satu instance hidup untuk plugin yang sama,
  • response kadang strict, kadang relaxed, tanpa pola yang jelas.

Perbaikan: lifecycle eksplisit dan swap atomik

Perbaikan yang aman bukan hanya “hapus cache”. Tujuannya adalah membuat lifecycle plugin eksplisit: build, activate, serve, retire, dispose. Registry harus menjadi satu-satunya sumber kebenaran, dan perpindahan dari instance lama ke baru harus dilakukan secara atomik.

Prinsip perbaikannya

  • Bangun instance baru berdasarkan snapshot config terbaru.
  • Validasi dan warm-up instance baru sebelum diaktifkan.
  • Lakukan atomic swap di registry.
  • Tandai instance lama sebagai retiring agar tidak dipakai request baru.
  • Lepas listener, timer, dan resource dari instance lama lewat metode dispose.
  • Hindari service lain menyimpan referensi plugin terlalu lama di luar registry.

Pseudocode perbaikan

class PluginRegistry {
  constructor(factory, configStore) {
    this.factory = factory
    this.configStore = configStore
    this.active = new Map()
  }

  get(pluginName) {
    return this.active.get(pluginName)
  }

  reload(pluginName) {
    const config = this.configStore.get(pluginName)
    const nextInstance = this.factory.create(pluginName, config)

    nextInstance.validate()
    nextInstance.warmup()

    const prevInstance = this.active.get(pluginName)

    // Swap atomik: setelah baris ini, request baru hanya melihat instance baru
    this.active.set(pluginName, nextInstance)

    if (prevInstance) {
      prevInstance.beginRetire()
      prevInstance.dispose()
    }
  }
}

Pada sistem dengan concurrency tinggi, implementasi nyatanya mungkin membutuhkan sinkronisasi tambahan. Misalnya:

  • read-write lock,
  • pointer atomik,
  • grace period untuk request in-flight,
  • reference counting jika handler tidak boleh dibuang sebelum semua request selesai.

Trade-off yang perlu dipahami

Ada beberapa pilihan desain, masing-masing dengan konsekuensi:

  • Singleton + reload: efisien, tetapi lifecycle harus sangat disiplin.
  • Factory per request: lebih aman dari stale singleton, tetapi bisa lebih mahal jika inisialisasi plugin berat.
  • Versioned handler: request lama menyelesaikan diri di versi lama, request baru memakai versi baru; lebih kompleks tetapi aman untuk transisi.

Untuk plugin yang memegang koneksi, scheduler, atau subscription, pendekatan versioned atau explicit retirement biasanya lebih aman daripada mengganti referensi begitu saja.

Validasi pasca-fix

Perbaikan belum selesai sampai ada pembuktian bahwa dua gejala utama hilang: inkonsistensi perilaku dan kebocoran memori ringan.

1. Uji konsistensi perilaku

  • Setelah reload, semua request baru harus memakai config hash yang sama.
  • Tidak boleh ada response campuran untuk input identik setelah masa transisi berakhir.
  • Endpoint debug harus menunjukkan satu instance aktif per plugin.

2. Uji lifecycle object

  • Setiap reload harus menambah satu instance baru dan mengurangi satu instance lama.
  • Jumlah listener, goroutine/thread/task, atau timer yang terkait plugin tidak boleh terus naik.
  • Metode dispose harus terpanggil tepat sekali untuk setiap instance yang dipensiunkan.

3. Uji memori dan resource

  • Lakukan reload config berulang kali dalam loop.
  • Pantau heap, object count, file descriptor, atau koneksi internal sesuai runtime Anda.
  • Pastikan penggunaan resource kembali stabil setelah beberapa siklus reload.

4. Uji request in-flight

Jika sistem mengizinkan request panjang, pastikan transisi tidak memutus request yang sedang berjalan. Solusi umum:

  • instance lama diberi grace period,
  • request baru diblokir masuk ke instance lama,
  • dispose final dilakukan setelah tidak ada request aktif.

Logging dan metrics yang benar-benar membantu

Pada bug seperti ini, observability lebih penting daripada menambah breakpoint acak. Data yang berguna adalah data lifecycle, bukan hanya log request biasa.

Logging yang disarankan

  • plugin_name
  • instance_id unik per konstruksi object
  • config_hash atau versi config
  • event: create, activate, retire, dispose
  • reload_id untuk mengelompokkan satu siklus reload
  • request_id agar request dapat ditelusuri ke handler yang dipakai

Contoh log yang berguna:

{
  "event": "plugin_activated",
  "plugin_name": "discount_policy",
  "instance_id": "dp-1842",
  "config_hash": "c91d...",
  "reload_id": "r-77"
}

Metrics yang relevan

  • jumlah instance aktif per plugin,
  • jumlah instance retired tetapi belum disposed,
  • waktu reload config,
  • jumlah request per plugin instance,
  • error reload dan rollback count,
  • heap usage setelah reload,
  • listener/subscription count per plugin.

Jika hanya boleh memilih sedikit metrik, prioritaskan tiga ini:

  1. active_plugin_instances{plugin_name}
  2. plugin_reload_total{plugin_name,status}
  3. plugin_retired_instances{plugin_name}

Kombinasi ketiganya cukup cepat mengungkap apakah registry plugin masih menyimpan instance yang seharusnya sudah hilang.

Kesalahan umum pada sistem backend berbasis plugin

  • Menganggap reload config sama dengan rekonstruksi dependency.
  • Membiarkan service lain menyimpan referensi plugin hasil lookup terlalu lama.
  • Tidak menyediakan kontrak dispose atau shutdown untuk plugin.
  • Reload dilakukan sebagian-sebagian sehingga state transisi tidak atomik.
  • Tidak mencatat identity instance dalam log, sehingga semua handler terlihat “sama”.
  • Mengabaikan listener, callback, scheduler, dan closure yang menahan referensi object lama.
  • Hanya menguji startup path, bukan repeated reload path.

Checklist pencegahan untuk registry plugin dan modul dinamis

  • Definisikan lifecycle plugin secara eksplisit: create, validate, activate, retire, dispose.
  • Pastikan registry adalah satu-satunya sumber plugin aktif.
  • Larangan atau batasi caching referensi plugin di luar registry.
  • Gunakan swap atomik saat mengaktifkan instance baru.
  • Sediakan mekanisme rollback jika plugin baru gagal warm-up.
  • Tambahkan instance ID dan config hash pada log request penting.
  • Uji hot reload berulang, bukan hanya sekali.
  • Tambahkan tes yang memverifikasi resource lama benar-benar dilepas.
  • Audit timer, event bus subscription, dan callback yang bisa menahan referensi stale.
  • Pertimbangkan versioned handler untuk request panjang atau plugin berat.

Penutup

Pelajaran utama dari Debugging Backend: Bug Registry Plugin dan Reload Config yang Bocor adalah bahwa masalah reload jarang murni soal konfigurasi. Sering kali sumbernya adalah lifecycle object yang tidak didesain untuk berubah di runtime. Registry plugin yang tadinya aman saat startup-only bisa menjadi sumber stale handler dan memory leak ringan ketika fitur hot reload ditambahkan.

Jika backend Anda memakai sistem modular atau plugin yang dapat diperbarui tanpa restart, fokuskan debugging pada tiga hal: siapa yang memiliki referensi object aktif, kapan referensi itu diganti, dan kapan instance lama benar-benar dibuang. Dengan observability yang tepat, reproduksi yang disiplin, dan lifecycle yang eksplisit, bug semacam ini bisa ditutup secara permanen, bukan hanya disamarkan dengan restart service.