Tre PR, una causa radice: il body HTML varia in base allo stato di login, allo UA e al flash — proprietà che la cache key della CDN ignora — facendo trapelare i contenuti tra utenti. Far leggere a Claude la catena di PR fa emergere tre pattern riutilizzabili: lazy personalize frame, occultamento lato client via CSS, guardia alla frontiera della cache.
Gli utenti entravano nel mio sito e in fondo a ogni pagina pubblica c'era una riga "Content missing" che galleggiava. Le prime segnalazioni venivano tutte da iOS — il mio istinto iniziale: qualche comportamento strano del client Hotwire Native. Solo scavando nei log di Cloudflare ho capito: non c'entra nulla con il client. La cache della CDN è avvelenata, e tutti e tre i fronti (Web / iOS / Android) possono finirci dentro — iOS è solo il primo a far emergere il problema.
Riflesso uno: bug di Hotwire Native. Riflesso due: qualche stranezza edge di Cloudflare. Nessuno dei due. Persino chiamare questa categoria di bug "un problema di CDN" è scorretto — la CDN sta facendo esattamente quello che le hai detto.
Quando ho fatto leggere a Claude la catena di PR uno dopo l'altro, tre PR consecutivi avevano tutti la stessa causa radice: il body HTML varia in base allo stato di login, allo UA, al flash — proprietà del request di cui la cache key della CDN non sa nulla. Tre sintomi diversi, una sola causa.
/, /topics, /square, /coins, /searches/app passano dalla cache CDN tramite public_expires_in. Nel layout c'era:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge renderizza un turbo-frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
L'intento è chiaro: gli utenti Hotwire Native autenticati vedono un puntino di notifica non letta nella tab bar in basso. Il Web non ne ha bisogno (ha un proprio percorso di rendering), e gli utenti non autenticati nemmeno.
Il problema: il branch mobile_hotwire? && user_signed_in? fa sì che la stessa URL produca due varianti HTML. Ma la cache key della CDN guarda solo cose tipo URL e header Accept — non ha idea se sei autenticato o no.
Cronologia:
/. La CDN va all'origin e mette in cache l'HTML che contiene il tab-badge frame./. La CDN colpisce la stessa cache e gli serve quell'HTML.src, chiede /notifications?badge_only=true.NotificationsController ha before_action :authenticate_user!, vede che non c'è sessione e fa un 302 a /users/sign_in.tab_badge e disegna "Content missing".I visitatori non autenticati di tutti e tre i fronti (Web / iOS / Android) possono cascarci — basta che un cache slot sia stato riempito prima da un utente autenticato. Le segnalazioni si sono concentrate su iOS perché il tasso di login degli utenti iOS è il più alto, e quindi la cache si contamina più frequentemente con stato autenticato — i visitatori iOS non autenticati avevano la più alta probabilità di imbattersi in una entry avvelenata. Web e Android non erano "al sicuro": avevano probabilità di trigger più basse e meno segnalazioni — un bug che in essenza è una catastrofe sito-wide è stato camuffato dai dati iniziali come "esclusivo di iOS".
Il fix in due tappe, entrambe per impedire alla cache di scindersi per stato di login:
Passo 1: rimuovere il && user_signed_in? dal layout. Altrimenti giochi a indovinare la cache key per sempre:
<%# Renderizzare per tutte le richieste mobile_hotwire?, indipendentemente
dal login — il branch user_signed_in? spezza l'HTML della stessa URL
in due varianti, e quella che si aggiudica lo slot CDN viene servita
al visitatore successivo. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Passo 2: l'endpoint del frame /notifications?badge_only=true deve restituire alle richieste non autenticate un frame 200 strutturalmente identico, non un 302. E l'endpoint stesso deve essere private, no-store, in modo che la CDN non condivida il contatore di non letti di un utente con un altro.
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!
# ... logica index normale
end
end
Nota: authenticate_user! viene saltato solo per index — e solo per le sub-request del frame; le richieste non-frame passano comunque dal redirect.
Nello stesso PR c'era un altro bug della stessa forma: gli utenti Hotwire Native non autenticati vedevano anche il FAB di compose (pulsante fluttuante) — stesso avvelenamento, una cache contaminata dallo stato di login che trapela ai visitatori non autenticati. La versione originale scriveva user_signed_in? direttamente nel layout per decidere se renderizzare il div dello stimulus controller. Fix: spostare anche il FAB in un lazy frame, renderizzato dall'endpoint /personalize/compose_fab con private, no-store.
<%# Il layout sputa sempre un frame vuoto %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Template /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
Il layout sputa ogni volta un frame vuoto; l'endpoint dentro il frame decide, in base allo stato di login, se inserire il div del controller. L'HTML della pagina principale non contiene alcun markup che si ramifichi per utente — la CDN può cacheare come vuole, niente si rompe.
Pochi giorni dopo il rilascio di #117, un'altra segnalazione: un utente non autenticato entra nella home e in alto compare un banner flash "Login effettuato con successo" — sconcertato.
Stessa malattia, sintomo nuovo.
Nel layout:
<%= render "shared/_notice" %>
Questa riga renderizza flash[:notice] / flash[:alert]. La prima request dopo qualsiasi redirect porta con sé una flash, ad esempio:
redirect_to root_path dopo login riuscitoredirect_to root_path dopo logoutredirect_back(fallback_location: ...) di PunditSe la URL su cui atterra il redirect è anche un percorso public_expires_in, quella flash viene cotta dentro l'HTML in cache. Il successivo visitatore anonimo a quella URL riceve lo stesso HTML — e vede la "Login effettuato con successo" di qualcun altro.
La tentazione è refattorizzare ogni view cacheabile spostando il rendering del flash in un frame. La via più solida è bloccare alla frontiera della cache: se la response porta una flash, non si può fare cache pubblica.
def public_expires_in(duration)
return unless Rails.env.live?
# flash viene renderizzato direttamente nel layout; cacheare questa
# response farebbe trapelare l'avviso dell'utente precedente al
# successivo visitatore anonimo
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Vantaggi di questa mossa:
public_expires_in ne beneficia automaticamente.Già che c'ero, ho notato che coins#show aveva scritto da sé expires_in 5.minutes, public: true, bypassando l'helper. L'ho uniformato a public_expires_in, altrimenti lo stesso bug di leak della flash sarebbe rispuntato da lì.
Dopo il deploy bisogna eseguire una volta cloudflare:purge_personalized_pages — le entry di cache già avvelenate non scadono da sole; resteranno fino all'esaurimento del TTL della URL corrispondente (/topics è 1 settimana).
/topicsQualche ora dopo, terzo sintomo: gli utenti autenticati su /topics non vedevano il pulsante "+ Nuovo Argomento". Il refresh non aiutava. Ma lo stesso utente, arrivando da un'altra pagina a /topics?r=1 (parametro casuale per bypassare la cache), si ritrovava il pulsante.
/topics è in public_expires_in 1.week — la cache più aggressiva. Il view originale:
<% 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 %>
Due guardie: una guardia UA (web vs native) e una guardia auth (autenticato o no). La CDN non conosce nessuna delle due — chi arriva per primo all'origin decide la forma della cache. Il primo visitatore web anonimo cachea "senza pulsante native bridge, con pulsante web"; il primo visitatore native autenticato cachea "con pulsante bridge, senza pulsante web" — il destino del successivo dipende dal cache slot in cui finisce.
Stesso approccio di #117:
Il pulsante web — nessun branch, sempre renderizzato:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only lo nasconde via CSS sotto UA native. L'HTML in cache CDN è sempre identico, il pulsante è sempre lì, lo UA decide se mostrarlo — CSS è una decisione lato client, la cache non vi partecipa.
Il pulsante native bridge — spostato in un lazy personalize frame:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Template /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
L'HTML della pagina principale contiene solo il container <turbo-frame> e il pulsante web — nessuno dei due si ramifica per utente. Tutto il markup "renderizzare in base all'utente" è migrato nell'endpoint personalize, che è private, no-store e non entra mai nella CDN.
Ogni sintomo, sul momento, sembrava un bug diverso. "Content missing" sembrava un problema di Hotwire Native; il leak della flash sembrava un problema di configurazione cookies/session; il pulsante mancante sembrava un errore di logica nel view template.
Mettendo i tre PR uno accanto all'altro, la causa radice è una sola: il contenuto del body HTML dipende da proprietà della richiesta esterne alla cache key. Il lavoro della CDN è cacheare HTML in base alla cache key — non può sapere che quell'HTML è corretto solo per gli utenti autenticati, o solo per Hotwire Native, o solo per le richieste con flash.
Tre pattern riutilizzabili per estirpare questa categoria:
Spingere la variabilità fuori dal body cacheato — qualsiasi markup che si ramifichi per utente diventa un lazy turbo-frame, renderizzato da un endpoint personalize private, no-store. L'HTML della pagina principale è invariante; la CDN cachea come vuole, è sempre giusto.
Renderizzare la versione universale, nascondere lato client — ad es. il pulsante web sta sempre nell'HTML, e il CSS lo nasconde con .web-only sotto UA native. Il branch vive in CSS/JS, non nell'HTML cache-keyed.
Guardia alla frontiera della cache — per le response in cui è già trapelato dello stato (come la flash), declassare Cache-Control a private, no-store alla frontiera. Il traffico normale resta intatto.
Il filo rosso dei tre pattern: l'HTML cacheato e il markup ramificato per utente non devono mai sovrapporsi.
Dopo il deploy del fix resta una cosa: le entry CDN avvelenate non scadono da sole, e i TTL possono arrivare a una settimana. Scrivi una rake task una tantum cloudflare:purge_personalized_pages che purga attivamente tutte le rotte sospette — altrimenti il bug continuerà a ricomparire fino alla scadenza naturale.
Il PR #122 è stato scoperto nello stesso periodo. Stessa forma, tipologia di bug diversa — vale la pena segnalarlo a parte perché condivide lo stesso meccanismo invisibile dei bug di cache poisoning.
La pagina /following usa un lazy turbo-frame per caricare la pagina successiva (pattern standard di infinite scroll). Il src del frame è calcolato da un helper chiamato set_load_more_path — decide la URL della pagina successiva in base al controller/action attuale.
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(...)
# ... molti branch ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← fallback
end
end
L'action posts#following non è nell'elenco dei branch, quindi finisce nel fallback finale posts_path — cioè /posts, l'explore feed.
Effetto: la pagina 1 di /following è corretta (l'azione del controller stessa imposta @posts = following_posts), ma dalla pagina 2 in poi il lazy frame va a prendere di nascosto /posts?page=2, caricando contenuto dell'explore feed — post di persone che non segui si infilano nel feed.
Il fix è breve:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Non è la stessa categoria dei tre bug di cache poisoning — il branch sbagliato in set_load_more_path non c'entra con la CDN. Ma condivide lo stesso meccanismo invisibile: il contenuto caricato da un lazy turbo-frame è contenuto che non revisioni attivamente.
Il frame di #117 faceva 302 in silenzio in caso di errore; #119/#121 avevano il contenuto in cache sbagliato e tu non lo vedevi; in #122 il path del frame era sbagliato e guardando solo la pagina 1 non te ne accorgevi. Una volta che metti "contenuto dinamico" dentro un lazy frame, devi rivedere attivamente lo stato dopo il caricamento del frame — quello che Claude ha intercettato per me in fase di PR review è esattamente la checklist "cosa succede dopo che il frame si è caricato".
Se la tua app Rails ha davanti una CDN (Cloudflare, Fastly, qualsiasi), è molto probabile che tu stia inciampando in almeno una di queste combinazioni:
public_expires_in / expires_in ..., public: true, esiste un branch tipo if user_signed_in??flash è renderizzato direttamente nel layout? Una rotta public_expires_in viene cacheata se è presente una flash?mobile_hotwire? che compare su rotte cacheabili?src di turbo-frame, il suo controller usa before_action :authenticate_user!? Le richieste non autenticate fanno 302?src di un lazy turbo-frame è calcolato da un helper? Quell'helper ha un branch fallback? Il fallback è una URL sbagliata?Fai passare a Claude questa checklist da cinque punti — se uno fa match, è il prossimo PR. Questi bug non vengono a galla da soli, perché i lazy frame falliscono in silenzio, le CDN colpiscono in silenzio, il CSS nasconde in silenzio. Tre meccanismi silenziosi impilati — e un bug può nascondersi in produzione per mesi.