Три 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. Web цього не потребує (окремий шлях рендеру), а неаутентифіковані теж.
Проблема: гілка 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 ховає тихо. Три тихі механізми нашаровуються — і баг може ховатись у продакшені місяцями.