Раздел 25 · Effect-TS

Инструменты и отладка: LSP, диагностики в CI, layerinfo, Cause, файбер-дамп

middle-senior~80 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Инструменты и отладка: LSP, диагностики в CI, layerinfo, Cause, файбер-дамп

Сцена · ошибка, из-за которой люди бросают Effect

Забыл подать слой, и компилятор говорит вот это:

Type 'Effect<void, never, Storage>' is not assignable to type 'Effect<void, never, never>'.
  Type 'Storage' is not assignable to type 'never'.

На реальном проекте, где в канале требований пять сервисов, а в ошибке ещё и дженерики Layer, такое сообщение растягивается на сорок строк, и в нём тонет ровно одно полезное слово.

С правильно настроенными инструментами то же самое выглядит так:

missing.ts(21,14): error effect(missingEffectContext):
  This Effect requires a service that is missing from the expected Effect context: `Storage`.

Одна строка, имя сервиса, номер строки. Разница между “три часа злости” и “тридцать секунд”.

Этот урок про то, как получить второй вариант: настроить LSP, вынести диагностики в CI, научиться читать Cause и находить утёкшие файберы. Тем и закрывается раздел.

Карта урока · что заберёшь домой

Восемь разделов:

  1. Раздел 1, как читать ошибки типов Effect: три канала и порядок действий.
  2. Раздел 2, @effect/language-service, что он ловит.
  3. Раздел 3, те же диагностики в CI.
  4. Раздел 4, layerinfo, разбор графа слоёв.
  5. Раздел 5, Cause на отладке: pretty, tapCause, уровни логов.
  6. Раздел 6, метрики рантайма, кто из файберов ещё живой.
  7. Раздел 7, профилирование: timed, спаны, DevTools.
  8. Раздел 8, чек-лист настройки проекта на Effect.

Раздел 1 · Как читать ошибки типов

Почти все длинные ошибки в Effect это несовпадение по одному из трёх каналов. Порядок разбора всегда один.

Шаг 1: определи канал. Смотри на позицию несовпадения в Effect<A, E, R>:

  • расходится первый параметр, значит дело в типе успеха. Обычно забытый yield* или лишняя обёртка Effect<Effect<...>>;
  • расходится второй, значит ошибка. Обычно необработанный вариант в catchTags или ошибка, которую не ждали в сигнатуре;
  • расходится третий, значит требования. Это девять случаев из десяти: не подан слой.

Шаг 2: ищи is not assignable to type 'never'. Эта фраза почти всегда значит “ты обещал, что требований (или ошибок) нет, а они есть”. Имя слева от is not assignable это и есть недостающий сервис или необработанная ошибка.

Шаг 3: читай снизу вверх. TypeScript печатает от общего к частному, а полезное лежит в самой глубокой строке. Первая строка почти всегда бесполезна.

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

Дальше можно этого всего не делать, потому что за тебя это делает плагин.

Раздел 2 · LSP-плагин

@effect/language-service подключается в tsconfig.json и работает прямо в редакторе:

{
  "compilerOptions": {
    "plugins": [{ "name": "@effect/language-service" }]
  }
}

В нашем проекте он уже стоит, посмотри tsconfig.json. В VS Code дополнительно нужно выбрать TypeScript из рабочей директории (TypeScript: Select TypeScript Version плюс Use Workspace Version), иначе редактор возьмёт свой встроенный компилятор и плагин не подхватится.

Что он даёт, кроме внятных сообщений. Возьмём файл с четырьмя типичными ошибками:

export const floating = Effect.gen(function* () {
  Effect.log('это никогда не выполнится');  // забыли yield*
  yield* Effect.void;
});

export const uselessCatch = Effect.succeed(1).pipe(Effect.catch(() => Effect.succeed(0)));

export const chained = Effect.void.pipe(
  Effect.provide(ConfigLive),
  Effect.provide(Layer.empty),
);

Реальный вывод:

bad.ts(9,3):  error   effect(floatingEffect): This Effect value is neither yielded nor used in an assignment.
bad.ts(20,52): message effect(catchUnfailableEffect): The previous Effect does not fail, so this error-handling branch will never run.
bad.ts(23,41): warning effect(multipleEffectProvide): This expression chains multiple `Effect.provide` calls.

Первая находка это самая дорогая ошибка новичка: эффект создан, но не запущен. Ни компилятор, ни тесты её не видят, потому что типы сходятся, а поведение молча теряется. Плагин ловит её за миллисекунды.

Категории диагностик, которые стоит знать:

Что ловитПримеры
КорректностьfloatingEffect, missingEffectContext, missingEffectError, missingStarInYieldEffectGen, classSelfMismatch
АнтипаттерныcatchUnfailableEffect, multipleEffectProvide, leakingRequirements, globalErrorInEffectFailure, layerMergeAllWithDependencies
УстаревшееoutdatedApi, duplicatePackage, effectGenUsesAdapter

Отдельно отмечу duplicatePackage: две версии effect в дереве зависимостей дают спецэффекты вида “сервис не находится, хотя он подан”, потому что теги из разных копий пакета это разные теги. Диагностика находит это сразу, а без неё уходит вечер.

Раньше эта проблема была куда острее: экосистема состояла из десятка отдельно версионируемых пакетов (@effect/platform, @effect/cli, @effect/schema и так далее), и разъехаться они могли на ровном месте, при обычном install. Сейчас почти всё живёт в самом effect, отдельными пакетами остались только привязанные к рантайму (@effect/platform-node, @effect/sql-pg и подобные), и версия у всей экосистемы одна. Дубликат теперь означает буквально “в дереве две копии effect”, и лечится это одной строкой в resolutions.

Кроме диагностик плагин умеет рефакторинги (превратить pipe в Effect.gen и обратно, обернуть в Effect.gen), подсказки по типам в hover и автодополнения. Это приятно, но диагностики важнее.

Раздел 3 · Диагностики в CI

Плагин TypeScript живёт только в редакторе: tsc о нём ничего не знает. Поэтому у пакета есть CLI:

npx effect-language-service diagnostics --project tsconfig.json --format text
bad.ts(9,3): error effect(floatingEffect): This Effect value is neither yielded nor used in an assignment.

Checked 1 files out of 1 files.
1 errors, 1 warnings and 1 messages.

Форматы вывода: text, pretty (с цветом и контекстом), json (для своих обработок) и github-actions, который печатает workflow-команды, и тогда GitHub сам подсвечивает проблемные строки прямо в диффе PR.

Флаг --strict делает предупреждения ошибками, то есть влияет на код возврата. Дальше это обычный шаг пайплайна:

- name: Effect diagnostics
  run: npx effect-language-service diagnostics --project tsconfig.json --format github-actions --strict

Есть и второй путь: effect-language-service patch патчит локальный typescript, и тогда диагностики появляются в обычном tsc, без отдельного шага. Проверить состояние можно командой check:

INFO: node_modules/typescript/lib/typescript.js patched with version 0.86.2
INFO: node_modules/typescript/lib/_tsc.js patched with version 0.86.2

Патч удобен (одна команда typecheck проверяет всё), но у него есть цена: он меняет файлы в node_modules, а значит его нужно накатывать после каждого pnpm install, обычно в postinstall. Отдельный шаг в CI честнее и не зависит от порядка установки. Обратная операция это unpatch.

Раздел 4 · layerinfo, разбор графа слоёв

Самая мутная часть любого проекта на Effect это сборка MainLive. Кто кому что предоставляет, где provide, где provideMerge, почему сервис не находится. Для этого есть отдельная команда:

npx effect-language-service layerinfo --file src/main.ts --name MainLive
MainLive
  ./missing.ts:23:14
  Layer<Storage | Logger, never, never>

Provides (2):
  - Logger
  - Storage

Suggested Composition:
  export const MainLive = LoggerLive.pipe(
    Layer.provideMerge(StorageLive)
  )

Три вещи разом: что слой в итоге предоставляет, что требует, и как его правильно собрать. Последнее особенно полезно: описываешь все слои через Layer.mergeAll(...), запускаешь команду и получаешь корректную композицию с правильными provideMerge.

Практический сценарий: сервис не находится в рантайме, хотя визуально всё подано. Смотришь layerinfo и видишь, что слой не только предоставляет, но и требует что-то, чего в сборке нет. Это тот же граф, что мы разбирали в 04 · Services и Layer, только показанный инструментом, а не выведенный головой.

Раздел 5 · Cause на отладке

В рантайме главный источник правды это Cause из 03 · Tagged-ошибки. Он умеет печататься по-человечески:

import { Cause, Effect } from 'effect';

const exit = yield* program.pipe(Effect.exit);

if (exit._tag === 'Failure') {
  console.log(Cause.pretty(exit.cause));
}
Error: внутренняя поломка
    at <anonymous> (src/probe.ts:5:29)
    at failing (src/probe.ts:10:43)

Cause.pretty разворачивает всё дерево: типизированную ошибку, дефект, прерывание, параллельные ветки. И, что ценно, стек включает логические кадры Effect, а не только физические кадры JavaScript. Отсюда практическое следствие: спаны из 18 · Observability улучшают не только трейсинг, но и стектрейсы, потому что имя спана попадает в кадр.

Три приёма, которые закрывают большинство сессий отладки.

Подсмотреть, не глотая. Effect.tapCause((cause) => Effect.logError('упало', cause)) пишет причину в лог и пропускает ошибку дальше. Ставится в подозрительном месте, снимается после.

Поднять уровень логов точечно. Минимальный уровень это обычная ссылка из References, поэтому меняется он тем же способом, что любой сервис: Effect.provideService(References.MinimumLogLevel, LogLevel.Trace) на конкретном куске программы, а не глобально. Отладочные логи включаются в одном сервисе, остальное приложение не шумит.

Кстати, вот вам и общий принцип v4: то, что раньше пряталось за специальными комбинаторами вида withXxx, стало обычными ссылками в контексте. Настроек стало не меньше, но способ их менять теперь один на все случаи.

Различать ошибку и дефект. Если в Cause лежит Die, это не ожидаемая ситуация, а баг: где-то бросили исключение мимо канала E. Чинить надо не обработчик, а место броска.

Раздел 6 · Кто из файберов ещё живой

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

Первый инструмент это метрики рантайма. Effect умеет считать файберы сам, надо только попросить:

import { Layer, Metric } from 'effect';

export const MainLive = Layer.mergeAll(
  // ...остальные слои
  Metric.enableRuntimeMetricsLayer,
);

После этого в общем реестре метрик появляются счётчики стартов и завершений файберов, и разница между ними это число живых. Смотреть на них можно там же, где на остальные метрики Pulse, через Metric.snapshot или экспорт в Prometheus (18 · Observability). Растущая без потолка разница это и есть утечка.

Если нужен точечный замер, а не постоянный сбор, тот же переключатель есть в форме комбинатора: Metric.enableRuntimeMetrics(effect) включает подсчёт только на конкретном куске программы, disableRuntimeMetrics наоборот выключает.

Метрика говорит “течёт”, но не говорит “где”. Дальше помогает второй приём: аннотации на файберах. Если каждый форк в подозрительном месте помечен спаном или лог-аннотацией, в логах видно, какая именно ветка не завершается.

Отдельного наблюдателя за деревом файберов, который раньше жил в модуле Supervisor и умел выдавать список живых потомков, в v4 нет. Идея заменилась на две более честные: числа берутся из метрик, а конкретика из трассировки. Это дешевле в рантайме и лучше ложится на прод, где смотреть надо не в консоль отладчика, а в дашборд.

Что до причины утечки, она почти всегда одна и та же: файбер форкнули без привязки к Scope. Лечится заменой Effect.forkDetach (файбер живёт сам по себе) на Effect.forkScoped (файбер умрёт вместе со скоупом) из 05 · Resources. Кстати, forkDetach это то, что раньше называлось forkDaemon, а обычный Effect.fork теперь Effect.forkChild: в v4 у всех трёх в имени написано, к чему привязана жизнь файбера.

Раздел 7 · Профилирование

Что и где занимает время отвечают спаны из 18 · Observability. Это первый инструмент, а не последний: дерево спанов сразу показывает, какой шаг съедает время, и дальше вопрос сужается.

Точечный замер делается Effect.timed:

const [duration, value] = yield* work.pipe(Effect.timed);
// Duration(11ms 720917ns), 42

Возвращается пара из Duration и результата. Для повторяющегося замера заведи Metric.timer(...) и обновляй его результатом timed, чтобы значения копились в гистограмме, а не терялись в логе.

DevTools. Модуль DevTools из effect/unstable/devtools подключает рантайм к внешнему инспектору: живое дерево спанов и метрик по WebSocket. Ставится одной строкой в сборку слоёв и снимается так же. Раньше он жил в отдельном пакете @effect/experimental; теперь это unstable-модуль ядра, и отдельно ставить нечего.

Отдельно про то, чего делать не надо. console.time внутри Effect меряет не то: между началом и концом файбер мог быть приостановлен, и в интервал попадёт чужое ожидание. Effect.timed меряет по тем же часам, что и весь рантайм, и потому корректен, включая тесты на TestClock.

Раздел 8 · Чек-лист настройки проекта

Если заводишь новый проект на Effect, вот минимальный набор, который окупается в первую неделю:

  1. @effect/language-service в tsconfig.json, и в редакторе выбран TypeScript из рабочей директории.
  2. Шаг диагностик в CI с --strict (или patch в postinstall, если предпочитаешь один typecheck).
  3. strict: true и noUncheckedIndexedAccess: true в tsconfig.json. Без них половина гарантий Effect не работает: Option и Result теряют смысл, если undefined пролезает мимо типов.
  4. exactOptionalPropertyTypes полезен, но включай его сразу, а не на проекте с историей.
  5. Одна версия effect в дереве зависимостей. Проверяется pnpm why effect, а нарушение ловит диагностика duplicatePackage. Помни, что версия у всей экосистемы общая: effect и, скажем, @effect/platform-node должны стоять одной и той же.
  6. Логгер в проде Logger.consoleJson, в разработке Logger.consolePretty (урок 18).
  7. Обработчик верхнего уровня, который печатает Cause.pretty и отдаёт разные коды выхода на ошибку, дефект и прерывание.

Pulse · вклад этого урока

Инфраструктурный урок, поэтому и вклад инфраструктурный.

В tsconfig.json появляется плагин, в CI шаг диагностик с --strict, в package.json скрипт pnpm diagnostics. Первый же прогон по всей кодовой базе Pulse обычно находит два-три floatingEffect (забытые yield* в местах, где ничего не сломалось видимым образом) и один multipleEffectProvide в тестах.

MainLive приводится к композиции, которую предложил layerinfo. Заодно выясняется, что один слой подавался дважды, из-за чего сервис создавался в двух экземплярах, а MemoMap из 04 · Services и Layer их не схлопывал, потому что слои были разными значениями.

Появляется точка входа с человеческим обработчиком отказов:

const main = program.pipe(
  Effect.tapCause((cause) => Effect.logError(Cause.pretty(cause))),
  Effect.catchCause((cause) =>
    Effect.sync(() => {
      process.exitCode = Cause.hasInterruptsOnly(cause) ? 0 : Cause.hasDies(cause) ? 2 : 1;
    }),
  ),
);

Прерывание по Ctrl+C это не ошибка, поэтому код 0. Дефект это баг, поэтому отдельный код 2, по которому оркестратор поймёт, что перезапуск не поможет.

Имена предикатов тут во множественном числе не случайно: Cause в v4 плоский, внутри лежит массив причин, и вопрос звучит как “есть ли среди них дефекты”. Ловцы называются по тому же принципу: Effect.catch для ожидаемых ошибок, Effect.catchCause для всей причины целиком, Effect.catchDefect для дефектов.

И подкоманда pulse fibers, печатающая снимок метрик рантайма: сколько файберов запущено, сколько завершено, сколько живо прямо сейчас. Ей же ловится регрессия, если кто-то добавит форк без привязки к scope.

Финал · чек-лист

Чек-листготово

ДЗ

Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-25.

Финал раздела

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

Ядро. Effect<A, E, R> и три канала, Schema на границе, tagged-ошибки, Layer как граф зависимостей, Scope и finalizer-ы, файберы и координация, транзакции, Stream, батчинг, рантайм и Schedule.

Прод-обвязка. Телеметрия (логи, спаны, метрики, экспорт), HttpClient и HttpApi с middleware и OpenAPI, конфигурация и секреты, файлы и процессы через платформенные сервисы ядра.

Данные. Schema вглубь: рекурсия, накопление ошибок, асинхронная валидация, эволюция форматов, property-тесты из схемы, схема как контракт для языковой модели. Приёмники, пачки, DLQ.

Инструменты. Стандартная библиотека и, наконец, настроенный редактор с CI.

Сквозной проект Pulse прошёл весь путь: от скрипта, который дёргает адрес, до сервиса с одним графом зависимостей, двумя точками входа, телеметрией, авторизацией, конфигурацией, конвейером записи и человеческой диагностикой.

Главная мысль, которую стоит унести, та же, что была в конце 13 · Testing и code style. Effect это инструмент, а не идеология. Каждый примитив, который ты закрыл, решает одну конкретную задачу. Когда следующий проект её поставит, ты вспомнишь нужное имя, прочитаешь сигнатуру за минуту и встроишь за день. А когда не поставит, спокойно напишешь обычный async и не будешь чувствовать себя предателем.

Дальше

  • 26 · Архитектура бэкенда, капстоун раздела: разбор боевого платёжного шлюза на Effect, где сегодняшние примитивы складываются в целый сервис с очередью на Postgres, outbox и воркером.
  • 14 · DDD-типы и следующие три урока, если ты пропустил блок про functional DDD: Decider, Event Store на @effect/sql-pg, CQRS-проекции. Они самодостаточны и хорошо ложатся поверх всего, что было.
  • 02-cs · io-monad, воркшоп, где ты собираешь IO руками. После этой серии он читается совсем иначе.
  • 29-testing, раздел про тестирование целиком. Половина приёмов оттуда на Effect делается в две строки, и это хорошая проверка того, насколько идеи усвоились.

В репозитории курса лежат examples/our-pulse/, examples/our-db/effect/ и examples/ddd-hotel/, три разных по духу проекта на Effect. Открывай любой и читай как код коллеги: теперь ты знаешь там каждую конструкцию.