Effect Курс acquireRelease

acquireRelease

Безопасное определение ресурсов.

Мотивация

Каждая production-система работает с ресурсами, которые нужно корректно освобождать. Типичные примеры:

┌───────────────────────────────────────────────────────────┐
│                 Ресурсы в production                      │
├───────────────────────────────────────────────────────────┤
│ Database connections    — pool.acquire() / pool.release() │
│ File handles           — open() / close()                 │
│ HTTP connections       — connect() / disconnect()         │
│ Locks / Semaphores     — acquire() / release()            │
│ Temp files             — create() / delete()              │
│ Child processes        — spawn() / kill()                 │
│ Subscriptions          — subscribe() / unsubscribe()      │
│ Transactions           — begin() / commit() or rollback() │
│ WebSocket connections  — open() / close()                 │
│ gRPC channels          — create() / shutdown()            │
└───────────────────────────────────────────────────────────┘

У всех этих ресурсов общий жизненный цикл: acquire → use → release. Effect формализует этот паттерн через два примитива.


Концепция ФП: Bracket Pattern

📖 Оба API в Effect основаны на классическом функциональном паттерне bracket, который впервые появился в Haskell:

-- Haskell bracket
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket acquire release use = ...

Bracket гарантирует три вещи:

  1. Acquire выполняется непрерываемо (uninterruptible) — нельзя прервать процесс на стадии частичного захвата ресурса.
  2. Release выполняется всегда после acquire — независимо от результата use.
  3. Use выполняется нормально — может быть прервано или завершиться ошибкой.
  Uninterruptible          Normal            Guaranteed
  ┌──────────┐      ┌──────────────┐      ┌──────────┐
  │ ACQUIRE  │─────▶│     USE      │─────▶│ RELEASE  │
  │          │      │              │      │          │
  │ Нельзя   │      │ Может быть   │      │ Всегда   │
  │ прервать │      │ прервано /   │      │ выполнен │
  │          │      │ упасть       │      │          │
  └──────────┘      └──────────────┘      └──────────┘
       │                   │                    ▲
       │                   │ error/interrupt    │
       │                   └────────────────────┘
       │                        release всё равно
       │                        вызывается

  Если acquire упал →
  release НЕ вызывается
  (ресурс не был захвачен)

acquireRelease

Определение

Effect.acquireRelease создаёт scoped-ресурс — ресурс, привязанный к scope и автоматически освобождаемый при его закрытии:


// Signature:
// Effect.acquireRelease: <A, E, R, R2>(
//   acquire: Effect<A, E, R>,
//   release: (a: A, exit: Exit<unknown, unknown>) => Effect<void, never, R2>
// ) => Effect<A, E, R | R2 | Scope>

const resource: Effect.Effect<MyResource, Error, Scope.Scope> =
  Effect.acquireRelease(
    acquireEffect,   // Как получить ресурс
    releaseFunction  // Как освободить ресурс
  )

Ключевые характеристики:

  • Возвращает Effect<A, E, Scope> — ресурс привязан к scope.
  • Acquire выполняется uninterruptible — защита от частичного захвата.
  • Release вызывается при закрытии scope — не при завершении текущего эффекта.
  • Release получает Exit — можно адаптировать поведение к результату.

Базовый пример


// Определение интерфейса ресурса
interface DatabaseConnection {
  readonly id: string
  readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>
}

// Acquire: создаём подключение
const acquireConnection = Effect.gen(function* () {
  yield* Console.log("Connecting to database...")
  const connection: DatabaseConnection = {
    id: `conn-${Date.now()}`,
    query: (sql) => Effect.succeed([{ result: sql }])
  }
  yield* Console.log(`Connected: ${connection.id}`)
  return connection
})

// Release: закрываем подключение
const releaseConnection = (conn: DatabaseConnection) =>
  Console.log(`Disconnected: ${conn.id}`)

// Scoped ресурс
//      ┌─── Effect<DatabaseConnection, never, Scope>
//      ▼
const dbConnection = Effect.acquireRelease(
  acquireConnection,
  releaseConnection
)

// Использование
const program = Effect.scoped(
  Effect.gen(function* () {
    const conn = yield* dbConnection
    const result = yield* conn.query("SELECT * FROM users")
    yield* Console.log(`Query result: ${JSON.stringify(result)}`)
    return result
  })
)

Effect.runPromise(program)
/*
  Connecting to database...
  Connected: conn-1234567890
  Query result: [{"result":"SELECT * FROM users"}]
  Disconnected: conn-1234567890
*/

Почему acquire — uninterruptible?

Представьте ситуацию без uninterruptible acquire:

  Fiber A: acquireConnection
  ┌──────────────────────────────┐
  │ 1. Создали TCP socket        │
  │ 2. Отправили handshake       │
  │ 3. ←── INTERRUPT ───         │  ← Прерывание между шагами!
  │ 4. Получили auth token       │  ← Не выполнилось
  │ 5. return connection         │  ← Не выполнилось
  └──────────────────────────────┘

  Результат: TCP socket утёк — release не вызван,
  потому что acquire не завершился

Effect защищает от этого, делая весь acquire-блок непрерываемым. Прерывание будет применено только после завершения acquire (и в этом случае release будет вызван).

Release получает Exit

Функция release принимает два аргумента: ресурс A и Exit, описывающий как завершился scope:


const resource = Effect.acquireRelease(
  Effect.succeed({ id: "res-1", tempFile: "/tmp/data.bin" }),
  (res, exit) => {
    // Всегда закрываем основной ресурс
    const cleanup = Console.log(`Closing resource ${res.id}`)

    // При ошибке — дополнительная очистка
    const errorCleanup = Exit.isFailure(exit)
      ? Console.log(`Cleaning up temp file: ${res.tempFile}`)
      : Effect.void

    return Effect.all([cleanup, errorCleanup]).pipe(Effect.asVoid)
  }
)

acquireUseRelease

Определение

Effect.acquireUseRelease — это самодостаточный вариант bracket, который не требует Scope. Он объединяет acquire, use и release в одну операцию:


// Signature:
// Effect.acquireUseRelease: <A, E, R, A2, E2, R2, R3>(
//   acquire: Effect<A, E, R>,
//   use: (a: A) => Effect<A2, E2, R2>,
//   release: (a: A, exit: Exit<A2, E2>) => Effect<void, never, R3>
// ) => Effect<A2, E | E2, R | R2 | R3>

//      ┌─── Effect<string, Error, never>  ← НЕТ Scope в Requirements!
//      ▼
const result = Effect.acquireUseRelease(
  acquire,   // Как получить ресурс
  use,       // Что с ним делать
  release    // Как освободить
)

Ключевое отличие от acquireRelease:

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  acquireRelease            acquireUseRelease                │
│  ┌────────────────┐        ┌────────────────────┐           │
│  │ Требует Scope  │        │ НЕ требует Scope   │           │
│  │ Release при    │        │ Release сразу после│           │
│  │ закрытии scope │        │ завершения use     │           │
│  │ Композируемый  │        │ Самодостаточный    │           │
│  └────────────────┘        └────────────────────┘           │
│                                                             │
│  Ресурс "живёт"            Ресурс "живёт"                   │
│  пока scope открыт         только на время use              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Базовый пример


// Интерфейс ресурса
interface MyResource {
  readonly contents: string
  readonly close: () => Promise<void>
}

// Симуляция получения ресурса
const getMyResource = (): Promise<MyResource> =>
  Promise.resolve({
    contents: "lorem ipsum",
    close: () =>
      new Promise((resolve) => {
        console.log("Resource released")
        resolve()
      })
  })

// Определяем acquire
const acquire = Effect.tryPromise({
  try: () =>
    getMyResource().then((res) => {
      console.log("Resource acquired")
      return res
    }),
  catch: () => new Error("getMyResourceError")
})

// Определяем release
const release = (res: MyResource) => Effect.promise(() => res.close())

// Определяем use
const use = (res: MyResource) =>
  Console.log(`Content is: ${res.contents}`)

// Всё вместе — НЕ требует Scope
//      ┌─── Effect<void, Error, never>
//      ▼
const program = Effect.acquireUseRelease(acquire, use, release)

Effect.runPromise(program)
/*
  Resource acquired
  Content is: lorem ipsum
  Resource released
*/

Сравнение подходов

Когда использовать acquireRelease

Используйте acquireRelease, когда ресурс нужен на протяжении жизни scope и может быть использован несколькими операциями:


// Ресурс нужен нескольким операциям
const program = Effect.scoped(
  Effect.gen(function* () {
    const conn = yield* Effect.acquireRelease(
      Effect.succeed({ id: "conn-1" }),
      (c) => Console.log(`Closing ${c.id}`)
    )

    // Используем conn многократно
    yield* Console.log(`Operation 1 with ${conn.id}`)
    yield* Console.log(`Operation 2 with ${conn.id}`)
    yield* Console.log(`Operation 3 with ${conn.id}`)
    // conn освобождается при выходе из Effect.scoped
  })
)

Когда использовать acquireUseRelease

Используйте acquireUseRelease, когда ресурс нужен только для одной операции и его время жизни полностью определяется этой операцией:


// Ресурс нужен для одной задачи
const readFileContents = (path: string) =>
  Effect.acquireUseRelease(
    // Acquire: открыть файл
    Effect.sync(() => {
      console.log(`Opening ${path}`)
      return { path, fd: 42 }
    }),
    // Use: прочитать содержимое
    (handle) =>
      Effect.sync(() => {
        console.log(`Reading ${handle.path}`)
        return "file contents"
      }),
    // Release: закрыть файл
    (handle) =>
      Effect.sync(() => { console.log(`Closing ${handle.path}`) })
  )

Таблица сравнения

┌──────────────────────┬──────────────────┬─────────────────────┐
│ Характеристика       │ acquireRelease   │ acquireUseRelease   │
├──────────────────────┼──────────────────┼─────────────────────┤
│ Требует Scope        │ Да               │ Нет                 │
│ Момент release       │ При закрытии     │ Сразу после use     │
│                      │ scope            │                     │
│ Множественное        │ Да               │ Нет                 │
│ использование        │                  │                     │
│ Композиция ресурсов  │ Отличная         │ Ограниченная        │
│ Типичное применение  │ DB connections,  │ Одноразовое чтение, │
│                      │ HTTP clients,    │ кратковременные     │
│                      │ server sockets   │ операции            │
│ Интеграция с Layer   │ Через Layer.     │ Не интегрируется    │
│                      │ scoped           │ напрямую            │
│ Acquire              │ Uninterruptible  │ Uninterruptible     │
│ Release гарантия     │ При close scope  │ После use           │
└──────────────────────┴──────────────────┴─────────────────────┘

Условный release через Exit

Одна из мощнейших возможностей — адаптация release-логики в зависимости от результата:


// Паттерн: rollback при ошибке, ничего при успехе
const createBucketSafely = Effect.gen(function* () {
  const s3 = yield* S3Service
  return yield* Effect.acquireRelease(
    s3.createBucket("my-bucket"),
    (bucket, exit) =>
      Exit.isFailure(exit)
        ? s3.deleteBucket(bucket).pipe(
            Effect.tap(() =>
              Console.log(`Rollback: deleted bucket ${bucket.name}`)
            )
          )
        : Effect.void  // При успехе — ничего не делаем, bucket нужен
  )
})

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


API Reference

Effect.acquireRelease [STABLE]


// Создаёт scoped-ресурс
Effect.acquireRelease<A, E, R, R2>(
  acquire: Effect<A, E, R>,
  release: (a: A, exit: Exit<unknown, unknown>) => Effect<void, never, R2>
): Effect<A, E, R | R2 | Scope>

Параметры:

  • acquire — эффект захвата ресурса (выполняется uninterruptible)
  • release — функция освобождения, принимает ресурс и Exit

Возвращает: Effect<A, E, R | R2 | Scope> — ресурс, привязанный к scope

Effect.acquireUseRelease [STABLE]


// Самодостаточный bracket
Effect.acquireUseRelease<A, E, R, A2, E2, R2, R3>(
  acquire: Effect<A, E, R>,
  use: (a: A) => Effect<A2, E2, R2>,
  release: (a: A, exit: Exit<A2, E2>) => Effect<void, never, R3>
): Effect<A2, E | E2, R | R2 | R3>

Параметры:

  • acquire — эффект захвата (uninterruptible)
  • use — функция использования ресурса
  • release — функция освобождения

Возвращает: Effect<A2, E | E2, R | R2 | R3>без Scope в Requirements

Effect.ensuring [STABLE]

Упрощённый вариант — гарантирует выполнение эффекта при любом исходе, но без доступа к ресурсу:


// Финализатор без ресурса
const program = Effect.succeed(42).pipe(
  Effect.ensuring(Console.log("Always runs"))
)

Паттерн: Транзакционная цепочка операций

Один из самых важных production-паттернов — цепочка зависимых операций с полным rollback при любой ошибке. Рассмотрим создание “Workspace”, включающего S3 bucket, ElasticSearch index и запись в БД.

Определение сервисов


// Tagged errors для каждого сервиса
class S3Error extends Data.TaggedError("S3Error")<{
  readonly message: string
}> {}

class ElasticSearchError extends Data.TaggedError("ElasticSearchError")<{
  readonly message: string
}> {}

class DatabaseError extends Data.TaggedError("DatabaseError")<{
  readonly message: string
}> {}

// Типы ресурсов
interface Bucket {
  readonly name: string
}

interface Index {
  readonly id: string
}

interface Entry {
  readonly id: string
}

// Сервисы
class S3 extends Context.Tag("S3")<
  S3,
  {
    readonly createBucket: Effect.Effect<Bucket, S3Error>
    readonly deleteBucket: (bucket: Bucket) => Effect.Effect<void>
  }
>() {}

class ElasticSearch extends Context.Tag("ElasticSearch")<
  ElasticSearch,
  {
    readonly createIndex: Effect.Effect<Index, ElasticSearchError>
    readonly deleteIndex: (index: Index) => Effect.Effect<void>
  }
>() {}

class Database extends Context.Tag("Database")<
  Database,
  {
    readonly createEntry: (
      bucket: Bucket,
      index: Index
    ) => Effect.Effect<Entry, DatabaseError>
    readonly deleteEntry: (entry: Entry) => Effect.Effect<void>
  }
>() {}

Транзакционные операции с rollback

// Каждая операция использует acquireRelease с условным release

// Создание bucket с rollback при ошибке
const createBucket = Effect.gen(function* () {
  const { createBucket, deleteBucket } = yield* S3
  return yield* Effect.acquireRelease(
    createBucket,
    (bucket, exit) =>
      Exit.isFailure(exit) ? deleteBucket(bucket) : Effect.void
  )
})

// Создание index с rollback при ошибке
const createIndex = Effect.gen(function* () {
  const { createIndex, deleteIndex } = yield* ElasticSearch
  return yield* Effect.acquireRelease(
    createIndex,
    (index, exit) =>
      Exit.isFailure(exit) ? deleteIndex(index) : Effect.void
  )
})

// Создание записи с rollback при ошибке
const createEntry = (bucket: Bucket, index: Index) =>
  Effect.gen(function* () {
    const { createEntry, deleteEntry } = yield* Database
    return yield* Effect.acquireRelease(
      createEntry(bucket, index),
      (entry, exit) =>
        Exit.isFailure(exit) ? deleteEntry(entry) : Effect.void
    )
  })

// Транзакционная композиция
const createWorkspace = Effect.scoped(
  Effect.gen(function* () {
    const bucket = yield* createBucket
    const index = yield* createIndex
    const entry = yield* createEntry(bucket, index)
    return { bucket, index, entry }
  })
)

Как работает rollback

  Успех: Все операции выполнены
  ┌──────────┐   ┌──────────┐   ┌──────────┐
  │ Create   │──▶│ Create   │──▶│ Create   │──▶ ✅ Success
  │ S3 Bucket│   │ ES Index │   │ DB Entry │
  └──────────┘   └──────────┘   └──────────┘

  Ошибка на шаге 3: Rollback шагов 2 и 1
  ┌──────────┐   ┌──────────┐   ┌──────────┐
  │ Create   │──▶│ Create   │──▶│ Create   │──▶ ❌ DatabaseError
  │ S3 Bucket│   │ ES Index │   │ DB Entry │
  └──────────┘   └──────────┘   └──────────┘
       ▲              ▲
       │              │
  ┌──────────┐   ┌──────────┐
  │ Delete   │◀──│ Delete   │   Rollback в LIFO-порядке
  │ S3 Bucket│   │ ES Index │
  └──────────┘   └──────────┘

  Ошибка на шаге 2: Rollback только шага 1
  ┌──────────┐   ┌──────────┐
  │ Create   │──▶│ Create   │──▶ ❌ ElasticSearchError
  │ S3 Bucket│   │ ES Index │
  └──────────┘   └──────────┘


  ┌──────────┐
  │ Delete   │   Rollback только S3
  │ S3 Bucket│
  └──────────┘

Примеры

Пример 1: HTTP-клиент с connection pool


interface HttpClient {
  readonly baseUrl: string
  readonly get: (path: string) => Effect.Effect<string>
}

const makeHttpClient = (baseUrl: string) =>
  Effect.acquireRelease(
    // Acquire: создаём клиент
    Effect.gen(function* () {
      yield* Console.log(`Creating HTTP client for ${baseUrl}`)
      const client: HttpClient = {
        baseUrl,
        get: (path) => Effect.succeed(`Response from ${baseUrl}${path}`)
      }
      return client
    }),
    // Release: закрываем клиент
    (client) => Console.log(`Closing HTTP client for ${client.baseUrl}`)
  )

const program = Effect.scoped(
  Effect.gen(function* () {
    const client = yield* makeHttpClient("https://api.example.com")
    const users = yield* client.get("/users")
    const posts = yield* client.get("/posts")
    yield* Console.log(`Users: ${users}`)
    yield* Console.log(`Posts: ${posts}`)
  })
)

Effect.runPromise(program)
/*
  Creating HTTP client for https://api.example.com
  Users: Response from https://api.example.com/users
  Posts: Response from https://api.example.com/posts
  Closing HTTP client for https://api.example.com
*/

Пример 2: Чтение файла через acquireUseRelease


interface FileHandle {
  readonly path: string
  readonly fd: number
}

const readFile = (path: string): Effect.Effect<string, Error> =>
  Effect.acquireUseRelease(
    // Acquire
    Effect.sync(() => {
      console.log(`Opening file: ${path}`)
      return { path, fd: Math.floor(Math.random() * 1000) } as FileHandle
    }),
    // Use
    (handle) =>
      Effect.sync(() => {
        console.log(`Reading file: ${handle.path} (fd=${handle.fd})`)
        return `Contents of ${handle.path}`
      }),
    // Release
    (handle) =>
      Effect.sync(() => {
        console.log(`Closing file: ${handle.path} (fd=${handle.fd})`)
      })
  )

const program = Effect.gen(function* () {
  const content = yield* readFile("/etc/config.json")
  yield* Console.log(`Got: ${content}`)
})

Effect.runPromise(program)
/*
  Opening file: /etc/config.json
  Reading file: /etc/config.json (fd=42)
  Closing file: /etc/config.json (fd=42)
  Got: Contents of /etc/config.json
*/

Пример 3: Несколько ресурсов в одном scope


const makeResource = (name: string) =>
  Effect.acquireRelease(
    Console.log(`Acquire: ${name}`).pipe(Effect.as(name)),
    (n) => Console.log(`Release: ${n}`)
  )

const program = Effect.scoped(
  Effect.gen(function* () {
    const db = yield* makeResource("Database")
    const cache = yield* makeResource("Cache")
    const queue = yield* makeResource("MessageQueue")

    yield* Console.log(`Using: ${db}, ${cache}, ${queue}`)
  })
)

Effect.runPromise(program)
/*
  Acquire: Database
  Acquire: Cache
  Acquire: MessageQueue
  Using: Database, Cache, MessageQueue
  Release: MessageQueue     ← LIFO: последний — первый
  Release: Cache
  Release: Database
*/

Пример 4: Release с обработкой ошибки в acquire


// Если acquire падает — release НЕ вызывается
const program = Effect.scoped(
  Effect.gen(function* () {
    yield* Console.log("Before acquire")

    yield* Effect.acquireRelease(
      Effect.fail("Acquire failed!"),  // ❌ Acquire падает
      () => Console.log("This will NEVER run")  // Не вызовется
    )
  })
)

Effect.runPromiseExit(program).then(console.log)
/*
  Before acquire
  {
    _id: 'Exit',
    _tag: 'Failure',
    cause: { _id: 'Cause', _tag: 'Fail', failure: 'Acquire failed!' }
  }
  // "This will NEVER run" — не выведено
*/

Распространённые ошибки

Ошибка 1: Эффект в release, который может упасть


// ❌ Release не должен возвращать ошибки в канале E
// (тип release: Effect<void, never, R>)
const bad = Effect.acquireRelease(
  Effect.succeed("resource"),
  // @ts-expect-error — release должен быть Effect<void, never, R>
  (_res) => Effect.fail("release error")
)

// ✅ Правильно: обработать ошибку внутри release
const good = Effect.acquireRelease(
  Effect.succeed("resource"),
  (_res) =>
    Effect.gen(function* () {
      const result = yield* Effect.try(() => closeConnection())
      // Логируем ошибку, но не пробрасываем
    }).pipe(
      Effect.catchAll((e) => Effect.log(`Release error: ${e}`))
    )
)

Ошибка 2: Не обернуть acquireRelease в scoped


const resource = Effect.acquireRelease(
  Effect.succeed("data"),
  () => Console.log("released")
)

// ❌ Не скомпилируется — Scope не предоставлен
// Effect.runPromise(resource)

// ✅ Обернуть в Effect.scoped
Effect.runPromise(Effect.scoped(resource))

Ошибка 3: Путать acquireRelease и acquireUseRelease


// ❌ Используем acquireUseRelease, когда ресурс нужен за пределами use
const bad = Effect.acquireUseRelease(
  Effect.succeed({ id: "conn-1" }),
  (conn) => Effect.succeed(conn),  // Возвращаем conn, но он уже закрыт!
  (conn) => Console.log(`Closed ${conn.id}`)
)

// ✅ Используем acquireRelease, если ресурс нужен дольше
const good = Effect.acquireRelease(
  Effect.succeed({ id: "conn-1" }),
  (conn) => Console.log(`Closed ${conn.id}`)
)

Упражнения

Упражнение

Упражнение 1: Простой ресурс

Легко

Создайте acquireRelease для “ресурса” — таймера, который при acquire выводит “Timer started”, а при release — “Timer stopped with duration: Xms”.

import { Effect, Console } from "effect"

const timer = Effect.acquireRelease(
  Effect.sync(() => {
    const start = Date.now()
    console.log("Timer started")
    return start
  }),
  (start) =>
    Effect.sync(() => {
      const duration = Date.now() - start
      console.log(`Timer stopped with duration: ${duration}ms`)
    })
)

const program = Effect.scoped(
  Effect.gen(function* () {
    const start = yield* timer
    yield* Effect.sleep("100 millis")
    yield* Console.log("Doing work...")
  })
)

Effect.runPromise(program)
Упражнение

Упражнение 2: acquireUseRelease для файла

Легко

Используйте acquireUseRelease для симуляции чтения файла: acquire открывает файл, use читает содержимое, release закрывает.

import { Effect } from "effect"

interface FileHandle {
  readonly path: string
  readonly content: string
}

const readFile = (path: string) =>
  Effect.acquireUseRelease(
    Effect.sync((): FileHandle => {
      console.log(`Open: ${path}`)
      return { path, content: `data from ${path}` }
    }),
    (handle) =>
      Effect.sync(() => {
        console.log(`Read: ${handle.path}`)
        return handle.content
      }),
    (handle) =>
      Effect.sync(() => {
        console.log(`Close: ${handle.path}`)
      })
  )

Effect.runPromise(readFile("/etc/hosts")).then(console.log)
// Open: /etc/hosts
// Read: /etc/hosts
// Close: /etc/hosts
// data from /etc/hosts
Упражнение

Упражнение 3: Множественные ресурсы с порядком release

Средне

Создайте три ресурса (Logger, Database, Cache) с помощью acquireRelease. Убедитесь, что при ошибке после создания всех трёх — все три корректно освобождаются в LIFO-порядке.

import { Effect, Console } from "effect"

const makeNamedResource = (name: string) =>
  Effect.acquireRelease(
    Console.log(`[${name}] Acquired`).pipe(Effect.as(name)),
    (n) => Console.log(`[${n}] Released`)
  )

const program = Effect.scoped(
  Effect.gen(function* () {
    yield* makeNamedResource("Logger")
    yield* makeNamedResource("Database")
    yield* makeNamedResource("Cache")
    // Ошибка после захвата всех ресурсов
    return yield* Effect.fail("Application error")
  })
)

Effect.runPromiseExit(program).then(console.log)
/*
  [Logger] Acquired
  [Database] Acquired
  [Cache] Acquired
  [Cache] Released
  [Database] Released
  [Logger] Released
  { _tag: 'Failure', cause: { _tag: 'Fail', failure: 'Application error' } }
*/
Упражнение

Упражнение 4: Транзакционная цепочка с условным rollback

Сложно

Реализуйте создание “инфраструктуры проекта”: Kubernetes Namespace → Helm Chart → DNS Record. При ошибке на любом шаге все предыдущие шаги должны быть откачены. При успехе — ресурсы остаются.

import { Effect, Console, Exit, Data } from "effect"

class InfraError extends Data.TaggedError("InfraError")<{
  readonly step: string
  readonly reason: string
}> {}

const createNamespace = (name: string) =>
  Effect.acquireRelease(
    Console.log(`[K8s] Creating namespace: ${name}`).pipe(
      Effect.as({ name })
    ),
    (ns, exit) =>
      Exit.isFailure(exit)
        ? Console.log(`[K8s] Rollback: deleting namespace ${ns.name}`)
        : Effect.void
  )

const deployHelmChart = (namespace: string, chart: string) =>
  Effect.acquireRelease(
    Console.log(`[Helm] Deploying ${chart} to ${namespace}`).pipe(
      Effect.as({ chart, namespace })
    ),
    (release, exit) =>
      Exit.isFailure(exit)
        ? Console.log(`[Helm] Rollback: uninstalling ${release.chart}`)
        : Effect.void
  )

const createDnsRecord = (domain: string, shouldFail: boolean) =>
  Effect.acquireRelease(
    shouldFail
      ? Effect.fail(new InfraError({ step: "DNS", reason: "Zone not found" }))
      : Console.log(`[DNS] Creating record: ${domain}`).pipe(
          Effect.as({ domain })
        ),
    (record, exit) =>
      Exit.isFailure(exit)
        ? Console.log(`[DNS] Rollback: deleting record ${record.domain}`)
        : Effect.void
  )

const deployProject = (shouldFailDns: boolean) =>
  Effect.scoped(
    Effect.gen(function* () {
      const ns = yield* createNamespace("my-project")
      const helm = yield* deployHelmChart(ns.name, "my-app")
      const dns = yield* createDnsRecord("app.example.com", shouldFailDns)
      return { namespace: ns, helm, dns }
    })
  )

// Тест: успех
Effect.runPromiseExit(deployProject(false)).then(console.log)
// [K8s] Creating namespace: my-project
// [Helm] Deploying my-app to my-project
// [DNS] Creating record: app.example.com
// { _tag: 'Success', ... }

// Тест: ошибка на DNS
Effect.runPromiseExit(deployProject(true)).then(console.log)
// [K8s] Creating namespace: my-project
// [Helm] Deploying my-app to my-project
// [Helm] Rollback: uninstalling my-app
// [K8s] Rollback: deleting namespace my-project
// { _tag: 'Failure', cause: { _tag: 'Fail', failure: InfraError } }