Sequence of Operations with Compensating Actions on Failure
In certain scenarios, you might need to perform a sequence of chained operations where the success of each operation depends on the previous one. However, if any of the operations fail, you would want to reverse the effects of all previous successful operations. This pattern is valuable when you need to ensure that either all operations succeed, or none of them have any effect at all.
Effect offers a way to achieve this pattern using the acquireRelease
function in combination with the Exit
type. The acquireRelease
function allows you to acquire a resource, perform operations with it, and release the resource when you're done. The Exit
type represents the outcome of an effectful computation, indicating whether it succeeded or failed.
Let's go through an example of implementing this pattern. Suppose we want to create a "Workspace" in our application, which involves creating an S3 bucket, an ElasticSearch index, and a Database entry that relies on the previous two.
To begin, we define the domain model for the required services: S3
, ElasticSearch
, and Database
.
ts
import {Effect ,Context } from "effect"export classS3Error {readonly_tag = "S3Error"}export interfaceBucket {readonlyname : string}export classS3 extendsContext .Tag ("S3")<S3 ,{readonlycreateBucket :Effect .Effect <Bucket ,S3Error >readonlydeleteBucket : (bucket :Bucket ) =>Effect .Effect <void>}>() {}export classElasticSearchError {readonly_tag = "ElasticSearchError"}export interfaceIndex {readonlyid : string}export classElasticSearch extendsContext .Tag ("ElasticSearch")<ElasticSearch ,{readonlycreateIndex :Effect .Effect <Index ,ElasticSearchError >readonlydeleteIndex : (index :Index ) =>Effect .Effect <void>}>() {}export classDatabaseError {readonly_tag = "DatabaseError"}export interfaceEntry {readonlyid : string}export classDatabase extendsContext .Tag ("Database")<Database ,{readonlycreateEntry : (bucket :Bucket ,index :Index ) =>Effect .Effect <Entry ,DatabaseError >readonlydeleteEntry : (entry :Entry ) =>Effect .Effect <void>}>() {}
ts
import {Effect ,Context } from "effect"export classS3Error {readonly_tag = "S3Error"}export interfaceBucket {readonlyname : string}export classS3 extendsContext .Tag ("S3")<S3 ,{readonlycreateBucket :Effect .Effect <Bucket ,S3Error >readonlydeleteBucket : (bucket :Bucket ) =>Effect .Effect <void>}>() {}export classElasticSearchError {readonly_tag = "ElasticSearchError"}export interfaceIndex {readonlyid : string}export classElasticSearch extendsContext .Tag ("ElasticSearch")<ElasticSearch ,{readonlycreateIndex :Effect .Effect <Index ,ElasticSearchError >readonlydeleteIndex : (index :Index ) =>Effect .Effect <void>}>() {}export classDatabaseError {readonly_tag = "DatabaseError"}export interfaceEntry {readonlyid : string}export classDatabase extendsContext .Tag ("Database")<Database ,{readonlycreateEntry : (bucket :Bucket ,index :Index ) =>Effect .Effect <Entry ,DatabaseError >readonlydeleteEntry : (entry :Entry ) =>Effect .Effect <void>}>() {}
Next, we define the three create actions and the overall transaction (make
) for the Workspace.
ts
import {Effect ,Exit } from "effect"import * asServices from "./Services"// Create a bucket, and define the release function that deletes the bucket if the operation fails.constcreateBucket =Effect .gen (function* (_ ) {const {createBucket ,deleteBucket } = yield*_ (Services .S3 )return yield*_ (Effect .acquireRelease (createBucket , (bucket ,exit ) =>// The release function for the Effect.acquireRelease operation is responsible for handling the acquired resource (bucket) after the main effect has completed.// It is called regardless of whether the main effect succeeded or failed.// If the main effect failed, Exit.isFailure(exit) will be true, and the function will perform a rollback by calling deleteBucket(bucket).// If the main effect succeeded, Exit.isFailure(exit) will be false, and the function will return Effect.void, representing a successful, but do-nothing effect.Exit .isFailure (exit ) ?deleteBucket (bucket ) :Effect .void ))})// Create an index, and define the release function that deletes the index if the operation fails.constcreateIndex =Effect .gen (function* (_ ) {const {createIndex ,deleteIndex } = yield*_ (Services .ElasticSearch )return yield*_ (Effect .acquireRelease (createIndex , (index ,exit ) =>Exit .isFailure (exit ) ?deleteIndex (index ) :Effect .void ))})// Create an entry in the database, and define the release function that deletes the entry if the operation fails.constcreateEntry = (bucket :Services .Bucket ,index :Services .Index ) =>Effect .gen (function* (_ ) {const {createEntry ,deleteEntry } = yield*_ (Services .Database )return yield*_ (Effect .acquireRelease (createEntry (bucket ,index ), (entry ,exit ) =>Exit .isFailure (exit ) ?deleteEntry (entry ) :Effect .void ))})export constmake =Effect .scoped (Effect .gen (function* (_ ) {constbucket = yield*_ (createBucket )constindex = yield*_ (createIndex )return yield*_ (createEntry (bucket ,index ))}))
ts
import {Effect ,Exit } from "effect"import * asServices from "./Services"// Create a bucket, and define the release function that deletes the bucket if the operation fails.constcreateBucket =Effect .gen (function* (_ ) {const {createBucket ,deleteBucket } = yield*_ (Services .S3 )return yield*_ (Effect .acquireRelease (createBucket , (bucket ,exit ) =>// The release function for the Effect.acquireRelease operation is responsible for handling the acquired resource (bucket) after the main effect has completed.// It is called regardless of whether the main effect succeeded or failed.// If the main effect failed, Exit.isFailure(exit) will be true, and the function will perform a rollback by calling deleteBucket(bucket).// If the main effect succeeded, Exit.isFailure(exit) will be false, and the function will return Effect.void, representing a successful, but do-nothing effect.Exit .isFailure (exit ) ?deleteBucket (bucket ) :Effect .void ))})// Create an index, and define the release function that deletes the index if the operation fails.constcreateIndex =Effect .gen (function* (_ ) {const {createIndex ,deleteIndex } = yield*_ (Services .ElasticSearch )return yield*_ (Effect .acquireRelease (createIndex , (index ,exit ) =>Exit .isFailure (exit ) ?deleteIndex (index ) :Effect .void ))})// Create an entry in the database, and define the release function that deletes the entry if the operation fails.constcreateEntry = (bucket :Services .Bucket ,index :Services .Index ) =>Effect .gen (function* (_ ) {const {createEntry ,deleteEntry } = yield*_ (Services .Database )return yield*_ (Effect .acquireRelease (createEntry (bucket ,index ), (entry ,exit ) =>Exit .isFailure (exit ) ?deleteEntry (entry ) :Effect .void ))})export constmake =Effect .scoped (Effect .gen (function* (_ ) {constbucket = yield*_ (createBucket )constindex = yield*_ (createIndex )return yield*_ (createEntry (bucket ,index ))}))
We then create simple service implementations to test the behavior of our Workspace code.
To achieve this, we will utilize layers to construct test services.
These layers will be able to handle various scenarios, including errors, which we can control using the FailureCase
type.
ts
import {Effect ,Context ,Layer ,Console } from "effect"import * asServices from "./Services"import * asWorkspace from "./Workspace"// The `FailureCaseLiterals` type allows us to provide different error scenarios while testing our services.// For example, by providing the value "S3", we can simulate an error scenario specific to the S3 service.// This helps us ensure that our program handles errors correctly and behaves as expected in various situations.// Similarly, we can provide other values like "ElasticSearch" or "Database" to simulate error scenarios for those services.// In cases where we want to test the absence of errors, we can provide `undefined`.// By using this parameter, we can thoroughly test our services and verify their behavior under different error conditions.typeFailureCaseLiterals = "S3" | "ElasticSearch" | "Database" | undefinedclassFailureCase extendsContext .Tag ("FailureCase")<FailureCase ,FailureCaseLiterals >() {}// Create a test layer for the S3 serviceconstS3Test =Layer .effect (Services .S3 ,Effect .gen (function* (_ ) {constfailureCase = yield*_ (FailureCase )return {createBucket :Effect .gen (function* (_ ) {console .log ("[S3] creating bucket")if (failureCase === "S3") {return yield*_ (Effect .fail (newServices .S3Error ()))} else {return {name : "<bucket.name>" }}}),deleteBucket : (bucket ) =>Console .log (`[S3] delete bucket ${bucket .name }`)}}))// Create a test layer for the ElasticSearch serviceconstElasticSearchTest =Layer .effect (Services .ElasticSearch ,Effect .gen (function* (_ ) {constfailureCase = yield*_ (FailureCase )return {createIndex :Effect .gen (function* (_ ) {console .log ("[ElasticSearch] creating index")if (failureCase === "ElasticSearch") {return yield*_ (Effect .fail (newServices .ElasticSearchError ()))} else {return {id : "<index.id>" }}}),deleteIndex : (index ) =>Console .log (`[ElasticSearch] delete index ${index .id }`)}}))// Create a test layer for the Database serviceconstDatabaseTest =Layer .effect (Services .Database ,Effect .gen (function* (_ ) {constfailureCase = yield*_ (FailureCase )return {createEntry : (bucket ,index ) =>Effect .gen (function* (_ ) {console .log (`[Database] creating entry for bucket ${bucket .name } and index ${index .id }`)if (failureCase === "Database") {return yield*_ (Effect .fail (newServices .DatabaseError ()))} else {return {id : "<entry.id>" }}}),deleteEntry : (entry ) =>Console .log (`[Database] delete entry ${entry .id }`)}}))// Merge all the test layers for S3, ElasticSearch, and Database services into a single layerconstlayer =Layer .mergeAll (S3Test ,ElasticSearchTest ,DatabaseTest )// Create a runnable effect to test the Workspace code// The effect is provided with the test layer and a FailureCase service with undefined value (no failure case)construnnable =Workspace .make .pipe (Effect .provide (layer ),Effect .provideService (FailureCase ,undefined ))Effect .runPromise (Effect .either (runnable )).then (console .log )
ts
import {Effect ,Context ,Layer ,Console } from "effect"import * asServices from "./Services"import * asWorkspace from "./Workspace"// The `FailureCaseLiterals` type allows us to provide different error scenarios while testing our services.// For example, by providing the value "S3", we can simulate an error scenario specific to the S3 service.// This helps us ensure that our program handles errors correctly and behaves as expected in various situations.// Similarly, we can provide other values like "ElasticSearch" or "Database" to simulate error scenarios for those services.// In cases where we want to test the absence of errors, we can provide `undefined`.// By using this parameter, we can thoroughly test our services and verify their behavior under different error conditions.typeFailureCaseLiterals = "S3" | "ElasticSearch" | "Database" | undefinedclassFailureCase extendsContext .Tag ("FailureCase")<FailureCase ,FailureCaseLiterals >() {}// Create a test layer for the S3 serviceconstS3Test =Layer .effect (Services .S3 ,Effect .gen (function* (_ ) {constfailureCase = yield*_ (FailureCase )return {createBucket :Effect .gen (function* (_ ) {console .log ("[S3] creating bucket")if (failureCase === "S3") {return yield*_ (Effect .fail (newServices .S3Error ()))} else {return {name : "<bucket.name>" }}}),deleteBucket : (bucket ) =>Console .log (`[S3] delete bucket ${bucket .name }`)}}))// Create a test layer for the ElasticSearch serviceconstElasticSearchTest =Layer .effect (Services .ElasticSearch ,Effect .gen (function* (_ ) {constfailureCase = yield*_ (FailureCase )return {createIndex :Effect .gen (function* (_ ) {console .log ("[ElasticSearch] creating index")if (failureCase === "ElasticSearch") {return yield*_ (Effect .fail (newServices .ElasticSearchError ()))} else {return {id : "<index.id>" }}}),deleteIndex : (index ) =>Console .log (`[ElasticSearch] delete index ${index .id }`)}}))// Create a test layer for the Database serviceconstDatabaseTest =Layer .effect (Services .Database ,Effect .gen (function* (_ ) {constfailureCase = yield*_ (FailureCase )return {createEntry : (bucket ,index ) =>Effect .gen (function* (_ ) {console .log (`[Database] creating entry for bucket ${bucket .name } and index ${index .id }`)if (failureCase === "Database") {return yield*_ (Effect .fail (newServices .DatabaseError ()))} else {return {id : "<entry.id>" }}}),deleteEntry : (entry ) =>Console .log (`[Database] delete entry ${entry .id }`)}}))// Merge all the test layers for S3, ElasticSearch, and Database services into a single layerconstlayer =Layer .mergeAll (S3Test ,ElasticSearchTest ,DatabaseTest )// Create a runnable effect to test the Workspace code// The effect is provided with the test layer and a FailureCase service with undefined value (no failure case)construnnable =Workspace .make .pipe (Effect .provide (layer ),Effect .provideService (FailureCase ,undefined ))Effect .runPromise (Effect .either (runnable )).then (console .log )
Let's examine the test results for the scenario where FailureCase
is set to undefined
(happy path):
[S3] creating bucket[ElasticSearch] creating index[Database] creating entry for bucket <bucket.name> and index <index.id>{_id: "Either",_tag: "Right",right: {id: "<entry.id>"}}
[S3] creating bucket[ElasticSearch] creating index[Database] creating entry for bucket <bucket.name> and index <index.id>{_id: "Either",_tag: "Right",right: {id: "<entry.id>"}}
In this case, all operations succeed, and we see a successful result with right({ id: '<entry.id>' })
.
Now, let's simulate a failure in the Database
:
ts
const runnable = Workspace.make.pipe(Effect.provide(layer),Effect.provideService(Failure, "Database"))
ts
const runnable = Workspace.make.pipe(Effect.provide(layer),Effect.provideService(Failure, "Database"))
The console output will be:
[S3] creating bucket[ElasticSearch] creating index[Database] creating entry for bucket <bucket.name> and index <index.id>[ElasticSearch] delete index <index.id>[S3] delete bucket <bucket.name>{_id: "Either",_tag: "Left",left: {_tag: "DatabaseError"}}
[S3] creating bucket[ElasticSearch] creating index[Database] creating entry for bucket <bucket.name> and index <index.id>[ElasticSearch] delete index <index.id>[S3] delete bucket <bucket.name>{_id: "Either",_tag: "Left",left: {_tag: "DatabaseError"}}
You can observe that once the Database
error occurs, there is a complete rollback that deletes the ElasticSearch
index first and then the associated S3
bucket. The result is a failure with left(new DatabaseError())
.
Let's now make the index creation fail instead:
ts
const runnable = Workspace.make.pipe(Effect.provide(layer),Effect.provideService(Failure, "ElasticSearch"))
ts
const runnable = Workspace.make.pipe(Effect.provide(layer),Effect.provideService(Failure, "ElasticSearch"))
In this case, the console output will be:
[S3] creating bucket[ElasticSearch] creating index[S3] delete bucket <bucket.name>{_id: "Either",_tag: "Left",left: {_tag: "ElasticSearchError"}}
[S3] creating bucket[ElasticSearch] creating index[S3] delete bucket <bucket.name>{_id: "Either",_tag: "Left",left: {_tag: "ElasticSearchError"}}
As expected, once the ElasticSearch
index creation fails, there is a rollback that deletes the S3
bucket. The result is a failure with left(new ElasticSearchError())
.