Free

دع Claude يكتشف تسميم ذاكرة CDN في Rails — ثلاثة أعراض، سبب جذري واحد

ثلاثة PR وسبب جذري واحد: محتوى HTML body يتغير مع حالة تسجيل الدخول وUA وflash، لكن cache key لدى الـ CDN لا يعلم بذلك، فيتسرّب المحتوى بين المستخدمين. ترك Claude يقرأ سلسلة الـ PR يكشف ثلاثة أنماط قابلة لإعادة الاستعمال: lazy personalize frame، الإخفاء على جانب العميل بـ CSS، وحارس عند حدّ الذاكرة.


دخل المستخدمون موقعي ووجدوا في أسفل كل صفحة عامة سطرًا يطفو يقول "Content missing". أوائل البلاغات كلها جاءت من iOS — كان رد فعلي الأول أن هذا سلوك غريب من عميل Hotwire Native. بعد التنقيب في سجلات Cloudflare أدركت: لا علاقة لهذا بالعميل. ذاكرة CDN ملوّثة، والأطراف الثلاثة (Web / iOS / Android) كلها قد تقع فيها، إنما iOS هو من كشفها أولًا.

رد الفعل الأول: خلل في Hotwire Native. الثاني: سلوك حافة غريب من Cloudflare. كلاهما خاطئ. حتى تسمية هذا النوع من العلل بأنه "مشكلة CDN" غير دقيق — فالـ CDN ينفذ تمامًا ما طلبتَه منه.

حين جعلتُ Claude يقرأ سلسلة الـ PRs على التوالي، تبيّن أن ثلاثة PRs متتابعة لها نفس السبب الجذري: محتوى HTML body يتغير بحسب حالة تسجيل الدخول وUA وflash، بينما cache key لدى الـ CDN لا يعلم بشيء من ذلك. ثلاثة أعراض مختلفة، سبب جذري واحد.

الفخ 1: تسرّب tab-badge turbo-frame بين المستخدمين

الصفحات / و/topics و/square و/coins و/searches/app تمرّ عبر CDN cache بواسطة 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 المسجَّلون يرون نقطة إشعارات غير مقروءة في شريط التبويب السفلي. الويب لا يحتاج (له مسار تصيير خاص)، وغير المسجَّلين كذلك.

المشكلة: التفرّع mobile_hotwire? && user_signed_in? يُنتج لنفس الـ URL نسختين مختلفتين من HTML. غير أن cache key لدى الـ CDN لا يرى سوى أمور كالـ URL وترويسة Accept — وليس له أي فكرة عما إذا كنت مسجَّلًا أم لا.

التسلسل الزمني:

  1. مستخدم Hotwire Native مسجَّل يدخل /. يذهب الـ CDN إلى الأصل أول مرة، ويُخزّن HTML الذي يحتوي tab-badge frame.
  2. زائر غير مسجَّل (Web أو iOS أو Android، لا فرق) يدخل /. يضرب الـ CDN نفس الإدخال المخزَّن ويسلّمه ذلك الـ HTML.
  3. ينفّذ المتصفح turbo-frame، فيطلب وفق src المسار /notifications?badge_only=true.
  4. في NotificationsController يوجد before_action :authenticate_user!، يرى المستخدم غير مسجَّل فيُحوّل (302) إلى /users/sign_in.
  5. لا يجد Turbo داخل صفحة sign_in فريمًا بمعرّف tab_badge، فيكتب "Content missing".

الزوار غير المسجَّلين من الأطراف الثلاثة (Web / iOS / Android) جميعهم معرّضون — يكفي أن يكون ذلك الـ cache slot قد ملأه مستخدم مسجَّل قبلهم. تركّزت البلاغات في iOS لأن نسبة تسجيل الدخول لمستخدمي iOS هي الأعلى، ومن ثم تتلوّث الذاكرة بحالة مسجَّل قبل غيرها — فاحتمال أن يصطدم زائر iOS غير مسجَّل بإدخال ملوَّث هو الأعلى. الويب وأندرويد لم يكونا "بمنأى"، بل احتمال إطلاق المشكلة لديهم أقل والبلاغات أندر — علّة كانت في جوهرها كارثة على الموقع كله، لكن البيانات المبكرة موّهتها وكأنها "حصرية لـ 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: نقطة الـ frame في /notifications?badge_only=true يجب أن تُعيد للطلبات غير المسجَّلة frame 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، ولطلبات الـ frame الفرعية فقط — أما الطلبات غير الفرعية فلا تزال تمرّ بالـ redirect.

في نفس الـ PR كان هناك خلل آخر بنفس الشكل: مستخدمو عميل Hotwire Native غير المسجَّلين كانوا يرون أيضًا زر FAB العائم للتأليف — تسميم نفسه، ذاكرة ملوَّثة بحالة تسجيل تتسرّب إلى زوار غير مسجَّلين. كان الكود الأصلي يقرّر داخل القالب مباشرةً عبر user_signed_in? ما إذا كان سيُصيّر div لـ stimulus controller. الإصلاح: نقل FAB إلى lazy frame تُصيّره نقطة /personalize/compose_fab ذات الترويسة private, no-store.

<%# يُخرج القالب دائمًا 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 %>

يُخرج القالب في كل مرة frame فارغًا، والنقطة الداخلية للـ frame تقرّر بحسب حالة التسجيل ما إذا كانت ستُدرج div لـ controller. لا يحتوي HTML الصفحة الرئيسية أي ترميز يتفرّع بحسب المستخدم على الإطلاق — يخزّن الـ CDN كيفما شاء ولن يحدث خلل.

الفخ 2: تسرّب رسائل flash إلى مستخدمين آخرين

بعد أيام من إصدار #117، ورد بلاغ آخر: مستخدم غير مسجَّل دخل الصفحة الرئيسية فظهر له في أعلى الصفحة شريط flash يقول "تم تسجيل الدخول بنجاح"، فاندهش.

المرض نفسه، عرضٌ جديد.

في القالب:

<%= render "shared/_notice" %>

هذا السطر يُصيّر flash[:notice] / flash[:alert]. وأول طلب بعد أي redirect يحمل flash، مثل:

  • redirect_to root_path بعد نجاح تسجيل الدخول
  • redirect_to root_path بعد تسجيل الخروج
  • redirect_back(fallback_location: ...) من Pundit

إذا حطّ الـ redirect على عنوان من مسارات public_expires_in، يُخبَز الـ flash داخل الـ HTML المخزَّن. الزائر المجهول التالي إلى نفس الـ URL يستلم نفس الـ HTML — فيرى "تم تسجيل الدخول بنجاح" التي تخصّ شخصًا آخر.

الإغراء بالإصلاح هو إعادة هيكلة كل view قابل للتخزين، ونقل تصيير الـ flash إلى داخل frame. لكن الأمتن هو الردع عند حدّ الـ cache: إن حملت هذه الاستجابة 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

محاسن هذه الحيلة:

  1. لا حاجة لتعديل الـ view — كل صفحة تمرّ عبر public_expires_in تستفيد تلقائيًا
  2. لا تبديد لمساحة الذاكرة — حين يأتي الطلب التالي بلا flash، يعود الـ CDN إلى الأصل ليجلب HTML نظيفًا، فيعمل التخزين بشكله الطبيعي
  3. حركة المرور المعتادة بلا flash لا تتأثر إطلاقًا — 99% من الطلبات تظل تُخزَّن كما هي

اكتشفتُ بمناسبة الإصلاح أن coins#show كان يكتب لنفسه expires_in 5.minutes, public: true متجاوزًا الـ helper. وحّدتُه مع public_expires_in، وإلا لظهر نفس خلل تسرّب الـ flash من هناك.

بعد النشر يجب تشغيل cloudflare:purge_personalized_pages مرة واحدة — إدخالات الذاكرة الملوَّثة لا تنتهي صلاحيتها تلقائيًا، وستبقى حتى استنفاد TTL للعنوان المعني (/topics مدته أسبوع).

الفخ 3: المستخدمون المسجَّلون لا يرون زر "+ موضوع جديد" في /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 مقابل native)، وحارس auth (مسجَّل أم لا). لا يعلم الـ CDN عنهما شيئًا — مَن أتى أولًا إلى الأصل تتشكَّل الذاكرة بحسبه. أول زائر web مجهول يخزّن نسخة "بلا زر 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 المخزَّن في الـ CDN موحَّدًا دائمًا، والزر دائمًا حاضر، وUA يقرّر العرض من عدمه — وCSS قرار client-side، لا تشاركه الذاكرة.

زر 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 أبدًا.

الدرس الحقيقي: تسميم ذاكرة CDN ليس خللًا في الـ CDN

كل عَرض كان يبدو وقتها بمثابة خلل مختلف. "Content missing" يُشبه مشكلة Hotwire Native، تسرّب flash يبدو كاختلال في إعدادات cookies/session، اختفاء الزر يبدو خطأ منطقيًا في تفرّعات view template.

ضع الـ PRs الثلاثة جنبًا إلى جنب يتبيّن أن السبب الجذري واحد: محتوى HTML body يتحدّد بخصائص طلب خارج cache key. مهمة الـ CDN هي تخزين الـ HTML بحسب cache key — ولا قِبَل له بأن يعرف أن هذا الـ HTML صحيح فقط للمسجَّلين، أو فقط لـ Hotwire Native، أو فقط للطلبات التي تحمل flash.

لاجتثاث هذا النوع، ثلاثة أنماط قابلة لإعادة الاستعمال:

  1. ادفع التغير خارج الـ body المخزَّن — كل ترميز يتفرّع بحسب المستخدم يُحوَّل إلى lazy turbo-frame تُصيّره نقطة personalize ذات private, no-store. يبقى HTML الصفحة الرئيسية ثابتًا، فيخزّن الـ CDN كما يشاء ويظل صحيحًا.

  2. صيِّر نسخة عامة، أخفِها على جانب العميل — مثلاً زر web موجود دائمًا في الـ HTML، وCSS عبر .web-only يُخفيه عند UA الـ native. هذا ينقل التفرّع إلى طبقة CSS/JS، فيكون الـ HTML داخل cache key موحَّدًا تمامًا.

  3. حارس عند حدّ الذاكرة — للاستجابات التي تسرّبت إليها حالة (مثل flash)، خفّض Cache-Control إلى private, no-store عند الحدّ. الطلبات الاعتيادية لا تتأثر إطلاقًا.

القاسم المشترك بين الأنماط الثلاثة: اجعل "الـ HTML المخزَّن" و"الترميز المتفرّع بحسب المستخدم" لا يتقاطعان أبدًا.

بعد نشر الإصلاح بقي عمل واحد: إدخالات الـ CDN الملوَّثة لا تنتهي تلقائيًا، وTTL يصل إلى أسبوع. اكتب rake task يُستخدم مرة واحدة cloudflare:purge_personalized_pages تُطهّر كل المسارات المشكوك فيها استباقيًا — وإلا ستبقى العلّة تظهر حتى تنتهي الذاكرة بصورة طبيعية.

على الهامش: ليست لـ lazy frame مشاكل cache فحسب

اكتُشف الـ PR #122 في الفترة نفسها. شكل واحد، نوع خلل مختلف — يستحق إفراده لأنه يشترك مع تسميم الذاكرة في نفس الآلية الخفية.

تستخدم صفحة /following lazy turbo-frame لتحميل الصفحة التالية (الأسلوب المعتاد في infinite scroll). تُحسب src الإطار عبر 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, ...)  # ← الاحتياطي
    end
end

posts#following ليست في قائمة الفروع، فتسقط على الفرع الاحتياطي الأخير posts_path — أي /posts، وهي feed الاستكشاف.

النتيجة: أول صفحة في /following صحيحة (لأن controller action نفسه يضبط @posts = following_posts)، ومن الصفحة الثانية فصاعدًا يُحضِر الـ lazy frame خلسةً /posts?page=2، فيُحمَّل محتوى feed الاستكشاف — ومنشورات أشخاص لا تتابعهم تتسرّب إلى الصفحة.

الإصلاح قصير:

elsif controller_name == "posts" && action_name == "following"
    path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)

هذا ليس من نفس فئة الـ bugs الثلاثة لتسميم الذاكرة — خطأ التفرّع في set_load_more_path لا علاقة له بالـ CDN. لكنه يشترك معها في الآلية الخفية ذاتها: المحتوى الذي يحمّله lazy turbo-frame هو محتوى لن تراجعه بفاعلية.

في #117 يحوّل الـ frame عند الخطأ بصمت إلى 302، وفي #119/#121 محتوى الذاكرة خاطئ ولن تراه، وفي #122 مسار الـ frame خاطئ ولن تكتشفه إن نظرت إلى الصفحة الأولى وحدها. ما إن تُدخل "محتوى ديناميكيًا" داخل lazy frame وجب عليك أن تُراجع بفاعلية الحالة بعد تحميل الإطار — وهذا تحديدًا ما التقطه لي Claude في مرحلة مراجعة الـ PR: بند فحص "ماذا يحدث بعد أن يُحمَّل الإطار".

قائمة فحص ذاتي

إذا كان أمام تطبيق Rails لديك CDN (Cloudflare أو Fastly أو غيرهما)، فثمانٌ من عشرٍ أنك تتعثّر بأحد التركيبات التالية:

  • في أي قالب view يستخدم public_expires_in / expires_in ..., public: true، هل يوجد تفرّع من نوع if user_signed_in?؟
  • هل يُصيَّر flash مباشرةً في القالب؟ هل ستُخزَّن مسارات public_expires_in في وجود flash؟
  • هل في تطبيق Hotwire Native ترميز محميّ بـ mobile_hotwire? يظهر على مسار قابل للتخزين؟
  • لأي نقطة src في turbo-frame: هل يستخدم controllerها before_action :authenticate_user!؟ هل ستُحوَّل (302) الطلبات غير المسجَّلة؟
  • هل يُحسب مسار src لـ lazy turbo-frame عبر helper؟ هل لذلك الـ helper فرع احتياطي؟ هل الفرع الاحتياطي URL خاطئ؟

اجعل Claude يَمرّ بهذه القائمة الخمسية — إن وقع تطابق على بند، فذلك مادة الـ PR التالي. لن تطفو علل من هذا النوع وحدها، لأن lazy frame يفشل صامتًا، والـ CDN يُصيب صامتًا، وCSS يُخفي صامتًا. ثلاث آليات صامتة مكدّسة، فيقدر الخلل أن يختبئ في الإنتاج شهورًا.