Powered by AppSignal & Oban Pro

Coordinate Tasks with Effect

chapters/chap_10_effect.livemd

Coordinate Tasks with Effect

Mix.install([
  {:fun_park,
    git: "https://github.com/JKWA/funpark_notebooks.git",
    branch: "main"
  }
])

Advanced Functional Programming with Elixir

https://www.joekoski.com/assets/images/jkelixir_small.jpg” alt=”Book cover” width=”120” /> Interactive Examples from Chapter 10
Advanced Functional Programming with Elixir.

Build the Effect

```elixir
def pure(value) do
  %__MODULE__{
    effect: fn _env ->
      Task.async(fn -> Either.Right.pure(value) end)
    end
  }
end
```
```elixir
def pure(value) do
  %__MODULE__{
    effect: fn _env ->
      Task.async(fn -> Either.Left.pure(value) end)
    end
  }
end
```
```elixir
def run(effect, env \\ %{}) do
  effect.effect.(env)
  |> execute_effect()
end
```

Let’s start with a basic Effect, wrapping the number 40:

effect = FunPark.Monad.Effect.pure(40)

If we run it:

FunPark.Monad.Effect.run(effect)

An Elixir Task can fail. For example, we can create an Effect that contains a bomb to crash the underlying process:

bomb = fn -> raise "boom" end
bomb_effect = FunPark.Monad.Effect.lift_func(bomb)

If we run it:

FunPark.Monad.Effect.run(bomb_effect)

An Effect can also fail by timing out. Here is an Effect that takes 6 seconds, one more than our Task default:

long_delay = fn -> Process.sleep(6000) end
long_delay_effect = FunPark.Monad.Effect.lift_func(long_delay)

And if we run this long task:

FunPark.Monad.Effect.run(long_delay_effect)

Deferred Transformation

```elixir
def map(%__MODULE__{effect: effect_fn}, function) do
  %__MODULE__{
    effect: fn env ->
      Task.async(fn ->
        effect_fn.(env)
        |> Task.await()
        |> case do
          %Either.Right{right: value} ->
            try do
              Either.Right.pure(function.(value))
            rescue
              e -> Either.Left.pure(EffectError.new(:map, e))
            end
          
          %Either.Left{} = left -> left
        end
      end)
    end
  }
end
```
```elixir
def map(%__MODULE__{effect: effect_fn}, _function) do
  %__MODULE__{effect: effect_fn}
end
```

Let’s start with the Effect that, when run, returns the value 5:

five_effect = FunPark.Monad.Effect.pure(5)

And a simple function that adds one to the value submitted:

increment = fn v -> v + 1 end

We can compose a new Effect by mapping increment/1 to five_effect:

six_effect = five_effect |> FunPark.Monad.map(increment)

Now, when we run it in a protected boundary:

FunPark.Monad.Effect.run(six_effect)

Let’s see what happens when we start with the letter “A”:

alpha_effect = FunPark.Monad.Effect.pure("A")

And compose it with increment/1:

error_effect = alpha_effect |> FunPark.Monad.map(increment)

When we execute it in our controlled boundary:

FunPark.Monad.Effect.run(error_effect)

Let’s lift increment/1:

increment_effect = FunPark.Monad.Effect.from_try(increment)

And how do we compose with a Kleisli function? Use bind/2:

error_effect_bind = alpha_effect |> FunPark.Monad.bind(increment_effect)

And now, when we run it:

FunPark.Monad.Effect.run(error_effect_bind)

I/O typically takes time, so let’s simulate this with a half-second delay:

delay = fn value -> Process.sleep(500); value end

and map it to our six_effect:

long_six_effect = six_effect |> FunPark.Monad.map(delay)

And when we run it in our protected boundary:

FunPark.Monad.Effect.run(long_six_effect)

Effectful Store

```elixir
def add(%Ride{} = ride, table_name) when is_atom(table_name) do
  Effect.lift_either(fn ->
    Process.sleep(500)
    Store.insert_item(ride, table_name)
  end)
end
```
```elixir
def get(%Ride{id: id}, table_name) when is_atom(table_name) do
  Effect.lift_either(fn ->
    Process.sleep(500)
    case Store.get_item(id, table_name) do
      %Either.Right{right: data} -> Either.Right.pure(struct(Ride, data))
      %Either.Left{} = left -> left
    end
  end)
end
```
```elixir
def remove(%Ride{id: id}, table_name) when is_atom(table_name) do
  Effect.lift_either(fn ->
    Process.sleep(500)
    Store.delete_item(id, table_name)
  end)
end
```

Let’s start by adding a :schedule table to our ETS store:

FunPark.Store.create_table(:schedule)

Then generate the Apple Cart ride:

apple = FunPark.Ride.make("Apple Cart")

And create the effects for managing the Apple Cart:

save_effect = FunPark.Maintenance.Store.add(apple, :schedule)
get_effect = FunPark.Maintenance.Store.get(apple, :schedule)
remove_effect = FunPark.Maintenance.Store.remove(apple, :schedule)

We can run the save Effect:

FunPark.Monad.Effect.run(save_effect)

If we ask the store for Apple Cart:

FunPark.Monad.Effect.run(get_effect)

Now, if we remove Apple Cart:

FunPark.Monad.Effect.run(remove_effect)

And if we rerun the get_effect:

FunPark.Monad.Effect.run(get_effect)

We can also drop the entire table:

FunPark.Store.drop_table(:schedule)

And when we run the get Effect again:

FunPark.Monad.Effect.run(get_effect)

Maintenance Repository

```elixir
def create_store do
  tables = [:schedule, :unschedule, :lockout, :compliance]
  
  tables
  |> Enum.map(&Store.create_table/1)
  |> Either.sequence_a()
end
```

Success is represented by Right with the list of successfully created tables:

FunPark.Maintenance.Repo.create_store()

But if we run it again:

FunPark.Maintenance.Repo.create_store()
```elixir
def validate_ride_effect(%Ride{} = ride) do
  Effect.lift_either(fn -> Ride.validate(ride) end)
end
```
```elixir
def add_to_store_effect(%Ride{} = ride) do
  Effect.asks(fn %{table: table_name} ->
    Maintenance.Store.add(ride, table_name)
  end)
  |> Effect.bind(& &1)
end
```
```elixir
def add_ride_effect(%Ride{} = ride) do
  ride
  |> validate_ride_effect()
  |> Monad.bind(&add_to_store_effect/1)
end
```

Let’s start by creating our store and making a ride:

FunPark.Maintenance.Repo.create_store()
apple = FunPark.Ride.make("Apple Cart")

Next, create an Effect to add the Apple Cart to scheduled maintenance:

effect = FunPark.Maintenance.Repo.add_ride_effect(apple)

And finally execute our Effect with the table name:

FunPark.Monad.Effect.run(effect, %{table: :schedule})
```elixir
def add_schedule(%Ride{} = ride) do
  ride
  |> add_ride_effect()
  |> Effect.run(%{table: :schedule})
end
```

Let’s modify the Apple Cart with an invalid wait time:

invalid_apple = FunPark.Ride.change(apple, %{wait_time: -10})

Now, when we try to save it:

FunPark.Maintenance.Repo.add_schedule(invalid_apple)

If we delete the underlying table:

FunPark.Store.drop_table(:schedule)

And pass it a valid ride:

FunPark.Maintenance.Repo.add_schedule(apple)
```elixir
def add_unschedule(%Ride{} = ride) do
  ride |> add_ride_effect() |> Effect.run(%{table: :unschedule})
end

def add_lockout(%Ride{} = ride) do
  ride |> add_ride_effect() |> Effect.run(%{table: :lockout})
end

def add_compliance(%Ride{} = ride) do
  ride |> add_ride_effect() |> Effect.run(%{table: :compliance})
end
```
```elixir
def add_to_all(%Ride{} = ride) do
  ride
  |> Maintenance.Repo.add_schedule()
  |> Monad.bind(fn _ -> Maintenance.Repo.add_unschedule(ride) end)
  |> Monad.bind(fn _ -> Maintenance.Repo.add_lockout(ride) end)
  |> Monad.bind(fn _ -> Maintenance.Repo.add_compliance(ride) end)
end
```

Let’s start by generating a ride and making sure the store has all the tables:

apple = FunPark.Ride.make("Apple Cart")
FunPark.Maintenance.Repo.create_store()

Now we can add our ride to all the maintenance tables:

FunPark.Maintenance.add_to_all(apple)
```elixir
def remove_from_store_effect(%Ride{} = ride) do
  Effect.asks(fn %{table: table_name} ->
    Maintenance.Store.remove(ride, table_name)
  end)
  |> Effect.bind(& &1)
end
```
```elixir
def remove_ride_effect(%Ride{} = ride) do
  ride
  |> validate_ride_effect()
  |> Monad.bind(&remove_from_store_effect/1)
  |> Monad.map(fn _ -> ride end)
end
```
```elixir
def remove_schedule(%Ride{} = ride) do
  ride |> remove_ride_effect() |> Effect.run(%{table: :schedule})
end

def remove_unschedule(%Ride{} = ride) do
  ride |> remove_ride_effect() |> Effect.run(%{table: :unschedule})
end

def remove_lockout(%Ride{} = ride) do
  ride |> remove_ride_effect() |> Effect.run(%{table: :lockout})
end

def remove_compliance(%Ride{} = ride) do
  ride |> remove_ride_effect() |> Effect.run(%{table: :compliance})
end
```
```elixir
def remove_from_all(%Ride{} = ride) do
  operations = [
    &Maintenance.Repo.remove_schedule/1,
    &Maintenance.Repo.remove_unschedule/1,
    &Maintenance.Repo.remove_lockout/1,
    &Maintenance.Repo.remove_compliance/1
  ]
  
  operations
  |> Either.traverse_a(& &1.(ride))
  |> Monad.map(fn _ -> ride end)
end
```

First, let’s create our store and make an Apple Cart ride:

FunPark.Maintenance.Repo.create_store()
apple = FunPark.Ride.make("Apple Cart")

We can add the Apple Cart to all maintenance tables:

FunPark.Maintenance.add_to_all(apple)

and remove it as well:

FunPark.Maintenance.remove_from_all(apple)

But if we create an invalid Apple Cart:

invalid_apple = FunPark.Ride.change(apple, %{wait_time: -10})

The difference matters, adding Apple Cart to all our tables will stop at the first error:

FunPark.Maintenance.add_to_all(invalid_apple)

But removing it will attempt all removals independently, returning a list of validation errors:

FunPark.Maintenance.remove_from_all(invalid_apple)

Now let’s try a valid ride with a broken store:

FunPark.Store.drop_table(:schedule)

With add_to_all/1, the internal bind/2 will halt on the first error:

FunPark.Maintenance.add_to_all(apple)

But remove_from_all/1, uses sequence_a/1, and will independently run all removal effects:

FunPark.Maintenance.remove_from_all(apple)

Inject Behavior, Not Configuration

```elixir
def add_ride_effect(%Ride{} = ride) do
  ride
  |> validate_ride_effect()
  |> Monad.bind(&add_to_store_effect/1)
end
```
```elixir
def has_ride_effect(%Ride{} = ride) do
  Effect.asks(fn %{store: store, table: table_name} ->
    store.get(ride, table_name)
  end)
  |> Effect.bind(& &1)
end
```
```elixir
def in_schedule(%Ride{} = ride) do
  has_ride_effect(ride)
  |> Effect.map_env(fn env -> Map.put(env, :table, :schedule) end)
end

def in_unschedule(%Ride{} = ride) do
  has_ride_effect(ride)
  |> Effect.map_env(fn env -> Map.put(env, :table, :unschedule) end)
end

def in_lockout(%Ride{} = ride) do
  has_ride_effect(ride)
  |> Effect.map_env(fn env -> Map.put(env, :table, :lockout) end)
end

def in_compliance(%Ride{} = ride) do
  has_ride_effect(ride)
  |> Effect.map_env(fn env -> Map.put(env, :table, :compliance) end)
end
```

Again, let’s create the store and make an Apple Cart ride:

FunPark.Maintenance.Repo.create_store()
apple = FunPark.Ride.make("Apple Cart")

Next, generate an Effect to check if it is in scheduled maintenance:

effect = FunPark.Maintenance.Repo.in_schedule(apple)

and create the environment with our Store:

env = %{store: FunPark.Maintenance.Store}

When we run the Effect in the environment:

FunPark.Monad.Effect.run(effect, env)

But if we add Apple Cart to scheduled maintenance:

FunPark.Maintenance.Repo.add_schedule(apple)

and rerun our Effect:

FunPark.Monad.Effect.run(effect, env)
```elixir
def check_in_all(%Ride{} = ride) do
  env = %{store: Maintenance.Store}
  
  ride
  |> Maintenance.Repo.in_schedule()
  |> Monad.bind(&Maintenance.Repo.in_unschedule/1)
  |> Monad.bind(&Maintenance.Repo.in_lockout/1)
  |> Monad.bind(&Maintenance.Repo.in_compliance/1)
  |> Effect.run(env)
end
```

We start by creating our store and generating a new ride:

FunPark.Maintenance.Repo.create_store()
apple = FunPark.Ride.make("Apple Cart")

Since nothing has been added yet, check_in_all/1 fails with a Left:

FunPark.Maintenance.check_in_all(apple)

If we add the ride to all four maintenance tables:

FunPark.Maintenance.add_to_all(apple)

And rerun check_in_all/1:

FunPark.Maintenance.check_in_all(apple)

We remove it from our :lockout table:

FunPark.Maintenance.Repo.remove_lockout(apple)

Once again, if we run our check_in_all/1:

FunPark.Maintenance.check_in_all(apple)

Flip the Logic

```elixir
def assert_absent_effect(%Ride{} = ride, check_fn) do
  ride
  |> check_fn.()
  |> Effect.flip_either()
  |> Effect.bind(&right_if_absent/1)
  |> Effect.map_left(&replace_ride_with_reason/1)
end
```
```elixir
def not_in_schedule(%Ride{} = ride) do
  assert_absent_effect(ride, &in_schedule/1)
end

def not_in_unschedule(%Ride{} = ride) do
  assert_absent_effect(ride, &in_unschedule/1)
end

def not_in_lockout(%Ride{} = ride) do
  assert_absent_effect(ride, &in_lockout/1)
end

def not_in_compliance(%Ride{} = ride) do
  assert_absent_effect(ride, &in_compliance/1)
end
```
```elixir
def check_online_bind(%Ride{} = ride) do
  env = %{store: Maintenance.Store}
  
  ride
  |> Maintenance.Repo.not_in_schedule()
  |> Monad.bind(&Maintenance.Repo.not_in_unschedule/1)
  |> Monad.bind(&Maintenance.Repo.not_in_lockout/1)
  |> Monad.bind(&Maintenance.Repo.not_in_compliance/1)
  |> Effect.run(env)
end
```

Again, start by creating the store and a ride:

FunPark.Maintenance.Repo.create_store()
apple = FunPark.Ride.make("Apple Cart")

And let’s clean up the store to be certain Apple Cart is not saved:

FunPark.Maintenance.remove_from_all(apple)

Then check whether it’s online:

FunPark.Maintenance.check_online_bind(apple)

Now let’s add it back to all the tables:

FunPark.Maintenance.add_to_all(apple)

When we run the check again, we get an answer in just half a second:

FunPark.Maintenance.check_online_bind(apple)
```elixir
def online?(%Ride{} = ride) do
  ride |> check_online() |> Either.right?()
end
```
```elixir
def online?(%Ride{} = ride) do
  ride |> Maintenance.check_online() |> Either.right?()
end
```
```elixir
def ensure_online(%FunPark.Ride{online: false} = ride) do
  Left.pure(ValidationError.new("#{ride.name} is offline"))
end

def ensure_online(ride), do: Right.pure(ride)
```
```elixir
def ensure_online(%Ride{} = ride) do
  case Maintenance.check_online(ride) do
    %Either.Right{} -> Either.Right.pure(ride)
    %Either.Left{left: reason} -> Either.Left.pure(ValidationError.new(reason))
  end
end
```
```elixir
def check_online(%Ride{} = ride) do
  env = %{store: Maintenance.Store}
  validators = [
    &Maintenance.Repo.not_in_schedule/1,
    &Maintenance.Repo.not_in_unschedule/1,
    &Maintenance.Repo.not_in_lockout/1,
    &Maintenance.Repo.not_in_compliance/1
  ]
  
  Effect.validate(ride, validators)
  |> Effect.run(env)
end
```

Let’s clean up by removing Apple Cart from every table:

FunPark.Maintenance.remove_from_all(apple)

And check whether it’s online:

FunPark.Maintenance.check_online(apple)

And if we add it back to all the tables:

FunPark.Maintenance.add_to_all(apple)

When we run the check:

FunPark.Maintenance.check_online(apple)
```elixir
def validate_fast_pass_lane_b(patron, ride) do
  patron
  |> validate_eligibility(ride)
  |> Monad.bind(&ensure_vip_or_fast_pass(&1, ride))
  |> Monad.bind(fn validated_patron ->
    case Maintenance.ensure_online(ride) do
      %Either.Right{} -> Either.Right.pure(validated_patron)
      %Either.Left{} = left -> left
    end
  end)
end
```

First, let’s make sure our store is created, and generate a couple of patrons and a ride:

FunPark.Maintenance.Repo.create_store()

beth = FunPark.Patron.make("Beth", 16, 115)
elsie = FunPark.Patron.make("Elsie", 17, 135, ticket_tier: :vip)

haunted_mansion = FunPark.Ride.make(
  "Haunted Mansion", 
  min_age: 14, 
  min_height: 120
)

And check if Beth can enter the fast pass lane:

FunPark.Ride.FastLane.validate_fast_pass_lane_b(beth, haunted_mansion)

Let’s check Elsie:

FunPark.Ride.FastLane.validate_fast_pass_lane_b(elsie, haunted_mansion)

Later, the Haunted Mansion throws a fault and triggers a lockout:

FunPark.Maintenance.Repo.add_lockout(haunted_mansion)

Now, when we check Elsie:

FunPark.Ride.FastLane.validate_fast_pass_lane_b(elsie, haunted_mansion)