Ba PR cùng một nguyên nhân gốc: HTML body thay đổi theo trạng thái đăng nhập, UA và flash — những thuộc tính cache key của CDN không hề biết — khiến nội dung rò giữa các user. Để Claude đi qua chuỗi PR sẽ làm nổi lên ba pattern dùng lại được: lazy personalize frame, ẩn bên client bằng CSS, và chốt gác ở biên cache.
Người dùng vào trang web của tôi, ở chân mỗi trang công khai đều có một dòng "Content missing" lơ lửng. Báo cáo đầu tiên đều đến từ iOS — phản xạ ban đầu của tôi: một hành vi kỳ lạ nào đó của client Hotwire Native. Phải khi đào sâu logs của Cloudflare tôi mới hiểu: chuyện này không liên quan gì đến client. Cache CDN bị nhiễm độc, và cả ba đầu (Web / iOS / Android) đều có thể dính — iOS chỉ tình cờ là bên đầu tiên đưa nó ra ánh sáng.
Phản xạ một: bug của Hotwire Native. Phản xạ hai: hành vi edge kỳ quặc nào đó của Cloudflare. Cả hai đều sai. Ngay cả gọi loại bug này là "vấn đề CDN" cũng không đúng — CDN đang làm chính xác những gì bạn bảo nó làm.
Khi tôi nhờ Claude đọc lần lượt chuỗi PR, ba PR liên tiếp đều có cùng một nguyên nhân gốc: HTML body thay đổi theo trạng thái đăng nhập, UA, flash — những thuộc tính của request mà cache key của CDN không hề biết tới. Ba triệu chứng khác nhau, cùng một nguyên nhân.
/, /topics, /square, /coins, /searches/app đi qua cache CDN nhờ public_expires_in. Trong layout có:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge render một turbo-frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
Ý đồ rõ ràng: user đã đăng nhập của Hotwire Native nhìn thấy chấm đỏ thông báo chưa đọc trên tab bar dưới cùng. Web không cần (có nhánh render riêng), user chưa đăng nhập cũng không cần.
Vấn đề: nhánh mobile_hotwire? && user_signed_in? khiến cùng một URL cho ra hai biến thể HTML. Nhưng cache key của CDN chỉ nhìn vào những thứ như URL và header Accept — nó không biết bạn đã đăng nhập hay chưa.
Dòng thời gian:
/. CDN đi tới origin lần đầu, cache HTML có chứa tab-badge frame./. CDN trúng cùng cache đó và trả lại đúng HTML này.src request /notifications?badge_only=true.NotificationsController có before_action :authenticate_user!, thấy chưa đăng nhập liền 302 sang /users/sign_in.tab_badge trên trang sign_in nên vẽ ra "Content missing".Khách chưa đăng nhập của cả ba đầu (Web / iOS / Android) đều có thể vướng — chỉ cần cache slot đó từng được một user đã đăng nhập điền trước. Báo cáo dồn vào iOS vì tỉ lệ đăng nhập của user iOS cao nhất, cache vì thế bị nhiễm trạng thái đăng nhập trước nhiều nhất — khách iOS chưa đăng nhập có xác suất cao nhất rơi vào entry bị nhiễm. Web và Android không phải "an toàn", chỉ là tỉ lệ kích hoạt thấp hơn và báo cáo ít hơn — một bug bản chất là thảm họa toàn site bị dữ liệu ban đầu trá hình thành "đặc trưng iOS".
Sửa hai bước, cả hai đều nhằm khiến cache không tách theo trạng thái đăng nhập nữa:
Bước 1: && user_signed_in? trong layout phải bỏ đi. Nếu không bạn sẽ chơi đoán cache key mãi mãi:
<%# Render cho tất cả request mobile_hotwire?, không phụ thuộc đăng nhập —
nhánh user_signed_in? cắt HTML cùng URL thành hai biến thể, biến thể
nào thắng slot CDN sẽ được trả cho khách kế tiếp. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Bước 2: endpoint frame /notifications?badge_only=true phải trả về cho request chưa đăng nhập một frame 200 cấu trúc giống hệt, không được 302. Đồng thời bản thân endpoint phải private, no-store, kẻo CDN chia sẻ số đếm chưa đọc của một user cho người khác.
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!
# ... logic index bình thường
end
end
Lưu ý authenticate_user! chỉ được loại trừ với index — và chỉ với sub-request kiểu frame; các request không phải frame vẫn đi qua redirect.
Trong cùng PR còn một bug có hình dạng y hệt: user client Hotwire Native chưa đăng nhập cũng nhìn thấy compose FAB (nút nổi) — cùng kiểu nhiễm độc, cache bị tạp trạng thái đăng nhập rò sang khách chưa đăng nhập. Code gốc đặt user_signed_in? thẳng trong layout để quyết định có render div của stimulus controller không. Cách sửa: đẩy FAB vào lazy frame, do endpoint /personalize/compose_fab (private, no-store) render.
<%# Layout luôn nhả ra frame rỗng %>
<% 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 %>
Mỗi lần layout nhả ra một frame rỗng; endpoint bên trong frame mới quyết định, dựa trên trạng thái đăng nhập, có nhét div của controller hay không. HTML trang chính hoàn toàn không có markup nào "rẽ nhánh theo user" — CDN cache thế nào cũng không hỏng.
Vài ngày sau khi #117 ra, một báo cáo nữa: user chưa đăng nhập vào trang home, trên đầu trang lại hiện banner flash "Đăng nhập thành công" — ngơ ngác.
Cùng bệnh, triệu chứng mới.
Trong layout có:
<%= render "shared/_notice" %>
Dòng này render flash[:notice] / flash[:alert]. Request đầu tiên sau bất kỳ redirect nào đều mang theo flash, ví dụ:
redirect_to root_path sau khi đăng nhập thành côngredirect_to root_path sau khi đăng xuấtredirect_back(fallback_location: ...) của PunditNếu URL đáp xuống của redirect cũng là một path public_expires_in, flash sẽ bị nướng vào HTML đem cache. Khách ẩn danh kế tiếp truy cập cùng URL nhận về đúng HTML này — và thấy "Đăng nhập thành công" của người khác.
Cám dỗ là refactor mọi view có thể cache, đẩy phần render flash vào frame. Nhưng cách chắc tay hơn là chặn ở biên cache: nếu response này có flash thì không được public-cache.
def public_expires_in(duration)
return unless Rails.env.live?
# flash render thẳng trong layout; cache response này nghĩa là rò
# thông báo của user trước cho khách ẩn danh kế tiếp
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Điểm hay của đòn này:
public_expires_in đều tự động hưởng lợi.Tiện thể tôi phát hiện coins#show tự viết expires_in 5.minutes, public: true, đi vòng helper. Sửa cả vào dùng public_expires_in cho đồng bộ, không thì bug rò flash sẽ chui ra từ chỗ đó.
Sau khi deploy phải chạy cloudflare:purge_personalized_pages một lần — entry cache đã bị nhiễm sẽ không tự hết hạn, sẽ trụ tới khi TTL của URL tương ứng cạn (/topics là 1 tuần).
/topicsVài giờ sau, triệu chứng thứ ba: user đã đăng nhập vào /topics không thấy nút "+ Chủ đề mới". F5 cũng không lại. Nhưng cùng user đó đi từ trang khác sang /topics?r=1 (tham số ngẫu nhiên để né cache) thì nút quay lại.
/topics là public_expires_in 1.week — cache khốc liệt nhất. View ban đầu:
<% 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 %>
Hai chốt gác: chốt UA (web vs native) và chốt auth (đăng nhập hay không). CDN không biết cả hai — ai về tới origin trước thì hình dáng cache theo người đó. Khách ẩn danh web đầu tiên sẽ cache "không có nút native bridge, có nút web"; khách native đăng nhập đầu tiên sẽ cache "có nút bridge, không có nút web" — số phận khách kế tiếp tuỳ vào việc rơi đúng cache slot hay không.
Cách sửa giống #117:
Nút web — không cần nhánh, luôn render:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only dùng CSS giấu khi UA là native. HTML CDN cache luôn giống nhau, nút luôn ở đó, UA quyết định hiển thị hay không — CSS là quyết định client-side, cache không tham gia.
Nút native bridge — chuyển vào 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 %>
HTML trang chính chỉ còn container <turbo-frame> và nút web — cả hai đều không rẽ nhánh theo user. Mọi markup "tuỳ user mà render hay không" đã chuyển vào endpoint personalize, vốn private, no-store và không bao giờ vào CDN.
Lúc đó mỗi triệu chứng trông như một bug khác nhau. "Content missing" giống vấn đề của Hotwire Native; flash rò sang nhau giống lỗi cấu hình cookies/session; nút biến mất giống lỗi rẽ nhánh trong view template.
Đặt ba PR cạnh nhau, nguyên nhân gốc chỉ một: nội dung HTML body bị quyết định bởi thuộc tính request nằm ngoài cache key. Việc của CDN là cache HTML theo cache key — nó không thể biết HTML này thực ra chỉ đúng cho user đã đăng nhập, hoặc chỉ đúng cho Hotwire Native, hoặc chỉ đúng cho request có flash.
Ba pattern dùng lại được để chữa loại này:
Đẩy phần thay đổi ra khỏi body cache — mọi markup rẽ nhánh theo user biến thành lazy turbo-frame, do endpoint personalize private, no-store render. HTML trang chính luôn không đổi; CDN cache thế nào cũng đúng.
Render bản thông dụng, ẩn ở client — ví dụ nút web luôn ở trong HTML, CSS giấu nó dưới UA native bằng .web-only. Phần rẽ nhánh chuyển sang tầng CSS/JS, HTML trong cache key hoàn toàn đồng nhất.
Chốt gác ở biên cache — với những response đã có state lọt vào (như flash), tại biên cache hạ Cache-Control xuống private, no-store. Traffic thông thường không hề bị động tới.
Điểm chung của ba pattern: HTML đang được cache và markup rẽ nhánh theo user không bao giờ được trùng nhau.
Sau khi deploy fix còn một việc: entry CDN đã bị nhiễm sẽ không tự hết hạn, TTL có thể tới một tuần. Viết một rake task dùng một lần cloudflare:purge_personalized_pages để chủ động purge mọi path đáng ngờ — không thì bug sẽ chui ra cho đến khi cache hết hạn tự nhiên.
PR #122 được phát hiện cùng giai đoạn. Hình dạng giống, nhưng loại bug khác — đáng nói riêng vì nó chia sẻ cơ chế ẩn giống các bug cache poisoning.
Trang /following dùng lazy turbo-frame để load trang kế tiếp (kiểu infinite scroll điển hình). src của frame được tính bởi helper set_load_more_path — quyết định URL trang kế dựa trên controller/action hiện tại.
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(...)
# ... rất nhiều nhánh ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← fallback
end
end
Action posts#following không có trong danh sách nhánh, vì vậy rơi vào fallback cuối cùng posts_path — tức là /posts, explore feed.
Hệ quả: trang 1 của /following thì đúng (controller action tự đặt @posts = following_posts), nhưng từ trang 2 trở đi lazy frame lén kéo /posts?page=2, load nội dung của explore feed — bài của những người bạn không follow lọt vào feed.
Fix rất ngắn:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Đây không cùng loại với ba bug cache poisoning ở trên — nhánh sai trong set_load_more_path không liên quan tới CDN. Nhưng nó chia sẻ cùng cơ chế ẩn: nội dung được lazy turbo-frame load là nội dung bạn không chủ động review.
Frame của #117 lỗi thì lặng lẽ 302; ở #119/#121 nội dung trong cache sai và bạn không thấy; ở #122 path frame sai và chỉ nhìn trang 1 thì không phát hiện. Một khi bạn nhét "nội dung động" vào lazy frame, bạn phải chủ động review trạng thái sau khi frame load xong — Claude trong giai đoạn PR review đã giúp tôi chộp đúng cái checklist "sau khi frame load xong thì chuyện gì xảy ra".
Nếu app Rails của bạn đứng sau một CDN (Cloudflare, Fastly, hãng nào cũng được), tám trên mười khả năng bạn đã dính ít nhất một trong các tổ hợp sau:
public_expires_in / expires_in ..., public: true, có nhánh kiểu if user_signed_in? không?flash có render thẳng trong layout không? Path public_expires_in có bị cache khi có flash không?mobile_hotwire? xuất hiện trên đường dẫn có thể cache không?src của bất kỳ turbo-frame nào, controller của nó dùng before_action :authenticate_user! không? Request chưa đăng nhập có 302 không?src của lazy turbo-frame được tính ra từ helper? Helper đó có nhánh fallback không? Fallback có phải URL sai không?Bảo Claude chạy checklist năm mục này — cái nào trúng thì đó là chất liệu PR kế tiếp. Bug loại này không tự nổi lên, vì lazy frame thất bại lặng lẽ, CDN trúng lặng lẽ, CSS giấu lặng lẽ. Ba cơ chế lặng lẽ chồng lên nhau, bug có thể trốn trong production hàng tháng trời.