שלושה PRs וסיבת שורש אחת: גוף ה-HTML משתנה לפי מצב התחברות, UA ו-flash — תכונות שה-cache key של ה-CDN אינו מודע אליהן — והתוכן דולף בין המשתמשים. לתת ל-Claude לעבור על שרשרת ה-PR-ים חושף שלושה דפוסים שניתן לעשות בהם שימוש חוזר: lazy personalize frames, הסתרה בצד הלקוח עם CSS, ושומר בגבול ה-cache.
המשתמשים נכנסו לאתר שלי, ובתחתית כל עמוד פומבי ריחפה שורה "Content missing". הדיווחים הראשונים הגיעו כולם מ-iOS — האינסטינקט הראשון שלי: התנהגות מוזרה כלשהי של לקוח Hotwire Native. רק כשחפרתי בלוגים של Cloudflare הבנתי: לזה אין שום קשר ללקוח. ה-cache של ה-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 עוברים דרך cache של CDN באמצעות public_expires_in. ב-layout היה:
<% 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 בפעם הראשונה ושומר ב-cache את ה-HTML עם tab-badge frame./. ה-CDN פוגע באותו cache ומגיש לו את אותו HTML.src מבקש /notifications?badge_only=true.NotificationsController יש before_action :authenticate_user!, רואה שאין session ועושה 302 ל-/users/sign_in.tab_badge ומדפיס "Content missing".מבקרים לא מחוברים משלוש החזיתות (Web / iOS / Android) כולם יכולים ליפול בזה — מספיק שאיזה cache slot ימולא לפני כן בידי משתמש מחובר. הדיווחים התרכזו ב-iOS משום ששיעור ההתחברות של משתמשי iOS הוא הגבוה ביותר — ה-cache מזדהם בתדירות הגבוהה ביותר במצב מחובר, ולמבקרים לא מחוברים ב-iOS יש את הסיכוי הגבוה ביותר להיתקל ברישום מורעל. Web ו-Android לא היו "בסדר": שיעור ההפעלה אצלם נמוך יותר והדיווחים מעטים יותר — באג שהוא בעצם אסון של כל האתר הוסווה על ידי הנתונים המוקדמים כ"בלעדי ל-iOS".
תיקון בשני שלבים, שניהם נועדו לוודא שה-cache לא ייפצל יותר לפי מצב התחברות:
שלב 1: צריך להוריד מה-layout את && user_signed_in?. אחרת תשחק במשחק ניחושי cache key לעולם:
<%# מרנדרים עבור כל בקשת mobile_hotwire?, ללא קשר להתחברות —
ענף user_signed_in? חותך את ה-HTML של אותו URL לשני וריאנטים,
וזה שמנצח את ה-CDN slot יוגש למבקר הבא. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
שלב 2: ה-endpoint של ה-frame /notifications?badge_only=true חייב להחזיר לבקשות לא מחוברות frame 200 זהה במבנה, לא 302. ובמקביל ה-endpoint עצמו חייב להיות 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 — וגם רק עבור תת-בקשות של frame; בקשות שאינן frame עדיין עוברות דרך ה-redirect.
באותו PR היה באג נוסף בצורה זהה: משתמשי לקוח Hotwire Native לא מחוברים ראו גם את compose FAB (כפתור צף) — אותה הרעלה, cache שזוהם במצב התחברות דולף למבקרים לא מחוברים. הקוד המקורי כתב את user_signed_in? היישר ב-layout והחליט שם אם לרנדר את div ה-stimulus controller. תיקון: להעביר את ה-FAB גם הוא ל-lazy frame, שמרונדר על ידי endpoint /personalize/compose_fab שהוא private, no-store.
<%# ה-layout תמיד מחזיר frame ריק %>
<% 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 %>
ה-layout מחזיר בכל פעם frame ריק; ה-endpoint שבתוך ה-frame מחליט לפי מצב התחברות אם להכניס את div ה-controller. ב-HTML של עמוד הראשי אין כלל markup שמסתעף לפי משתמש — לא משנה איך ה-CDN ישמור ב-cache, שום דבר לא נשבר.
ימים ספורים אחרי שיצא #117, דיווח נוסף: משתמש לא מחובר נכנס ל-home, ובראש העמוד מופיע באנר flash "התחברת בהצלחה" — הוא נדהם.
אותה מחלה, תסמין חדש.
ב-layout היה:
<%= render "shared/_notice" %>
שורה זו מרנדרת flash[:notice] / flash[:alert]. הבקשה הראשונה אחרי כל redirect נושאת flash, למשל:
redirect_to root_path אחרי התחברות מוצלחתredirect_to root_path אחרי התנתקותredirect_back(fallback_location: ...) של Punditאם ה-URL שאליו ה-redirect נוחת הוא גם נתיב public_expires_in, ה-flash הזה נאפה לתוך ה-HTML שנשמר ב-cache. המבקר האנונימי הבא לאותו URL מקבל את אותו HTML — ורואה את "התחברת בהצלחה" של מישהו אחר.
הפיתוי בתיקון הוא לבצע refactor בכל view שניתן ל-cache ולהעביר את רינדור ה-flash ל-frame. אבל הדרך החזקה יותר היא לחסום בגבול ה-cache: אם התגובה הזו נושאת flash, אסור לבצע cache פומבי.
def public_expires_in(duration)
return unless Rails.env.live?
# flash מרונדר ישירות ב-layout; שמירת התגובה הזו ב-cache תדליף
# את ההודעה של המשתמש הקודם למבקר האנונימי הבא
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 ועקף את ה-helper. איחדתי גם אותו ל-public_expires_in, אחרת אותו באג של דליפת flash היה צץ משם.
אחרי deploy חובה להריץ פעם אחת cloudflare:purge_personalized_pages — רישומי cache שכבר הורעלו לא יפוגו אוטומטית; הם יישארו עד תום ה-TTL של ה-URL הרלוונטי (/topics הוא שבוע).
/topicsכמה שעות לאחר מכן, תסמין שלישי: משתמשים מחוברים ב-/topics לא ראו את כפתור "+ נושא חדש". רענון לא עזר. אבל אותו משתמש, מגיע מעמוד אחר ל-/topics?r=1 (פרמטר אקראי שמעקף את ה-cache) — מקבל את הכפתור בחזרה.
/topics רץ עם public_expires_in 1.week — ה-cache הכי אגרסיבי. ה-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 מול native) ושומר auth (מחובר או לא). ה-CDN לא יודע על אף אחד מהם — מי שיגיע ל-origin ראשון מכתיב את צורת ה-cache. המבקר האנונימי הראשון מ-web ישמור ב-cache "ללא כפתור native bridge, עם כפתור web"; המבקר המחובר הראשון מ-native ישמור "עם כפתור bridge, ללא כפתור web" — גורלו של הבא תלוי באיזה cache slot ייפול.
אותה גישה כמו ב-#117:
כפתור ה-web — ללא הסתעפות, מרונדר תמיד:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only מסתיר אותו דרך CSS תחת UA native. ה-HTML שב-cache של ה-CDN תמיד זהה, הכפתור תמיד שם, ה-UA מחליט אם להציגו — CSS היא החלטה בצד הלקוח, ה-cache לא משתתף בה.
כפתור ה-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 — אף אחד מהם לא מסתעף לפי משתמש. כל ה-markup ש"מחליט לרנדר לפי משתמש" עבר ל-endpoint 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 שב-cache — כל markup שמסתעף לפי משתמש הופך ל-lazy turbo-frame המרונדר על ידי endpoint personalize שהוא private, no-store. ה-HTML של עמוד הראשי בלתי משתנה; ה-CDN ישמור איך שירצה — תמיד נכון.
רנדרו גרסה אוניברסלית, הסתירו בצד הלקוח — לדוגמה, כפתור ה-web תמיד נמצא ב-HTML, ו-CSS מסתיר אותו תחת UA native באמצעות .web-only. ההסתעפות חיה בשכבת ה-CSS/JS, וה-HTML שבתוך ה-cache key אחיד לחלוטין.
שומר בגבול ה-cache — לתגובות שכבר זלגה אליהן מצב (כמו flash), הורידו את Cache-Control ל-private, no-store בגבול. תעבורה רגילה אינה נוגעת כלל.
המכנה המשותף לשלושת הדפוסים: ה-HTML שב-cache וה-markup המסתעף לפי משתמש לעולם אסור שיתחפפו.
לאחר deploy של התיקון נותר עוד דבר אחד: רישומי CDN שהורעלו לא פוגי-תוקף לבד, וה-TTL יכול להגיע לשבוע. כתבו rake task חד-פעמי cloudflare:purge_personalized_pages שיעשה purge פעיל לכל הנתיבים החשודים — אחרת הבאג ימשיך לצוץ עד שה-cache יפוג באופן טבעי.
PR #122 התגלה באותה תקופה. אותה צורה, סוג באג אחר — שווה להזכיר בנפרד מכיוון שהוא חולק את אותו מנגנון נסתר עם הבאגים של cache poisoning.
עמוד /following משתמש ב-lazy turbo-frame לטעון את העמוד הבא (תבנית סטנדרטית של גלילה אינסופית). ה-src של ה-frame מחושב על ידי helper בשם 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, ...) # ← fallback
end
end
ה-action posts#following לא נמצא ברשימת הענפים, אז הוא נופל ל-fallback האחרון posts_path — כלומר /posts, ה-explore feed.
האפקט: עמוד 1 של /following תקין (ה-controller action עצמו מגדיר @posts = following_posts), אבל מעמוד 2 והלאה ה-lazy frame מושך בחשאי את /posts?page=2, וטוען תוכן של explore feed — פוסטים של אנשים שאתה לא עוקב אחריהם זוחלים לתוך ה-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 ה-frame עשה 302 בשקט בשעת שגיאה; ב-#119/#121 התוכן ב-cache היה שגוי ולא ראית; ב-#122 הנתיב של ה-frame היה שגוי ולא היית מאתר זאת אם הסתכלת רק על עמוד 1. ברגע שאתה דוחף "תוכן דינמי" לתוך lazy frame, אתה חייב לסקור באופן פעיל את המצב לאחר שה-frame נטען — מה ש-Claude תפס לי בשלב ה-PR review הוא בדיוק רשימת המשימות "מה קורה אחרי שה-frame נטען".
אם לאפליקציית Rails שלך יש CDN לפניה (Cloudflare, Fastly, מי שלא יהיה), בסבירות גבוהה דרכת על אחת מהקומבינציות הבאות לפחות:
public_expires_in / expires_in ..., public: true — האם יש ענף מסוג if user_signed_in??flash מרונדר ישירות ב-layout? האם נתיב public_expires_in נשמר ב-cache כשיש flash?mobile_hotwire? ומופיע על נתיבים שניתן לאחסן ב-cache?src של turbo-frame כלשהו — האם ה-controller שלו משתמש ב-before_action :authenticate_user!? האם בקשות לא מחוברות יעשו 302?src של lazy turbo-frame מחושב על ידי helper? האם להלפר הזה יש ענף fallback? האם ה-fallback הוא URL שגוי?תן ל-Claude לעבור על רשימת חמשת הסעיפים הזו — אם איזה אחד נדלק, זה החומר ל-PR הבא. באגים מהסוג הזה לא צפים מעצמם, מכיוון ש-lazy frames נכשלים בשקט, CDN-ים פוגעים בשקט, ו-CSS מסתיר בשקט. שלושה מנגנונים שקטים נערמים זה על זה — וייתכן שבאג יסתתר בפרודקשן חודשים ארוכים.