Server-Side Visibility Cache adalah pola untuk membatasi pekerjaan backend hanya pada data yang sedang relevan, terlihat, atau benar-benar dibutuhkan oleh pengguna aktif. Alih-alih setiap event langsung memicu fanout ke semua subscriber atau semua entitas turunan, server lebih dulu mengecek visibility state: siapa yang sedang online, feed mana yang sedang dibuka, channel mana yang sedang di-subscribe, dan data mana yang memang perlu dihitung sekarang.

Pola ini berguna saat sistem real-time mulai bermasalah secara operasional: queue menumpuk, cache stampede, job dobel karena retry, lock bocor, dan state yang terkirim ke klien sudah usang. Gagasannya dekat dengan server-sided occlusion culling di server game: jangan kirim dan jangan hitung sesuatu yang tidak sedang “terlihat”. Dalam backend umum, konsep itu diterjemahkan menjadi visibility cache, deduplikasi job, invalidation yang hati-hati, distributed locking, idempotensi, serta backpressure yang terukur.

Kapan pola ini dibutuhkan

Pola ini biasanya muncul ketika sistem memiliki kombinasi berikut:

  • Event rate tinggi: misalnya notifikasi, update feed, presence, harga, status order, atau event IoT.
  • Fanout mahal: satu event bisa memicu ratusan sampai jutaan target.
  • Payload atau komputasi berat: misalnya merakit timeline, agregasi unread count, ranking feed, atau filtering akses.
  • Klien aktif hanya sebagian kecil: tidak semua user sedang online atau membuka layar yang relevan.

Tanpa visibility layer, arsitektur sering jatuh ke pola ini:

  1. Event masuk.
  2. Worker menghasilkan update untuk semua subscriber potensial.
  3. Cache diisi untuk banyak key.
  4. Sebagian besar hasil tidak pernah dibaca.

Ini memboroskan CPU, Redis, database, dan kapasitas queue. Pada skala tertentu, masalah utamanya bukan lagi latensi satu request, tetapi pekerjaan yang seharusnya tidak perlu dilakukan sejak awal.

Konsep inti: hanya proses data yang “terlihat”

Visibility cache menyimpan status apakah suatu kombinasi viewer-resource-context saat ini layak diproses secara real-time. “Terlihat” di sini tidak harus visual secara literal. Definisinya bisa berupa:

  • User sedang terhubung ke WebSocket dan subscribe ke channel tertentu.
  • User sedang membuka feed, room chat, dashboard, atau halaman notifikasi.
  • Consumer downstream sedang menarik data untuk tenant, shard, atau topik tertentu.
  • Data termasuk dalam jendela waktu aktif, misalnya 5 menit terakhir.

Dengan demikian, pipeline event berubah menjadi:

  1. Event diterima.
  2. Sistem memeriksa visibility state terkait target event.
  3. Jika terlihat, proses jalur cepat: hitung, cache, push, atau fanout.
  4. Jika tidak terlihat, lakukan salah satu dari tiga hal: tunda, ringkas, atau abaikan dengan aman.

Bagian pentingnya adalah kata dengan aman. Anda tidak boleh sekadar membuang event tanpa model konsistensi yang jelas. Biasanya ada dua jalur:

  • Hot path real-time: untuk audience yang aktif dan terlihat.
  • Cold path lazy recompute: untuk audience yang tidak aktif; data dihitung saat benar-benar diminta.

Desain visibility cache

1. Apa yang disimpan

Visibility cache sebaiknya kecil, murah diperbarui, dan punya TTL. Yang umum disimpan:

  • Presence: user online/offline, last seen, node pemilik koneksi.
  • Subscription index: user atau sesi aktif terhadap channel/room/feed tertentu.
  • Interest set: himpunan resource yang sedang diamati oleh user tertentu.
  • Derived visibility: misalnya room X punya 120 watcher aktif.

Penyimpanan yang sering dipakai adalah Redis karena cocok untuk TTL, set, sorted set, dan operasi atomik ringan. Namun jangan jadikan visibility cache sebagai sumber kebenaran permanen. Ia adalah soft state yang boleh hilang dan bisa dibangun ulang dari heartbeat, reconnect, atau request klien berikutnya.

2. Bentuk key yang stabil

Gunakan key yang mudah dipahami dan bisa dihapus selektif. Contoh pola:

presence:user:{userId} -> { connectionId, serverId, expiresAt }
watchers:feed:{feedId} -> set(sessionId)
interests:session:{sessionId} -> set(resourceKey)
visible:resource:{resourceKey} -> watcher_count / last_seen

Hindari key yang terlalu granular jika update event sangat tinggi, karena biaya invalidation akan naik. Sebaliknya, key yang terlalu kasar membuat banyak pekerjaan sia-sia. Pilih granularitas berdasarkan unit fanout yang ingin dikendalikan, misalnya per-room, per-feed, atau per-tenant.

3. TTL dan heartbeat

TTL adalah perlindungan terhadap state yatim. Jika koneksi putus tanpa cleanup, state akan hilang sendiri. Namun TTL terlalu pendek bisa menyebabkan status aktif sering kadaluarsa dan memicu flapping. TTL terlalu panjang membuat visibility palsu bertahan lama.

Pola umum:

  • Klien atau gateway mengirim heartbeat periodik.
  • Server memperpanjang TTL pada presence dan subscription terkait.
  • Worker membaca state tersebut sebagai sinyal “masih terlihat”.

Catatan: visibility cache tidak perlu akurat 100% setiap milidetik. Dalam banyak sistem real-time, sedikit keterlambatan pada status terlihat lebih murah daripada fanout ke semua target.

Arsitektur pipeline: event, worker, queue, dan cache

Alur dasar

Contoh sederhana untuk feed real-time:

  1. User A membuat postingan.
  2. Layanan event menulis event post_created.
  3. Fanout worker mengambil event.
  4. Worker mengecek siapa follower yang sedang aktif melihat feed yang relevan.
  5. Untuk follower aktif: push update atau precompute feed fragment.
  6. Untuk follower tidak aktif: tandai feed sebagai dirty, tanpa precompute penuh.

Dengan model ini, event tidak otomatis berubah menjadi N job mahal. Sebagian target hanya menerima penanda bahwa datanya perlu dihitung ulang nanti.

Hot path vs cold path

Hot path dipakai saat visibility positif. Misalnya user aktif di room chat, maka unread count, ringkasan room, dan event push diprioritaskan. Cold path dipakai saat visibility negatif. Misalnya user offline, maka sistem cukup menyimpan penanda dirty atau satu ringkasan notifikasi agregat.

Cold path sangat penting untuk mencegah queue dipenuhi pekerjaan yang nilainya rendah. Banyak sistem gagal bukan karena satu job mahal, tetapi karena jutaan job kecil yang sebenarnya tidak perlu dijalankan saat itu.

Contoh kasus praktis

Notifikasi real-time

Misalkan satu event transaksi bisa memicu notifikasi ke banyak pihak: pembeli, penjual, admin, sistem audit, dan follower. Tanpa visibility gating, setiap notifikasi dapat menghasilkan render template, hit database preferensi user, dan push ke kanal delivery meski penerimanya offline.

Pendekatan yang lebih efisien:

  • Jika user sedang online dan subscribe kanal notifikasi, kirim event real-time.
  • Jika offline, simpan notifikasi persisten dan tunda materialisasi detail yang berat.
  • Gunakan agregasi untuk event berulang, misalnya “15 update baru”, bukan 15 push terpisah.

Feed dan fanout event

Feed klasik sering memakai dua pendekatan:

  • Fanout-on-write: cepat dibaca, mahal saat publish.
  • Fanout-on-read: murah saat publish, mahal saat baca.

Visibility cache memberi jalan tengah praktis:

  • Untuk follower yang feed-nya sedang aktif, lakukan fanout-on-write terbatas.
  • Untuk follower yang tidak aktif, cukup tandai feed dirty dan hitung saat dibaca.

Ini efektif ketika proporsi user aktif jauh lebih kecil dibanding total follower.

Room atau channel real-time

Dalam sistem chat, room presence dapat dipakai untuk menentukan apakah server perlu menghitung turunan event tertentu, misalnya summary panel, unread badge, mention expansion, atau preview konten. Jika room tidak punya watcher aktif, jalankan jalur minimal: simpan pesan, update offset dasar, lalu tunda perhitungan dekoratif sampai dibutuhkan.

Invalidation: bagian tersulit

Visibility cache berguna hanya jika invalidation-nya disiplin. Masalah umum bukan cache miss, melainkan cache yang tetap “benar secara teknis” tetapi sudah tidak relevan operasional.

1. Invalidation berbasis waktu

TTL adalah lapisan pertama. Presence, watcher list, dan dirty marker sebaiknya punya masa hidup yang eksplisit. Ini membantu membersihkan state yang bocor saat disconnect tidak tercatat.

2. Invalidation berbasis event

Saat klien berpindah halaman, unsubscribe room, atau socket reconnect ke node lain, sistem harus memperbarui interest set. Jika tidak, worker akan terus mengira resource itu masih terlihat.

3. Dirty bit lebih murah daripada rebuild penuh

Untuk data turunan seperti feed summary atau unread aggregates, sering kali lebih murah menandai dirty:{resource} daripada langsung menghitung ulang. Data dibangun saat pembaca aktif benar-benar memintanya atau saat watcher count naik lagi.

// Pseudocode
onEvent(event):
  targets = resolveTargets(event)
  for target in targets:
    if isVisible(target):
      enqueueUnique("recompute:" + target)
    else:
      markDirty(target, ttl=300)

Keuntungan pola ini adalah event yang datang bertubi-tubi ke target yang tidak aktif tidak akan terus memicu rebuild mahal.

Dedup job dan pencegahan job dobel

Pada sistem queue nyata, job dobel hampir pasti terjadi: producer retry, consumer timeout, crash setelah side effect sebagian, atau at-least-once delivery dari broker. Karena itu, deduplikasi dan idempotensi harus dianggap wajib.

1. Gunakan idempotency key atau unique job key

Untuk pekerjaan yang mewakili resource yang sama, gunakan key deterministik, misalnya:

job:recompute_feed:{userId}
job:push_room_summary:{roomId}
job:deliver_notification:{notificationId}:{channel}

Sebelum enqueue, lakukan set-if-not-exists dengan TTL singkat. Jika key sudah ada, producer tidak perlu menambah job yang sama.

// Pseudocode Redis-style
if SETNX(dedupKey, 1):
  EXPIRE(dedupKey, 30)
  enqueue(job)
else:
  skip

TTL harus cukup panjang untuk menahan ledakan duplikasi, tetapi tidak terlalu panjang hingga memblokir pekerjaan sah berikutnya.

2. Bedakan dedup enqueue dan dedup execute

Dedup saat enqueue mengurangi tekanan queue. Namun worker juga tetap perlu idempotensi saat execute, karena job bisa diantarkan ulang atau lock bisa hilang. Jangan mengandalkan satu lapisan saja.

3. Coalescing lebih baik daripada sekadar skip

Jika beberapa event identik datang cepat, bukan hanya di-skip, tetapi digabung. Contohnya, sepuluh event perubahan feed cukup menghasilkan satu job recompute_feed. Ini menurunkan beban tanpa kehilangan hasil akhir.

Distributed locking: perlu, tetapi jangan dijadikan fondasi tunggal

Lock sering dipakai untuk memastikan hanya satu worker menghitung resource yang sama pada saat yang sama. Ini berguna untuk mencegah stampede cache dan update saling menimpa. Namun lock terdistribusi punya risiko: clock drift, network partition, worker pause, dan lock bocor.

Kapan lock layak dipakai

  • Recompute mahal dan hasilnya akan ditulis ke cache bersama.
  • Satu resource tidak boleh dihitung paralel oleh banyak worker.
  • Side effect ke sistem eksternal harus dibatasi.

Pola lock yang aman secara praktis

  • Gunakan token pemilik lock, bukan hanya boolean.
  • Pasang TTL pada lock agar tidak deadlock permanen.
  • Saat release, hapus hanya jika token masih milik worker yang sama.
  • Jangan melakukan pekerjaan yang durasinya jauh melebihi TTL tanpa mekanisme perpanjangan yang aman.
// Pseudocode
lockKey = "lock:feed:" + userId
token = randomUUID()
acquired = setNxWithTtl(lockKey, token, 15)
if !acquired:
  return
try:
  data = buildFeed(userId)
  cacheSet("feed:" + userId, data, ttl=60)
finally:
  releaseIfOwner(lockKey, token)

Kesalahan umum: setelah memperoleh lock, engineer menganggap semua aman. Padahal jika worker hang, lock kadaluarsa, lalu worker kedua masuk, dua komputasi bisa berjalan bersamaan. Karena itu, hasil write tetap harus toleran terhadap race, misalnya dengan versi data, timestamp, atau pengecekan freshness.

Idempotensi: lapisan pertahanan terakhir

Jika distributed lock gagal atau queue mengulang pengiriman, idempotensi mencegah efek ganda. Ini sangat penting untuk notifikasi, perubahan state, dan pembaruan materialized view.

Prinsip idempotensi yang berguna

  • Upsert lebih aman daripada insert buta.
  • Version check: tulis hanya jika event lebih baru dari versi tersimpan.
  • Processed-event log: simpan event ID yang sudah diterapkan untuk jangka waktu tertentu.
  • Natural key: misalnya satu notifikasi per kombinasi actor-object-type dalam interval tertentu.
// Pseudocode
if alreadyProcessed(event.id):
  return
applyUpdateIfNewer(resourceId, event.version, payload)
markProcessed(event.id, ttl=86400)

Idempotensi tidak gratis karena menambah write dan metadata. Namun pada sistem real-time terdistribusi, biaya itu hampir selalu lebih murah daripada membersihkan side effect ganda di produksi.

Consistency trade-off yang perlu diterima

Server-side visibility cache jarang memberi konsistensi kuat penuh. Yang biasanya dicapai adalah kombinasi berikut:

  • Eventual consistency untuk target yang tidak aktif.
  • Near-real-time freshness untuk target yang aktif.
  • At-least-once processing dengan idempotensi untuk keamanan.

Trade-off utamanya:

  • Anda menghemat banyak komputasi, tetapi sebagian data hanya diperbarui saat dibaca.
  • Visibility state bisa sedikit usang karena heartbeat dan TTL.
  • Watcher count bisa tidak presisi sesaat, tetapi cukup baik untuk keputusan operasional.

Penting untuk mendefinisikan SLO freshness per jenis data. Misalnya:

  • Badge notifikasi aktif: target 1-3 detik.
  • Feed user offline: boleh stale sampai request berikutnya.
  • Unread aggregate: eventual, tetapi harus terkoreksi saat room dibuka.

Backpressure: supaya queue tidak tumbang

Visibility cache mengurangi beban, tetapi tidak menghapus kebutuhan backpressure. Saat lonjakan event terjadi, sistem tetap perlu mengendalikan produksi pekerjaan.

Strategi yang efektif

  • Bounded queue: batasi ukuran queue atau inflight job per resource.
  • Drop low-value work: abaikan recompute dekoratif jika target tidak terlihat.
  • Coalescing window: gabungkan event selama beberapa ratus milidetik atau beberapa detik.
  • Priority lane: pisahkan queue untuk hot path dan cold path.
  • Rate limit per tenant/resource: satu sumber panas tidak boleh menghabiskan seluruh worker.

Pisahkan jalur must-deliver dan jalur nice-to-have. Misalnya, penyimpanan pesan adalah wajib, sedangkan pembaruan preview sidebar bisa ditunda atau digabung.

Contoh keputusan degradasi yang sehat

  • Saat queue > ambang tertentu, hentikan precompute untuk user offline.
  • Kurangi fanout per-event menjadi dirty marker per-feed.
  • Turunkan resolusi agregasi dari per-item menjadi per-batch.

Degradasi terkontrol lebih baik daripada membiarkan semua job masuk lalu seluruh sistem jatuh latensinya.

Mencegah stampede cache

Stampede terjadi ketika banyak worker atau request mencoba membangun cache yang sama bersamaan setelah cache expired atau invalid. Visibility-aware system tetap rentan terhadap ini, terutama saat resource tiba-tiba menjadi populer.

Mitigasi praktis

  • Single-flight per key dengan lock ringan dan TTL.
  • Soft TTL + background refresh: data masih boleh disajikan sebentar sambil refresh berjalan.
  • Jitter TTL: hindari banyak key kadaluarsa bersamaan.
  • Probabilistic early refresh untuk key panas.

Jika resource tiba-tiba terlihat oleh banyak user sekaligus, lebih baik satu worker membangun snapshot lalu watcher lain membaca hasil yang sama daripada semuanya menghitung ulang.

Observability dan debugging

Masalah pada pola ini sering tidak terlihat dari metrik request biasa. Anda perlu metrik yang menunjukkan apakah sistem benar-benar hanya memproses data yang diperlukan.

Metrik yang penting

  • Persentase event yang masuk hot path vs cold path.
  • Watcher count per resource dan distribusinya.
  • Jumlah enqueue yang di-skip karena dedup.
  • Jumlah lock acquisition gagal dan durasi hold lock.
  • Age dari dirty marker sebelum diproses.
  • Queue lag per lane prioritas.
  • Freshness data yang dikirim ke klien.

Gejala dan diagnosis

  • Queue menumpuk: cek apakah producer tetap mem-fanout target offline, apakah coalescing tidak bekerja, atau lane prioritas bercampur.
  • Job dobel: cek idempotency key, retry policy, dan apakah dedup hanya dilakukan saat enqueue.
  • Lock bocor: cek token owner, TTL, dan jalur release saat exception.
  • State usang: cek heartbeat, invalidation saat reconnect, serta perbedaan antara source of truth dan soft state di Redis.
  • Cache stampede: cek expiry serentak, tidak adanya single-flight, atau resource panas tanpa snapshot bersama.

Contoh implementasi alur sederhana

Berikut pseudocode alur untuk event feed real-time yang visibility-aware:

onPostCreated(post):
  followers = getFollowers(post.authorId)

  for userId in followers:
    if isFeedVisible(userId):
      enqueueUnique("recompute_feed:" + userId)
    else:
      markDirty("feed:" + userId, ttl=300)

workerRecomputeFeed(userId):
  if !tryLock("lock:feed:" + userId, ttl=15):
    return

  try:
    if !isFeedVisible(userId) and !isDirty("feed:" + userId):
      return

    feed = buildFeedFromSourceOfTruth(userId)
    cacheSet("feed:data:" + userId, feed, ttl=60, jitter=true)
    clearDirty("feed:" + userId)

    if isFeedVisible(userId):
      pushRealtime("feed_updated", userId)
  finally:
    unlockIfOwner("lock:feed:" + userId)

Ada beberapa hal penting dari alur ini:

  • Visibility memutuskan apakah precompute dijalankan sekarang.
  • Dirty marker menjaga agar target offline tidak hilang dari perhatian.
  • Unique enqueue mencegah ledakan job identik.
  • Lock menahan stampede pada resource yang sama.
  • Source of truth tetap berada di database atau event log, bukan di visibility cache.

Batasan pola ini

Server-side visibility cache bukan solusi universal. Ada situasi di mana pendekatan ini kurang cocok:

  • Sistem wajib konsisten kuat untuk semua subscriber, misalnya beberapa domain keuangan atau kontrol industri tertentu.
  • Jumlah target kecil, sehingga fanout langsung lebih sederhana dan cukup murah.
  • Sumber visibility tidak andal, misalnya koneksi klien sering hilang tanpa heartbeat yang memadai.
  • Biaya salah klasifikasi tinggi, misalnya update yang tidak boleh tertunda sama sekali.

Jika visibility signal Anda lemah, sistem bisa tampak efisien tetapi sebenarnya menyimpan banyak state stale dan menunda koreksi terlalu lama.

Pedoman implementasi yang realistis

  1. Mulai dari satu use case yang paling mahal, misalnya feed atau unread aggregate, bukan seluruh sistem sekaligus.
  2. Pisahkan source of truth dan soft state. Visibility cache hanya sinyal operasional.
  3. Terapkan dedup dan idempotensi lebih dulu sebelum menambah lock yang kompleks.
  4. Gunakan dirty marker untuk target tidak aktif, jangan buang event tanpa jalur koreksi.
  5. Tambahkan backpressure eksplisit dan lane prioritas di queue.
  6. Instrumentasi sejak awal agar Anda tahu berapa banyak pekerjaan yang benar-benar dihemat.

Penutup

Server-Side Visibility Cache untuk worker dan queue real-time adalah cara praktis untuk mengadopsi ide “hanya proses yang terlihat” ke backend terdistribusi. Nilai utamanya bukan sekadar cache lebih cepat, tetapi menghindari pekerjaan yang tidak perlu: fanout berlebihan, recompute sia-sia, job dobel, dan stampede saat beban naik.

Jika diterapkan dengan visibility state yang sederhana, invalidation yang disiplin, dedup job, idempotensi, distributed locking yang terbatas, dan backpressure yang jelas, pola ini bisa membuat sistem real-time jauh lebih stabil. Kuncinya adalah menerima trade-off konsistensi secara sadar: target aktif diprioritaskan untuk freshness, sementara target tidak aktif dipindahkan ke jalur lazy recompute yang aman dan terukur.