Раздел 25 · Effect-TS

Resources: Scope, finalizers, acquireRelease, forkScoped, graceful shutdown

middle-senior~110 мин

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

Resources: Scope, finalizers, acquireRelease, forkScoped, graceful shutdown

Сцена · комната с ключом

Зашёл, взял ключ (acquire), сделал дело (use), сдал ключ (release). Если в коридоре загорелась пожарная тревога, ключ всё равно нужно сдать, иначе следующий не зайдёт. В JavaScript этот сценарий обычно пишут через try/finally. У try/finally две беды: блок finally не знает, чем закончилось дело (упало, прервалось, прошло), и поверх асинхронного и параллельного кода у него быстро рассыпается порядок.

В Effect область жизни ресурса называется Scope, а вызовы освобождения называются finalizer-ами. К концу урока ты будешь свободно собирать сценарии вида “открой соединение, открой файл, поработай, закрой файл, закрой соединение”, и Effect гарантирует, что закрытие случится в правильном порядке независимо от исхода.

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

Семейство finalizer-ов: Effect.ensuring, Effect.onError, Effect.onExit, Effect.onInterrupt, и почему оно лучше, чем голый try/finally. Exit.match для ветвления на успех и провал. Scope как первоклассное значение, ручное управление через Scope.make, Scope.addFinalizer, Scope.close, и почему ручное управление протекает. Effect.addFinalizer плюс Effect.scoped, рабочий способ собирать scope. Внутренности Effect.scoped, написанные руками поверх Scope.provide. Effect.acquireRelease против Effect.acquireUseRelease, когда какой брать. Effect.forkScoped, Effect.scope, Effect.forkIn, привязка файбера к scope, а не к родительскому эффекту. Pool как scoped-ресурс, который переиспользуется вместо повторного открытия. Прямой мост на 04 · Services и Layer и 06 · Файберы и concurrency.

Что будет в уроке

  1. Раздел 1, finalizers 101. ensuring, onError, onExit, onInterrupt, Exit.match.
  2. Раздел 2, Scope первого класса. Scope.make, ручное закрытие, утечка при ошибке.
  3. Раздел 3, Effect.addFinalizer плюс Effect.scoped. Как Effect автоматизирует закрытие.
  4. Раздел 4, Effect.acquireRelease и Effect.acquireUseRelease. Декларативное приобретение ресурса.
  5. Раздел 5, fork в scope. forkScoped, Effect.scope, forkIn.
  6. Раздел 6, Pool, когда открывать ресурс дорого.
  7. Раздел 7, Pulse, SIGTERM и graceful shutdown.

Раздел 1 · Finalizers 101

Сцена · что не так с finally

В обычном TypeScript ресурсы освобождают через try/finally:

let handle: FileHandle | null = null;
try {
  handle = await fs.open('events.jsonl', 'a');
  await handle.write(line);
} catch (err) {
  console.error('write failed', err);
} finally {
  if (handle) await handle.close();
}

Работает, но смотри, что мы потеряли. Внутри finally нет доступа к исходу: успех, ошибка, прерывание (AbortSignal), всё одна и та же ветка. Если хочется логировать только провалы, придётся дублировать код в catch. И поверх параллельного кода (Promise.race, AbortController) finally ведёт себя непредсказуемо: контракт “выполниться при отмене” в Promise-ах не записан, отмена в JavaScript кооперативная и не гарантированная.

Effect разводит эти случаи на отдельные комбинаторы:

КомбинаторКогда срабатываетЧто получает на вход
Effect.ensuringвсегданичего, просто Effect
Effect.onErrorтолько при Cause-провалеcause: Cause<E>
Effect.onExitвсегдаexit: Exit<A, E>
Effect.onInterruptтолько при прерыванииinterruptors: HashSet<FiberId>

Имя комбинатора уже описывает его контракт. Глядя в код, ты сразу видишь, отрабатывает ли cleanup при успехе тоже, или только в одной ветке. Это та же ясность, что и tagged-ошибки против ловли всего в один catch (e) из 03 · Tagged-ошибки.

Шаг 1 · Effect.ensuring

Аналог finally. Принимает один Effect, выполняет его всегда, не имеет доступа к исходу.

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  yield* Effect.sleep('1 second');
  // полезная работа
}).pipe(Effect.ensuring(Effect.log('done')));

Effect.runPromise(program);
// done

Падение через Effect.dieMessage:

const program = Effect.gen(function* () {
  yield* Effect.dieMessage('Boom');
}).pipe(Effect.ensuring(Effect.log('done')));

Effect.runPromise(program);
// done
// (program rejects with FiberFailure: Boom)

Effect.ensuring отработал на падении тоже. Это и есть его контракт: запустить cleanup в любом случае.

Шаг 2 · Effect.onError, доступ к причине

Если cleanup нужен только на провале, бери Effect.onError. Колбэк получает Cause с типом ошибки.

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  yield* Effect.fail('boom');
}).pipe(Effect.onError((cause) => Effect.log(`cleanup: ${cause}`)));

Effect.runPromise(program);
// cleanup: Error: boom
// (program rejects)

На успехе колбэк не вызывается. Если эффект упал через Effect.fail('boom'), у cause тип Cause<string>. Через Cause.failures(cause) или pattern-matching из 03 · Tagged-ошибки ты вытащишь конкретные значения и развилкой решишь, что делать в каждой ветке.

Шаг 3 · Effect.onExit и Exit.match

Когда нужен полный исход (успех или провал, со значением или с причиной), бери Effect.onExit. Колбэк получает Exit.

import { Effect, Exit } from 'effect';

const program = Effect.gen(function* () {
  yield* Effect.fail('boom');
}).pipe(
  Effect.onExit(
    Exit.match({
      onSuccess: (value) => Effect.log(`ok: ${value}`),
      onFailure: (cause) => Effect.log(`error: ${cause}`),
    }),
  ),
);

Effect.runPromise(program);
// error: Error: boom

Exit.match это data-last комбинатор, идеально ложится в pipe. У него ровно два колбэка: onSuccess получает значение, onFailure получает Cause. На успехе ты пишешь “что считать с результатом”, на провале “что записать в журнал”.

Логика выбора между ensuring и onExit чисто читательская. Если cleanup не зависит от исхода и тебе не нужен Exit, бери ensuring. Если тебе важен Exit, бери onExit. Читателю кода видно намерение по имени комбинатора, не нужно гадать.

Шаг 4 · Effect.onInterrupt

Cleanup только на прерывание:

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  yield* Effect.sleep('5 seconds');
}).pipe(Effect.onInterrupt((interruptors) => Effect.log(`interrupted by ${[...interruptors]}`)));

Под капотом Effect.onInterrupt написан поверх Effect.onExit. Логика такая: на успехе вернуть Effect.void, на провале посмотреть на Cause, и если внутри есть Cause.interrupt, выполнить cleanup, иначе тоже Effect.void. Все четыре комбинатора это разные фильтры поверх одного и того же механизма.

Где это всплывёт у Pulse: когда главная программа получает SIGTERM, активные файберы (один на пробинг URL) прерываются. Через onInterrupt каждый файбер успеет дописать “interrupted” в журнал, прежде чем умрёт. Без этого журнал будет молчать, и ты не отличишь “пробинг закончился ничем” от “пробинг был отменён”.

Что взять с собой

  • Effect.ensuring(eff) всегда, без Exit.
  • Effect.onError((cause) => eff) только на ошибке, с Cause<E>.
  • Effect.onExit((exit) => eff) всегда, с Exit<A, E>. Ветви через Exit.match.
  • Effect.onInterrupt((interruptors) => eff) только на прерывании.
  • Имя комбинатора, это контракт. Читатель видит намерение, не лезет в реализацию.

Раздел 2 · Scope как первоклассное понятие

Сцена · ведро с finalizer-ами

Представь HTTP-обработчик, который на одном запросе открывает три ресурса: коннекшен к базе, файл лога, поток к S3. Каждый из них хочет finalizer на закрытии. Обработчик мог бы сложить три Effect.ensuring стопкой, но порядок развалится: например, лог пишется поверх коннекшена к базе, и закрывать его нужно до того, как закроется коннекшен. Стопка ensuring не даёт прямого контроля над порядком, и через child-файберов он окончательно теряется.

Scope, это явное ведро для finalizer-ов. Его создают, в него регистрируют finalizer-ы, потом ведро закрывают, и Effect выполняет все зарегистрированные finalizer-ы в обратном порядке. Под капотом это массив, ничего магического.

У scope-finalizer-ов два важных свойства:

  1. Получают Exit: можно различить успех, провал, прерывание, как в Effect.onExit.
  2. Идут в обратном порядке (LIFO): позже зарегистрированный finalizer выполняется первым, чтобы соблюсти зависимости (если ты открыл соединение, потом поверх него файл, файл закроется первым).

Шаг 1 · Создать scope руками

import { Effect, Exit, Scope } from 'effect';

const program = Effect.gen(function* () {
  const scope = yield* Scope.make();

  yield* Scope.addFinalizer(scope, Effect.log('closing network connection'));
  yield* Scope.addFinalizer(scope, Effect.log('closing remote file'));

  yield* Effect.log('work done');

  yield* Scope.close(scope, Exit.void);
});

Effect.runPromise(program);
// work done
// closing remote file
// closing network connection

Scope.make() возвращает CloseableScope, scope, который умеет закрываться. Scope.addFinalizer(scope, eff) принимает уже готовый Effect, не функцию. Это важно: если хочешь динамический финализатор, заворачивай его в Effect.gen или в Effect.suspend.

Scope.close(scope, exit) запускает finalizer-ы. В нашем случае мы передаём Exit.void (это Exit.succeed(undefined)), но в реальной программе exit придёт от исхода работы.

Вывод подтверждает обратный порядок. Сначала “remote file”, потом “connection”, в зеркальном порядке к регистрации.

Шаг 2 · Утечка при ручном Scope.close

Что если между Scope.make и Scope.close эффект упал?

const program = Effect.gen(function* () {
  const scope = yield* Scope.make();

  yield* Scope.addFinalizer(scope, Effect.log('closing network connection'));
  yield* Scope.addFinalizer(scope, Effect.log('closing remote file'));

  yield* Effect.dieMessage('Boom');

  yield* Effect.log('work done');

  yield* Scope.close(scope, Exit.void); // сюда уже не дошли
});

Effect.runPromise(program);
// (program rejects with FiberFailure: Boom)
// никаких "closing ..." логов

Эффект умер до Scope.close, finalizer-ы не отстрелили, ресурсы потекли. Та же беда, что у “положил release в самом конце функции, потом наверху бросил throw”. Эффект-канал чище обычного try/throw, но ручное закрытие Scope даёт ровно те же грабли.

Вывод: ручное создание scope через Scope.make плюс Scope.close опасно, его почти никогда не пишут. Дальше мы посмотрим, как Effect автоматизирует закрытие через Effect.scoped.

Что взять с собой

  • Scope, ведро для finalizer-ов, закрывается одной операцией.
  • Scope.addFinalizer(scope, eff) принимает готовый Effect, выполняется на закрытии.
  • Finalizer-ы идут LIFO, чтобы зависимости (соединение раньше, файл позже) закрывались в правильном порядке.
  • Ручной Scope.make плюс Scope.close течёт при ошибке. На практике используют автоматизацию, см. ниже.

Раздел 3 · Effect.addFinalizer и Effect.scoped

Шаг 1 · Effect.addFinalizer, finalizer-ы как требование к Scope

Вместо ручного Scope.make/addFinalizer/close Effect даёт Effect.addFinalizer((exit) => cleanup), который не требует scope как аргумент, но поднимает требование Scope в канал R:

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  yield* Effect.addFinalizer((exit) => Effect.log(`closing network connection (${exit._tag})`));
  yield* Effect.addFinalizer((exit) => Effect.log(`closing remote file (${exit._tag})`));

  yield* Effect.log('work done');
});
// program: Effect<void, never, Scope>

Тип program, это Effect<void, never, Scope>. Третий параметр R, это уже знакомый по 04 · Services и Layer канал требований. Effect использует его не только под dependency injection, но и под Scope: сама запись “программа требует Scope” совпадает с записью “программа требует HttpService”. Один механизм, два применения.

Если попробовать запустить:

Effect.runPromise(program);
// type error: Effect<void, never, Scope> не присваивается Effect<void, never, never>

Компилятор останавливает: R = Scope, а runPromise хочет R = never. Логично: программа просит ведро, но никто его ей не даёт.

Шаг 2 · Свой scoped, чтобы понять механику

Прежде чем взять готовый Effect.scoped, напишем такой же руками. Это ровно та же тренировка, что и “напишем свой Promise” из 05-async/02 · Колбэки и event loop, без этого имена в Effect остаются магией.

import { Effect, Scope } from 'effect';

const scoped = <A, E, R>(
  self: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, Exclude<R, Scope.Scope>> =>
  Effect.gen(function* () {
    const scope = yield* Scope.make();

    // Подмешать наш scope в контекст self.
    const extended = yield* Scope.provide(self, scope);

    // Закрыть scope ровно одним исходом, на завершении extended.
    return yield* Effect.onExit(extended, (exit) => Scope.close(scope, exit));
  });

Что здесь происходит. Мы создаём scope. Через Scope.provide(self, scope) мы кладём scope в контекст эффекта (как Layer кладёт сервис). Имя выбрано намеренно тем же, что и у подкладывания сервиса: Scope и есть сервис, никакой отдельной механики под него нет. После этого тип extended уже не требует Scope, требование вычеркнуто. Дальше через Effect.onExit мы вешаем закрытие scope на любой исход: успех, провал, прерывание.

Тип возврата: Effect<A, E, Exclude<R, Scope>>. Мы убираем Scope из требований, но прочие требования (например, HttpService) сохраняем. Это ровно та же сигнатура, что у настоящего Effect.scoped.

Шаг 3 · Применяем

const program = Effect.gen(function* () {
  yield* Effect.addFinalizer((exit) => Effect.log(`closing network connection (${exit._tag})`));
  yield* Effect.addFinalizer((exit) => Effect.log(`closing remote file (${exit._tag})`));

  yield* Effect.dieMessage('Boom');

  yield* Effect.log('work done');
}).pipe(scoped);

Effect.runPromise(program);
// closing remote file (Failure)
// closing network connection (Failure)
// (program rejects with FiberFailure: Boom)

Сравни с разделом 2, где ручной Scope.close пропустили. Здесь finalizer-ы отработали оба, и в правильном (обратном) порядке, несмотря на падение эффекта. Effect.onExit гарантирует закрытие scope на любом исходе, и в этом разница.

Шаг 4 · Готовый Effect.scoped

Готовая версия делает ровно то же. Вместо своего scoped пишут Effect.scoped:

const program = Effect.gen(function* () {
  yield* Effect.addFinalizer((exit) => Effect.log(`closing network connection (${exit._tag})`));
  yield* Effect.addFinalizer((exit) => Effect.log(`closing remote file (${exit._tag})`));

  yield* Effect.dieMessage('Boom');
}).pipe(Effect.scoped);

Effect.runPromise(program);
// closing remote file (Failure)
// closing network connection (Failure)

Effect.scoped создаёт scope, подмешивает его в контекст, гарантирует закрытие на исходе. Под капотом он реализован через Scope.provide плюс Effect.onExit, как наш scoped, плюс несколько мелочей, которые к сути не относятся.

Шаг 5 · DI как карта ключ-значение

Чтобы не было магии. Effect-овская система зависимостей, это под капотом карта ключ-значение: ключ, это Tag (для сервиса) или Scope (для области жизни), значение, это конкретный экземпляр. Scope.provide(self, scope) кладёт пару (Scope, scope) в карту, после чего self достаёт scope тем же механизмом, которым достаёт сервисы.

Если бы мы передавали scope явно как аргумент функции:

const program = (scope: Scope.CloseableScope) =>
  Effect.gen(function* () {
    yield* Scope.addFinalizer(scope, Effect.log('closing network connection'));
    yield* Scope.addFinalizer(scope, Effect.log('closing remote file'));
    yield* Effect.log('work done');
  });

const scopedFn = <A, E, R>(
  self: (scope: Scope.CloseableScope) => Effect.Effect<A, E, R>,
): Effect.Effect<A, E, R> =>
  Effect.gen(function* () {
    const scope = yield* Scope.make();
    return yield* Effect.onExit(self(scope), (exit) => Scope.close(scope, exit));
  });

scopedFn(program).pipe(Effect.runPromise);

это всё ещё работает. Effect-овский DI, это та же явная передача через аргументы, только Effect делает её через карту в контексте, чтобы не таскать scope (и сервисы) через каждую промежуточную функцию. Тот самый prop drilling из 04-го урока, только теперь для области жизни ресурса.

Что взять с собой

  • Effect.addFinalizer((exit) => eff) регистрирует finalizer и поднимает Scope в требования.
  • Effect.scoped(eff) создаёт scope, подмешивает в контекст, гарантирует закрытие на любом исходе.
  • Effect.scoped это и есть рабочий способ собирать scope, ручной Scope.make плюс Scope.close оставляют под собой утечку.
  • Scope в контексте, это тот же механизм, что и сервисы. Один DI-механизм на всё.

Раздел 4 · acquireRelease и acquireUseRelease

Сцена · acquire, use, release

Рядом с finalizer-ом всегда живёт acquire, шаг приобретения ресурса. У Pulse это fs.open(...), у HTTP-клиента это создание AbortController-а, у БД-коннекта это pool.connect(). Для пары acquire-release Effect даёт два специальных комбинатора, чтобы ты не собирал её каждый раз руками.

Вводный пример. У нас есть эффект openReport, который открывает отчётный handle, и функция close, которая возвращает Promise:

type ReportHandle = { read: () => Promise<string>; close: () => Promise<void> };

declare const openReport: Effect.Effect<ReportHandle, never, never>;
declare const closeReport: (handle: ReportHandle) => Promise<void>;

Шаг 1 · Ручной способ через addFinalizer

В лоб собирается так:

const program = Effect.gen(function* () {
  const handle = yield* openReport;

  yield* Effect.addFinalizer(() => Effect.promise(() => closeReport(handle)));

  // дальше работа с handle
});
// program: Effect<void, never, Scope>

Работает: есть acquire (yield* openReport), есть release (через addFinalizer). Но они разнесены на две строки и связаны только переменной handle. На code review легко не заметить, что один из двух шагов не сделали. И в типе программы видна Scope, но не видно “это пара acquire-release”.

Шаг 2 · Effect.acquireRelease, декларативная пара

import { Effect } from 'effect';

const reportResource = Effect.acquireRelease(
  openReport,
  (handle, exit) => Effect.promise(() => closeReport(handle)),
);
// reportResource: Effect<ReportHandle, never, Scope>

Что мы получили:

  • Один вызов вместо двух.
  • Release знает exit, может в зависимости от исхода писать в журнал или ретраить.
  • В контракте типа написано: “это ресурс”. Effect<A, E, Scope>, где A, это handle, E, это ошибки acquire, R = Scope, требование на ведро.
  • Под капотом тот же addFinalizer, но acquire и release склеены неразделимо: невозможно случайно зарегистрировать одно без другого.

Использовать ресурс:

const program = Effect.gen(function* () {
  const handle = yield* reportResource;
  const data = yield* Effect.promise(() => handle.read());
  yield* Effect.log(`got ${data.length} bytes`);
}).pipe(Effect.scoped);

Effect.runPromise(program);

Effect.scoped закрывает scope на исходе, acquireRelease регистрирует release, всё аккуратно.

Шаг 3 · Effect.acquireUseRelease, ресурс на один блок работы

Когда ресурс нужен строго на одну операцию и тащить его как переменную лень, бери Effect.acquireUseRelease. У него три аргумента: acquire, use (функция от ресурса в эффект), release.

const program = Effect.acquireUseRelease(
  openReport,
  (handle) =>
    Effect.gen(function* () {
      const data = yield* Effect.promise(() => handle.read());
      yield* Effect.log(`got ${data.length} bytes`);
      return data;
    }),
  (handle, exit) => Effect.promise(() => closeReport(handle)),
);
// program: Effect<string, never, never>

Effect.runPromise(program);

Заметь тип: R = never. acquireUseRelease не требует Scope на выходе, потому что ресурс целиком живёт внутри use и закрывается прямо там. Никакого Effect.scoped снаружи не нужно.

Шаг 4 · Разница в распространении ошибок

Ключевое различие двух комбинаторов прячется в типах ошибок и требований:

КомбинаторОшибки use пробрасываются?Требования use пробрасываются?Нужен Effect.scoped?
Effect.acquireReleaseнет, use ещё не былонетда, потому что R = Scope
Effect.acquireUseReleaseдаданет

acquireRelease отдаёт ресурс, и тип отражает только ресурс. Если потом внутри Effect.gen ты упадёшь на парсинге тела, это ошибка вызывающего кода, не ресурса. Сигнатура остаётся чистой.

acquireUseRelease отдаёт результат use, и тип отражает что угодно, что в use может случиться. Если use требует HttpService и может упасть с NetworkError, итоговый эффект тоже это покажет.

В обоих случаях release гарантированно отрабатывает, даже если use упал.

Шаг 5 · Когда какой брать

Бери Effect.acquireRelease, когда:

  • ресурс используется в нескольких шагах подряд, и ты хочешь иметь его как именованную переменную в Effect.gen;
  • ресурс должен пережить несколько вызовов, например один файловый handle переиспользуется на сотне append;
  • ты собираешь сервис с собственным ресурсом (его конструктор это и есть Effect<Shape, E, Scope>), там без acquireRelease не обойтись.

Бери Effect.acquireUseRelease, когда:

  • ресурс нужен один раз, на один блок;
  • ты не хочешь засорять область видимости переменной с handle-ом;
  • ты не хочешь объяснять, почему программа требует Scope, для одной мелкой операции.

Pulse-овский Storage живёт долго, его handle переиспользуется на каждом probe. Это первый случай, мы пишем Effect.acquireRelease и собираем StorageLive через Layer.effect, который сам вычитает Scope из требований конструктора.

Что взять с собой

  • Effect.acquireRelease(open, close), ресурс с гарантированным release. Возвращает Effect<A, E, Scope>.
  • Effect.acquireUseRelease(open, use, close), ресурс на один блок. Возвращает Effect<UseA, UseE | OpenE, UseR>, без Scope.
  • Release получает (resource, exit), можно ветвить логику cleanup-а по исходу.
  • В обоих случаях release отрабатывает на любом исходе. Разница в том, как тип видит use-шаг.

Раздел 5 · Fork в scope

Сцена · файбер, который никто не убирает

В 04 · Services и Layer мы запускали фоновый flusher через Effect.forkScoped. Логика была такая: файбер прожил, пока живёт scope сервиса (а его держит ManagedRuntime), на runtime.dispose() файбер прервался.

Альтернативный путь, Effect.forkDetach, отцепляет файбер от родителя и привязывает к корню программы, а не к локальному scope. Это значит, отцепленный файбер переживёт и сервис, и обработчик HTTP-запроса, который его создал. На graceful shutdown такой файбер никто не остановит, и он останется висеть до process.exit. В реальном коде это нужно редко, и почти всегда это ошибка.

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

Шаг 1 · Effect.forkScoped, файбер в текущий scope

import { Effect } from 'effect';

declare const heartbeat: Effect.Effect<never, never, never>;

const program = Effect.gen(function* () {
  yield* Effect.forkScoped(heartbeat);
  // ... основная работа
});
// program: Effect<void, never, Scope>

Effect.forkScoped(eff) запускает файбер и регистрирует на scope finalizer вида “прервать этот файбер”. Scope появляется в требованиях. Запустить можно через Effect.scoped:

program.pipe(Effect.scoped, Effect.runPromise);

или через ManagedRuntime (как в 04 · Services и Layer), когда scope живёт между запросами.

Шаг 2 · Effect.scope, как достать текущий scope

Под капотом Effect.forkScoped достаёт текущий scope из контекста. Это умеет любой эффект через Effect.scope:

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const scope = yield* Effect.scope;
  // дальше можно делать что угодно с scope, например передать в другой код
});
// program: Effect<void, never, Scope>

Effect.scope это эффект, который требует Scope и отдаёт его. Тот же DI-механизм, что и для сервисов: через yield* Effect.scope ты добавляешь Scope в свои требования и достаёшь из контекста уже готовое значение.

Шаг 3 · Effect.forkIn, файбер в явный scope

А что, если у тебя в руках другой scope (например, ты создал свой через Scope.make, или ты получил scope от родительского обработчика и хочешь, чтобы фоновые файберы пережили текущую функцию)?

Effect.forkScoped не подойдёт, он привязывается к текущему scope. Для явной привязки есть Effect.forkIn(eff, scope):

import { Effect, Scope } from 'effect';

const program = Effect.gen(function* () {
  const outerScope = yield* Scope.make();
  // outerScope живёт пока мы его не закроем

  yield* Effect.forkIn(heartbeat, outerScope);
  // файбер привязан к outerScope, переживёт текущую функцию
});

Effect.forkIn это базовая операция, Effect.forkScoped это сахар над ней:

const forkScoped = <A, E, R>(self: Effect.Effect<A, E, R>) =>
  Effect.gen(function* () {
    const scope = yield* Effect.scope;
    return yield* Effect.forkIn(self, scope);
  });

forkScoped достал scope из контекста, передал в forkIn. Когда у тебя scope в руках (а не в контексте), forkIn это правильный инструмент.

Шаг 4 · Концептуальная реализация forkIn

Чтобы понять, что внутри. Концептуально (без обработки требований и контекста):

const forkIn = <A, E>(
  self: Effect.Effect<A, E>,
  scope: Scope.Scope,
): Effect.Effect<Fiber.RuntimeFiber<A, E>> =>
  Effect.gen(function* () {
    const fiber = Effect.runFork(self);
    yield* Scope.addFinalizer(scope, Fiber.interrupt(fiber));
    return fiber;
  });

Запустили файбер через Effect.runFork (он отдаёт RuntimeFiber, не привязанный ни к чему), повесили на scope finalizer “прервать этот файбер на закрытии”. Когда scope закроется, finalizer пошлёт файберу Fiber.interrupt, и файбер аккуратно завершится.

Реальная реализация в Effect сложнее: она правильно обрабатывает требования, передаёт текущий runtime, разруливает контекст. Но идея та же: fork плюс finalizer.

Шаг 5 · Когда что использовать

СитуацияЧто брать
Фоновый процесс внутри ресурсного Layer или Effect.scopedEffect.forkScoped
Scope в руках как переменная (создал через Scope.make, получил из родителя)Effect.forkIn(eff, scope)
Хочется файбер, который переживёт всё, до конца программыEffect.forkDetach (редко, обычно это ошибка)
Файбер на время одного эффекта, ровно столькоEffect.forkChild

Effect.forkChild привязывает файбер к родительскому файберу, что и записано прямо в имени. Когда родительский файбер завершается (или прерывается), его дочерние файберы тоже прерываются. Это разумное умолчание для разовых параллельных пробингов в 06 · Файберы и concurrency.

Что взять с собой

  • Effect.forkScoped, файбер в текущий scope. Под капотом сахар над Effect.scope плюс Effect.forkIn.
  • Effect.scope, эффект, отдающий текущий scope из контекста.
  • Effect.forkIn(eff, scope), файбер в явный scope.
  • Effect.forkDetach существует, но в реальном коде он почти всегда симптом плохого дизайна: отвязанный файбер не подчиняется shutdown-у. Имя честнее старого forkDaemon, оно прямо говорит, что файбер отцепили от родителя.

Раздел 6 · Pool, когда открывать ресурс дорого

Сцена · сто соединений на сто запросов

acquireRelease из Раздела 4 честно открывает ресурс и честно закрывает. Проблема начинается, когда открытие стоит дорого, а нужно оно часто: соединение с базой, TLS-сессия, дочерний процесс. Сто параллельных запросов дадут сто открытий и сто закрытий, и большую часть времени программа проведёт в рукопожатиях.

Ответ известен всем и называется пул: держим несколько готовых экземпляров и выдаём их по очереди.

Шаг 1 · Pool.make

import { Effect, Pool } from 'effect';

const program = Effect.gen(function* () {
  const pool = yield* Pool.make({ acquire: openConnection, size: 2 });

  yield* Effect.all(
    requests.map((request) =>
      Effect.scoped(
        Pool.get(pool).pipe(Effect.flatMap((connection) => handle(connection, request))),
      ),
    ),
    { concurrency: 4 },
  );
}).pipe(Effect.scoped);

// открыл соединение 1
// открыл соединение 2
// всего соединений создано = 2

Четыре параллельных запроса, два соединения. Два лишних запроса подождали, пока освободится соединение, и это ожидание встроено: Pool.get просто не завершается, пока нет свободного экземпляра.

Шаг 2 · Два scope, и это важно

Тут два разных времени жизни, и путать их нельзя.

Scope пула живёт столько же, сколько сам пул: Pool.make это scoped-эффект, и при закрытии внешнего scope закрываются все соединения разом.

Scope выдачи живёт один запрос: Effect.scoped вокруг Pool.get означает “верни соединение в пул, когда закончишь”. Забыть его нельзя, компилятор потребует Scope, но забыть можно поставить его слишком высоко: обернёшь весь цикл целиком, и соединение не вернётся в пул до конца цикла.

Правило: Effect.scoped ставится на один заход за ресурсом, а не на весь блок работы.

Шаг 3 · makeWithTTL и невалидные экземпляры

У боевых пулов есть ещё две потребности.

Pool.makeWithTTL задаёт границы (min, max) и время жизни простаивающего экземпляра. Пул расширяется под нагрузкой и сжимается, когда нагрузка ушла, а протухшие соединения закрываются сами.

Pool.invalidate(pool, item) помечает конкретный экземпляр как испорченный: пул закроет его и откроет новый вместо переиспользования. Ставится там, где по ошибке видно, что соединение больше не годится (сервер разорвал сессию, протокол рассинхронизировался).

Что взять с собой

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

Раздел 7 · Pulse · вклад этого урока

Шаг 1 · StorageLive через acquireRelease

Из 04-го урока у тебя в pulse-<nick>/src/services/storage.ts уже намечено:

import { Context, Effect, Layer } from 'effect';
import * as fs from 'node:fs/promises';

import type { MonitorEvent } from '../events.ts';

class Storage extends Context.Service<Storage>()('Pulse/Storage', {
  make: Effect.gen(function* () {
    const handle = yield* Effect.acquireRelease(
      Effect.tryPromise(() => fs.open('events.jsonl', 'a')),
      (h) => Effect.promise(() => h.close()),
    );

    return {
      append: (event: MonitorEvent) =>
        Effect.tryPromise(() => handle.write(`${JSON.stringify(event)}\n`)),
    };
  }),
}) {
  static readonly layer = Layer.effect(this, this.make);
}

После этого урока ты понимаешь тут каждую строчку: acquireRelease это пара открыть-закрыть, конструктор с ресурсом требует Scope, а Layer.effect вычитает это требование сам. Finalizer закроет handle на закрытии scope, который держит ManagedRuntime. Отдельного Layer.scoped для такого случая в v4 нет, и он не нужен: ресурсный конструктор и обычный собираются одним и тем же вызовом.

Шаг 2 · SIGTERM и graceful shutdown

В CLI-команде pulse watch нужно ловить SIGTERM и SIGINT, и на каждом из них корректно закрыть рантайм:

// src/runtime.ts
import { ManagedRuntime } from 'effect';

import { MainLive } from './main.ts';

export const runtime = ManagedRuntime.make(MainLive);
// src/cli/watch.ts
import process from 'node:process';

import { Effect, Fiber } from 'effect';

import { runtime } from '../runtime.ts';
import { watch } from '../program.ts';

const main = Effect.gen(function* () {
  yield* watch;
});

const fiber = runtime.runFork(main);

const shutdown = async (signal: string) => {
  console.log(`got ${signal}, shutting down`);
  // 1. прерываем основной fiber и ждём, пока отработают finalizer-ы
  await runtime.runPromise(Fiber.interrupt(fiber));
  // 2. закрываем сам runtime, finalizer-ы Layer-а отрабатывают
  await runtime.dispose();
  process.exit(0);
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Что отрабатывает на SIGTERM, по шагам:

  1. Fiber.interrupt(fiber) посылает interrupt главному файберу и ждёт его завершения. Все внутри (включая Effect.forkScoped-ы) получают прерывание.
  2. На каждом файбере срабатывают Effect.onInterrupt-handler-ы (из раздела 1), они дописывают “interrupted” в журнал.
  3. runtime.dispose() закрывает scope MainLive. На нём висят finalizer-ы Storage (handle.close()), HttpService (abort всех активных запросов), и любых других scoped-сервисов.
  4. Finalizer-ы идут в LIFO-порядке: позже зарегистрированные (например, Storage поверх HttpService) закрываются первыми.
  5. process.exit(0) отправляет процесс в землю, когда journal-ы дописаны и handle-ы закрыты.

Без этой цепочки на SIGTERM ты потеряешь буфер Storage (там остаются события, которые не успел записать flusher), и handle протекёт. Тест на это: запускаешь pulse watch, шлёшь SIGTERM, проверяешь, что events.jsonl валидный JSONL до последней строки, и что handle действительно закрыт (например, через lsof или дополнительный лог в finalizer-е).

Шаг 3 · Вложенный scope для одного цикла пробинга

Внутри Pulse один цикл пробинга открывает свой AbortController, дёргает HTTP, может ретраить. Это локальные ресурсы, не долгоживущие. Их жизнь это дочерний scope относительно scope ManagedRuntime:

import { Effect } from 'effect';

import { HttpService } from './services/http.ts';
import { Storage } from './services/storage.ts';

const probeOne = (url: string) =>
  Effect.gen(function* () {
    const http = yield* HttpService;
    const storage = yield* Storage;

    // локальный AbortController как scoped ресурс
    const controller = yield* Effect.acquireRelease(
      Effect.sync(() => new AbortController()),
      (c) => Effect.sync(() => c.abort()),
    );

    const response = yield* http.get(url, controller.signal);
    yield* storage.append({ _tag: 'ProbeSuccess', url, status: response.status });
  }).pipe(Effect.scoped); // <-- закрытие дочернего scope здесь

Effect.scoped оборачивает один цикл пробинга. Все acquireRelease-ресурсы, объявленные внутри probeOne, попадают в этот scope, не в scope ManagedRuntime. Закончился цикл, finalizer-ы отстрелили (controller.abort()), даже если внутри упал HTTP. Дальше следующий цикл откроет свой controller, со своим scope.

При этом сервисы (HttpService, Storage), которые мы достали через yield* HttpService, продолжают жить, они привязаны к scope-у MainLive, не к локальному. Один scope сидит внутри другого, finalizer-ы каждого работают только на своём уровне.

Это и называется композиция scope-ов: родительский для процесса, дочерние для отдельных операций. Именно так Effect строит structured resource management, как в functional Effect-системах JVM (ZIO, Cats Effect), и почему у тебя теперь нет сценария “лог-файл протёк, потому что один из обработчиков забыл закрыть AbortController”.

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

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

ДЗ

Дальше

Следующий урок · 06. Файберы и concurrency. Параллельный пробинг N URL опирается на гарантию структурной отмены, она же опирается на Scope и forkScoped из этого урока. Дальше всё по нарастающей: координация через 07. Coordination, STM в 08. STM, Stream и backpressure в 09. Stream.