İçeriğe geç
Muhammet Şafak
en
Günlük 9 dk okuma

Aynı mesaj, farklı sonuç: event-driven mimaride determinizm

Event-driven mimaride aynı mesaj neden farklı sonuç üretir? Gerçek bir fatura akışından: determinizmi bozan gizli girdiler ve geri kazandıran dört hamle.


Bu sabah bir fatura “gönderilemedi” ile geri döndü. Log’da tek satır vardı: faturanın öneki izin verilen listede değil. Aynı biçimdeki bir mesaj birkaç saat önce sorunsuz geçmişti. Kod değişmemişti. Mesajın içeriği değişmemişti. Değişen tek şey, aynı öneklerin listesinin iki ayrı serviste ayrı ayrı tutuluyor olması ve birinin güncellenmiş olmasıydı. Bir tarafta geçerli olan bir değer, diğer tarafta sahte payload muamelesi gördü.

Haziran’da outbox ve idempotent tüketimi yazarken bir not düşmüştüm: bir deseni çözmek bir sonrakini doğuruyor. Bu yazı o sonraki. Outbox, olayın teslim edileceğini garanti etti. Teslim edilen olayın aynı sonucu üreteceğini garanti etmedi. Aradaki fark küçük görünüyor; pratikte iki ayrı problem sınıfı.

Determinizm neydi, biz onu nerede bıraktık?

Determinizm sıkıcı bir söz: aynı girdi, aynı çıktı. Sıkıcı olduğu için de mühendislikte en çok üstü örtülen varsayım.

Oysa üzerine kurduğumuz şeylerin çoğu ona yaslanıyor. Retry determinizm ister — “tekrar dene” ancak ikinci denemenin birinciyle aynı şeyi yapacağını biliyorsanız bir çözümdür, aksi hâlde kumardır. İdempotency determinizm ister — aynı mesajı iki kez işlemenin zararsız olduğunu söylemek, ikinci işlemenin ilkiyle aynı kararı vereceğini varsaymaktır. Test determinizm ister — kararsız testleri sade.dev’de ayrı yazdım. Olay sonrası analiz determinizm ister: “neden bu sonuç çıktı” sorusunun cevabı yoksa, düzelttiğinizi sandığınız şeyin gerçekten düzelip düzelmediğini de bilemezsiniz.

Senkron dünyada bunu bedava alıyorduk. Kullanıcı bir düğmeye basıyordu; okuma, karar ve yazma aynı transaction’ın, aynı process’in, aynı anın içindeydi. Veriyi okuduğunuz an ile karar verdiğiniz an arasında dünyanın değişme ihtimali yoktu, çünkü an tekti.

Event-driven mimari bu tekliği bilerek kırıyor. Olayın üretildiği an ile tüketildiği an arasına zaman giriyor. Milisaniye de olabilir, kuyruk birikince yirmi dakika da, hatalı mesaj yeniden sürülünce üç gün de. Ve o aralıkta dünya değişebilir: bir ayar güncellenir, bir firma kaydı düzenlenir, yıl değişir, bir servis yeniden dağıtılır.

Determinizm bedava olmaktan çıktı. Artık bilinçli olarak ödemeniz gereken bir maliyet.

Mesaj, girdinin tamamı değil

Kuyruğa bakınca insan şunu düşünüyor: girdi mesaj. Değil.

Bir tüketicinin okuduğu her şey girdidir. Mesajda yazmayan her girdi gizlidir; gizli girdi, sonucu sonradan açıklayamadığınız yerdir.

Yani gerçek denklem şu:

sonuç = f(mesaj, gizli girdiler)

Gizli girdiler tüketicinin kodunda göze batmaz, çünkü hepsi masum görünür. Birkaç tanesi, akışımızda gerçekten karşıma çıkmış hâlleriyle:

Gizli girdiSomut hâliÜrettiği sapma
ZamanTüketicinin belge tarihi için o anki saati okumasıYıl dönümünde bir retry: belge numarası bir yıla, belge tarihi diğerine ait olur; karşı taraf reddeder
Veritabanının o anki hâliBelge oluşturulurken firmanın öneki X’ti, gönderilirken YNumara bambaşka bir seriden üretilir
KonfigürasyonAynı izin listesi iki ayrı serviste yaşıyorBir taraf kabul eder, diğeri reddeder
Dış sistemin cevabı”Bu zaten var” — ama neyin var olduğu belirsizAynı istisnadan zıt iki eylem türetilebilir
Çalıştıran ortamAynı kütüphane; bir framework uyarıyı yutuyor, diğeri exception’a çeviriyorAynı kod, aynı girdi, farklı sonuç
Hangi worker kazandıParalel replica, kilit, tekrar teslim edilen mesajSonuç, işlem sırasına bağlı hâle gelir

Şu son iki satırı ayrıca not etmek istiyorum, çünkü fark ettiğimde beni en çok rahatsız edenler onlardı.

Aynı entegrasyon kütüphanesini hem eski senkron uygulamamız hem yeni kuyruk servisimiz kullanıyor. Kütüphane belirli bir durumda bir PHP uyarısı üretiyor. Eski uygulama bu uyarıyı yutup devam ediyordu; yeni servis onu exception’a çevirip akışı durduruyordu. Kütüphane aynı, çağrı aynı, girdi aynı. Sonucu belirleyen şey, kodu kimin host ettiğiydi. Böyle bir farkı hiçbir birim testi yakalamaz, çünkü fark test edilen kodun içinde değil.

İkincisi daha sinsiydi: kritik olmayan bir log kaydının yazılamaması, tüm gönderim akışını durduruyordu. Yani karşı tarafa belge başarıyla iletilmiş olsa bile, bizim tarafımızdaki hiçbir kayıt yazılmıyordu. Bir yan etkinin başarısızlığı, ana sonucu değiştiriyordu. Log kaybı kabul edilebilir; gönderim kaydının kaybı kabul edilemez — ama bunu ancak ikisinin ayrı ele alınması gerektiğini fark ettiğinizde yazabiliyorsunuz.

Buradaki kritik nokta şu: bu maddelerin hiçbiri hata değil. Her biri tek tek makul kararlar. Rol sınırlarını yazarken de aynı şeyi söylemiştim — her adım tek başına savunulabilirdi, sorunlu olan toplamdı. Mimaride de böyle: determinizmi tek bir kötü karar bozmuyor, birbirinden habersiz on tane makul karar bozuyor.

Çatal çoğalması: bir koşulun gerçek maliyeti

Bir if yazdığınızda ne ödediğinizi biliyoruz sanıyoruz: biraz okunabilirlik, biraz karmaşıklık. Event-driven bir tüketicide ödediğiniz şey bu değil. Ödediğiniz şey, kanıtlanması gereken yeni bir sonuç.

Ve bu maliyet doğrusal değil. Klasik cevap “N koşul, 2ᴺ yol” demek olurdu ama asıl sorun bu değil. Asıl sorun koşulların bağımsız olmaması: aynı gizli girdileri paylaşıyorlar. Zamanı bir yerde okuyup başka bir yerde tekrar okuduğunuzda, iki farklı koşul aynı anda ve birbirine bağlı biçimde kayıyor.

En net örneğim şu. Entegratörden tek bir hata dönüyor: “bu belge zaten var.”

Bu cümle tek başına hiçbir şey ifade etmiyor. İki tamamen zıt dünyaya karşılık geliyor:

Ayırt eden şeyGerçekte olanDoğru eylem
Karşı tarafın bildirdiği belge kimliği bizimkiyle aynıBelge daha önce gitmişDurumu kurtar, tekrar gönderme
Kimlik farklıO numara başka bir belgeye ait; bizimki hiç ulaşmamışYeni numara üret, tekrar gönder

İki kolun eylemi birbirinin tersi. Ve ikisini ayırt eden bilgi mesajda yok — karşı tarafın cevabının içindeki bir alanda. Yani bu çatalın girdisi, bizim kontrolümüzde olmayan bir dış sistemin ne kadar konuşkan olduğu.

Yanlış kolu seçmenin bedeli de simetrik değil. Birini karıştırırsanız mükerrer belge kesersiniz; diğerini karıştırırsanız hiç gitmemiş bir belgeyi “kayıp” sanıp yanlış alarm üretirsiniz. Birincisi mali bir olay, ikincisi gürültü. Aynı if, iki farklı ağırlıkta yanlış.

Bir de üçüncü hâl var, ve bu yazının benim için en öğretici kısmı: karşı taraf gönderim çağrısında “bu belge zaten var” diyor, ama aynı kimlikle sorguladığınızda “belge bulunamadı” diyor. Kimlik onların tarafında rezerve edilmiş ama nihai kuruma iletilmemiş. Belge ne gönderilmiş ne gönderilmemiş.

Bu karar verilemeyen durum. Determinizmin sınırı tam burası. Buraya bir varsayım koyup otomatik ilerlemek — “herhalde gitmiştir” ya da “herhalde gitmemiştir” — determinizm üretmez; sadece belirsizliği kodun içine gömüp görünmez kılar. Bazı çatalların doğru cevabı bir eylem değil: karar verme, işaretle, insana devret.

İki yol, tek sonuç olmak zorunda

Bir işin iki ayrı koddan yapılabildiği her yerde determinizm artık tek servisin içinde aranmaz. Determinizm iki yolun anlam eşitliğinde yaşar.

Bizde durum tam olarak buydu: aynı belgeyi hem eski senkron yol hem yeni kuyruk yolu gönderebiliyor. Karşı taraftan gelen sert bir hata kodunu eski yol geri alıp “başarısız” sayıyordu. Yeni yol, aynı kodu alıp kayıt yazsaydı belge “gönderildi” olarak işaretlenecekti. Aynı hata, aynı belge, iki farklı gerçeklik. Kullanıcı ekranda yeşil bir tik görür, karşı tarafta belge yoktur — ve bunu ancak aylar sonra bir mutabakatta fark edersiniz.

Aynı problem paylaşılan sayaçta da çıkıyor. İki yazıcı aynı seriye numara üretiyorsa, “sıradaki numara” artık tek bir kaynaktan okunamaz. Sayaç, kendi kendini onarmak zorunda kalan bir hesaba dönüşür: sayacın değeri, kesinleşmiş kayıtların en büyüğü ve rezerve edilmiş numaraların en büyüğü — üçünden hangisi büyükse ondan devam. Bu çirkin. Ama çirkinliği kodun değil, iki yazıcıya izin vermiş olmanın.

Buradan çıkardığım ilke şu: davranış paritesi, kod paylaşımından daha önemlidir — ama paritenin bir sahibi olmalı. Ortak bir kütüphaneye çıkmadan da parite sağlanabilir; sağlanamayan şey, sahibi olmayan paritenin zamanla korunması. Rol sınırlarını yazarken kullandığım tanım burada da geçerli: kimsenin tanımında olmayan iş, yapılmadığında kimsenin fark etmediği iştir. İki yolun aynı davranması da öyle bir iş.

Determinizmi geri kazanmak: dört hamle

Koşulları silmek çözüm değil; iş zaten koşullardan ibaret. Yapılabilecek şey, koşulların cevabını sabitlemek.

1. Bağlamı üretim anında dondur. Tüketici, kararı için gereken hiçbir şeyi kendi çözmesin. Olay kendi bağlamını taşısın: hangi önek kullanılacak, hangi tarih geçerli, hangi oran, hangi kur, hangi şablon. Biz bunu önekte yaptık — önek belge oluşturulurken sabitleniyor, gönderim anında firma kaydından yeniden okunmuyor. Aksi hâlde oluşturma ile gönderim arasında firmanın öneki değiştirilirse belge başka bir seriden numaralanır. Bedeli var: mesaj şişer, şema sürümlenmek zorunda kalır. Kazancı da net: aynı mesaj her zaman aynı sonucu üretir.

2. Zamanı bir girdi hâline getir. Tüketicide o anki saati okumak, akışın ortasına isimsiz bir değişken sokmaktır. Karar anını mesajdan alın; alamıyorsanız ilk denemede sabitleyin ve kalıcılaştırın. Kritik cümle şu: retry, zamanı değiştirmemeli. Değiştirirse retry mekanizmasının kendisi yeni bir çatal olur — ki yıl dönümünde tam olarak bu oluyor. Belge numarasını koruyup tarihi kaydıran bir yeniden deneme, ikisinin yılını ayrıştırır ve reddedilir. Yılda bir gün ortaya çıkan hataların en pahalı yanı, bulunana kadar geçen süredir.

3. Çatalı isimlendir, gerekçeyi yaz. Log “başarısız” demesin. Hangi koşul, hangi girdiyle, hangi kola götürdü onu yazsın. Bir kararın determinist olup olmadığını sonradan doğrulamanın tek yolu, kararın gerekçesinin kaydedilmiş olması. Önek reddi hatasına “iki taraftaki liste hâlâ aynı mı?” ipucunu koyduğumuzda, aynı sorunun ikinci kez teşhisi dakikalar sürdü. Bir sonraki okuyan siz olmayacaksınız; hata mesajına yön koymak lüks değil, spesifikasyonun parçası.

4. Belirsizliği sessizce çözme. Anlamı belirsiz bir dış cevabı varsayımla kapatmak, düzeltilebilir bir hatayı düzeltilemez bir hataya çevirir. Tanımadığımız bir önek geldiğinde firmanın varsayılanına düşmeyi tercih edebilirdik — akış devam ederdi, kimse bir şey fark etmezdi, ve belge yanlış seriden kesilirdi. Gürültülü başarısızlık, sessiz yanlış sonuca yeğdir. Başarısız bir gönderim tekrar denenebilir; yanlış seriden kesilmiş bir belge düzeltme evrakı ister.

Bir de beşinci, çoğu zaman en iyisi: çatalı çözmek yerine ortadan kaldırmak. Tüketicideki bazı koşullar aslında koşul değil, iki farklı olay tipidir. Ayrı mesaj tipine bölerseniz if tamamen kaybolur — ve olmayan bir çatalın yanlış kolu da olmaz.

Peki bu determinizm mi, taklidi mi?

Dürüst olmak lazım: dağıtık bir sistemde tam determinizm yok. Ağ sizin değil. Teslim sırası sizin değil. Mesajın kaç kez geleceği sizin değil. Bunları payload’a dondurarak çözemezsiniz.

O yüzden hedefi doğru koymak gerekiyor. Peşinde olduğumuz şey sonuç determinizmi değil, karar determinizmi: aynı girdi setiyle her zaman aynı kararı vermek. Mesajın ne zaman geleceği bizim elimizde değil; geldiğinde ne yapacağımız bizim elimizde.

“Öyleyse her şeyi payload’a koyalım” da bir tuzak, ve hızla ters teper. Mesaj şişer, şema kırılganlaşır; daha kötüsü, kuyrukta bekleyen eski mesajlar yeni kodla tüketildiğinde bambaşka bir determinizm problemi doğar. Çözdüğünüzü sandığınız şey, replay olarak geri gelir.

Bende işe yarayan sınır kuralı şu: karara giren şey dondurulur, karara girmeyen şey referansla taşınır. Belgenin öneki karara giriyor — donar. Firmanın logosu girmiyor — referans yeter. Bu ayrım her zaman net değil, ama en azından doğru soruyu sordurur.

Kapanış

O sabahki fatura, sonunda tek satırlık bir konfigürasyon düzeltmesiyle geçti. Ama gerçek düzeltme o değildi. Gerçek düzeltme, o koşulun cevabını mesajın dışından almasına en baştan izin vermiş olduğumuzu kabul etmekti.

Kodunuz determinist olabilir; sisteminiz olmaz. Determinizm bir kod özelliği değil, bir bağlam disiplinidir: kararın girdisi olan her şeyi, kararın alındığı ana değil, olayın doğduğu ana bağlamak.

Bir koşul yazmadan önce sorulacak tek soru şu: bu koşulun cevabını değiştiren şey mesajın içinde mi, yoksa dünyanın o anki hâlinde mi?

Etiketler: #PHP#RabbitMQ
Paylaş:

Yorumlar

Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.

İlgili Yazılar

Sitede Ara

Yazı, proje ve sayfalarda arama yapmak için yazmaya başlayın.

Esc ile kapat Pagefind ile güçlendirildi