Free

תן ל-Claude למצוא הרעלת cache של CDN ב-Rails — שלושה תסמינים, סיבת שורש אחת

שלושה 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 לא יודע עליהן דבר. שלושה תסמינים שונים, סיבה אחת.

מלכודת 1: turbo-frame של tab-badge דולף בין משתמשים

/, /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 — אין לו מושג אם אתה מחובר.

ציר הזמן:

  1. משתמש Hotwire Native מחובר נכנס ל-/. ה-CDN ניגש ל-origin בפעם הראשונה ושומר ב-cache את ה-HTML עם tab-badge frame.
  2. מבקר לא מחובר (Web / iOS / Android, לא משנה) נכנס ל-/. ה-CDN פוגע באותו cache ומגיש לו את אותו HTML.
  3. הדפדפן מבצע את ה-turbo-frame ולפי ה-src מבקש /notifications?badge_only=true.
  4. ל-NotificationsController יש before_action :authenticate_user!, רואה שאין session ועושה 302 ל-/users/sign_in.
  5. Turbo לא מוצא בעמוד sign_in frame עם id 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, שום דבר לא נשבר.

מלכודת 2: הודעות flash דולפות למשתמשים אחרים

ימים ספורים אחרי שיצא #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

יתרונות הצעד הזה:

  1. אין צורך לגעת באף view — כל עמוד שעובר דרך public_expires_in נהנה אוטומטית.
  2. לא מבזבזים cache — כשהבקשה הבאה ללא flash תגיע, ה-CDN יחזור ל-origin ויביא HTML נקי, וה-cache יעבוד כרגיל.
  3. תעבורה רגילה ללא flash לא מושפעת כלל — 99% מהבקשות ימשיכו להישמר ב-cache כמו קודם.

תוך כדי גיליתי ש-coins#show כתב לעצמו expires_in 5.minutes, public: true ועקף את ה-helper. איחדתי גם אותו ל-public_expires_in, אחרת אותו באג של דליפת flash היה צץ משם.

אחרי deploy חובה להריץ פעם אחת cloudflare:purge_personalized_pages — רישומי cache שכבר הורעלו לא יפוגו אוטומטית; הם יישארו עד תום ה-TTL של ה-URL הרלוונטי (/topics הוא שבוע).

מלכודת 3: משתמשים מחוברים לא רואים את כפתור "+ נושא חדש" ב-/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.

הלקח האמיתי: הרעלת cache של CDN איננה באג של ה-CDN

ברגע נתון, כל תסמין נראה כמו באג שונה. "Content missing" נראה כבעיה של Hotwire Native; דליפת flash נראתה כבעיית הגדרת cookies/session; כפתור שנעלם נראה כשגיאת לוגיקה ב-view template.

כשמניחים את שלושת ה-PR-ים זה ליד זה, סיבת השורש אחת בלבד: תוכן ה-HTML body נקבע על ידי תכונות של בקשה הנמצאות מחוץ ל-cache key. עבודת ה-CDN היא לאחסן HTML לפי cache key — אין באפשרותו לדעת שה-HTML הזה תקין רק עבור משתמשים מחוברים, או רק עבור Hotwire Native, או רק עבור בקשות עם flash.

שלושה דפוסים שניתן לעשות בהם שימוש חוזר כדי לעקור את הקטגוריה הזו:

  1. דחפו את השונות מחוץ ל-body שב-cache — כל markup שמסתעף לפי משתמש הופך ל-lazy turbo-frame המרונדר על ידי endpoint personalize שהוא private, no-store. ה-HTML של עמוד הראשי בלתי משתנה; ה-CDN ישמור איך שירצה — תמיד נכון.

  2. רנדרו גרסה אוניברסלית, הסתירו בצד הלקוח — לדוגמה, כפתור ה-web תמיד נמצא ב-HTML, ו-CSS מסתיר אותו תחת UA native באמצעות .web-only. ההסתעפות חיה בשכבת ה-CSS/JS, וה-HTML שבתוך ה-cache key אחיד לחלוטין.

  3. שומר בגבול ה-cache — לתגובות שכבר זלגה אליהן מצב (כמו flash), הורידו את Cache-Control ל-private, no-store בגבול. תעבורה רגילה אינה נוגעת כלל.

המכנה המשותף לשלושת הדפוסים: ה-HTML שב-cache וה-markup המסתעף לפי משתמש לעולם אסור שיתחפפו.

לאחר deploy של התיקון נותר עוד דבר אחד: רישומי CDN שהורעלו לא פוגי-תוקף לבד, וה-TTL יכול להגיע לשבוע. כתבו rake task חד-פעמי cloudflare:purge_personalized_pages שיעשה purge פעיל לכל הנתיבים החשודים — אחרת הבאג ימשיך לצוץ עד שה-cache יפוג באופן טבעי.

אגב: ל-lazy frames יש בעיות מעבר ל-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, מי שלא יהיה), בסבירות גבוהה דרכת על אחת מהקומבינציות הבאות לפחות:

  • בכל view template שמשתמש ב-public_expires_in / expires_in ..., public: true — האם יש ענף מסוג if user_signed_in??
  • האם flash מרונדר ישירות ב-layout? האם נתיב public_expires_in נשמר ב-cache כשיש flash?
  • ביישום Hotwire Native, האם יש markup ששמור על ידי mobile_hotwire? ומופיע על נתיבים שניתן לאחסן ב-cache?
  • עבור endpoint src של turbo-frame כלשהו — האם ה-controller שלו משתמש ב-before_action :authenticate_user!? האם בקשות לא מחוברות יעשו 302?
  • האם הנתיב src של lazy turbo-frame מחושב על ידי helper? האם להלפר הזה יש ענף fallback? האם ה-fallback הוא URL שגוי?

תן ל-Claude לעבור על רשימת חמשת הסעיפים הזו — אם איזה אחד נדלק, זה החומר ל-PR הבא. באגים מהסוג הזה לא צפים מעצמם, מכיוון ש-lazy frames נכשלים בשקט, CDN-ים פוגעים בשקט, ו-CSS מסתיר בשקט. שלושה מנגנונים שקטים נערמים זה על זה — וייתכן שבאג יסתתר בפרודקשן חודשים ארוכים.