Cloudflare Durable Objects bellek aşımı hatalarımızı nasıl düzelttik
Explore with AI
Polylane’deki her ajan thread’i kendi Cloudflare Durable Object’inin içinde çalışır, hepsi tek bir sınıfın örnekleri. Ağustos 2026’da threadlerimizin Durable Object’leri bellek sınırını aştıkları için günde yaklaşık 300 kez sıfırlanıyordu. Aynı hikayeyi ajan akışlarını Cloudflare Durable Objects üzerinde çalıştıran birden fazla ekipten duyduk.
Bir Durable Object’te exceededMemory ile karşılaştıysan alışıldık tavsiye, isteklerinin neyi ayırdığına bakmaktır: çok büyük bir yük, sınırsız büyüyen bir sohbet geçmişi, hiç boşaltmayan bir önbellek. Bunların hiçbiri bize uymuyordu. Isolate tek bir istek bile sunmadan önce sınırın üzerindeydi, dolayısıyla ağırlık sunduğumuz veride değil, yayınladığımız kodda olmalıydı.
Bu yazı heap’te gerçekte ne olduğu, onu profiler bağlamana izin vermeyen bir platformda nasıl bulduğumuz ve modül kapsamı belleğini 218 MB’tan 82 MB’a, sıfırlanmaları sıfıra indiren iki değişiklik hakkında.
Bir dakikada Cloudflare Durable Objects
Kullanmadıysan: bir Durable Object, Cloudflare’in belirli bir kimlik için benzersiz olduğunu garanti ettiği küçük, durumlu, tek iş parçacıklı bir sunucudur. Bir Worker’dan o kimlikle idFromName() çağır; dünyanın her yerinden ona gelen her istek aynı örneğe iner, kendi SQLite veritabanı, bellek içi durumu ve alarmlarıyla. Boştayken uykuya dalar ve kaldığı yerden uyanır.
Polylane’de her thread için bir Durable Object örneği oluşturuyoruz. Ajanla her sohbet, bir kişi başlatmış olsun ya da bir uyarı, kendi Durable Object’ini alır. Nesnenin SQLite’ı thread’in mesajlarını ve araç sonuçlarını tutar, ajan döngüsü içinde çalışır ve araçları thread’in ihtiyaç duyduğu sağlayıcılara çağrı yapar: Datadog, Sentry, Honeycomb, GitHub, Cloudflare ve diğerleri. Thread sessizleştiğinde nesne uykuya dalar ve bir sonraki mesaj geldiğinde tam durduğu yerden devam eder.
Durable Objects belleği nasıl çalışır
- Isolate’ler ve 128 MB tavanı. Durable Objects V8 isolate’lerinin içinde çalışır ve bir isolate’in her planda aynı olan ve yapılandırılamayan sert bir 128 MB bellek sınırı vardır.
- Aynı yerde barınma. Tek bir isolate aynı sınıftan birçok Durable Object barındırır, etraflarındaki Worker koduyla birlikte, ve hepsi o isolate’in belleğini paylaşır. Sınır nesne başına değil, isolate başınadır.
- Gürültülü komşular. Aynı yerde barınma yüzünden, isolate belleği tükendiğinde sıfırlanan nesne çoğunlukla belleği kullanan nesne değildir. Cloudflare’in bildirdiği her bellek örneği bütün isolate’inkidir ve her sıfırlanma da öyle.
Üretimdeki bir Durable Object’e bellek profiler’ı bağlamanın yolu yok: Cloudflare’in belgelediği heap anlık görüntü araçları yerel bir geliştirme oturumuna karşı çalışır. Kubernetes’te profillemeyi açıp problemi bulana kadar bağlı kalırdın. workerd’de process.memoryUsage() sıfır döndüren bir unenv polyfill’idir ve taban bellek ve CPU rakamları içinde çalışan koda görünmez. Elinde olan Cloudflare’in GraphQL analitik API’si: namespace başına bellek yüzdelikleri ve çökme sayıları, olay olup bittikten sonra.
Bellek grafiği bize ne söyledi
Cloudflare’in analitik API’si bir Durable Object namespace’i için belleği, onu çalıştıran isolate’ler boyunca yüzdelikler olarak, on beş dakikalık kovalarda, her kovadaki bellek aşımı hatası sayısıyla birlikte bildirir. Bu yazıdaki her grafik o API’den geliyor; thread namespace’ine filtrelenmiş ve düzeltmeleri yayınlamaya başladığımızda dağıtım işaretleri boyunca karşılaştırılmış.
Medyan isolate, şekil 1’deki mavi çizgi, bütün hafta yaklaşık 140 MB’ta durdu ve 99. yüzdelik 190 MB’a yakın durdu. İki çizgi de kesikli 128 MB sınırının üzerinde; bu, namespace’teki tipik isolate’in Cloudflare’in onu sıfırlama hakkına sahip olduğu noktayı zaten geçmiş olduğu ve yalnızca bir sonraki ayırma onu devirene kadar bağışlandığı anlamına geliyor. Grafiğin göstermediği şey trafikle herhangi bir ilişkiydi. Çizgi sessiz saatlerde de yoğun saatlerdeki kadar düzdü ve günde yaklaşık 300 sıfırlanmanın hiçbiri bir yığın iziyle gelmedi, çünkü hatayı fırlatan hiçbir zaman bizim kodumuz değildi.
Önce bakılması beklenen yerlere baktık. Yük boyutlarını okuduk, sohbet geçmişinin nasıl kesildiğini kontrol ettik ve bellek eğrisinde bir istek deseni aradık; çizgiyi hareket ettiren hiçbir şey bulamadık. O düzlük önemli ipucu çıktı. Bir isolate’in belleği iki şeyden biridir: ya veridir, yani istek yükleri, sohbet geçmişi, araç çıktıları ve trafik sunulurken ayrılan başka her şey, ya da tabandır, yani kodun kendisinin bir modül yüklenirken oluşturduğu ve isolate’in yaşamı boyunca canlı tuttuğu nesneler: import’lar, fonksiyonlar ve şemalar gibi. Veri isteklerle yükselir ve düşer; taban ise ilk istekten önce oradadır ve asla gitmez, dolayısıyla boştayken yüksek ve düz bir grafik tabanı anlatıyordur. Problem sunduğumuz şeyde değil, yayınladığımız şeyde olmalıydı.
Taban belleği gözden kaçırmak neden bu kadar kolay
Taban belleği normalde bakmadığın iki yerde gizlenir.
Birincisi bundler. Tree shaking’in kullanmadığımız kodu kaldırdığını varsayıyoruz ve çoğunlukla kaldırır, ama esbuild’in ne zaman budayabileceğine dair kuralları var ve bunlar tahmin edeceğin kurallar değil. package.json dosyasında "sideEffects": false olmayan bir paket hiç budanmaz. Tembel değerlendirilen bir modülün içindeki bir export * da hiç budanmaz. Bir paket kökünün dinamik import()’u her export’u kullanılmış olarak işaretler. Düzeltmelerde bunların her birine geri döneceğiz, çünkü 130 MB’ın yarısını açıklıyorlar.
// 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");
İkincisi şema kütüphanesi. Tipler bedava gibi gelir çünkü derleme zamanında yok olurlar, ama bir zod şeması bir tip değildir, modülü değerlendirildiği anda kurulan bir closure ağacıdır. Orta boy bir zod 4 nesne şeması, açıklamaları ve refinement’ları olan bir düzine alan, ~134 KB heap’e mal olur. Çıplak bir z.string() ~12 KB’a mal olur. Eşdeğer düz JSON şema nesnesi birkaç yüz bayta mal olur. Bunların hiçbiri README’de yok.
// 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;
Egzotik hiçbir şey yapmamıştık
Problemlerimizin her biri bir varsayılandan geldi. Her ajan aracı girdisini modül kapsamında z.object() ile bildiriyordu, çünkü belgeler böyle yapıyor. Her iç paketin export * from "./zod" içeren bir barrel index.ts dosyası vardı, çünkü bu derli toplu. Birkaç yol await import("@scope/package") kullanıyordu, çünkü tembel yüklemenin daha ucuz olması gerekiyor. Her biri kendi başına makul bir seçim. Birlikte, harcanacak 128’i olan bir isolate’te 130 MB’tılar.
// 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");
Bize neye mal oluyordu
Ajanımızın code mode ve dinamik worker’lar üzerinden kullandığı 250’den fazla aracı var. Yalnızca zod tanımları modül yüklenirken 78 MB heap’e mal oluyordu, isolate bütçesinin yarısından fazlası, herhangi biri çağrılmadan önce argüman açıklamalarına harcanıyordu. Eklediğimiz her araç, o araç hiç çalışsın ya da çalışmasın, ~134 KB daha ediyordu.
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
Sıfırlanmalar da bedava değildi. Her exceededMemory devam eden bir ajan turunu çöpe atar: bir yeniden deneme, ikinci bir model çağrısı, dönen bir simgeyi izleyen bir kullanıcı. Günde ~300 kez olduğunda bu bir olay olmaktan çıkıp kalıcı bir vergiye dönüşür ve aynı yerde barınma yüzünden onu o anda isolate’te hangi nesne varsa o ödüyordu.
Error: Durable Object's isolate exceeded its memory limit and was reset.
Nasıl bulduk: üretim bundle’ı için bir heap profiler’ı
Üretimi profilleyemiyorduk, bu yüzden üretimin çalıştırdığı bundle’ın tam aynısı üzerinde yerel olarak çalışan küçük bir profiler kurduk. Her modülü değerlendirmenin V8 heap maliyetini ölçüyor ve sıralı bir tablo yazdırıyor. Onu bir döngüde çalıştırdık: profille, sıralamanın tepesini kaldır, yeniden profille. Her düzeltme için son kontrol, dağıtım işaretinin iki yanındaki Cloudflare bellek metrikleriyle karşılaştırılan bir üretim dağıtımıydı.
Üç fikir bunu çalıştırıyor:
- Aynı bundle.
wrangler deploy --dry-run --outdir --metafile, bir dağıtımın yükleyeceği esbuild bundle’ının tam aynısını, modül grafiğiyle birlikte üretir. Her şey o bundle üzerinde ölçülür. - Modül ilişkilendirmesi. esbuild tembel değerlendirilen modülleri
__esm(...)başlatıcı closure’larına sarar. Kurulmuş bundle’daki o tek yardımcıyı yeniden yazıyoruz, böylece her modül başlatması kendisinden önce ve sonrav8.getHeapStatistics().used_heap_sizedeğerini kaydediyor; bir başlatma yığını bir modülün kendi maliyetini (dışlayıcı) bağımlılıklarınınkinden (kapsayıcı) ayırıyor. Sonuç flamegraph biçimli bir veri kümesi. - Kontrollü çalıştırmalar. Enstrümante edilmiş bundle,
cloudflare:*import’larını stub’lara çözen bir loader shim’i ile düz Node altında çalışır.
Bir kodlama ajanına yapıştırmaya hazır tam tarif bu yazının sonunda. Yukarıda alıntılanan zod birim maliyetleri aynı harness üzerinden çalıştırılan bir mikro benchmark’tan geliyor.
İlk profil ne gösterdi
| Kaynak | Dışlayıcı heap |
|---|---|
packages/tools (250+ ajan aracı tanımı, zod) | 78.0 MB |
| zod şema modüllerini yeniden dışa aktaran paket barrel’ları (aşağıdaki satırlar) | ~66 MB |
durable-workspaces (çalışma alanı durumu ve zamanlamalar) | 15.6 MB |
thread-core (thread veri katmanı) | 10.1 MB |
durable-automations (otomasyon tanımları) | 9.6 MB |
durable-threads (thread listesi ve canlı güncellemeler) | 7.3 MB |
durable-automation (tek otomasyon çalıştırması) | 6.8 MB |
db (D1 istemcisi ve modeller) | 6.3 MB |
durable-skills (skill tanımları) | 5.7 MB |
durable-autofixes (otomatik düzeltme dalları ve birleştirmeleri) | 4.9 MB |
| 12 daha küçük paket | ~9 MB |
İkinci satır şaşırtıcı olan. Bunlar bundle’a yalnızca paket barrel’larındaki export * from "./zod" üzerinden ulaşan şemalar. Kodumuz onları hiç kullanmadı, tree shaker onları kaldıramadı ve bellek sınırının üçte birine mal oldular, hepsi hiçbir şeyin hiç çağırmadığı modüllerde.
Düzeltme A: kod değil, veri olarak araç tanımları
Her araç tanımı girdi şemasını zod ile bildiriyor ve çalışma zamanında JSON şemaya dönüştürüyordu, çünkü modele gönderilen zaten JSON şema. Birkaç yüz bayt veri üretmek için araç başına ~134 KB closure kuruyorduk, bu yüzden veriyi doğrudan yazdık.
// 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, AI SDK’nın jsonSchema() fonksiyonu üzerinde ince bir sarmalayıcı. ParametersInput, zod’un eskiden yaptığı tip çıkarımını yapan json-schema-to-ts. Çalışma zamanı doğrulaması, ajan döngüsünün bağlı olduğu özellikleri yeniden üreten ~300 satırlık bir doğrulayıcıya taşındı: bilinmeyen anahtarlar atılır, varsayılanlar doldurulur, union’lar discriminator ile çözülür ve hata mesajları modelin yeniden denemede kendi araç çağrısını onarabileceği şekilde yazılır.
Modül değerlendirme heap’i yerel olarak 218.6 MB’tan 154.0 MB’a ve packages/tools 78 MB’tan 0.7 MB’a düştü. Üretimde sıfırlanmalar dağıtım işaretinde saatte 40-110’dan saatte 0-6’ya, medyan isolate’in belleği ~140 MB’tan ~120 MB’a düştü.
Düzeltme B: gerçekten tree-shake edilebilen barrel’lar
Kalan ~66 MB, isolate’in hiç kullanmadığı şemalardı. Üç bundler davranışı bunu açıklıyor:
- Bir paketin
package.jsondosyasında"sideEffects": falseolmadan esbuild ondan hiçbir şey budamaz. - Bayrak olsa bile
export * from "./zod"tembel değerlendirilen bir modülün içinde hiç budanmaz ve dinamik birimport()üzerinden erişilebilen her şey tembel değerlendirilir. Adlı yeniden dışa aktarımlar (export { zFoo } from "./zod") sorunsuz budanır. await import("@scope/package")paketin bütün namespace nesnesini gerçekleştirir ve her export’u kullanılmış olarak işaretler: şemalar, sınıflar, her şey.
Bunların her birini wrangler’ın gömdüğü esbuild sürümüyle kurulmuş beş dosyalık bir fixture ile doğruladık: bir giriş, index.ts barrel’ı olan bir paket, kurucusu çalıştığında haber veren tek bir şema tutan bir zod.ts, o şemayı import eden bir do.ts sınıfı ve aralarında tembel bir aracı. Tablo her kombinasyon için şemanın kurucusunun değerlendirmede çalışıp çalışmadığını kaydediyor.
| Giriş barrel’ı şununla import eder | Barrel yeniden dışa aktarım biçimi | sideEffects: false | Şema dahil |
|---|---|---|---|
| Statik import | export * | Evet | Hayır |
| Statik import | export * | Hayır | Evet |
Paketin dinamik import()’u | Herhangi | Evet | Evet |
| Tembel yüklenen bir modülden statik import | export * | Evet | Evet |
| Tembel yüklenen bir modülden statik import | Adlı liste | Evet | Hayır |
| Herhangi | Adlı liste | Hayır | Evet |
- ve 4. satırlar insanları şaşırtan ikisi ve birlikte ~66 MB’ı açıklıyorlar. Bir barrel’ın bize yaptığı şuydu:
Düzeltme mekanik: 110’dan fazla çalışma alanı paketinin hepsine "sideEffects": false, barrel’lardaki export * ifadelerini adlı yeniden dışa aktarım listelerine yeniden yaz ve paket köklerinin await import("@scope/package") ifadelerini statik adlı import’larla değiştir.
Gerçek üretim bundle’ı üzerindeki bir ablasyon her parçanın gerekli olduğunu gösteriyor.
| Yapılandırma | Modül değerlendirmesinden sonraki heap | Bundle’daki şema modülleri |
|---|---|---|
| Taban (düzeltme A’dan sonra) | 154.0 MB | 77 |
Yalnızca sideEffects: false | 143.6 MB | 77 |
+ Statik import’lar, barrel’lar yeniden export * (kontrol) | 98.6 MB | 29 |
| + Adlı yeniden dışa aktarım listeleri (tam düzeltme) | 82.1 MB | 0 |
Kontrol satırı ilginç olan: tamamen statik bir import grafiği bile 29 şema modülünü tutuyor, dolayısıyla adlı listeler isteğe bağlı değil.
Son durum
| Modül kapsamı heap’i (yerel sonda) | Üretim medyan belleği | Üretim sıfırlanmaları | |
|---|---|---|---|
| Öncesi | 218.6 MB | ~140 MB | ~300/gün |
| A’dan sonra | 154.0 MB | ~120 MB | ~10/gün |
| A+B’den sonra | 82.1 MB | ~70 MB | 0 |
Platformun bellek sınırının üzerinde boşta duran bir Durable Object artık onun yarısından biraz fazlasında boşta duruyor ve günlük ~300 sıfırlanma gitti.
Bu bir yapılandırma bayrağı değil
250’den fazla araç tanımını zod’dan ham JSON şemaya yeniden yazmak ve zod’un bizim için yaptığını yerine koyan 300 satırlık bir doğrulayıcı yazmak, kodlama ajanlarıyla bile birkaç gün aldı. 100’den fazla pakete sideEffects: false eklemek ve her export * ifadesini üretilmiş bir adlı listeye çevirmek gösterişsiz iş ve sonrasında bütün depoyu tip kontrolünden geçirip bozulanı düzeltiyorsun. Kolay alternatif bir yeniden deneme eklemek ve sıfırlanmalarla yaşamak, ve pek çok ekip bunu yapıyor. Durable Object’in boştayken sınıra yakınsa o haftaya değdiğini savunurum, çünkü sınır yerinden kımıldamıyor ve araç sayın yalnızca artıyor.
İlk günden bilmek isteyeceğim üç şey:
- Boştayken yüksek ve düz, taban demektir. Sunduğunu değil, yayınladığını profille.
- Tree shaking’in kuralları var.
sideEffects: false, adlı yeniden dışa aktarımlar, statik import’lar. Birini kaçır, bütün paket peşinden gelir. - Şemalar tip değil, koddur. Tüketici JSON şema istiyorsa JSON şema yaz.
Durable Object’inin belleğini düzeltmek için bunu bugün yap
Profiler, Node ve wrangler dışında bağımlılığı olmayan ~100 satır. Aşağıdaki tarifi wrangler ile dağıtım yapan herhangi bir deponun kökünde kodlama ajanına yapıştır, yazdırdığı tablonun ilk on satırını oku ve orada ne olduğunu gör. Sonra listeyi aşağı doğru işle: modelin zaten JSON şema olarak aldığı her şey için düz JSON şema, barrel’lar için "sideEffects": false ve adlı yeniden dışa aktarımlar, paket kökleri için statik import’lar.
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.
Bütün denetimin senin için yapılmasını istiyorsan, kodlama ajanın için bir skill:
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.
Bu skill’i Polylane’in kendisine de ekledik, böylece her Polylane kullanıcısı Durable Object belleğine dair bu derin incelemeyi hiçbir şey yapıştırmadan, kutudan çıktığı gibi alıyor.