Дашборд
26 августа 2026 г.

Как мы исправили ошибки превышения памяти в наших Cloudflare Durable Objects

Explore with AI

Каждый тред агента в Polylane работает внутри собственного Cloudflare Durable Object, и все они являются экземплярами одного класса. В августе 2026 года Durable Objects наших тредов сбрасывались примерно 300 раз в день за превышение лимита памяти. Ту же историю мы слышали от нескольких команд, которые запускают свои агентские процессы на Cloudflare Durable Objects.

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

Эта статья о том, что на самом деле лежало в heap, как мы нашли это на платформе, которая не позволяет подключить профайлер, и о двух изменениях, которые снизили память уровня модулей с 218 MB до 82 MB, а сбросы до нуля.

Cloudflare Durable Objects за одну минуту

Если вы ими не пользовались: Durable Object: это небольшой однопоточный сервер с состоянием, уникальность которого для заданного ID гарантирует Cloudflare. Вызовите idFromName() с этим ID из 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

Подключить профайлер памяти к production Durable Object невозможно: инструменты снимков heap, которые документирует Cloudflare, работают с локальной dev-сессией. В Kubernetes вы бы включили профилирование и подключались, пока не нашли проблему. В workerd process.memoryUsage(): это полифил unenv, возвращающий нули, а базовые показатели памяти и CPU не видны коду, который работает внутри. Что у вас есть: GraphQL analytics API от Cloudflare: перцентили памяти и счётчики падений по пространству имён, постфактум.

Что нам сказал график памяти

Analytics API Cloudflare сообщает память для пространства имён Durable Object как перцентили по изолятам, которые его запускают, в пятнадцатиминутных корзинах, вместе с числом ошибок превышения памяти в каждой корзине. Каждый график в этой статье получен из этого API, отфильтрован по пространству имён тредов и сравнён по маркерам деплоев, как только мы начали выпускать исправления.

Перцентили памяти production-изолятов за неделю до исправления: медиана стабильно около 140 MB, выше пунктирной линии лимита 128 MB
Рисунок 1
Неделя до исправления
Медианный изолят (синий) стабильно на ~140 MB, полностью выше линии лимита 128 MB, а 99-й перцентиль около 190 MB. Каждая точка выше пунктирной линии: это изолят, живущий взаймы.

Медианный изолят, синяя линия на рисунке 1, всю неделю держался около 140 MB, а 99-й перцентиль около 190 MB. Обе линии выше пунктирного лимита 128 MB, а значит, типичный изолят в пространстве имён уже прошёл точку, за которой 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 MB.

// 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 KB heap. Голый z.string() стоит ~12 KB. Эквивалентный простой объект 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 MB в изоляте, у которого есть 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 MB heap при загрузке модулей, больше половины бюджета изолята, потраченного на описания аргументов до того, как хоть один из них был вызван. Каждый добавленный инструмент стоил ещё ~134 KB, независимо от того, запускался ли он когда-либо.

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.

Как мы это нашли: heap-профайлер для production-бандла

Мы не могли профилировать production, поэтому построили небольшой профайлер, который запускается локально на точно том бандле, который работает в production. Он измеряет стоимость вычисления каждого модуля в heap V8 и печатает ранжированную таблицу. Мы запускали его в цикле: профилировать, убрать верхушку рейтинга, профилировать снова. Финальной проверкой каждого исправления был production-деплой, сравнённый с метриками памяти 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 до и после себя, а стек инициализации отделял собственную стоимость модуля (эксклюзивную) от стоимости его зависимостей (инклюзивной). Результат: набор данных в форме флеймграфа.
  • Контролируемые запуски. Инструментированный бандл запускается под обычным Node с шимом загрузчика, который разрешает импорты cloudflare:* в заглушки.

Полный рецепт, готовый для вставки в кодинг-агент, находится в конце статьи. Единичные стоимости zod, приведённые выше, получены из микробенчмарка, прогнанного через тот же стенд.

Что показал первый профиль

ИсточникЭксклюзивный heap
packages/tools (250+ определений агентских инструментов, zod)78.0 MB
Barrel’ы пакетов, реэкспортирующие модули схем zod (строки ниже)~66 MB
   durable-workspaces (состояние рабочего пространства и расписания)15.6 MB
   thread-core (слой данных тредов)10.1 MB
   durable-automations (определения автоматизаций)9.6 MB
   durable-threads (список тредов и живые обновления)7.3 MB
   durable-automation (один прогон автоматизации)6.8 MB
   db (клиент D1 и модели)6.3 MB
   durable-skills (определения навыков)5.7 MB
   durable-autofixes (ветки и merge автофиксов)4.9 MB
   12 пакетов поменьше~9 MB
Таблица 1
Первый профиль production-бандла
Эксклюзивный heap по источникам, верхушка рейтинга.

Вторая строка: самая удивительная. Это схемы, которые попадают в бандл только через export * from "./zod" в barrel’ах пакетов. Наш код никогда их не использовал, tree shaker не мог их удалить, а стоили они треть лимита памяти, и всё это в модулях, которые никто никогда не вызывал.

Исправление A: определения инструментов как данные, а не код

Каждое определение инструмента объявляло схему ввода в zod и конвертировало её в JSON schema во время выполнения, потому что JSON schema: это то, что в любом случае отправляется модели. Мы строили ~134 KB замыканий на инструмент, чтобы получить несколько сотен байт данных, поэтому мы записали данные напрямую.

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 MB, а packages/tools с 78 MB до 0.7 MB. В production сбросы упали с 40-110 в час до 0-6 в час на маркере деплоя, а память медианного изолята с ~140 MB до ~120 MB.

Ошибки превышения памяти по корзинам обваливаются на деплое исправления A
Рисунок 2
Сбросы обваливаются на деплое исправления A
Ошибки по корзинам для пространства имён (PDT). 321 в показанном окне, почти все до деплоя вечером 24 августа. Отставшие после него: хвост в 0-6 в час, который убрало исправление B.

Исправление B: barrel’ы, которые действительно поддаются tree shaking

Оставшиеся ~66 MB были схемами, которые изолят никогда не использовал. Это объясняют три поведения бандлера:

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

Абляция на реальном production-бандле показывает, что каждая часть необходима.

КонфигурацияHeap после вычисления модулейМодулей схем в бандле
Базовый уровень (после исправления A)154.0 MB77
Только sideEffects: false143.6 MB77
+ Статические импорты, barrel’ы снова на export * (контроль)98.6 MB29
+ Именованные списки реэкспортов (полное исправление)82.1 MB0
Таблица 3
Абляция исправления B
Heap после вычисления модулей и выжившие модули схем, с добавлением по одной части за раз.

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

Конечное состояние

Перцентили памяти production-изолятов через оба деплоя, ступенями опускающиеся ниже лимита 128 MB
Рисунок 3
Память изолятов через оба исправления
Исправление A выходит вечером 24 августа, исправление B 26 августа (PDT). Медиана ступенями опускается с ~140 MB до 50-90 MB, а 99-й перцентиль впервые опускается ниже линии 128 MB.
Heap уровня модулей (локальный пробник)Медианная память в productionСбросы в production
До218.6 MB~140 MB~300/день
После A154.0 MB~120 MB~10/день
После A+B82.1 MB~70 MB0
Таблица 4
До и после
Heap уровня модулей по локальному пробнику относительно медианной памяти изолятов в production и ежедневных сбросов.

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 году никто не должен дежурить on-call. Polylane наблюдает за вашей инфраструктурой, расследует и исправляет то, что ломается.

Встать в список ожидания