Scope (effect)
finalizerが必要なモノを扱う際に使う
Scopeは1つ以上のリソースのlifetimeを表す
Scopeがcloseされると、そのScope内の全てのリソースが解放される
定義時にfinalizerを指定して、リソースの解放方法を定義できる
リソース管理する言語機能やパターンだmrsekut.icon
解放される際に、finalizerを追加した逆の順序で実行される
docs
Scopeの理解
Scopeは型安全でないので切るタイミングは注意が必要
2つの構成物に分けて理解すると良さそう
① finalizerの追加
RにScopeが追加される
e.g.
Effect.addFinalizer
Effect.acquireRelease
Effect.forkScoped
Scope.make
② 新たなscopeの作成
つまり、scopeの開始と終了を明示する
Scopeの終了を明示するためにHoge.scopedを使うと捉えるのが良いと思うmrsekut.icon
開始はリソースの取得時なので、別にまあ、って感じなので
RのScopeを除去する
e.g.
Effect.scoped
Layer.scoped
Effect.acquireUseReleaseだけやや特殊
同じようなことをやっているが、Scopeという概念は出てこない
Scope.xxxはScopeを扱う低レベルなAPI
Scopeを明示的に作成したり、それを操作したりするのに使う
基本的には、Effect.hoge形式のもので十分そう
code:ts
const program =
Scope.make().pipe(
Effect.tap(scope =>
Scope.addFinalizer(scope, Console.log("finalizer 1"))
),
Effect.tap(scope =>
Scope.addFinalizer(scope, Console.log("finalizer 2"))
),
Effect.andThen(scope =>
Scope.close(scope, Exit.succeed("scope closed successfully"))
)
)
/*
出力:
finalizer 2 ← ファイナライザは逆順に実行される
finalizer 1
*/
Scope.make でscopeをを作る
Scope.addFinalizer で、この Scope が閉じられたときに実行する処理を登録
Scope.close した瞬間に finalizer が実行される
#wip
Scopeを使うと下記のことができるようにな
finalizerの追加
scopeを閉じる
3. Exit と「成功・失敗・割り込み」でも必ず後始末する
どの例も共通していて、
成功 (Exit.Success)
失敗 (Exit.Failure)
割り込み (Interrupt)
どの場合でも finalizer は必ず走る、という保証がポイント。
code:ts
yield* Effect.addFinalizer(exit =>
Console.log(Finalizer executed. Exit status: ${exit._tag})
)
成功パターン → exit._tag === "Success"
Effect.fail("Uh oh!") → Failure
Effect.interrupt → Failure だが cause が Interrupt
なので exit を見て「失敗したときだけ rollback する」「成功したらなにもしない」みたいな制御ができる。
4. Scope のマージと手動での作成/クローズ
デフォルト:スコープはマージされる
code:ts
const task1 = Effect.gen(function* () {
console.log("task 1")
yield* Effect.addFinalizer(() => Console.log("finalizer after task 1"))
})
const task2 = Effect.gen(function* () {
console.log("task 2")
yield* Effect.addFinalizer(() => Console.log("finalizer after task 2"))
})
const program = Effect.gen(function* () {
yield* task1
yield* task2
})
Effect.runPromise(Effect.scoped(program))
ここでは:
task1 と task2 の finalizer は 同じ Scope に積まれる
実行順
task 1
task 2
finalizer after task 2
finalizer after task 1
つまり ひとつの大きなスコープの中で順にやって、最後に全部片付ける。
6. 複数ステップの「全部成功 or きれいにロールバック」パターン
最後の S3 / ElasticSearch / Database の例は、
「複数のリソースを順番に作るけど、途中で失敗したらそれまで作ったものを全部巻き戻したい」 というパターンの実装です。
それぞれのステップで acquireRelease + Exit 判定
code:ts
const createBucket = Effect.gen(function* () {
const { createBucket, deleteBucket } = yield* S3
return yield* Effect.acquireRelease(
createBucket, // bucket を作る
(bucket, exit) => // Scope close 時に呼ばれる
Exit.isFailure(exit)
? deleteBucket(bucket) // 失敗したら bucket を消す
: Effect.void // 成功なら何もしない(保持したまま)
)
})
同じことを ElasticSearch の index、Database の entry にもやっている。
全体を Effect.scoped で包む
code:ts
const make = Effect.scoped(
Effect.gen(function* () {
const bucket = yield* createBucket
const index = yield* createIndex
return yield* createEntry(bucket, index)
})
)
make の中で:
1. bucket を acquire(成功時、rollback 用 finalizer が Scope に積まれる)
2. index を acquire(同じく finalizer が Scope に積まれる)
3. entry を acquire(同上)
ここで どこかで失敗すると:
最外の Scope が Failure として閉じられる
さっき登録した finalizer たちが 逆順に実行 される
entry → delete
index → delete
bucket → delete
↓ 実際のログ
Database で失敗した場合:
code:_
S3 creating bucket
ElasticSearch creating index
Database creating entry ...
ElasticSearch delete index <index.id>
S3 delete bucket <bucket.name>
ElasticSearch で失敗した場合:
code:_
S3 creating bucket
ElasticSearch creating index
S3 delete bucket <bucket.name>
つまり:
Scope + acquireRelease + Exit.isFailure を組み合わせると
「疑似トランザクション」みたいなロールバック制御 が Effect レベルで書ける
操作の順次実行パターン
前の操作の成功に依存するような段階的な処理では、途中で失敗したときに、rollbackしたい
https://effect.website/docs/resource-management/scope/#example-pattern-sequencing-operations
↑ここに例が書いているので、一部抜粋
S3バケットをcreateする関数
code:ts
const createBucket = Effect.gen(function* () {
const { createBucket, deleteBucket } = yield* S3;
return yield* Effect.acquireRelease(createBucket, (bucket, exit) =>
Exit.isFailure(exit) ? deleteBucket(bucket) : Effect.void
);
});
Effect.acquireReleaseを使ってる
release時の処理を失敗したかどうかで書き分けている
失敗した場合はそのbucketを削除して、rollbackしている
S3 bucket作成に失敗するtestを書く
code:ts
const S3Test = Layer.effect(
S3,
Effect.gen(function* () {
const failureCase = yield* FailureCase;
return {
createBucket: Effect.gen(function* () {
console.log("S3 バケットを作成中");
if (failureCase === "S3") {
return yield* Effect.fail(new S3Error());
} else {
return { name: "<bucket.name>" };
}
}),
deleteBucket: (bucket) =>
Console.log([S3] バケット ${bucket.name} を削除),
};
})
);
Scopeのmerge
https://effect.website/docs/resource-management/scope/#manually-create-and-close-scopes
code:ts
import { Effect, Console } from "effect"
const task1 = Effect.gen(function* () {
console.log("task 1")
yield* Effect.addFinalizer(() => Console.log("finalizer after task 1"))
})
const task2 = Effect.gen(function* () {
console.log("task 2")
yield* Effect.addFinalizer(() => Console.log("finalizer after task 2"))
})
const program = Effect.gen(function* () {
// task1とtask2のスコープはマージされる
yield* task1
yield* task2
})
Effect.runPromise(Effect.scoped(program))
/*
出力:
task 1
task 2
finalizer after task 2
finalizer after task 1
*/
task1 と task2 のscopeがまとめられ、逆順でfinalierが実行される
手動にscopeのmakeとcloseもできる
http://effect.website/docs/resource-management/scope/#manually-create-and-close-scopes
Scope.extend
code:ts
const program = Effect.gen(function* () {
const scope1 = yield* Scope.make()
const scope2 = yield* Scope.make()
// task1 を scope1 に拡張
yield* task1.pipe(Scope.extend(scope1))
// task2 を scope2 に拡張
yield* task2.pipe(Scope.extend(scope2))
// scope1 を先にクローズ
yield* Scope.close(scope1, Exit.void)
yield* Console.log("doing something else")
// scope2 をクローズ
yield* Scope.close(scope2, Exit.void)
})
Effect.runPromise(program)
/*
出力:
task 1
task 2
finalizer after task 1
doing something else
finalizer after task 2
*/
Scope.extend
スコープ付きEffectを既存のスコープに延長(mount)する
Effectの実行が終わってもスコープは閉じられない
あるEffectのライフサイクルを外部のスコープに委ねることができる
Scopeをcloseしても、その中のタスクは中断されない
https://effect.website/docs/resource-management/scope/#manually-create-and-close-scopes