Консоль
26 серпня 2026 р.

Як ми виправили помилки перевищення пам'яті в наших Cloudflare Durable Objects

Explore with AI

Кожен тред агента в Polylane працює всередині власного Cloudflare Durable Object, і всі вони є екземплярами одного класу. У серпні 2026 року Durable Objects наших тредів скидалися приблизно 300 разів на день через перевищення ліміту пам’яті. Ту саму історію ми чули від кількох команд, які запускають свої агентні процеси на Cloudflare Durable Objects.

Якщо ви натрапляли на exceededMemory у Durable Object, звична порада полягає в тому, щоб подивитися, що виділяють ваші запити: надто великий payload, історія чату, яка росте без обмежень, кеш, який ніколи не витісняє. Ніщо з цього не стосувалося нас. Ізолят перевищував ліміт ще до того, як обслужив перший запит, тож вага мала бути в коді, який ми випускали, а не в даних, які ми обслуговували.

Ця публікація про те, що насправді було в купі, як ми це знайшли на платформі, яка не дозволяє під’єднати профайлер, і про дві зміни, які зменшили пам’ять на рівні модулів із 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: інструменти знімків купи, які документує Cloudflare, працюють із локальною dev-сесією. У Kubernetes ви б увімкнули профілювання і під’єднувалися, доки не знайшли проблему. У workerd process.memoryUsage() є поліфілом unenv, який повертає нулі, а базові показники пам’яті та CPU невидимі для коду, що працює всередині. Що ви отримуєте, це GraphQL API аналітики Cloudflare: перцентилі пам’яті і кількість збоїв на простір імен, заднім числом.

Що нам сказав графік пам’яті

API аналітики Cloudflare повідомляє пам’ять для простору імен Durable Object як перцентилі по ізолятах, які його виконують, у п’ятнадцятихвилинних відрізках, разом із кількістю помилок перевищення пам’яті в кожному відрізку. Кожен графік у цій публікації походить із цього API, відфільтрований до простору імен тредів і порівняний за маркерами розгортань, щойно ми почали випускати виправлення.

Перцентилі пам'яті продакшн-ізолятів за тиждень до виправлення: медіана стабільно близько 140 МБ, над пунктирною лінією ліміту 128 МБ
Рисунок 1
Тиждень до виправлення
Медіанний ізолят (синій) стабільно на ~140 МБ, повністю над лінією ліміту 128 МБ, з 99-м перцентилем близько 190 МБ. Кожна точка над пунктирною лінією є ізолятом, який живе на позичений час.

Медіанний ізолят, синя лінія на рисунку 1, увесь тиждень тримався близько 140 МБ, а 99-й перцентиль близько 190 МБ. Обидві лінії над пунктирним лімітом 128 МБ, а це означає, що типовий ізолят у просторі імен уже перейшов точку, за якою Cloudflare має право його скинути, і його лишали в спокої лише до наступного виділення пам’яті, яке перекидало його через межу. Чого графік не показував, це будь-якого зв’язку з трафіком. Лінія була однаково рівною в тихі години і в години навантаження, і жодне з приблизно 300 скидань на день не супроводжувалося трасуванням стека, бо виняток ніколи не кидав наш код.

Ми дивилися туди, куди ви подивилися б спочатку. Ми читали розміри payload, перевіряли, як обрізається історія чату, шукали в кривій пам’яті патерн запитів і не знайшли нічого, що зсувало б лінію. Ця рівність виявилася важливою підказкою. Пам’ять ізолята є однією з двох речей: або це дані, тобто payload запитів, історія чату, виводи інструментів і все інше, виділене під час обслуговування трафіку, або це базовий рівень, тобто об’єкти, які сам код створює під час завантаження модуля і тримає живими протягом усього життя ізолята, як-от імпорти, функції та схеми. Дані ростуть і спадають із запитами, а базовий рівень існує до першого запиту і ніколи не зникає, тож графік, високий і рівний у простої, описує базовий рівень. Проблема мала бути в тому, що ми випускали, а не в тому, що ми обслуговували.

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

Чому пам’ять базового рівня так легко пропустити

Пам’ять базового рівня ховається у двох місцях, куди ви зазвичай не дивитеся.

Перше: бандлер. Ми вважаємо, що tree shaking видаляє код, який ми не використовуємо, і здебільшого так і є, але esbuild має правила щодо того, коли він може обрізати, і це не ті правила, які ви б припустили. Пакет без "sideEffects": false у своєму package.json не обрізається взагалі. 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 не є типом, це дерево замикань, яке будується в момент обчислення її модуля. Одна середня об’єктна схема zod 4, десяток полів з описами і уточненнями, коштує ~134 КБ купи. Голий 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 МБ в ізоляті, який має 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 МБ купи під час завантаження модулів, більше половини бюджету ізолята, витрачених на описи аргументів до того, як будь-який із них викликали. Кожен доданий інструмент коштував ще ~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 на день це перестає бути інцидентом і стає постійним податком, а через спільне розміщення його платив той об’єкт, який у цей момент опинився в ізоляті.

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

Як ми це знайшли: профайлер купи для продакшн-бандла

Ми не могли профілювати продакшн, тож побудували невеликий профайлер, який працює локально на тому самому бандлі, що й продакшн. Він вимірює вартість обчислення кожного модуля в купі 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 із шимом завантажувача, який розв’язує імпорти cloudflare:* у заглушки.

Повний рецепт, готовий для вставлення в агент кодування, наведено в кінці цієї публікації. Наведені вище одиничні вартості zod походять із мікробенчмарка, виконаного через ту саму обгортку.

Що показав перший профіль

ДжерелоЕксклюзивна купа
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
Перший профіль продакшн-бандла
Ексклюзивна купа на джерело, верхівка рейтингу.

Другий рядок є несподіваним. Це схеми, які потрапляють у бандл лише через 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 рядків, який відтворює властивості, від яких залежить цикл агента: невідомі ключі вилучаються, значення за замовчуванням заповнюються, об’єднання розв’язуються за дискримінатором, а повідомлення про помилки сформульовані так, щоб модель могла виправити власний виклик інструмента під час повтору.

Купа після обчислення модулів локально зменшилася з 218,6 до 154,0 МБ, а packages/tools з 78 МБ до 0,7 МБ. У продакшні скидання впали з 40-110 на годину до 0-6 на годину на маркері розгортання, а пам’ять медіанного ізолята з ~140 МБ до ~120 МБ.

Помилки перевищення пам'яті на відрізок, які обвалюються на розгортанні виправлення A
Рисунок 2
Скидання обвалюються на розгортанні виправлення A
Помилки на відрізок для простору імен (PDT). 321 у показаному вікні, майже всі до розгортання ввечері 24 серпня. Залишки після нього є хвостом 0-6 на годину, який прибрало виправлення B.

Виправлення B: barrel, які справді піддаються tree shaking

Решта ~66 МБ була схемами, які ізолят ніколи не використовував. Це пояснюють три поведінки бандлера:

  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") коренів пакетів на статичні іменовані імпорти.

Абляція на реальному продакшн-бандлі показує, що кожна частина необхідна.

КонфігураціяКупа після обчислення модулівМодулі схем у бандлі
Базова (після виправлення A)154,0 МБ77
Лише sideEffects: false143,6 МБ77
+ Статичні імпорти, barrel повернуто до export * (контроль)98,6 МБ29
+ Іменовані списки реекспортів (повне виправлення)82,1 МБ0
Таблиця 3
Абляція виправлення B
Купа після обчислення модулів і модулі схем, що вижили, з додаванням по одній частині за раз.

Контрольний рядок є найцікавішим: навіть повністю статичний граф імпортів зберігає 29 модулів схем, тож іменовані списки не є необов’язковими.

Кінцевий стан

Перцентилі пам'яті продакшн-ізолятів через обидва розгортання, які сходинками опускаються під ліміт 128 МБ
Рисунок 3
Пам'ять ізолятів через обидва виправлення
Виправлення A виходить ввечері 24 серпня, виправлення B 26 серпня (PDT). Медіана сходинками опускається з ~140 МБ до 50-90 МБ, а 99-й перцентиль уперше опускається під лінію 128 МБ.
Купа на рівні модулів (локальний зонд)Медіанна пам’ять у продакшніСкидання в продакшні
До218,6 МБ~140 МБ~300/день
Після A154,0 МБ~120 МБ~10/день
Після A+B82,1 МБ~70 МБ0
Таблиця 4
До і після
Купа на рівні модулів із локального зонда відносно медіанної пам'яті ізолятів у продакшні та щоденних скидань.

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 Objects із коробки, без жодного вставлення.

У 2026 році ніхто не має бути на чергуванні. Polylane стежить за вашою інфраструктурою, розслідує і виправляє те, що ламається.

Приєднатися до списку очікування