كيف أصلحنا أخطاء تجاوز الذاكرة في 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 وغيرها. وعندما تهدأ المحادثة يدخل الكائن في الإسبات، وعندما تصل الرسالة التالية يستأنف بالضبط من حيث توقف.
كيف تعمل ذاكرة Durable Objects
- الـ isolates وحد الـ 128 ميغابايت. تعمل Durable Objects داخل isolates من V8، وللـ isolate حد ذاكرة صارم قدره 128 ميغابايت هو نفسه في كل خطة وغير قابل للتعديل.
- التجاور. يستضيف isolate واحد عدة Durable Objects من الفئة نفسها، إلى جانب كود Worker المحيط بها، وتتشارك كلها ذاكرة ذلك isolate. الحد لكل isolate لا لكل كائن.
- الجيران المزعجون. بسبب التجاور، الكائن الذي يُعاد تهيئته عندما تنفد ذاكرة isolate ليس غالبًا الكائن الذي استخدم الذاكرة. كل عينة ذاكرة تبلّغها Cloudflare هي لـ isolate كامل، وكذلك كل إعادة تهيئة.
لا توجد طريقة لتوصيل محلل ذاكرة بـ Durable Object في الإنتاج: أدوات لقطات heap التي توثّقها Cloudflare تعمل على جلسة تطوير محلية. في Kubernetes كنت ستفعّل التحليل وتتصل حتى تجد المشكلة. في workerd، process.memoryUsage() هو polyfill من unenv يعيد أصفارًا، وأرقام الذاكرة والمعالج الأساسية غير مرئية للكود الذي يعمل داخله. وما تحصل عليه هو واجهة GraphQL analytics API من Cloudflare: مئينات الذاكرة وأعداد الانهيارات لكل مساحة أسماء، بعد وقوعها.
ما الذي أخبرنا به رسم الذاكرة
تبلّغ واجهة analytics API من Cloudflare عن ذاكرة مساحة أسماء Durable Object بصفتها مئينات عبر isolates التي تشغّلها، في دلاء من خمس عشرة دقيقة، إلى جانب عدد أخطاء تجاوز الذاكرة في كل دلو. كل رسم في هذه المقالة يأتي من تلك الواجهة، مصفّى إلى مساحة أسماء المحادثات ومقارنًا عبر علامات النشر بعد أن بدأنا شحن الإصلاحات.
كان isolate الوسيط، الخط الأزرق في الشكل 1، عند نحو 140 ميغابايت طوال الأسبوع، وكان المئين التاسع والتسعون قرب 190 ميغابايت. كلا الخطَّين فوق حد الـ 128 ميغابايت المتقطع، ما يعني أن isolate النموذجي في مساحة الأسماء كان قد تجاوز أصلًا النقطة التي يحق فيها لـ Cloudflare إعادة تهيئته، ولم يُعفَ إلا حتى يدفعه التخصيص التالي فوق الحافة. وما لم يُظهره الرسم هو أي علاقة بحركة المرور. كان الخط مستويًا في الساعات الهادئة كما في المزدحمة، ولم يأت أي من نحو 300 إعادة تهيئة يوميًا مع أثر مكدس، لأن كودنا لم يكن أبدًا هو من رمى الخطأ.
نظرنا حيث تتوقع أن تنظر أولًا. قرأنا أحجام الحمولات، وفحصنا كيف يُقتطع تاريخ الدردشة، وبحثنا في منحنى الذاكرة عن نمط طلبات، ولم نجد شيئًا يحرّك الخط. وتبيّن أن ذلك الاستواء هو الدليل المهم. ذاكرة isolate واحدة من شيئَين: إما بيانات، أي حمولات الطلبات وتاريخ الدردشة ومخرجات الأدوات وكل ما يُخصَّص في أثناء خدمة حركة المرور، أو أساس، أي الكائنات التي ينشئها الكود نفسه عند تحميل وحدة ويبقيها حية طوال عمر isolate، مثل الاستيرادات والدوال والمخططات. ترتفع البيانات وتنخفض مع الطلبات، بينما يكون الأساس موجودًا قبل الطلب الأول ولا يذهب أبدًا، فالرسم المرتفع والمستوي في وضع الخمول يصف الأساس. لا بد أن المشكلة كانت في ما نشحنه لا في ما نخدمه.
لماذا يسهل تفويت ذاكرة الأساس
تختبئ ذاكرة الأساس في مكانَين لا تنظر فيهما عادةً.
الأول هو المجمّع (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 على جانبَي علامة النشر.
ثلاث أفكار تجعله يعمل:
- الحزمة نفسها. يُصدر
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 ميغابايت |
الصف الثاني هو المفاجئ. تلك مخططات تصل إلى الحزمة فقط عبر export * from "./zod" في ملفات barrel للحزم. لم يستخدمها كودنا أبدًا، ولم يستطع tree shaker إزالتها، وكلّفت ثلث حد الذاكرة، كله في وحدات لم يستدعها شيء أبدًا.
الإصلاح A: تعريفات الأدوات بصفتها بيانات، لا كودًا
أعلن كل تعريف أداة مخطط مدخلاته بـ zod وحوّله إلى JSON schema في وقت التشغيل، لأن JSON schema هو ما يُرسل إلى النموذج على أي حال. كنا نبني ~134 كيلوبايت من الإغلاقات لكل أداة لإنتاج بضع مئات من البايتات من البيانات، فكتبنا البيانات مباشرة.
// 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 ميغابايت.
الإصلاح B: ملفات barrel يمكن فعلًا تقليمها بـ tree shaking
كانت الـ ~66 ميغابايت المتبقية مخططات لم يستخدمها isolate أبدًا. تفسّرها ثلاثة سلوكيات للمجمّع:
- من دون
"sideEffects": falseفيpackage.jsonالحزمة، لا يقلّم esbuild شيئًا منها. - حتى مع العلامة، لا يُقلَّم
export * from "./zod"أبدًا داخل وحدة مقيَّمة كسولًا، وكل ما يمكن الوصول إليه عبرimport()ديناميكي يُقيَّم كسولًا. أما إعادات التصدير المسمّاة (export { zFoo } from "./zod") فتُقلَّم كما ينبغي. - يجسّد
await import("@scope/package")كائن مساحة الأسماء الكامل للحزمة، ويسم كل تصدير بأنه مستخدم: المخططات، والفئات، وكل شيء.
أكّدنا كل واحد من هذه بتجربة من خمسة ملفات مبنية بإصدار esbuild الذي يضمّنه wrangler: مدخل، وحزمة فيها ملف barrel index.ts، وzod.ts يحمل مخططًا واحدًا يعلن مُنشئه عندما يعمل، وفئة do.ts تستورد ذلك المخطط، ووسيط كسول بينهما. يسجّل الجدول إن كان مُنشئ المخطط قد عمل عند التقييم لكل تركيبة.
| المدخل يستورد ملف barrel عبر | شكل إعادة التصدير في barrel | sideEffects: false | المخطط مضمّن |
|---|---|---|---|
| استيراد ثابت | export * | نعم | لا |
| استيراد ثابت | export * | لا | نعم |
import() ديناميكي للحزمة | أي شكل | نعم | نعم |
| استيراد ثابت من وحدة محمّلة كسولًا | export * | نعم | نعم |
| استيراد ثابت من وحدة محمّلة كسولًا | قائمة مسمّاة | نعم | لا |
| أي طريقة | قائمة مسمّاة | لا | نعم |
الصفان 3 و4 هما اللذان يفاجئان الناس، ومعًا كانا مسؤولَين عن الـ ~66 ميغابايت. إليك ما كان ملف barrel واحد يفعله بنا:
الإصلاح آلي: "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 |
صف الضابط هو المثير للاهتمام: حتى مخطط استيراد ثابت بالكامل يحتفظ بـ 29 وحدة مخططات، فالقوائم المسمّاة ليست اختيارية.
الحالة النهائية
| heap نطاق الوحدات (المجس المحلي) | ذاكرة الإنتاج الوسيطة | إعادات التهيئة في الإنتاج | |
|---|---|---|---|
| قبل | 218.6 ميغابايت | ~140 ميغابايت | ~300 يوميًا |
| بعد A | 154.0 ميغابايت | ~120 ميغابايت | ~10 يوميًا |
| بعد A+B | 82.1 ميغابايت | ~70 ميغابايت | 0 |
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 لديه من دون إعداد، ومن دون أي شيء يلصقه.