Trzy PR-y, jedna przyczyna źródłowa: HTML body zmienia się zależnie od stanu logowania, UA i flash — właściwości, o których cache key CDN-u nie ma pojęcia — przez co treść przecieka między użytkownikami. Pozwolenie Claude'owi przejść łańcuch PR-ów ujawnia trzy wzorce do wielokrotnego użycia: lazy personalize frames, ukrywanie po stronie klienta w CSS i wartownik na granicy cache'u.
Użytkownicy wchodzili na moją stronę i u dołu każdej publicznej strony unosił się napis "Content missing". Pierwsze zgłoszenia pochodziły wszystkie z iOS — mój pierwszy odruch: jakieś dziwne zachowanie klienta Hotwire Native. Dopiero po wgryzieniu się w logi Cloudflare zrozumiałem: to w ogóle nie kwestia klienta. Cache CDN jest zatruty, a wszystkie trzy fronty (Web / iOS / Android) mogą się o to potknąć — iOS po prostu pierwszy wyciągnął to na wierzch.
Pierwszy odruch: bug Hotwire Native. Drugi: jakieś dziwne edge'owe zachowanie Cloudflare. Żadne z tych. Nawet nazywanie tej kategorii bugów „problemem CDN" jest niepoprawne — CDN robi dokładnie to, co mu kazałeś.
Kiedy kazałem Claude'owi przejść po łańcuchu PR-ów po kolei, trzy kolejne PR-y miały jedną i tę samą przyczynę źródłową: treść HTML body zmienia się w zależności od stanu logowania, UA, flash — właściwości requestu, o których cache key CDN-u nie ma pojęcia. Trzy różne objawy, jedna przyczyna.
/, /topics, /square, /coins, /searches/app przechodzą przez cache CDN przez public_expires_in. W layoucie było:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge renderuje turbo-frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
Intencja jasna: zalogowani użytkownicy Hotwire Native widzą czerwoną kropkę nieprzeczytanych powiadomień w dolnym tab barze. Web tego nie potrzebuje (osobna ścieżka renderowania), niezalogowani również nie.
Problem: gałąź mobile_hotwire? && user_signed_in? powoduje, że ten sam URL produkuje dwie różne wersje HTML. A cache key CDN-u patrzy tylko na rzeczy w stylu URL i nagłówek Accept — nie ma pojęcia, czy jesteś zalogowany.
Linia czasu:
/. CDN idzie do origin i cache'uje HTML zawierający tab-badge frame./. CDN trafia w ten sam cache i wręcza mu ten HTML.src żąda /notifications?badge_only=true.NotificationsController ma before_action :authenticate_user!, widzi brak sesji i robi 302 na /users/sign_in.tab_badge i wypisuje "Content missing".Niezalogowani odwiedzający z trzech frontów (Web / iOS / Android) wszyscy mogą tu wpaść — wystarczy, że jakiś cache slot został wcześniej wypełniony przez zalogowanego użytkownika. Zgłoszenia skupiły się na iOS, ponieważ użytkownicy iOS mają najwyższy odsetek logowań — cache najczęściej jest zatruwany stanem zalogowanym, a niezalogowani odwiedzający z iOS mają największe prawdopodobieństwo, że trafią na zatrute wpisy. Web i Android nie były „w porządku": miały niższe prawdopodobieństwo wyzwolenia i mniej zgłoszeń — bug, który w istocie jest katastrofą całego serwisu, został przez wczesne dane przebrany za „wyłączny dla iOS".
Naprawa w dwóch krokach, oba mają zapobiec rozłamowi cache'u po stanie logowania:
Krok 1: usunąć && user_signed_in? z layoutu. W przeciwnym razie wiecznie grasz w zgadywanie cache key:
<%# Renderujemy dla wszystkich requestów mobile_hotwire?, niezależnie
od logowania — gałąź user_signed_in? rozcina HTML jednego URL
na dwie wersje, a ta, która zwycięży w slocie CDN, zostanie
zaserwowana następnemu odwiedzającemu. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Krok 2: endpoint frame'a /notifications?badge_only=true musi zwracać niezalogowanym requestom strukturalnie identyczny frame 200, nie 302. A sam endpoint musi być private, no-store, żeby CDN nie współdzielił licznika nieprzeczytanych jednego użytkownika z innymi.
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!
# ... normalna logika index
end
end
Uwaga: authenticate_user! jest pomijany tylko dla index — i tylko dla pod-requestów frame; requesty nie-frame'owe nadal idą przez redirect.
W tym samym PR był jeszcze jeden bug w identycznej formie: niezalogowani użytkownicy klienta Hotwire Native widzieli też compose FAB (pływający przycisk) — to samo zatrucie, cache zanieczyszczony stanem logowania przeciekający do niezalogowanych odwiedzających. Pierwotny kod wstawiał user_signed_in? wprost do layoutu, by decydować, czy renderować div ze stimulus controllerem. Naprawa: przenieść FAB też do lazy frame'u, renderowanego przez endpoint /personalize/compose_fab z private, no-store.
<%# Layout zawsze wypluwa pusty frame %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Szablon /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
Layout za każdym razem wypuszcza pusty frame; endpoint wewnątrz frame'u decyduje na podstawie stanu logowania, czy wstawić div controllera. W HTML strony głównej nie ma żadnego markupu rozgałęziającego się po użytkowniku — CDN może cache'ować jak chce, nic się nie psuje.
Kilka dni po wydaniu #117 kolejne zgłoszenie: niezalogowany użytkownik wchodzi na home i u góry strony pojawia się banner flash „Logowanie zakończone sukcesem" — głupio mu.
Ta sama choroba, nowy objaw.
W layoucie:
<%= render "shared/_notice" %>
Ta linijka renderuje flash[:notice] / flash[:alert]. Pierwszy request po jakimkolwiek redirect niesie flash, np.:
redirect_to root_path po udanym logowaniuredirect_to root_path po wylogowaniuredirect_back(fallback_location: ...) z PunditaJeśli URL, na który redirect ląduje, jest też ścieżką public_expires_in, ten flash wypieka się w cache'owanym HTML. Następny anonimowy odwiedzający tego samego URL dostaje ten sam HTML — i widzi cudze „Logowanie zakończone sukcesem".
Pokusa to przerefaktorować każdy cache'owalny view i przenieść renderowanie flash do frame'u. Ale solidniejsze jest zatrzymanie tego na granicy cache'u: jeśli odpowiedź niesie flash, nie wolno cache'ować publicznie.
def public_expires_in(duration)
return unless Rails.env.live?
# flash jest renderowany wprost w layoucie; cache'owanie tej odpowiedzi
# przeciekłoby powiadomienie poprzedniego użytkownika do następnego
# anonimowego odwiedzającego
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Plusy tego podejścia:
public_expires_in skorzystają automatycznie.Przy okazji zauważyłem, że coins#show sam wpisał expires_in 5.minutes, public: true, omijając helper. Ujednoliciłem na public_expires_in, w przeciwnym razie ten sam bug wycieku flash wyskoczyłby stamtąd.
Po deploy'u trzeba raz uruchomić cloudflare:purge_personalized_pages — już zatrute wpisy cache'u nie wygasną automatycznie; będą wisieć do końca TTL odpowiedniego URL (/topics to 1 tydzień).
/topicsKilka godzin później trzeci objaw: zalogowani użytkownicy na /topics nie widzą przycisku „+ Nowy temat". Reload nie pomaga. A ten sam użytkownik, wchodząc z innej strony na /topics?r=1 (losowy parametr do obejścia cache'u), odzyskuje przycisk.
/topics chodzi z public_expires_in 1.week — najbardziej agresywny cache. Pierwotny 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 %>
Dwóch wartowników: wartownik UA (web vs native) i wartownik auth (zalogowany lub nie). CDN nie zna żadnego — kto pierwszy zapuka do origin, ten ustala kształt cache'u. Pierwszy anonimowy odwiedzający web cache'uje „bez native bridge button, z web button"; pierwszy zalogowany odwiedzający native cache'uje „z bridge button, bez web button" — los następnego zależy od tego, w którym cache slocie wyląduje.
To samo podejście co w #117:
Przycisk web — bez gałęzi, zawsze renderowany:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only przez CSS chowa go pod native UA. HTML cache'owany przez CDN jest zawsze identyczny, przycisk zawsze obecny, UA decyduje, czy go pokazać — CSS to decyzja po stronie klienta, cache w niej nie uczestniczy.
Przycisk native bridge — przeniesiony do lazy personalize frame'a:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Szablon /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
W HTML strony głównej zostają tylko kontener <turbo-frame> i przycisk web — żaden z nich nie rozgałęzia się po użytkowniku. Cały markup „renderować w zależności od użytkownika lub nie" przeniósł się do endpointu personalize, który jest private, no-store i nigdy nie wchodzi do CDN.
Każdy objaw w danej chwili wyglądał jak inny bug. „Content missing" wyglądało jak problem Hotwire Native; wyciek flash jak problem konfiguracji cookies/session; brakujący przycisk jak błąd logiki w view template.
Kładąc trzy PR-y obok siebie, przyczyna źródłowa jest jedna: treść HTML body zależy od właściwości requestu znajdujących się poza cache key. Zadanie CDN to cache'ować HTML po cache key — nie ma jak wiedzieć, że dany HTML jest poprawny tylko dla zalogowanych, albo tylko dla Hotwire Native, albo tylko dla requestów z flash.
Trzy reużywalne wzorce do wyplenienia tej kategorii:
Wypchnąć zmienność poza cache'owany body — każdy markup rozgałęziający się po użytkowniku staje się lazy turbo-frame'em renderowanym przez endpoint personalize private, no-store. HTML strony głównej jest niezmienny; CDN cache'uje jak chce — zawsze poprawnie.
Renderować wersję uniwersalną, ukrywać po stronie klienta — np. przycisk web jest zawsze w HTML, a CSS chowa go pod native UA przez .web-only. Gałąź żyje w warstwie CSS/JS, HTML wewnątrz cache key jest całkowicie ujednolicony.
Wartownik na granicy cache'u — dla odpowiedzi, do których wycieknął już stan (np. flash), na granicy cache'u zdegradować Cache-Control do private, no-store. Normalny ruch jest nietknięty.
Wspólny mianownik trzech wzorców: cache'owany HTML i markup rozgałęziony po użytkowniku nigdy nie mogą się pokrywać.
Po deploy'u fixa zostaje jeszcze jedno: zatrute wpisy CDN nie wygasną automatycznie, a TTL-e idą do tygodnia. Napisz jednorazowy rake task cloudflare:purge_personalized_pages, który aktywnie purgnie wszystkie podejrzane ścieżki — w przeciwnym razie bug będzie wyskakiwał aż do naturalnego wygaśnięcia.
PR #122 odkryto w tym samym okresie. Ten sam kształt, inny typ buga — warto wyróżnić, bo dzieli ten sam ukryty mechanizm z bugami cache poisoning.
Strona /following używa lazy turbo-frame'a do ładowania kolejnej strony (standardowy wzorzec infinite scroll). src frame'a jest obliczany przez helper o nazwie set_load_more_path — który decyduje URL kolejnej strony na podstawie aktualnego controllera/akcji.
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(...)
# ... wiele gałęzi ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← fallback
end
end
Akcja posts#following nie znajduje się na liście gałęzi, więc spada na końcowy fallback posts_path — czyli /posts, explore feed.
Efekt: pierwsza strona /following jest poprawna (sama akcja kontrolera ustawia @posts = following_posts), ale od drugiej strony lazy frame po cichu ciągnie /posts?page=2, wczytując treść explore feeda — posty od osób, których nie obserwujesz, wpadają do feeda.
Fix jest krótki:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
To nie ta sama kategoria co trzy bugi cache poisoning — błędna gałąź w set_load_more_path nie ma związku z CDN. Ale dzielą ten sam ukryty mechanizm: treść ładowana przez lazy turbo-frame to treść, której aktywnie nie reviewujesz.
Frame z #117 przy błędzie po cichu robił 302; w #119/#121 zawartość w cache była błędna i tego nie widziałeś; w #122 ścieżka frame'a była błędna i patrząc tylko na pierwszą stronę byś tego nie wyłapał. Z chwilą, gdy wsadzasz „dynamiczną zawartość" do lazy frame'a, musisz aktywnie reviewować stan po załadowaniu frame'a — co Claude na etapie PR review wyłapał dla mnie, to dokładnie ta checklista „co dzieje się po załadowaniu frame'a".
Jeśli przed twoją appką Rails stoi CDN (Cloudflare, Fastly, jakikolwiek), z dużym prawdopodobieństwem wpadasz w przynajmniej jedną z tych kombinacji:
public_expires_in / expires_in ..., public: true — czy istnieje gałąź typu if user_signed_in??flash renderuje się wprost w layoucie? Czy ścieżka public_expires_in jest cache'owana, gdy obecny jest flash?mobile_hotwire? pojawia się na cache'owalnych ścieżkach?src turbo-frame'a — czy jego controller używa before_action :authenticate_user!? Czy niezalogowane requesty zrobią 302?src lazy turbo-frame'a jest obliczana przez helper? Czy ten helper ma gałąź fallback? Czy fallback to zły URL?Każ Claude'owi przejść tę checklistę z pięcioma punktami — jeśli któryś trafi, to materiał na następny PR. Takie bugi nie wypływają same, bo lazy frame'y zawodzą cicho, CDN-y trafiają cicho, a CSS chowa cicho. Trzy ciche mechanizmy ułożone jeden na drugim — i bug może chować się w produkcji miesiącami.