Effect Курс Scope — Концепция

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-компилятор на этапе компиляции гарантирует:

  1. Вы не забудете предоставить scope — если эффект требует Scope, его нельзя запустить без него.
  2. Scope не “утечёт”Effect.scoped удаляет Scope из типа Requirements, закрывая scope при завершении.
  3. Ресурсы всегда освобождены — если код скомпилировался, ресурсы будут освобождены.
     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

Легко

Создайте программу, которая:

  1. Создаёт scope через Scope.make()
  2. Добавляет три финализатора с сообщениями “A”, “B”, “C”
  3. Закрывает scope
  4. Убедитесь, что финализаторы выполняются в порядке 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
// 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
// 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)
// ⚡ Ресурс закрыт из-за прерывания
Упражнение

Упражнение 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
*/