Три PR — одна корневая причина: тело HTML меняется в зависимости от состояния входа, UA и flash — свойств, о которых cache key CDN ничего не знает, — и контент течёт между пользователями. Если дать Claude пройти по цепочке PR, всплывают три переиспользуемых паттерна: lazy personalize frames, скрытие на клиенте через CSS и страж на границе кэша.
Пользователи заходят на мой сайт, и у каждой публичной страницы внизу болтается строка "Content missing". Первые сообщения пришли все с iOS — первая мысль: какое-то странное поведение клиента Hotwire Native. И только когда я полез в логи Cloudflare, я понял: это вообще не про клиента. Кэш CDN отравлен, и все три фронта (Web / iOS / Android) могут на него попасть — iOS просто первым вытащил это наружу.
Рефлекс первый: баг Hotwire Native. Рефлекс второй: какое-то странное edge-поведение Cloudflare. Ни то, ни другое. Даже называть этот класс багов «проблемой CDN» — неправильно: CDN делает ровно то, что вы ему сказали.
Когда я попросил Claude пройти по цепочке PR подряд, у трёх PR подряд оказалась одна и та же корневая причина: тело HTML меняется в зависимости от состояния входа, UA, flash — свойств запроса, о которых cache key CDN ничего не знает. Три разных симптома, одна корневая причина.
/, /topics, /square, /coins, /searches/app идут через CDN-кэш по public_expires_in. В лейауте было:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge рендерит turbo-frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
Намерение понятно: залогиненные пользователи Hotwire Native видят красную точку непрочитанных уведомлений в нижнем tab bar. Веб этого не нуждается (отдельный путь рендера), и неаутентифицированные тоже.
Проблема: ветка mobile_hotwire? && user_signed_in? приводит к тому, что один и тот же URL даёт два разных HTML. Но cache key у CDN смотрит только на URL и заголовки вроде Accept — он понятия не имеет, залогинены ли вы.
Хронология:
/. CDN идёт к origin и кэширует HTML с tab-badge frame./. CDN бьёт в тот же кэш и отдаёт ему тот же HTML.src запрашивает /notifications?badge_only=true.NotificationsController стоит before_action :authenticate_user!, видит, что не залогинен, и делает 302 на /users/sign_in.tab_badge и рисует "Content missing".Неаутентифицированные посетители всех трёх фронтов (Web / iOS / Android) могут попасть — достаточно, чтобы тот cache slot был сначала заполнен залогиненным пользователем. Сообщения сконцентрировались на iOS, потому что у пользователей iOS самая высокая доля логинов — кэш чаще отравляется в залогиненном состоянии, и неаутентифицированные iOS-посетители имеют наибольшую вероятность попасть в отравленную запись. Web и Android не были «в порядке»: у них была ниже вероятность срабатывания и меньше сообщений — баг, который по сути является сайт-уровневой катастрофой, был замаскирован ранними данными как «эксклюзив iOS».
Исправление в два шага, оба чтобы кэш перестал расщепляться по статусу логина:
Шаг 1: убрать && user_signed_in? из лейаута. Иначе вы вечно играете в угадайку cache key:
<%# Рендерим для всех запросов mobile_hotwire?, без оглядки на логин —
ветка user_signed_in? разрезает HTML одного URL на два варианта,
и тот, что выиграет CDN slot, будет отдан следующему посетителю. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Шаг 2: эндпоинт фрейма /notifications?badge_only=true должен возвращать неаутентифицированным запросам структурно идентичный 200-фрейм, а не 302. Сам эндпоинт должен быть private, no-store, чтобы CDN не делил счётчик непрочитанных одного пользователя с другим.
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!
# ... обычная логика index
end
end
Важно: authenticate_user! исключён только для index — и только для подзапросов фрейма; не-фреймовые запросы по-прежнему идут через redirect.
В том же PR был ещё один баг идентичной формы: неаутентифицированные пользователи клиента Hotwire Native видели также compose FAB (плавающую кнопку) — то же самое отравление, кэш, заражённый состоянием логина, утекает к неаутентифицированным посетителям. В исходном коде user_signed_in? стоял прямо в лейауте и решал, рендерить ли div со stimulus controller. Исправление: вынести FAB в lazy frame, рендерящийся эндпоинтом /personalize/compose_fab, у которого private, no-store.
<%# Лейаут всегда выдаёт пустой фрейм %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Шаблон /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
Лейаут каждый раз выдаёт пустой фрейм; эндпоинт внутри фрейма решает по статусу логина, вставлять ли div контроллера. В HTML главной страницы нет ни единого участка разметки, ветвящегося по пользователю — CDN может кэшировать как угодно, ничто не сломается.
Через несколько дней после релиза #117 пришло другое сообщение: неаутентифицированный пользователь зашёл на главную, и сверху висит flash-баннер «Вход выполнен» — он в ступоре.
Та же болезнь, новый симптом.
В лейауте:
<%= render "shared/_notice" %>
Эта строка рендерит flash[:notice] / flash[:alert]. Первый запрос после любого редиректа несёт flash, например:
redirect_to root_path после успешного логинаredirect_to root_path после логаутаredirect_back(fallback_location: ...) от PunditЕсли URL приземления редиректа — это ещё и public_expires_in-путь, то flash запекается в HTML, который кладётся в кэш. Следующий анонимный посетитель того же URL получает тот же HTML — и видит чужое «Вход выполнен».
Соблазн — отрефакторить каждое кэшируемое view и вынести рендер flash во фрейм. Но более надёжный путь — поставить заслон на границе кэша: если ответ несёт flash, его нельзя кэшировать публично.
def public_expires_in(duration)
return unless Rails.env.live?
# flash рендерится прямо в лейауте; кэшировать этот ответ означает
# утечь уведомление предыдущего пользователя следующему анонимному
# посетителю
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Плюсы такого подхода:
public_expires_in, выигрывают автоматически.Заодно я заметил, что coins#show сам прописал expires_in 5.minutes, public: true в обход хелпера. Унифицировал на public_expires_in, иначе тот же баг утечки flash вылез бы оттуда.
После деплоя обязательно прогнать cloudflare:purge_personalized_pages один раз — уже отравленные записи кэша автоматически не истекут, провисят до конца TTL соответствующего URL (/topics — 1 неделя).
/topicsЧерез несколько часов третий симптом: залогиненные пользователи на /topics не видят кнопку «+ Новая тема». Перезагрузка не помогает. А тот же пользователь, заходя с другой страницы на /topics?r=1 (случайный параметр для обхода кэша), получает кнопку обратно.
/topics стоит на public_expires_in 1.week — кэш самый агрессивный. Изначальное 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 %>
Два стража: страж UA (web vs native) и страж auth (залогинен или нет). CDN не знает ни о том, ни о другом — кто первым достучался до origin, тот и определяет форму кэша. Первый анонимный web-посетитель кэширует «без native bridge button, с web button»; первый залогиненный native-посетитель кэширует «с bridge button, без web button» — судьба следующего зависит от того, в какой cache slot он попадёт.
Тот же подход, что и для #117:
Web-кнопка — без ветвления, всегда рендерится:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only через CSS прячет её под native UA. HTML, кэшированный CDN, всегда одинаковый, кнопка всегда есть, UA решает, показывать ли её — CSS это решение клиентской стороны, кэш в нём не участвует.
Native bridge-кнопка — переехала в lazy personalize frame:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Шаблон /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
В HTML главной страницы остаётся только контейнер <turbo-frame> и web-кнопка — ни тот, ни другая не ветвятся по пользователю. Вся разметка «решать по пользователю, рендерить или нет» переехала в personalize-эндпоинт, который private, no-store и никогда не попадает в CDN.
В моменте каждый симптом выглядел как отдельный баг. «Content missing» казался проблемой Hotwire Native; утечка flash — конфигом cookies/session; пропавшая кнопка — ошибкой логического ветвления в view template.
Поставив три PR рядом, корневая причина оказалась одна: содержимое HTML body определяется свойствами запроса, лежащими вне cache key. Работа CDN — кэшировать HTML по cache key; он не может знать, что данный HTML корректен только для залогиненных пользователей, или только для Hotwire Native, или только для запросов с flash.
Три переиспользуемых паттерна для лечения этого класса:
Вынести изменчивость за пределы кэшируемого body — вся разметка, ветвящаяся по пользователю, превращается в lazy turbo-frame, рендерящийся personalize-эндпоинтом private, no-store. HTML главной страницы инвариантен, CDN кэширует как хочет — всегда правильно.
Рендерить универсальную версию, прятать на клиенте — например, web-кнопка всегда есть в HTML, а CSS прячет её под native UA через .web-only. Ветвление переехало в слой CSS/JS, HTML внутри cache key полностью однообразен.
Страж на границе кэша — для ответов, в которые уже подмешалось состояние (например, flash), на границе кэша понизить Cache-Control до private, no-store. Обычный трафик не затронут.
Общее у трёх паттернов: кэшируемый HTML и разметка, ветвящаяся по пользователю, никогда не должны пересекаться.
После деплоя фикса остаётся одно: отравленные записи CDN автоматически не истекут, TTL до недели. Напишите одноразовый rake task cloudflare:purge_personalized_pages, который активно purg'ит все подозрительные пути — иначе баг будет выскакивать до естественного истечения.
PR #122 был обнаружен в тот же период. Та же форма, тип бага другой — стоит выделить отдельно, потому что он разделяет с cache poisoning тот же скрытый механизм.
Страница /following использует lazy turbo-frame, чтобы загружать следующую страницу (стандартный приём infinite scroll). src фрейма вычисляется хелпером set_load_more_path — он определяет URL следующей страницы по текущему controller/action.
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(...)
# ... много веток ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← фолбэк
end
end
Action posts#following не входит в список веток, поэтому проваливается в финальный фолбэк posts_path — то есть /posts, explore feed.
Эффект: первая страница /following корректна (controller action сам выставляет @posts = following_posts), а со второй страницы lazy frame втихую тянет /posts?page=2, грузя контент explore feed — посты людей, на которых вы не подписаны, проникают в ленту.
Фикс короткий:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Это не та же категория, что три бага cache poisoning — неправильная ветка в set_load_more_path не имеет отношения к CDN. Но она разделяет тот же скрытый механизм: контент, загружаемый lazy turbo-frame, — это контент, который вы активно не пересматриваете.
Фрейм #117 при ошибке тихо делал 302; в #119/#121 содержимое в кэше было неправильным, а вы не видели; в #122 путь фрейма был неправильным, и взглянув только на первую страницу, вы бы не заметили. Как только вы пихаете «динамический контент» в lazy frame, надо активно ревьюить состояние после загрузки фрейма — Claude на стадии PR review для меня поймал именно эту чек-проверку: «что произойдёт после того, как фрейм загрузится».
Если перед вашим Rails-приложением стоит CDN (Cloudflare, Fastly, любой), скорее всего вы наступили хотя бы на одну из этих комбинаций:
public_expires_in / expires_in ..., public: true есть ли ветка вида if user_signed_in??flash рендерится прямо в лейауте? Кэшируется ли путь public_expires_in, когда есть flash?mobile_hotwire?, появляющаяся на кэшируемых путях?src-эндпоинта: использует ли его controller before_action :authenticate_user!? Будут ли неаутентифицированные запросы делать 302?src lazy turbo-frame вычисляется хелпером? У этого хелпера есть фолбэк-ветка? Это правильный URL?Прогоните этот чек-лист из пяти пунктов через Claude — если хоть один совпал, это материал следующего PR. Такие баги не всплывают сами собой, потому что lazy frame падает молча, CDN попадает молча, а CSS прячет молча. Три тихих механизма наслаиваются — и баг может прятаться в продакшене месяцами.