Scope — Концепция
Scope — это фундаментальный тип данных Effect для безопасного управления жизненным циклом ресурсов.
Проблема управления ресурсами
Каждая production-система работает с ресурсами, которые необходимо корректно освобождать: файловые дескрипторы, сетевые соединения, подключения к базам данных, мьютексы, временные файлы, процессы. Неправильное управление ресурсами приводит к утечкам памяти, исчерпанию дескрипторов, зависанию соединений и, в конечном счёте, к деградации и падению сервиса.
Рассмотрим, как выглядит управление ресурсами без специальных абстракций:
// ❌ Наивный подход — утечка ресурсов при ошибке
async function processFile(path: string): Promise<string> {
const handle = await openFile(path)
const data = await readAll(handle) // Если здесь ошибка — handle утечёт
const result = await transform(data) // И здесь тоже
await closeFile(handle) // Может не выполниться
return result
}
Классическое решение — блок try/finally:
// ⚠️ try/finally — работает, но не композируется
async function processFile(path: string): Promise<string> {
const handle = await openFile(path)
try {
const data = await readAll(handle)
const result = await transform(data)
return result
} finally {
await closeFile(handle)
}
}
Проблемы подхода с try/finally:
┌─────────────────────────────────────────────────────────┐
│ Проблемы try/finally │
├─────────────────────────────────────────────────────────┤
│ 1. Вложенность: 3 ресурса = 3 уровня try/finally │
│ 2. Не композируется: нельзя передать "право владения" │
│ 3. Порядок закрытия: нужно отслеживать вручную │
│ 4. Условный release: сложно менять логику закрытия │
│ 5. Ошибки в finally: могут подавить основную ошибку │
│ 6. Нет типизации: компилятор не знает о ресурсах │
└─────────────────────────────────────────────────────────┘
С тремя ресурсами код превращается в пирамиду:
// ❌ Вложенный try/finally — "пирамида ужаса"
async function createWorkspace(): Promise<Workspace> {
const bucket = await createS3Bucket()
try {
const index = await createElasticIndex()
try {
const entry = await createDatabaseEntry(bucket, index)
try {
return { bucket, index, entry }
} catch (e) {
await deleteDatabaseEntry(entry)
throw e
}
} catch (e) {
await deleteElasticIndex(index)
throw e
}
} catch (e) {
await deleteS3Bucket(bucket)
throw e
}
}
Effect решает эту проблему через абстракцию Scope.
Концепция ФП: Region-Based Resource Management
📖 Scope в Effect реализует паттерн, корни которого лежат в функциональном программировании — Region-Based Memory/Resource Management.
Идея проста: каждый ресурс привязывается к определённому региону (scope), и когда регион завершается, все привязанные к нему ресурсы автоматически освобождаются.
┌────────────────────────────────────────────────────────────┐
│ Region (Scope) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Resource │ │ Resource │ │ Resource │ │
│ │ A │ │ B │ │ C │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Finalizer C ← Finalizer B ← Finalizer A (LIFO order) │
│ │
└──────────────────────────── close() ───────────────────────┘
│
▼
Все ресурсы освобождены
Ключевые свойства этого подхода:
- Гарантия освобождения — ресурсы всегда освобождаются при закрытии региона, независимо от того, завершился ли код успешно или с ошибкой.
- LIFO-порядок — финализаторы выполняются в порядке, обратном добавлению (как стек), что гарантирует корректную последовательность освобождения зависимых ресурсов.
- Композиция — регионы могут быть вложенными и могут расширяться, что позволяет строить сложные графы владения ресурсами.
- Типобезопасность — потребность в Scope отражается в системе типов через параметр
RтипаEffect<A, E, R>.
Этот подход вдохновлён такими системами, как:
- Bracket pattern из Haskell (
bracket acquire release use) - Resource monad из ZIO (Scala)
- RAII (Resource Acquisition Is Initialization) из C++
- Drop trait из Rust
Что такое Scope
Scope — это тип данных в Effect, представляющий время жизни (lifetime) одного или нескольких ресурсов. Scope выступает как контейнер для финализаторов — функций очистки, которые будут выполнены при закрытии scope.
┌───────────────────────────────────────────┐
│ Scope │
│ │
│ Responsibilities: │
│ ┌─────────────────────────────────────┐ │
│ │ • Хранение финализаторов │ │
│ │ • Гарантия выполнения при закрытии │ │
│ │ • LIFO-порядок финализаторов │ │
│ │ • Обработка Exit статуса │ │
│ └─────────────────────────────────────┘ │
│ │
│ Operations: │
│ ┌─────────────────────────────────────┐ │
│ │ addFinalizer(f) — добавить │ │
│ │ close(exit) — закрыть │ │
│ │ fork() — ответвить │ │
│ └─────────────────────────────────────┘ │
└───────────────────────────────────────────┘
Scope можно рассматривать как стек финализаторов. Каждый вызов addFinalizer помещает новый финализатор на вершину стека. При закрытии scope все финализаторы выполняются от вершины к основанию (LIFO — Last In, First Out).
💡 Scope — это не “ресурс” сам по себе, а менеджер времени жизни ресурсов. Он не знает, что именно за ресурсы он управляет — он только хранит функции очистки и гарантирует их выполнение.
Анатомия Scope
Интерфейс Scope
Scope в Effect реализует два ключевых интерфейса:
// Упрощённая модель Scope
interface Scope {
// Добавить финализатор, который выполнится при закрытии scope
addFinalizer(
finalizer: (exit: Exit<unknown, unknown>) => Effect<void>
): Effect<void>
// Ответвить дочерний scope
fork(
strategy: ExecutionStrategy
): Effect<Scope.Closeable>
}
// Closeable Scope — scope, который можно закрыть
interface Closeable extends Scope {
close(exit: Exit<unknown, unknown>): Effect<void>
}
Scope vs Scope.Closeable
Важное архитектурное различие:
┌─────────────────────────────────────────────────────────┐
│ │
│ Scope Scope.Closeable │
│ ┌────────────┐ ┌────────────────┐ │
│ │addFinalizer│ │ addFinalizer │ │
│ │ fork │ │ fork │ │
│ └────────────┘ │ close │ │
│ └────────────────┘ │
│ │
│ "Я могу добавить "Я могу добавить │
│ финализатор" финализатор И закрыть scope" │
│ │
└─────────────────────────────────────────────────────────┘
- Scope — “потребительский” интерфейс. Код, получающий
Scope, может добавлять финализаторы и создавать дочерние scope, но не может закрыть его. Это обеспечивает принцип наименьших привилегий. - Scope.Closeable — “владельческий” интерфейс. Только владелец scope может его закрыть. Обычно это
Effect.scopedили ручное управление черезScope.make().
Такое разделение предотвращает ситуации, когда потребитель ресурса случайно закрывает scope, лишая другие части системы доступа к ресурсам.
Финализатор
Финализатор — это функция типа (exit: Exit<unknown, unknown>) => Effect<void>, которая получает информацию о том, как завершился scope:
// Финализатор получает Exit, описывающий причину закрытия
const myFinalizer = (exit: Exit.Exit<unknown, unknown>) => {
if (Exit.isSuccess(exit)) {
return Effect.log("Ресурс закрыт нормально")
}
return Effect.log("Ресурс закрыт из-за ошибки — выполняем rollback")
}
Это позволяет финализаторам адаптировать своё поведение: при успешном завершении — просто закрыть ресурс, при ошибке — дополнительно выполнить rollback или логирование.
Scope в системе типов Effect
Одна из самых мощных особенностей Effect — зависимость от Scope отражается в типе эффекта через параметр R (Requirements):
// Этот эффект ТРЕБУЕТ Scope для выполнения
// ▼
const scoped: Effect.Effect<string, Error, Scope.Scope> = /* ... */
// Этот эффект НЕ требует Scope — scope уже предоставлен
// ▼
const unscoped: Effect.Effect<string, Error, never> = /* ... */
Это означает, что TypeScript-компилятор на этапе компиляции гарантирует:
- Вы не забудете предоставить scope — если эффект требует
Scope, его нельзя запустить без него. - Scope не “утечёт” —
Effect.scopedудаляетScopeиз типа Requirements, закрывая scope при завершении. - Ресурсы всегда освобождены — если код скомпилировался, ресурсы будут освобождены.
Effect<A, E, R | Scope>
│
│ Effect.scoped
▼
Effect<A, E, R>
│
│ Scope создаётся автоматически
│ Финализаторы выполняются при выходе
▼
Ресурсы гарантированно освобождены
Жизненный цикл Scope
Фазы жизненного цикла
┌─────────┐ ┌──────────────┐ ┌─────────┐
│ CREATE │────▶│ USE │────▶│ CLOSE │
│ │ │ │ │ │
│Scope. │ │addFinalizer │ │close() │
│make() │ │acquireRelease│ │ или │
│ или │ │fork │ │Effect. │
│Effect. │ │ │ │scoped │
│scoped │ │ │ │ │
└─────────┘ └──────────────┘ └─────────┘
│
▼
Финализаторы LIFO
┌──────────────┐
│ Finalizer N │
│ Finalizer .. │
│ Finalizer 2 │
│ Finalizer 1 │
└──────────────┘
Фаза 1 — Создание. Scope создаётся через Scope.make() (ручное управление) или неявно через Effect.scoped.
Фаза 2 — Использование. К scope привязываются ресурсы через addFinalizer или acquireRelease. Можно создавать дочерние scope через fork.
Фаза 3 — Закрытие. При вызове close(exit) (или при завершении Effect.scoped) все финализаторы выполняются в LIFO-порядке. Каждый финализатор получает Exit, описывающий результат работы scope.
LIFO-порядок финализаторов
Порядок выполнения финализаторов критически важен для корректной работы зависимых ресурсов:
const program = Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("1: Закрыть соединение с БД"))
yield* Scope.addFinalizer(scope, Console.log("2: Закрыть файл на сервере"))
yield* Scope.addFinalizer(scope, Console.log("3: Закрыть сетевое соединение"))
yield* Scope.close(scope, Exit.void)
})
Effect.runPromise(program)
/*
3: Закрыть сетевое соединение ← добавлен последним, выполнен первым
2: Закрыть файл на сервере
1: Закрыть соединение с БД ← добавлен первым, выполнен последним
*/
Это аналогично раскрутке стека (stack unwinding): если вы открыли соединение с БД, потом открыли файл на удалённом сервере через это соединение — файл нужно закрыть ДО того, как закроется соединение.
API Reference
Scope.make
Создаёт новый пустой Scope.Closeable:
// Signature:
// Scope.make: () => Effect<Scope.Closeable, never, never>
const program = Effect.gen(function* () {
const scope = yield* Scope.make()
// scope готов к использованию
})
[STABLE] — основной способ ручного создания scope.
Scope.addFinalizer
Добавляет финализатор к существующему scope:
// Signature:
// Scope.addFinalizer: (
// scope: Scope,
// finalizer: Effect<void, never, never>
// ) => Effect<void>
const program = Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("Cleanup!"))
})
[STABLE] — низкоуровневый API; чаще используется Effect.addFinalizer.
Scope.close
Закрывает scope, выполняя все финализаторы:
// Signature:
// Scope.close: (
// scope: Scope.Closeable,
// exit: Exit<unknown, unknown>
// ) => Effect<void>
const program = Effect.gen(function* () {
const scope = yield* Scope.make()
// ... добавление финализаторов ...
yield* Scope.close(scope, Exit.void) // Закрыть с успехом
})
[STABLE] — принимает Exit, который передаётся каждому финализатору.
Effect.scoped
Автоматически создаёт scope, выполняет эффект и закрывает scope:
// Signature:
// Effect.scoped: <A, E, R>(
// effect: Effect<A, E, R | Scope>
// ) => Effect<A, E, Exclude<R, Scope>>
// Убирает Scope из Requirements
const program: Effect.Effect<string, never, never> = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.log("Cleanup"))
return "result"
})
)
[STABLE] — рекомендуемый способ работы со scope в большинстве случаев.
Effect.scope
Получает текущий scope из контекста:
// Signature:
// Effect.scope: Effect<Scope, never, Scope>
const program = Effect.scoped(
Effect.gen(function* () {
const currentScope = yield* Effect.scope
// Можно передать scope в другие операции
})
)
[STABLE] — полезен для передачи scope в Effect.forkIn или Scope.extend.
Примеры
Пример 1: Ручное создание и закрытие scope
const program =
Scope.make().pipe(
Effect.tap((scope) =>
Scope.addFinalizer(scope, Console.log("Финализатор 1"))
),
Effect.tap((scope) =>
Scope.addFinalizer(scope, Console.log("Финализатор 2"))
),
Effect.andThen((scope) =>
Scope.close(scope, Exit.succeed("scope closed"))
)
)
Effect.runPromise(program)
/*
Финализатор 2 ← LIFO: последний добавленный — первый выполненный
Финализатор 1
*/
Пример 2: Scope через Effect.gen
const program = Effect.gen(function* () {
const scope = yield* Scope.make()
// Добавляем финализаторы
yield* Scope.addFinalizer(
scope,
Console.log("1: Shutdown database pool")
)
yield* Scope.addFinalizer(
scope,
Console.log("2: Close HTTP server")
)
yield* Scope.addFinalizer(
scope,
Console.log("3: Flush metrics buffer")
)
// Выполняем основную работу
yield* Console.log("App running...")
// Закрываем scope
yield* Scope.close(scope, Exit.void)
yield* Console.log("All resources released")
})
Effect.runPromise(program)
/*
App running...
3: Flush metrics buffer ← LIFO
2: Close HTTP server
1: Shutdown database pool
All resources released
*/
Пример 3: Автоматическое управление через Effect.scoped
const program = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer((exit) =>
Console.log(`Finalizer executed. Exit: ${exit._tag}`)
)
yield* Console.log("Работаем с ресурсом...")
return "result"
})
)
// Scope автоматически:
// 1. Создаётся перед выполнением Effect.gen
// 2. Закрывается после завершения (успех или ошибка)
Effect.runPromise(program).then(console.log)
/*
Работаем с ресурсом...
Finalizer executed. Exit: Success
result
*/
Пример 4: Финализатор при ошибке
const program = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer((exit) =>
Console.log(`Cleanup. Exit: ${exit._tag}`)
)
yield* Console.log("Начинаем работу...")
return yield* Effect.fail("Something went wrong")
})
)
Effect.runPromiseExit(program).then(console.log)
/*
Начинаем работу...
Cleanup. Exit: Failure ← финализатор вызван даже при ошибке
{
_id: 'Exit',
_tag: 'Failure',
cause: { _id: 'Cause', _tag: 'Fail', failure: 'Something went wrong' }
}
*/
Пример 5: Финализатор при прерывании
const program = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer((exit) =>
Console.log(`Cleanup. Exit: ${exit._tag}`)
)
return yield* Effect.interrupt
})
)
Effect.runPromiseExit(program).then(console.log)
/*
Cleanup. Exit: Failure ← Interrupt тоже является Failure
{
_id: 'Exit',
_tag: 'Failure',
cause: { _id: 'Cause', _tag: 'Interrupt', ... }
}
*/
Распространённые ошибки
Ошибка 1: Забыть предоставить Scope
const scoped = Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.log("cleanup"))
return 42
})
// ❌ Ошибка компиляции: Scope не предоставлен
// Effect.runPromise(scoped)
// Type 'Scope' is not assignable to type 'never'
// ✅ Правильно: обернуть в Effect.scoped
Effect.runPromise(Effect.scoped(scoped))
Ошибка 2: Закрыть scope дважды
const program = Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("cleanup"))
yield* Scope.close(scope, Exit.void)
// ⚠️ Повторное закрытие — финализаторы уже не выполнятся
yield* Scope.close(scope, Exit.void)
})
Ошибка 3: Использовать ресурс после закрытия scope
// ❌ Неправильно: ресурс используется после закрытия scope
const bad = Effect.gen(function* () {
const resource = yield* Effect.scoped(
Effect.acquireRelease(
Effect.succeed({ data: "important" }),
() => Effect.log("released!")
)
)
// resource уже "мёртв" — scope закрыт после Effect.scoped
console.log(resource.data) // Undefined behavior
})
Упражнения
Упражнение 1: Создание и закрытие scope
Создайте программу, которая:
- Создаёт scope через
Scope.make() - Добавляет три финализатора с сообщениями “A”, “B”, “C”
- Закрывает scope
- Убедитесь, что финализаторы выполняются в порядке C, B, A
import { Scope, Effect, Console, Exit } from "effect"
const exercise1 = Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("A"))
yield* Scope.addFinalizer(scope, Console.log("B"))
yield* Scope.addFinalizer(scope, Console.log("C"))
yield* Scope.close(scope, Exit.void)
})
Effect.runPromise(exercise1)
// C
// B
// Aimport { Scope, Effect, Console, Exit } from "effect"
const exercise1 = Effect.gen(function* () {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("A"))
yield* Scope.addFinalizer(scope, Console.log("B"))
yield* Scope.addFinalizer(scope, Console.log("C"))
yield* Scope.close(scope, Exit.void)
})
Effect.runPromise(exercise1)
// C
// B
// AУпражнение 2: Effect.scoped с финализатором
Перепишите Exercise 1, используя Effect.scoped и Effect.addFinalizer вместо ручного управления scope.
import { Effect, Console } from "effect"
const exercise2 = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Console.log("A"))
yield* Effect.addFinalizer(() => Console.log("B"))
yield* Effect.addFinalizer(() => Console.log("C"))
yield* Console.log("Working...")
})
)
Effect.runPromise(exercise2)
// Working...
// C
// B
// Aimport { Effect, Console } from "effect"
const exercise2 = Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Console.log("A"))
yield* Effect.addFinalizer(() => Console.log("B"))
yield* Effect.addFinalizer(() => Console.log("C"))
yield* Console.log("Working...")
})
)
Effect.runPromise(exercise2)
// Working...
// C
// B
// AУпражнение 3: Финализатор, зависящий от Exit
Создайте эффект с финализатором, который логирует разные сообщения в зависимости от того, завершился ли scope успешно, с ошибкой или был прерван. Протестируйте все три сценария.
import { Effect, Console, Exit, Cause } from "effect"
const createTrackedEffect = (shouldFail: boolean, shouldInterrupt: boolean) =>
Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer((exit) => {
if (Exit.isSuccess(exit)) {
return Console.log("✅ Ресурс закрыт нормально")
}
const cause = exit.cause
if (Cause.isInterruptedOnly(cause)) {
return Console.log("⚡ Ресурс закрыт из-за прерывания")
}
return Console.log("❌ Ресурс закрыт из-за ошибки")
})
if (shouldInterrupt) return yield* Effect.interrupt
if (shouldFail) return yield* Effect.fail("error")
return "success"
})
)
// Тест успеха
Effect.runPromiseExit(createTrackedEffect(false, false)).then(console.log)
// ✅ Ресурс закрыт нормально
// Тест ошибки
Effect.runPromiseExit(createTrackedEffect(true, false)).then(console.log)
// ❌ Ресурс закрыт из-за ошибки
// Тест прерывания
Effect.runPromiseExit(createTrackedEffect(false, true)).then(console.log)
// ⚡ Ресурс закрыт из-за прерыванияimport { Effect, Console, Exit, Cause } from "effect"
const createTrackedEffect = (shouldFail: boolean, shouldInterrupt: boolean) =>
Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer((exit) => {
if (Exit.isSuccess(exit)) {
return Console.log("✅ Ресурс закрыт нормально")
}
const cause = exit.cause
if (Cause.isInterruptedOnly(cause)) {
return Console.log("⚡ Ресурс закрыт из-за прерывания")
}
return Console.log("❌ Ресурс закрыт из-за ошибки")
})
if (shouldInterrupt) return yield* Effect.interrupt
if (shouldFail) return yield* Effect.fail("error")
return "success"
})
)
// Тест успеха
Effect.runPromiseExit(createTrackedEffect(false, false)).then(console.log)
// ✅ Ресурс закрыт нормально
// Тест ошибки
Effect.runPromiseExit(createTrackedEffect(true, false)).then(console.log)
// ❌ Ресурс закрыт из-за ошибки
// Тест прерывания
Effect.runPromiseExit(createTrackedEffect(false, true)).then(console.log)
// ⚡ Ресурс закрыт из-за прерыванияУпражнение 4: Два независимых scope
Создайте два отдельных scope для двух ресурсов. Первый scope должен быть закрыт после использования первого ресурса, но до начала использования второго ресурса. Второй scope закрывается в конце.
import { Console, Effect, Exit, Scope } from "effect"
const exercise4 = Effect.gen(function* () {
const scope1 = yield* Scope.make()
const scope2 = yield* Scope.make()
const task1 = Effect.gen(function* () {
yield* Console.log("Task 1: acquired")
yield* Effect.addFinalizer(() => Console.log("Task 1: released"))
})
const task2 = Effect.gen(function* () {
yield* Console.log("Task 2: acquired")
yield* Effect.addFinalizer(() => Console.log("Task 2: released"))
})
yield* task1.pipe(Scope.extend(scope1))
yield* Scope.close(scope1, Exit.void)
yield* Console.log("--- Between tasks ---")
yield* task2.pipe(Scope.extend(scope2))
yield* Scope.close(scope2, Exit.void)
})
Effect.runPromise(exercise4)
/*
Task 1: acquired
Task 1: released
--- Between tasks ---
Task 2: acquired
Task 2: released
*/import { Console, Effect, Exit, Scope } from "effect"
const exercise4 = Effect.gen(function* () {
const scope1 = yield* Scope.make()
const scope2 = yield* Scope.make()
const task1 = Effect.gen(function* () {
yield* Console.log("Task 1: acquired")
yield* Effect.addFinalizer(() => Console.log("Task 1: released"))
})
const task2 = Effect.gen(function* () {
yield* Console.log("Task 2: acquired")
yield* Effect.addFinalizer(() => Console.log("Task 2: released"))
})
yield* task1.pipe(Scope.extend(scope1))
yield* Scope.close(scope1, Exit.void)
yield* Console.log("--- Between tasks ---")
yield* task2.pipe(Scope.extend(scope2))
yield* Scope.close(scope2, Exit.void)
})
Effect.runPromise(exercise4)
/*
Task 1: acquired
Task 1: released
--- Between tasks ---
Task 2: acquired
Task 2: released
*/