Üç PR'ın ortak kök nedeni: HTML body, oturum durumu, UA ve flash'a göre değişiyor — CDN'in cache key'inin haberi olmadığı özellikler — ve içerik kullanıcılar arası sızıyor. Claude'a PR zincirini okutmak üç tekrar kullanılabilir deseni gün yüzüne çıkarır: lazy personalize frame'ler, CSS ile istemci tarafında gizleme, cache sınırı nöbetçisi.
Kullanıcılar siteme giriyor ve her açık sayfanın altında "Content missing" satırı havada asılı duruyordu. İlk raporlar tamamen iOS'tan geldi — ilk içgüdüm: Hotwire Native istemcisinin tuhaf bir davranışı. Ancak Cloudflare loglarını eşeleyince anladım: bunun istemciyle alakası yok. CDN cache'i zehirlenmiş, üç cephe de (Web / iOS / Android) buna takılabilir — iOS sadece olayı önce gün yüzüne çıkardı.
Birinci refleks: Hotwire Native bug'ı. İkinci refleks: Cloudflare'in tuhaf bir edge davranışı. İkisi de değil. Bu bug sınıfını "CDN sorunu" diye nitelemek bile yanlış — CDN tam olarak ona söylediğin işi yapıyor.
Claude'a PR zincirini sırasıyla okuttuğumda, üç ardışık PR aynı kök nedene sahipti: HTML body, oturum durumu / UA / flash gibi request özelliklerine göre değişiyor; ama CDN'in cache key'i bunlardan habersiz. Üç farklı belirti, tek bir neden.
/, /topics, /square, /coins, /searches/app public_expires_in ile CDN cache'inden geçer. Layout'ta şu vardı:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge bir turbo-frame render eder:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
Niyet açık: oturum açmış Hotwire Native kullanıcıları alttaki tab bar'da okunmamış bildirim noktasını görsün. Web'in buna ihtiyacı yok (kendi render yolu var); oturum açmamış kullanıcının da yok.
Sorun: mobile_hotwire? && user_signed_in? dalı, aynı URL'in iki farklı HTML üretmesine yol açıyor. Ama CDN'in cache key'i sadece URL ve Accept gibi şeylere bakar — oturum açmış mısın bilemez.
Zaman çizelgesi:
/ adresine girer. CDN ilk kez origin'e gider ve içinde tab-badge frame'i olan HTML'i cache'ler./ adresine girer. CDN aynı cache'i bulur ve aynı HTML'i ona da verir.src üzerinden /notifications?badge_only=true ister.NotificationsController bir before_action :authenticate_user! taşır; oturum açmamış olduğunu görüp /users/sign_in'e 302 atar.tab_badge id'li frame'i bulamaz ve "Content missing" yazar.Üç cephenin (Web / iOS / Android) tüm oturum açmamış ziyaretçileri buna takılabilir — yeter ki o cache slot'u önce oturum açmış bir kullanıcı doldursun. Raporlar iOS'ta yoğunlaştı çünkü iOS kullanıcılarının oturum açma oranı en yüksek; cache en sık oturum açmış halde zehirleniyor — oturum açmamış iOS ziyaretçileri zehirlenmiş bir kayda çarpma olasılığı en yüksek olanlar oluyor. Web ve Android "selamette" değildi: tetiklenme olasılıkları daha düşük, raporlar daha az — özünde site genelinde bir felaket olan bir bug, erken veriler tarafından "yalnızca iOS'a özgü" olarak kılık değiştirmişti.
Düzeltme iki adım, ikisi de cache'in artık oturum durumuna göre yarılmaması için:
1. adım: layout'taki && user_signed_in? kalkmalı. Aksi halde sürekli cache key tahmin oyunu oynarsın:
<%# mobile_hotwire? olan tüm istekler için render et, oturumdan bağımsız —
user_signed_in? dalı aynı URL'in HTML'ini iki varyanta böler ve
hangisi CDN slot'unu kazanırsa, o sürüm bir sonraki ziyaretçiye
sunulur. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
2. adım: /notifications?badge_only=true frame endpoint'i, oturum açmamış istekler için yapısal olarak özdeş bir 200 frame döndürmeli, 302 değil. Ayrıca endpoint'in kendisi private, no-store olmalı; öyle ki CDN bir kullanıcının okunmamış sayısını başkasıyla paylaşmasın.
class NotificationsController < ApplicationController
before_action :authenticate_user!, except: [:index]
def index
if params[:badge_only] || params[:count_only]
response.headers["Cache-Control"] = "private, no-store"
redirect_to notifications_path and return unless turbo_frame_request?
@unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
if params[:badge_only]
render(partial: "notifications/tab_badge")
else
render(partial: "notifications/bell")
end
return
end
authenticate_user!
# ... normal index mantığı
end
end
Dikkat: authenticate_user! yalnızca index için ve yalnızca frame alt-istekleri için atlanır; frame olmayan istekler hâlâ redirect'ten geçer.
Aynı PR'da formu birebir aynı bir bug daha vardı: oturum açmamış Hotwire Native kullanıcıları compose FAB'ı (yüzen düğme) da görüyordu — aynı zehirlenme; oturum durumuyla zehirlenmiş cache, oturum açmamış ziyaretçilere sızıyordu. Orijinal kod, user_signed_in?'i doğrudan layout'ta yazarak stimulus controller div'inin render edilip edilmeyeceğine karar veriyordu. Düzeltme: FAB'ı da lazy frame'e taşı; render'ı private, no-store olan /personalize/compose_fab endpoint'i yapsın.
<%# Layout her seferinde boş bir frame çıkartır %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# /personalize/compose_fab şablonu %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
Layout her seferinde boş bir frame yollar; frame'in içindeki endpoint, oturum durumuna göre controller div'ini ekleyip eklemeyeceğine karar verir. Ana sayfanın HTML'inde "kullanıcıya göre dallanan" hiçbir markup yoktur — CDN nasıl cache'lerse cachelesin, hiçbir şey kırılmaz.
Aynı hastalık, yeni belirti.
Layout'ta:
<%= render "shared/_notice" %>
Bu satır flash[:notice] / flash[:alert] render eder. Herhangi bir redirect'ten sonraki ilk istek flash taşır; örneğin:
redirect_to root_pathredirect_to root_pathredirect_back(fallback_location: ...)Redirect'in indiği URL aynı zamanda public_expires_in yolu ise, o flash cache'lenen HTML'in içine pişer. O URL'i ziyaret eden bir sonraki anonim aynı HTML'i alır — ve birinin "Giriş başarılı" mesajını görür.
Cazip yöntem her cache'lenebilir view'u refactor edip flash render'ını frame'e taşımak. Ama daha sağlam yol cache sınırında kesmektir: bu yanıt flash taşıyorsa public cache yasak.
def public_expires_in(duration)
return unless Rails.env.live?
# flash layout'ta doğrudan render edilir; bu yanıtı cache'lemek
# önceki kullanıcının uyarısını sonraki anonim ziyaretçiye sızdırır
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Bu hamlenin avantajları:
public_expires_in kullanan tüm sayfalar otomatik yararlanır.Bu vesileyle coins#show'un kendi başına expires_in 5.minutes, public: true yazıp helper'ı atladığını fark ettim. Hepsini public_expires_in'e bağladım; aksi halde aynı flash sızıntısı bug'ı oradan tekrar başını kaldırır.
Deploy sonrasında cloudflare:purge_personalized_pages'in bir kez çalıştırılması şart — zehirlenmiş cache kayıtları kendiliğinden süresi dolmaz; ilgili URL'in TTL'si bitene dek dururlar (/topics 1 hafta).
/topics sayfasında "+ Yeni Konu" düğmesini görmüyorBirkaç saat sonra üçüncü belirti: oturum açmış kullanıcılar /topics sayfasında "+ Yeni Konu" düğmesini göremiyor. Yenileme işe yaramıyor. Ama aynı kullanıcı başka bir sayfadan /topics?r=1 (cache'i atlatan rastgele parametre) ile gelince düğme geri geliyor.
/topics public_expires_in 1.week ile çalışır — en agresif cache. Orijinal view:
<% unless mobile_hotwire? %>
<a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>
<% if mobile_hotwire? %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
İki nöbetçi: UA nöbetçisi (web vs native) ve auth nöbetçisi (oturum açmış mı, açmamış mı). CDN her ikisini de bilmez — origin'e ilk vuran kullanıcı cache'in şeklini belirler. İlk anonim web ziyaretçi "native bridge düğmesi yok, web düğmesi var"ı cache'ler; ilk oturum açmış native ziyaretçi "bridge düğmesi var, web düğmesi yok"u cache'ler — sıradakinin kaderi hangi cache slot'una düştüğüne bağlı.
Web düğmesi — dallanma yok, hep render edilir:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only CSS aracılığıyla native UA altında onu gizler. CDN'in cache'lediği HTML hep aynıdır; düğme her zaman oradadır; UA gösterilip gösterilmeyeceğine karar verir — CSS bir client-side karardır, cache buna katılmaz.
Native bridge düğmesi — lazy personalize frame'e taşındı:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# /personalize/topic_new_button şablonu %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
Ana sayfanın HTML'inde yalnızca <turbo-frame> kapsayıcısı ve web düğmesi vardır — ikisi de kullanıcıya göre dallanmaz. "Kullanıcıya göre render edilip edilmeyecek" tüm markup personalize endpoint'ine taşındı; o endpoint private, no-store ve asla CDN'e girmez.
Belirtilerin her biri o anda farklı bir bug gibi görünür. "Content missing" Hotwire Native problemi gibi; flash sızıntısı cookies/session yapılandırma sorunu gibi; kayıp düğme view template'inde mantık hatası gibi.
Üç PR'ı yan yana koyduğunda kök neden tek: HTML body'nin içeriği, cache key dışındaki request özellikleriyle belirleniyor. CDN'in işi cache key'e göre HTML cache'lemektir — bu HTML'in aslında yalnızca oturum açmış kullanıcılar için, ya da yalnızca Hotwire Native için, ya da yalnızca flash taşıyan istekler için doğru olduğunu bilmesi imkânsızdır.
Bu sınıfı kökünden temizlemek için üç tekrar kullanılabilir desen:
Değişkenliği cache'lenen body'nin dışına itmek — kullanıcıya göre dallanan tüm markup, private, no-store bir personalize endpoint'i tarafından render edilen lazy turbo-frame'e dönüşür. Ana sayfanın HTML'i değişmez; CDN ne yaparsa yapsın doğrudur.
Evrensel sürümü render etmek, client-side'da gizlemek — örneğin web düğmesi her zaman HTML'dedir, CSS onu native UA altında .web-only ile gizler. Dallanma CSS/JS katmanına taşınır; cache key'in içindeki HTML tamamen tek tiptir.
Cache sınırı nöbetçisi — yanıtlara durum sızmışsa (örneğin flash), cache sınırında Cache-Control'u private, no-store seviyesine indirir. Olağan trafik el sürülmemiş kalır.
Üç desenin ortak noktası: cache'lenen HTML ile kullanıcıya göre dallanan markup asla örtüşmemelidir.
Düzeltmeyi deploy ettikten sonra bir iş daha kalır: CDN'in zehirli kayıtları kendiliğinden süresi dolmaz; TTL bir haftaya çıkabilir. Tek seferlik bir rake task cloudflare:purge_personalized_pages yaz; tüm şüpheli yolları aktif olarak purge etsin — yoksa bug doğal süresi dolana kadar belirmeye devam eder.
PR #122 aynı dönemde keşfedildi. Şekil aynı, bug türü farklı — ayrı anılmaya değer çünkü cache poisoning ile aynı görünmez mekanizmayı paylaşıyor.
/following sayfası bir sonraki sayfayı yüklemek için lazy turbo-frame kullanır (klasik sonsuz kaydırma). Frame'in src'si set_load_more_path adlı bir helper tarafından hesaplanır — sonraki sayfa URL'ini geçerli controller/action'a göre belirler.
def set_load_more_path(page:, anchor_id: nil)
if controller_name == "coins" && action_name == "show"
path = coin_symbol_path(...)
elsif controller_name == "posts" && action_name == "hot"
path = hot_path(...)
# ... pek çok dal ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← yedek
end
end
posts#following action'ı dal listesinde yok; bu yüzden son posts_path yedeğine düşer — yani /posts, explore feed.
Sonuç: /following sayfa 1 doğrudur (controller action'ı kendi başına @posts = following_posts set eder); ama 2. sayfadan itibaren lazy frame sessizce /posts?page=2'yi çeker, explore feed içeriğini yükler — takip etmediğin kişilerin gönderileri feed'ine sızar.
Düzeltme kısa:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Bu, üç cache poisoning bug'ıyla aynı kategoride değil — set_load_more_path'teki yanlış dalın CDN ile ilgisi yok. Ama aynı görünmez mekanizmayı paylaşıyor: lazy turbo-frame'in yüklediği içerik, aktif olarak gözden geçirmediğin içeriktir.
Rails uygulamanın önünde bir CDN duruyorsa (Cloudflare, Fastly, hangisi olursa), aşağıdaki kombinasyonlardan en az birine basmış olma ihtimalin oldukça yüksek:
public_expires_in / expires_in ..., public: true kullanan herhangi bir view template'inde if user_signed_in? türünden bir dal var mı?flash doğrudan layout'ta mı render ediliyor? public_expires_in yolu, flash mevcutken cache'leniyor mu?mobile_hotwire? ile korunan markup, cache'lenebilir yollarda görünüyor mu?src endpoint'inde controller before_action :authenticate_user! kullanıyor mu? Oturum açmamış istek 302 mı atıyor?src yolu bir helper tarafından mı hesaplanıyor? O helper'ın yedek dalı var mı? Yedek yanlış URL mi?Claude'a bu beş maddelik listeyi taratırsan — biri tutarsa, sıradaki PR'ın malzemesi odur. Bu tür buglar kendiliğinden yüzeye çıkmaz; çünkü lazy frame'ler sessizce başarısız olur, CDN'ler sessizce isabet eder, CSS sessizce gizler. Üç sessiz mekanizma üst üste binince bug üretimde aylarca saklanabilir.