Deferred execution
Defer
adds three methods for working with deferred code execution:
defer
accepts a block and executes it (potentially) on a thread pool. It returns an object that can be awaited withwait
. These objects arePromise
s made byconcurrent-ruby
. You can use their API, but it's not fully supported and tested in conjunction with effects.wait
accepts a promise or an array of promises returned bydefer
and returns their values. The method blocks the current thread until all values are available.later
postpones block execution until the handler is finished (see examples below).
Defer
A simple example:
class CreateUser
include Dry::Effects.Resolve(:user_repo, :send_invitation)
include Dry::Effects.Defer
def call(values)
user = user_repo.create(values)
defer { send_invitation.(user) }
user
end
end
In the code above, send_invitation
is run on a thread pool. It's the simplest way to run code concurrently.
Code within the defer
block can use some effects but not all of them. For instance, Interrupt
is not supported because you cannot return from one thread to another. This is a limitation of Ruby and threads in general.
Handling
The default handler uses concurrent-ruby
to do the heavy lifting. As an option, it accepts the executor—usually a thread pool where the code will be run.
Three special values are also supported:
:io
returns the global pool for long, blocking (IO) tasks,:fast
returns the global pool for short, fast operations, and:immediate
returns the global ImmediateExecutor object.
By default, Dry::Effects::Handler.Defer
uses :io
.
class HandleDefer
include Dry::Effects::Handler.Defer(executor: :immediate)
def initialize(app)
@app = app
end
def call(env)
# defer tasks in @app will be run on the same thread
with_defer { @app.(env) }
end
end
The executor can be passed directly to with_defer
:
def call(env)
with_defer(executor: :fast) { @app.(env) }
end
Using null executor
For skipping deferred tasks, create a mocked executor
require 'concurrent/executor/executor_service'
NullExecutor = Object.new.extend(Concurrent::ExecutorService).tap do |null|
def null.post
end
end
and provide it in middleware
class HandleDefer
include Dry::Effects::Handler.Defer
include Dry::Effects::Handler.Env(:environment)
def initialize(app)
@app = app
end
def call(env)
with_defer(executor: executor) { @app.(env) }
end
def executor
environment.equal?(:test) ? NullExecutor : :io
end
end
Later
later
doesn't return a result that can be awaited. Instead, it starts deferred blocks on handler exit. Consider this example:
class CreateUser
def call(values)
user_repo.transaction do
user = user_repo.create(values)
defer { send_invitation.(user) }
user_account = account_repo.create(user)
user
end
end
end
There is no guarantee send_invitation
will be run after the transaction finishes. It may lead to race conditions or anomalies. If account_repo.create
fails with an exception, the transaction will be rolled back yet the invitation will be sent!
later
captures the block but doesn't run it:
later { send_invitation.(user) }
The invitaition will be sent when with_defer
exits:
with_defer { @app.(env) }
It usually happens outside of any transaction so that anomalies don't occur.