Coordinate Tasks with Effect
Mix.install([
{:fun_park,
git: "https://github.com/JKWA/funpark_notebooks.git",
branch: "main"
}
])
Advanced Functional Programming with Elixir
|
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)