لوحة المعلومات
26 أغسطس 2026

كيف أصلحنا أخطاء تجاوز الذاكرة في Cloudflare Durable Objects لدينا

Explore with AI

تعمل كل محادثة وكيل في Polylane داخل Cloudflare Durable Object خاص بها، وكلها نسخ من فئة واحدة. في أغسطس 2026، كانت Durable Objects الخاصة بمحادثاتنا تُعاد تهيئتها نحو 300 مرة يوميًا لتجاوز حد الذاكرة. وسمعنا القصة نفسها من فرق عديدة تشغّل تدفقات وكلائها على Cloudflare Durable Objects.

إن كنت قد واجهت exceededMemory على Durable Object، فالنصيحة المعتادة هي أن تنظر إلى ما تخصصه طلباتك: حمولة أكبر من اللازم، أو تاريخ دردشة ينمو بلا حدود، أو ذاكرة مؤقتة لا تُخلي شيئًا أبدًا. لم ينطبق علينا أي من ذلك. كان isolate فوق الحد قبل أن يخدم طلبًا واحدًا، فلا بد أن الوزن كان في الكود الذي نشحنه لا في البيانات التي نخدمها.

هذه المقالة عمّا كان فعلًا في heap، وكيف وجدناه على منصة لا تتيح لك توصيل محلل أداء، والتغييرَين اللذين أخذا ذاكرة نطاق الوحدات من 218 ميغابايت إلى 82 ميغابايت وإعادات التهيئة إلى الصفر.

Cloudflare Durable Objects في دقيقة واحدة

إن لم تكن قد استخدمتها: Durable Object خادم صغير ذو حالة وخيط واحد تضمن Cloudflare أنه فريد لمعرّف معين. استدعِ idFromName() بذلك المعرّف من Worker ويقع كل طلب له، من أي مكان في العالم، على النسخة نفسها، بقاعدة بيانات SQLite خاصة بها وحالة في الذاكرة ومنبّهات. يدخل في وضع الإسبات عند الخمول ويستيقظ من حيث توقف.

في Polylane، ننشئ نسخة Durable Object لكل محادثة. كل حوار مع الوكيل، سواء بدأه شخص أو تنبيه، يحصل على Durable Object خاص به. تحمل قاعدة SQLite الخاصة بالكائن رسائل المحادثة ونتائج الأدوات، وتعمل حلقة الوكيل داخله، وتستدعي أدواته أي مزودين تحتاجهم المحادثة: Datadog وSentry وHoneycomb وGitHub وCloudflare وغيرها. وعندما تهدأ المحادثة يدخل الكائن في الإسبات، وعندما تصل الرسالة التالية يستأنف بالضبط من حيث توقف.

graph TB
    U["New message or alert"] --> W["Worker"]
    W -->|"idFromName(threadId)"| DO["One Durable Object per thread"]
    DO --> S["SQLite: messages, tool results"]
    DO --> L["Agent loop"]
    L --> T["Tools"]
    T --> P1["Datadog"]
    T --> P2["Sentry"]
    T --> P3["Honeycomb"]
    T --> P4["GitHub, Cloudflare, ..."]
    style DO fill:#d1fae5,stroke:#6ee7b7,color:#065f46

كيف تعمل ذاكرة Durable Objects

graph TB
    subgraph ISO["One V8 isolate, 128 MB cap"]
        M["Module scope, loaded once"]
        D1["Instance A"]
        D2["Instance B"]
        D3["Instance C"]
    end
    M --- D1
    M --- D2
    M --- D3
    D3 -->|"heap crosses 128 MB"| R["exceededMemory, C is reset"]
    style M fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style R fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d

لا توجد طريقة لتوصيل محلل ذاكرة بـ Durable Object في الإنتاج: أدوات لقطات heap التي توثّقها Cloudflare تعمل على جلسة تطوير محلية. في Kubernetes كنت ستفعّل التحليل وتتصل حتى تجد المشكلة. في workerd، process.memoryUsage() هو polyfill من unenv يعيد أصفارًا، وأرقام الذاكرة والمعالج الأساسية غير مرئية للكود الذي يعمل داخله. وما تحصل عليه هو واجهة GraphQL analytics API من Cloudflare: مئينات الذاكرة وأعداد الانهيارات لكل مساحة أسماء، بعد وقوعها.

ما الذي أخبرنا به رسم الذاكرة

تبلّغ واجهة analytics API من Cloudflare عن ذاكرة مساحة أسماء Durable Object بصفتها مئينات عبر isolates التي تشغّلها، في دلاء من خمس عشرة دقيقة، إلى جانب عدد أخطاء تجاوز الذاكرة في كل دلو. كل رسم في هذه المقالة يأتي من تلك الواجهة، مصفّى إلى مساحة أسماء المحادثات ومقارنًا عبر علامات النشر بعد أن بدأنا شحن الإصلاحات.

مئينات ذاكرة isolates الإنتاج للأسبوع الذي سبق الإصلاح: الوسيط ثابت حول 140 ميغابايت، فوق خط حد الـ 128 ميغابايت المتقطع
الشكل 1
الأسبوع الذي سبق الإصلاح
الـ isolate الوسيط (بالأزرق) ثابت عند ~140 ميغابايت، بالكامل فوق خط حد الـ 128 ميغابايت، والمئين التاسع والتسعون قرب 190 ميغابايت. كل نقطة فوق الخط المتقطع هي isolate يعيش على وقت مستعار.

كان isolate الوسيط، الخط الأزرق في الشكل 1، عند نحو 140 ميغابايت طوال الأسبوع، وكان المئين التاسع والتسعون قرب 190 ميغابايت. كلا الخطَّين فوق حد الـ 128 ميغابايت المتقطع، ما يعني أن isolate النموذجي في مساحة الأسماء كان قد تجاوز أصلًا النقطة التي يحق فيها لـ Cloudflare إعادة تهيئته، ولم يُعفَ إلا حتى يدفعه التخصيص التالي فوق الحافة. وما لم يُظهره الرسم هو أي علاقة بحركة المرور. كان الخط مستويًا في الساعات الهادئة كما في المزدحمة، ولم يأت أي من نحو 300 إعادة تهيئة يوميًا مع أثر مكدس، لأن كودنا لم يكن أبدًا هو من رمى الخطأ.

نظرنا حيث تتوقع أن تنظر أولًا. قرأنا أحجام الحمولات، وفحصنا كيف يُقتطع تاريخ الدردشة، وبحثنا في منحنى الذاكرة عن نمط طلبات، ولم نجد شيئًا يحرّك الخط. وتبيّن أن ذلك الاستواء هو الدليل المهم. ذاكرة isolate واحدة من شيئَين: إما بيانات، أي حمولات الطلبات وتاريخ الدردشة ومخرجات الأدوات وكل ما يُخصَّص في أثناء خدمة حركة المرور، أو أساس، أي الكائنات التي ينشئها الكود نفسه عند تحميل وحدة ويبقيها حية طوال عمر isolate، مثل الاستيرادات والدوال والمخططات. ترتفع البيانات وتنخفض مع الطلبات، بينما يكون الأساس موجودًا قبل الطلب الأول ولا يذهب أبدًا، فالرسم المرتفع والمستوي في وضع الخمول يصف الأساس. لا بد أن المشكلة كانت في ما نشحنه لا في ما نخدمه.

graph TB
    H["Isolate heap"] --> B["Baseline: built at module load"]
    H --> D["Data: allocated per request"]
    B --> B1["imports"]
    B --> B2["tool schemas"]
    B --> B3["package barrels"]
    D --> D1["payloads"]
    D --> D2["chat history"]
    D --> D3["tool outputs"]
    style B fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d

لماذا يسهل تفويت ذاكرة الأساس

تختبئ ذاكرة الأساس في مكانَين لا تنظر فيهما عادةً.

الأول هو المجمّع (bundler). نفترض أن tree shaking يزيل الكود الذي لا نستخدمه، وهو يفعل ذلك غالبًا، لكن لدى esbuild قواعد حول متى يستطيع التقليم وليست القواعد التي تتوقعها. الحزمة التي ليس في package.json الخاص بها "sideEffects": false لا تُقلَّم أبدًا على الإطلاق. وexport * داخل وحدة تُقيَّم بشكل كسول لا يُقلَّم أبدًا أيضًا. وimport() الديناميكي لجذر حزمة يسم كل تصدير بأنه مستخدم. سنعود إلى كل من هذه في الإصلاحات، لأنها تفسّر نصف الـ 130 ميغابايت.

// Three ways to keep every zod schema in a package alive in the isolate,
// none of which look like a mistake.

// The package has no "sideEffects": false, so nothing in it is pruned.
import { getWorkspaceStub } from "@scope/durable-workspaces";

// Inside a lazily evaluated module, export * is never pruned.
export * from "./zod";

// A namespace object marks every export as used, schemas included.
const pkg = await import("@scope/durable-workspaces");

الثاني هو مكتبة المخططات. تبدو الأنواع مجانية لأنها تختفي في وقت التصريف، لكن مخطط zod ليس نوعًا، بل شجرة من الإغلاقات (closures) تُبنى لحظة تقييم وحدته. مخطط كائن zod 4 متوسط الحجم، بعشرات الحقول مع أوصاف وتحسينات، يكلّف ~134 كيلوبايت من heap. وz.string() مجرد يكلّف ~12 كيلوبايت. وكائن JSON schema العادي المكافئ يكلّف بضع مئات من البايتات. لا شيء من ذلك في ملف README.

// What each of these costs the moment its module evaluates.
const Params = z.object({                     // ~134 KB for a dozen fields like these
  owner: z.string().describe("Repository owner"),
  repo: z.string().describe("Repository name"),
  pullNumber: z.number().int().describe("PR number"),
});
const Name = z.string();                      // ~12 KB

const params = {                              // a few hundred bytes
  type: "object",
  properties: { owner: { type: "string" }, repo: { type: "string" } },
} as const;

لم نفعل شيئًا غريبًا

جاءت كل مشكلة من مشكلاتنا من إعداد افتراضي. كل أداة وكيل أعلنت مدخلاتها بـ z.object() في نطاق الوحدة، لأن هذا ما تفعله الوثائق. وكل حزمة داخلية لديها ملف barrel index.ts فيه export * from "./zod"، لأن ذلك مرتّب. واستخدمت بضعة مسارات await import("@scope/package") لأن التحميل الكسول يُفترض أن يكون أرخص. كل واحد منها خيار معقول بمفرده. ومعًا كانت 130 ميغابايت، في isolate لديه 128 لينفقها.

// packages/tools/src/github/get-pull-request.ts, and 250 more like it
export const parameters = z.object({
  owner: z.string().describe("Repository owner"),
  repo: z.string().describe("Repository name"),
  pullNumber: z.number().int().describe("PR number"),
});

// packages/thread-core/src/index.ts, and every other internal package
export * from "./zod";
export * from "./thread";

// packages/durable-threads/src/agent.ts
const { getWorkspaceStub } = await import("@scope/durable-workspaces");

ما الذي كان يكلّفنا

لدى وكيلنا أكثر من 250 أداة، يستخدمها عبر Code mode والعمّال الديناميكيين. كلّفت تعريفات zod الخاصة بها وحدها 78 ميغابايت من heap عند تحميل الوحدات، أكثر من نصف ميزانية isolate، أُنفقت على أوصاف الوسائط قبل أن يُستدعى أي منها. وكل أداة أضفناها كلّفت ~134 كيلوبايت أخرى، سواء عملت تلك الأداة يومًا أم لا.

250+ tools × ~134 KB of zod each   ≈  78 MB   evaluated before the first request
128 MB isolate cap − 78 MB         =  50 MB   left for every thread's actual data

ولم تكن إعادات التهيئة مجانية أيضًا. كل exceededMemory يرمي دور وكيل جارٍ: إعادة محاولة، واستدعاء نموذج ثانٍ، ومستخدم يشاهد مؤشر انتظار. وعند ~300 يوميًا يتوقف ذلك عن كونه حادثة ويصير ضريبة دائمة، وبسبب التجاور كان يدفعها أي كائن صادف وجوده في isolate في ذلك الوقت.

Error: Durable Object's isolate exceeded its memory limit and was reset.

كيف وجدناه: محلل heap لحزمة الإنتاج

لم نستطع تحليل الإنتاج، فبنينا محللًا صغيرًا يعمل محليًا على الحزمة نفسها التي يشغّلها الإنتاج بالضبط. يقيس تكلفة heap في V8 لتقييم كل وحدة ويطبع جدولًا مرتبًا. شغّلناه في حلقة: حلّل، وأزل رأس الترتيب، وحلّل مجددًا. وكان الفحص النهائي لكل إصلاح عملية نشر إلى الإنتاج مقارنة مع مقاييس ذاكرة Cloudflare على جانبَي علامة النشر.

graph TB
    A["wrangler deploy --dry-run"] --> B["production bundle + metafile"]
    B --> C["instrument the __esm helper"]
    C --> D["run under Node with stubs"]
    D --> E["exclusive heap per module"]
    style E fill:#d1fae5,stroke:#6ee7b7,color:#065f46

ثلاث أفكار تجعله يعمل:

  • الحزمة نفسها. يُصدر wrangler deploy --dry-run --outdir --metafile حزمة esbuild نفسها التي كانت عملية النشر سترفعها، إضافة إلى مخطط وحداتها. يُقاس كل شيء على تلك الحزمة.
  • إسناد الوحدات. يغلّف esbuild الوحدات المقيَّمة كسولًا في إغلاقات تهيئة __esm(...). نعيد كتابة تلك الدالة المساعدة الوحيدة في الحزمة المبنية بحيث يسجّل كل تهيئة وحدة v8.getHeapStatistics().used_heap_size قبل نفسه وبعده، مع مكدس تهيئة يفصل تكلفة الوحدة الخاصة (الحصرية) عن تكلفة تبعياتها (الشاملة). والنتيجة مجموعة بيانات على شكل flamegraph.
  • تشغيلات محكومة. تعمل الحزمة المُجهَّزة تحت Node عادي مع shim للمحمّل يحل استيرادات cloudflare:* إلى بدائل وهمية.

الوصفة الكاملة، الجاهزة للصقها في وكيل برمجة، في نهاية هذه المقالة. وتكاليف وحدات zod المذكورة أعلاه تأتي من قياس أداء دقيق شُغّل عبر الإطار نفسه.

ما الذي أظهره التحليل الأول

المصدرheap الحصري
packages/tools (أكثر من 250 تعريف أداة وكيل، zod)78.0 ميغابايت
ملفات barrel للحزم تعيد تصدير وحدات مخططات zod (الصفوف أدناه)~66 ميغابايت
   durable-workspaces (حالة مساحة العمل والجداول)15.6 ميغابايت
   thread-core (طبقة بيانات المحادثات)10.1 ميغابايت
   durable-automations (تعريفات الأتمتة)9.6 ميغابايت
   durable-threads (قائمة المحادثات والتحديثات الحية)7.3 ميغابايت
   durable-automation (جولة أتمتة واحدة)6.8 ميغابايت
   db (عميل D1 والنماذج)6.3 ميغابايت
   durable-skills (تعريفات المهارات)5.7 ميغابايت
   durable-autofixes (فروع الإصلاح التلقائي وعمليات الدمج)4.9 ميغابايت
   12 حزمة أصغر~9 ميغابايت
الجدول 1
التحليل الأول لحزمة الإنتاج
heap الحصري لكل مصدر، أعلى الترتيب.

الصف الثاني هو المفاجئ. تلك مخططات تصل إلى الحزمة فقط عبر export * from "./zod" في ملفات barrel للحزم. لم يستخدمها كودنا أبدًا، ولم يستطع tree shaker إزالتها، وكلّفت ثلث حد الذاكرة، كله في وحدات لم يستدعها شيء أبدًا.

الإصلاح A: تعريفات الأدوات بصفتها بيانات، لا كودًا

أعلن كل تعريف أداة مخطط مدخلاته بـ zod وحوّله إلى JSON schema في وقت التشغيل، لأن JSON schema هو ما يُرسل إلى النموذج على أي حال. كنا نبني ~134 كيلوبايت من الإغلاقات لكل أداة لإنتاج بضع مئات من البايتات من البيانات، فكتبنا البيانات مباشرة.

graph TB
    Z["zod schema, ~134 KB per tool"] --> J["toJSONSchema() at runtime"]
    J --> M["JSON schema sent to the model"]
    D["JSON schema, ~0.3 KB per tool"] --> M
    style Z fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46

// before: ~134 KB of closures per tool, built at module load
export const parameters = z.object({
  owner: z.string().describe("Repository owner"),
  repo: z.string().describe("Repository name"),
  pullNumber: z.number().int().describe("PR number"),
});

// after: a few hundred bytes of data, and the type comes from the schema
export const parameters = defineParameters({
  type: "object",
  properties: {
    owner: { type: "string", description: "Repository owner" },
    repo: { type: "string", description: "Repository name" },
    pullNumber: { type: "number", description: "PR number" },
  },
  required: ["owner", "repo", "pullNumber"],
} as const);
export type Input = ParametersInput<typeof parameters>;

defineParameters غلاف رفيع فوق jsonSchema() من AI SDK. وParametersInput هو json-schema-to-ts يؤدي استدلال الأنواع الذي كان zod يؤديه. وانتقل التحقق في وقت التشغيل إلى مُتحقِّق من ~300 سطر يعيد إنتاج الخصائص التي تعتمد عليها حلقة الوكيل: تُجرَّد المفاتيح غير المعروفة، وتُملأ القيم الافتراضية، وتُحل الاتحادات بالمميِّز، وتُصاغ رسائل الأخطاء بحيث يستطيع النموذج إصلاح استدعاء أداته بنفسه عند إعادة المحاولة.

انخفض heap تقييم الوحدات من 218.6 إلى 154.0 ميغابايت محليًا، وpackages/tools من 78 ميغابايت إلى 0.7 ميغابايت. وفي الإنتاج، انخفضت إعادات التهيئة من 40 إلى 110 في الساعة إلى 0 إلى 6 في الساعة عند علامة النشر، وذاكرة isolate الوسيط من ~140 ميغابايت إلى ~120 ميغابايت.

أخطاء تجاوز الذاكرة لكل دلو تنهار عند نشر الإصلاح A
الشكل 2
تنهار إعادات التهيئة عند نشر الإصلاح A
الأخطاء لكل دلو لمساحة الأسماء (بتوقيت PDT). 321 في النافذة المعروضة، كلها تقريبًا قبل عملية النشر مساء 24 أغسطس. والمتخلفة بعدها هي ذيل الـ 0 إلى 6 في الساعة الذي أزاله الإصلاح B.

الإصلاح B: ملفات barrel يمكن فعلًا تقليمها بـ tree shaking

كانت الـ ~66 ميغابايت المتبقية مخططات لم يستخدمها isolate أبدًا. تفسّرها ثلاثة سلوكيات للمجمّع:

  1. من دون "sideEffects": false في package.json الحزمة، لا يقلّم esbuild شيئًا منها.
  2. حتى مع العلامة، لا يُقلَّم export * from "./zod" أبدًا داخل وحدة مقيَّمة كسولًا، وكل ما يمكن الوصول إليه عبر import() ديناميكي يُقيَّم كسولًا. أما إعادات التصدير المسمّاة (export { zFoo } from "./zod") فتُقلَّم كما ينبغي.
  3. يجسّد await import("@scope/package") كائن مساحة الأسماء الكامل للحزمة، ويسم كل تصدير بأنه مستخدم: المخططات، والفئات، وكل شيء.

أكّدنا كل واحد من هذه بتجربة من خمسة ملفات مبنية بإصدار esbuild الذي يضمّنه wrangler: مدخل، وحزمة فيها ملف barrel index.ts، وzod.ts يحمل مخططًا واحدًا يعلن مُنشئه عندما يعمل، وفئة do.ts تستورد ذلك المخطط، ووسيط كسول بينهما. يسجّل الجدول إن كان مُنشئ المخطط قد عمل عند التقييم لكل تركيبة.

المدخل يستورد ملف barrel عبرشكل إعادة التصدير في barrelsideEffects: falseالمخطط مضمّن
استيراد ثابتexport *نعملا
استيراد ثابتexport *لانعم
import() ديناميكي للحزمةأي شكلنعمنعم
استيراد ثابت من وحدة محمّلة كسولًاexport *نعمنعم
استيراد ثابت من وحدة محمّلة كسولًاقائمة مسمّاةنعملا
أي طريقةقائمة مسمّاةلانعم
الجدول 2
مصفوفة التجربة
هل يعمل مُنشئ وحدة المخطط عند التقييم، لكل شكل استيراد.

الصفان 3 و4 هما اللذان يفاجئان الناس، ومعًا كانا مسؤولَين عن الـ ~66 ميغابايت. إليك ما كان ملف barrel واحد يفعله بنا:

graph TB
    A["do.ts imports one function"] --> B["durable-workspaces barrel"]
    B -->|"export * from './zod'"| C["zod/*.ts, 15.6 MB of schemas"]
    B -->|"used"| D["getWorkspaceStub(), ~1 KB"]
    style C fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46

الإصلاح آلي: "sideEffects": false في كل حزم مساحة العمل التي تتجاوز 110، وإعادة كتابة export * في ملفات barrel إلى قوائم إعادة تصدير مسمّاة، واستبدال await import("@scope/package") لجذور الحزم باستيرادات ثابتة مسمّاة.

ويُظهر استئصال على حزمة الإنتاج الحقيقية أن كل قطعة ضرورية.

الإعدادheap بعد تقييم الوحداتوحدات المخططات في الحزمة
الأساس (بعد الإصلاح A)154.0 ميغابايت77
sideEffects: false وحده143.6 ميغابايت77
+ استيرادات ثابتة، وملفات barrel تعود إلى export * (ضابط)98.6 ميغابايت29
+ قوائم إعادة تصدير مسمّاة (الإصلاح الكامل)82.1 ميغابايت0
الجدول 3
استئصال الإصلاح B
heap بعد تقييم الوحدات ووحدات المخططات الباقية، بإضافة قطعة واحدة في كل مرة.

صف الضابط هو المثير للاهتمام: حتى مخطط استيراد ثابت بالكامل يحتفظ بـ 29 وحدة مخططات، فالقوائم المسمّاة ليست اختيارية.

الحالة النهائية

مئينات ذاكرة isolates الإنتاج عبر عمليتَي النشر، تنخفض تدريجيًا تحت حد الـ 128 ميغابايت
الشكل 3
ذاكرة isolate عبر الإصلاحَين
يصل الإصلاح A مساء 24 أغسطس، والإصلاح B في 26 أغسطس (بتوقيت PDT). ينخفض الوسيط من ~140 ميغابايت إلى 50 إلى 90 ميغابايت، ويهبط المئين التاسع والتسعون تحت خط الـ 128 ميغابايت للمرة الأولى.
heap نطاق الوحدات (المجس المحلي)ذاكرة الإنتاج الوسيطةإعادات التهيئة في الإنتاج
قبل218.6 ميغابايت~140 ميغابايت~300 يوميًا
بعد A154.0 ميغابايت~120 ميغابايت~10 يوميًا
بعد A+B82.1 ميغابايت~70 ميغابايت0
الجدول 4
قبل وبعد
heap نطاق الوحدات من المجس المحلي مقابل ذاكرة isolate الوسيط في الإنتاج وإعادات التهيئة اليومية.

Durable Object كان يستقر في وضع الخمول فوق حد ذاكرة المنصة يستقر الآن عند نصفه بالكاد، وذهبت الـ ~300 إعادة تهيئة يوميًا.

هذه ليست علامة إعداد

استغرقت إعادة كتابة أكثر من 250 تعريف أداة من zod إلى JSON schema خام، وكتابة مُتحقِّق من 300 سطر ليحل محل ما كان zod يفعله لنا، بضعة أيام، حتى مع وكلاء البرمجة. وإضافة sideEffects: false إلى أكثر من 100 حزمة وتحويل كل export * إلى قائمة مسمّاة مولّدة عمل غير مبهر، وبعده تفحص أنواع المستودع كله وتصلح ما يتعطل. والبديل السهل أن تضيف إعادة محاولة وتعيش مع إعادات التهيئة، وكثير من الفرق تفعل ذلك. إن كان Durable Object لديك قريبًا بأي قدر من الحد في وضع الخمول، فأنا أرى أن الأسبوع يستحق، لأن الحد لا يتحرك وعدد أدواتك لا يزيد إلا صعودًا.

الأشياء الثلاثة التي كنت أتمنى معرفتها من اليوم الأول:

  • مرتفع ومستوٍ في وضع الخمول يعني أساسًا. حلّل ما تشحنه، لا ما تخدمه.
  • لـ tree shaking قواعد. sideEffects: false، وإعادات تصدير مسمّاة، واستيرادات ثابتة. أفلت واحدة وتركب الحزمة كلها معك.
  • المخططات كود، لا أنواع. إن كان المستهلك يريد JSON schema، فاكتب JSON schema.

افعل هذا اليوم لإصلاح ذاكرة Durable Object لديك

المحلل ~100 سطر من دون تبعيات سوى Node وwrangler. الصق الوصفة أدناه في وكيل البرمجة الخاص بك في جذر أي مستودع يُنشر بـ wrangler، واقرأ الصفوف العشرة الأولى من الجدول الذي يطبعه، وانظر ما هناك. ثم انزل في القائمة: JSON schema عادي لكل ما يستلمه النموذج بصفته JSON schema على أي حال، و"sideEffects": false وإعادات تصدير مسمّاة لملفات barrel، واستيرادات ثابتة لجذور الحزم.

Build a per-module heap profiler for my worker's production bundle.

1. Emit the exact production bundle and metafile:

   cd <worker-dir>
   npx wrangler deploy --dry-run --env <stage> --config wrangler.jsonc \
     --outdir /tmp/heap-probe --metafile /tmp/heap-probe/meta.json

   If the worker's import graph is fully static, esbuild emits no lazy
   `__esm` wrappers and per-module attribution is impossible. In that case
   build from a probe-only entry that reaches the real entry through a
   dynamic import (and satisfies wrangler's Durable Object export check
   with a placeholder class):

   // probe-entry.ts
   export default { fetch: () => new Response("probe") };
   export class <YourDurableObjectClassName> {}
   export const probeLoad = () => import("<path-to-real-entry>");

   npx wrangler deploy --dry-run --env <stage> --config wrangler.jsonc \
     --outdir /tmp/heap-probe --metafile /tmp/heap-probe/meta.json probe-entry.ts

2. Instrument the bundle. Write instrument.mjs and run
   `node instrument.mjs /tmp/heap-probe`:

   import { readFileSync, writeFileSync } from "node:fs";
   import { join } from "node:path";

   const outDir = process.argv[2];
   const bundlePath = join(outDir, "probe-entry.js"); // or index.js
   const source = readFileSync(bundlePath, "utf-8");

   const esmHelperPattern = /var __esm = \(fn, res(?:, \w+)?\) => function __init\(\) \{[\s\S]*?\n\};\n/;
   if (!esmHelperPattern.test(source)) throw new Error("__esm helper not found; esbuild output shape changed");

   const instrumentedHelper = `var __probeInitStack = [];
   var __esm = (fn, res, err2) => function __init() {
     if (err2) throw err2[0];
     if (!fn) return res;
     const probe = globalThis.__moduleHeapProbe;
     if (!probe) {
       try { return (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }
       catch (e) { throw ((err2 = [e]), e); }
     }
     const moduleName = __getOwnPropNames(fn)[0];
     const frame = { child: 0 };
     const before = probe.heap();
     __probeInitStack.push(frame);
     try { return (res = (0, fn[moduleName])(fn = 0)), res; }
     catch (e) { throw ((err2 = [e]), e); }
     finally {
       const total = probe.heap() - before;
       __probeInitStack.pop();
       if (__probeInitStack.length > 0) __probeInitStack[__probeInitStack.length - 1].child += total;
       probe.record(moduleName, total - frame.child, total);
     }
   };
   `;
   writeFileSync(join(outDir, "instrumented.mjs"), source.replace(esmHelperPattern, instrumentedHelper));

3. Write a Node loader shim so worker-targeted code loads under Node.
   register.mjs resolves `cloudflare:*` to a stub module (an empty module
   exporting throwing placeholders for DurableObject, WorkerEntrypoint,
   env, etc.) via module.registerHooks; add a `load` hook for any
   non-JS rules in your wrangler config (for us: `.sql` files become
   text default exports, mirroring wrangler's Text rule).

4. Write probe.mjs and run it:

   node --expose-gc --import ./register.mjs probe.mjs /tmp/heap-probe

   import { join } from "node:path";
   import { pathToFileURL } from "node:url";
   import v8 from "node:v8";

   const outDir = process.argv[2];
   const records = [];
   globalThis.__moduleHeapProbe = {
     heap: () => v8.getHeapStatistics().used_heap_size,
     record: (moduleName, exclusive, total) => records.push({ moduleName, exclusive, total }),
   };
   const settle = async () => { await new Promise((r) => setTimeout(r, 0)); globalThis.gc(); globalThis.gc(); };
   const mb = (b) => (b / 1048576).toFixed(2);

   await settle();
   const bundle = await import(pathToFileURL(join(outDir, "instrumented.mjs")).href);
   if (bundle.probeLoad) await bundle.probeLoad();
   await settle();
   console.log(`heap after module evaluation: ${mb(v8.getHeapStatistics().used_heap_size)} MB`);

   for (const r of records.sort((a, b) => b.exclusive - a.exclusive).slice(0, 40))
     console.log(`${mb(r.exclusive).padStart(8)} MB  ${r.moduleName}`);

   const byPackage = new Map();
   for (const r of records) {
     const m = r.moduleName.match(/node_modules\/((?:@[^/]+\/)?[^/]+)|(packages\/[^/]+)/);
     const key = m ? (m[1] ? `npm:${m[1]}` : m[2]) : "other";
     byPackage.set(key, (byPackage.get(key) ?? 0) + r.exclusive);
   }
   for (const [k, v] of [...byPackage.entries()].sort((a, b) => b[1] - a[1]))
     if (v > 131072) console.log(`${mb(v).padStart(8)} MB  ${k}`);

5. Gotchas that will otherwise burn an afternoon:
   - The bundle's unenv polyfill replaces globalThis.process at init and its
     memoryUsage() reports zeros. Read v8.getHeapStatistics() instead.
   - Compare deltas between runs of this probe, never absolutes against
     production: Node's heap baseline differs from workerd's.
   - Modules evaluated eagerly at the top level (not wrapped in __esm) are
     invisible to attribution; the probe-entry trick in step 1 fixes that.

وإن أردت أن يُنجز التدقيق كاملًا لك، فإليك مهارة لوكيل البرمجة الخاص بك:

Audit this repo's worker bundles for module-scope schema weight, and fix what you find. Work in this order and show me numbers at every step.

1. Baseline. Using the "profile module-scope heap" recipe, build the
   production bundle of our most memory-sensitive worker and produce the
   per-module and per-package exclusive-heap ranking. Report heap after
   module evaluation.

2. Identify schema weight. From the ranking and the esbuild metafile, list
   every module matching your schema conventions (zod/valibot/etc. modules,
   e.g. packages/*/zod*) that survived into the bundle, with bytes. For each,
   compute one import chain from the entry using the metafile's `imports`
   graph (BFS), so we know *why* it is in the bundle.

3. Classify each surviving schema module:
   a. Actually used at runtime by this worker: leave it, or move the boundary.
   b. Reached through `export *` in a package barrel: candidate for named lists.
   c. Reached through `await import("<package root>")`: candidate for a static
      named import.
   d. Reached because the package lacks `"sideEffects": false`: candidate flag.

4. Apply, in this order, re-profiling after each:
   a. Add `"sideEffects": false` to every internal package that has no
      import-time side effects. Audit first: grep package sources for
      top-level globalThis mutations, addEventListener, polyfill assignment.
      Any true side-effect file gets `"sideEffects": ["./that-file.ts"]`.
   b. Rewrite `export * from "./<schemas>"` in package barrels to explicit
      `export { ... }` / `export type { ... }` lists. Generate the lists with
      the TypeScript compiler API (walk ExportDeclarations recursively,
      classify value vs type), never by regex. Typecheck the repo after.
   c. Replace every value-position `await import("@scope/pkg")` of a bare
      package root with a static named import of the symbols actually used.
      Check the site is not lazy for a *different* reason first (circular
      imports, Node-only test loading, genuine cold-path npm dependency).

5. If tool/LLM definitions build schema-library objects at module scope,
   propose converting them to plain JSON schema with types via
   json-schema-to-ts, and estimate the saving from step 1's ranking before
   doing it.

6. Verify: re-profile (report the delta), typecheck, run the affected
   packages' tests, and dry-run build every worker. Then add a CI assertion
   that reads the metafile of the memory-sensitive worker and fails if any
   schema module survives tree shaking into it, printing the import chain.

7. After deploy, compare the platform memory metrics across the deploy
   marker and report before/after median and 99th percentile memory, and reset
   counts.

وقد أضفنا هذه المهارة إلى Polylane نفسها أيضًا، فيحصل كل مستخدم لـ Polylane على هذا التحقيق العميق في ذاكرة Durable Object لديه من دون إعداد، ومن دون أي شيء يلصقه.

لا ينبغي لأحد أن يكون في المناوبة عام 2026. تراقب Polylane بنيتك التحتية، وتحقق، وتصلح ما يتعطل.

انضم إلى قائمة الانتظار