Yunus Emre Alpak

Yaşayan Hafıza — NATS ile Olay Akışından Anılara · Mnemo Serisi 4/5

2026 · 05 · 108 dk okuma

İlk üç hafta Mnemo'da sadece manuel yazdığım şeyler vardı. Birkaç not, birkaç karar, birkaç deployment. Sonra fark ettim — Vault'un olayları, Foreman'ın PR review'ları zaten oradaydı. Onları yakalamak yerine ben elimle yazıyordum. O hafta, Mnemo "yaşamaya" başladı.

Serinin dördüncüsü. İlk üç yazıda Mnemo'nun "neden", "temel mimari" ve "akıl" tarafları vardı. Şimdi onu canlandıran şeye geliyoruz: olay akışları.

Mnemo'nun ölü hissi

Phase 2 bittiğinde Mnemo teknik olarak işliyordu — MCP üzerinden Claude'a bağlandım, remember, recall, stats çalışıyordu. Her şey "yeşil". Ama bir hafta kullandıktan sonra rahatsız edici bir his oluştu.

Mnemo'da çok az şey vardı.

Çünkü hatırlanması gereken her şeyi ben yazıyordum. "Bugün Vault'a şu dosyayı yükledim, onu hatırla" diye Claude'a söylüyordum. "Foreman şu PR'a yorum yaptı, not düş" diyordum. Yani Mnemo, manuel beslenen bir günlük gibiydi.

Bu yanlıştı. Çünkü:

Yani Mnemo'nun gerçek hafıza olabilmesi için, sistemin içindeki olayları dinlemesi gerekiyordu — benden yazmamı beklemek değil.

İşte Phase 4'ün motivasyonu buydu: Mnemo'yu konuşkan değil, duyarlı yapmak.

Event-driven olmanın felsefesi

"Event-driven mimari" çoğu zaman karmaşık bir mühendislik problemi gibi sunulur — Kafka, schema registry, saga pattern, eventual consistency. Hepsi yer yer doğru. Ama altında çok basit bir fikir var:

"Yapan kim, dinleyen kim — ayır."

Vault dosya yüklüyor. Bunu yaparken kimsenin haberi olmasını beklemeyebilir. Ama yine de "olduğunu söylesin" — yayınlasın. Kimin dinlediği onu ilgilendirmiyor.

Mnemo dinleyebiliyor olsun. Hangi event'leri dinleyeceğini kendisi seçsin. Vault'a "beni de tetikle" diye bağırmasın. Sadece NATS'ta bekleyen mesajları çekip alsın.

Bu ayrım — publish ile subscribe'ın birbirinden habersiz olması — yeni servis eklendiğinde, eski servisi değiştirmeden büyüyebilmeyi sağlıyor. Yarın ses asistanım da Vault olaylarını dinlemek isterse, Vault'ta hiçbir kod değişmeden bu olabiliyor.

Bu yüzden NATS seçtim, REST callback'leri değil. Webhook tabanlı bir entegrasyon yapsaydım, Vault'un her yeni tüketici için config değişmesi gerekecekti. Pub/sub modelinde gerekmiyor.

Two-stage processing: çabuk yaz, sonra zenginleştir

Bir olay geldiğinde Mnemo'nun yapması gereken iki şey vardı:

  1. Olayı veri tabanına yazmak — fast.
  2. Embedding üretip yine veri tabanına yazmak — slow (çünkü Voyage API çağrısı, 200-500ms).

İlk içgüdü tabii bu ikisini tek aşamada yapmaktı: olay gel → embedding üret → DB'ye yaz → ack et. Düz çizgi. Ama beş dakika düşününce sorunları gördüm:

İki aşamaya bölmek bu sorunların hepsini çözdü:

Stage 1 (consumer):

Stage 2 (embedding worker):

Bu ayrımın iki güzel yan etkisi var:

Ve önemli bir nüans: MCP üzerinden remember çağrısı hâlâ senkron embed yapıyor. Yani kullanıcı/LLM bilinçli olarak "bunu kaydet" dediğinde, embedding o anda hazır olsun istiyoruz. Ama Vault/Foreman'dan gelen otomatik event'lerin embedding'i biraz gecikse zarar yok. İki giriş yolu, iki farklı SLA. Bilinçli ayrım.

Bu ders bana şunu öğretti: dış servise bağımlı her işi async yap, ama "anlık yanıt bekleyen yol"u senkron tutmaktan korkma. İki yol aynı sistemde, farklı disiplinlerle yaşayabilir.

Pull vs push consumer — geri basınç kimde olmalı?

NATS'ta consumer iki şekilde çalışabilir: push (NATS sana mesaj yollar, sen callback'te işler) veya pull (sen fetch(N) çağırırsın, NATS sana N mesaj verir).

Push daha "modern" görünüyor — event-driven kültürün ruhuna uygun. Ama bir kör noktası var: geri basınç kontrolünü NATS'a bırakıyor.

Mnemo'da embedding worker yavaşsa veya bir hata yiyorsa, pull consumer'ım fetch(10) çağrısını bekletir. NATS'ta mesajlar birikir ama Mnemo "boğulmaz". Hızını kendisi belirler.

Push'ta NATS sürekli mesaj fırlatır. Eğer Mnemo işleyemiyorsa, in-memory buffer şişer, memory kullanımı artar. Defense mekanizmaları (max in-flight, ack timeout) yazılabilir ama her zaman bir adım geride.

Pull = "ihtiyacım kadar al" = tüketici hız kontrolü. Mnemo gibi external service'lere bağımlı bir consumer için doğal seçim.

Dedup: Redis yerine PostgreSQL unique constraint

NATS JetStream'de pull consumer'ın bir özelliği var: bir mesajı ack etmezsen redelivery yapar. Eğer Mnemo bir olayı işliyorken crash ederse, restart sonrası NATS aynı olayı tekrar yollayacak. Yani idempotency zorunlu.

İlk düşünce: "Redis'e bir SET koy, daha önce gördüğüm event ID'lerini orada tut."

Sonra durdum. Çünkü bu Redis demek. Yani:

Redis'i reddettim. Bunun yerine PostgreSQL'in kendi yaptığı şeyi kullandım: UNIQUE constraint.

CREATE UNIQUE INDEX idx_memories_source_event
ON memories(source, source_event_id)
WHERE source_event_id IS NOT NULL;

Olay her geldiğinde Mnemo INSERT deniyor. Eğer aynı (source, source_event_id) zaten varsa, PostgreSQL 23505 unique_violation döndürüyor. Mnemo bunu sessizce ack olarak ele alıyor — "zaten işlenmiş, devam".

Kazanç:

Maliyet: hatta hatta sıfır maliyet. UNIQUE index zaten lazımdı (consolidation için), dedup ücretsiz geldi.

Buradan çıkardığım ders: var olan altyapının zaten yaptığı şeyi yeni altyapı ekleyerek tekrarlama. PostgreSQL UNIQUE constraint Redis dedup cache'inden bin kat daha basittir.

Formatter registry: kod-as-config

Bir olay geldiğinde "bu olayı insan tarafından okunabilir bir cümleye nasıl çevireyim" sorusu var. Vault'tan gelen file.uploaded olayı şuna dönmeli:

"Vault'a aylik-rapor.pdf yüklendi (1.2 MB, application/pdf, yunus tarafından)."

Bu cümleyi nasıl üretiriz? İki yol:

  1. Template engine (YAML, Jinja, Handlebars).
  2. Kod fonksiyonu (Go fonksiyonu).

İlk eğilim template'ti — "config" gibi hissettiriyor. Ama şunu fark ettim: her olayda farklı alanları farklı şekilde formatlamam gerekiyor. file_size'ı human-readable yapacak helper ("1.2 MB"), uploaded_at'i relative time'a çevirecek başka bir helper. Template engine'de bu helper'ları register etmem, parametre ile çağırmam gerekiyor — kod yazmaktan farkı kalmıyor, sadece daha çirkin sözdiziminde.

Sonuç: her olay tipi için bir Go fonksiyonu yazdım. Bir registry map'i tutuyor:

"vault.file.uploaded"        → formatFileUploaded
"foreman.pr.review.completed" → formatPRReviewCompleted
...

Yeni event tipi eklemek = bir fonksiyon yazmak + bir registry satırı. PR review için, 10 dakika.

Çıkardığım ders: küçük projelerde "config dosyası" kod kadar veya daha karmaşık olabilir. Pure function ile ifade edilebilen şeyi config'e taşıma.

Importance haritası: bilinçli bir önceliklendirme

Her formatter (content, tags, importance) üçlüsü döndürüyor. Importance kısmı önemli çünkü decay formülünde kullanılıyor — yani bu olay ne kadar uzun süre hatırlansın.

Sezgisel olarak şu skalayı oturttum:

Bu sayılar kalıba dökülmüş değil — bilinçli olarak değiştirilebilir. Ama "hatalar daha uzun yaşasın" sezgisi tasarımda hissedilen şey. Çünkü unutulan hata tekrar eden hatadır.

Graceful shutdown: küçük bir özen

Bu kısım çoğu zaman "biraz da bunu ekleyelim" denilip kabaca yazılır. Mnemo'da bilinçli yaptım:

  1. SIGTERM gelir → HTTP server kapanır (5sn timeout, yeni request yok).
  2. Background context cancel → NATS consumer goroutine'ler çıkar.
  3. Subscriber wg.Wait() → in-flight mesajlar tamamlansın.
  4. NATS client Drain(5sn) → pending mesajlar flush.
  5. Embedding worker context cancel (zaten 2'de yapıldı) → current batch tamamla.
  6. DB pool Close().

Bu sıralama veri kaybını minimize ediyor. Önce yeni iş kabulü kesilir, sonra in-flight iş tamamlanır, en sonda persistent connection'lar kapanır. Container restart sırasında "ack edilmemiş ama işlenmiş" durumu en aza iniyor.

Küçük şey gibi görünüyor, ama Phase 4 ile gerçekten gerekli oldu — çünkü artık async pipeline var, "tam bu anda kapatırsam ne kaybederim" sorusu kritik.

Sonraki yazıda

Beşinci ve son yazıda geriye bakacağız. Mnemo'yu yazmak bana ne öğretti — 8 ders, her biri başka bir projeye taşınabilecek. Ayrıca Mnemo'nun şu an ne yaptığını ve nereye gidiyor olabileceğini paylaşacağım.

Ama bu yazıyı şununla bitirmek istiyorum: bir sistemin yan etkilerini başka bir sistemin girdisi haline getirmek, basit ama dönüştürücü bir disiplin. Vault'un yan etkisi olarak ürettiği olaylar Mnemo'nun ana besini oldu. Bu, ekosistemini büyütürken senin daha az kod yazmanı sağlıyor. Çünkü her yeni servis, var olan olay akışına bağlandığı an, oradaki birikmiş zekâdan otomatik faydalanıyor.

Ve bu zekâ — Vault'un "geçen hafta neler yüklendi" cevabını verebilen bir Mnemo — manuel beslemekten çok daha derin bir şey.