Effect systems like Polysemy provide the programmer with a flexible way to keep the business logic of a program as flexible as possible by separating the definition of effects and their interpretation. This is useful for many reasons, but especially for testing and mocking. For example, instead of using an interpreter that runs a task over the network, one can swap it for an in-memory implementation when running tests. This allows to test features in isolation.
To achieve this goal, effects should clearly convey functionality without exposing their implementation.
In many cases, this means exposing interpreters involving low-level constructs, such as IO, StateT or exceptions, only at the
outermost levels of the application.
However, for some kinds of effects, it can be hard to design an expressive interface due to the semantics of their primitive resources. One instance of those are resources whose lifetime is scoped to a small part of a program (called a region in this post), like a database connection.
In this post I will show why these effects are tricky, and outline the thought process that led to a solution that allows for transparent locally scoped resources.
The Use Case: A Synchronization Effect
Take for example an abstraction of an MVar, named Sync, used to signal a synchronization point between two threads
in this program:
import Polysemy
import Polysemy.Async
program :: Sem [Sync, Async, Output Text] ()
program = do
async do -- uses Async
output "background thread" -- uses Output Text
signal -- uses Sync
wait -- uses Sync
output "main thread" -- uses Output Text
main :: IO ()
main = do
log <- (runFinal . asyncToIOFinal . runOutputList . interpretSync) do
program
program
traverse_ putStrLn logThe semantics of Sync are that wait should block until signal gets executed; and when running program twice in a
row, the semantics shouldn’t change.
A simple implementation might look like the following:
data Sync :: Effect where
Wait :: Sync m ()
Signal :: Sync m ()
interpretSync ::
Member (Embed IO) r =>
InterpreterFor Sync r
interpretSync sem =
mv <- embed newEmptyMVar
run mv sem
where
run mv =
interpret \case
Wait -> embed (takeMVar mv)
Signal -> embed (putMVar mv)This interpreter chooses a concrete implementation with the primitives MVar and IO, which embody the “low-level
constructs” that, as mentioned in the introduction, should be run as far removed from the logic as possible.
Despite the MVar being shared among the two executions of program, this construct works as intended, since the calls
to wait are sequential.
However, the problems of this naive implementation start to show when running two instances of program
concurrently, causing a race condition – the second call to wait might take the MVar while the first call to
signal is executed. In other words, the interpreter cannot distinguish between the consumers of the effect:
main :: IO ()
main = do
(runFinal . asyncToIOFinal . interpretSync) do
async program
programLeaky Abstraction: Using Interpreters in Business Logic
A straightforward solution for the race condition above would be to run interpretSync directly at the call site.
program :: Sem [Async, Output, Embed IO] ()
program = do
interpretSync do -- Transforms the `Sync` requirement to `Embed IO`
async do
output "background thread"
signal
wait
output "main thread"This solution is nice because it restricts the use of the corresponding MVar to the region in which it is
used.
A restriction of a resource to a region, or scoping of a resource, is commonly performed using the bracket
combinator; the resource in question for this example is the MVar.
Unfortunately, like bracket, the interpreter acquires a concrete resource in the supposedly abstract business logic
that propagates its constraints to any program that calls this function, as is evident from the Embed IO member
constraint.
This issue might be more clearly undesirable for effects that do actual I/O work, like database transactions:
data Database :: Effect where
Query :: AbstractQuery a -> Database query m a
Transact :: m a -> Database query m a
interpretDatabasePostgres ::
Member PostgresConnection r =>
InterpreterFor (Database PostgresQuery) r
interpretDatabasePostgres =
undefined
postgresProgram ::
Member PostgresConnection r =>
Sem r ()
postgresProgram =
interpretDatabasePostgres do
transact do
query (AbstractQuery.fetchById 1)This effect’s implementation (only sketched here) is more complex than Sync’s, but it
illustrates how committing to a concrete resource (here, a database connection) can
ruin the flexibility that effect systems provide — using interpretDatabasePostgres in postgresProgram
causes the implementation to be fixed to PostgreSQL, prohibiting the testing of
postgresProgram with an in-memory version of Database.
The Old Interpreter Switcheroo: Hiding the Implementation with Higher-Order Effects
In order to fix that implementation leak, the scoping part of interpretSync/interpretDatabasePostgres has to be
separated from the rest of the interpretation, so that the interpreter for Wait and Signal is provided with a
dynamically allocated resource.
Transact’s signature hints at a feature that can be exploited to achieve this: Higher-order effects.
This term denotes an effect constructor that uses the monad m in its parameters, allowing it to store an entire region
for evaluation in an interpreter.
A higher-order Sync.use :: Member Sync r => Sem r a -> Sem r a should have the following semantics, using program
from before:
main :: IO ()
main = do
(runFinal . asyncToIOFinal . interpretSync) do
async (Sync.use program) -- both calls to `use` should have their own `MVar`
Sync.use programThis snippet introduces a new effect constructor, Sync.use, which stores one instance of program.
Higher-order regions are notoriously difficult to deal with in interpreters, so the following sketches a simplified
version:
data Sync :: Effect where
Wait :: Sync m ()
Signal :: Sync m ()
Use :: m a -> Sync m a
interpretSyncWithMVar ::
Members [Error Text, Embed IO] r =>
MVar () ->
InterpreterFor Sync r
interpretSyncWithMVar mv =
interpretH \case
Wait -> embed (takeMVar mv)
Signal -> embed (putMVar mv)
Use region -> do
mv <- embed newEmptyMVar
interpretSyncWithMVar mv =<< runT region
interpretSync ::
Members [Error Text, Embed IO] r =>
InterpreterFor Sync r
interpretSync sem =
interpretH \case
Wait -> throw "Called Wait without Use"
Signal -> throw "Called Signal without Use"
Use region -> do
mv <- embed newEmptyMVar
interpretSyncWithMVar mv =<< runT regionThese two interpreters split the work – interpretSync is allocating the MVar resource, while interpretSyncWithMVar
implements the effect logic, the former refusing to handle any action it’s not equipped to deal with by throwing
runtime errors.
Our interpreter, interpretSync, makes use of one of Polysemy’s
features for higher-order interpretation: the function runT does not
interpret the Sync effect in region. This lets us switch from
interpretSync to interpretSyncWithMVar when interpreting Use.
The caveat of this solution is that runtime errors don’t prevent incorrect programs from being compiled; in other words,
the interpreter is unsound.
In the following example, it allows an accidental call to wait outside of the use region:
prog1 :: Members [Sync, Async] r => Sem r ()
prog1 = do
use do
async do
doStuff
signal
doOtherStuff
waitIn the rest of this article, however, I will build upon this idea, and describe the general scoped-resource abstraction that was built for Polysemy, where only sound programs can be written.
Generalizing the Problem
Let’s forget the specifics of Sync and focus on the subject matter: the allocation of resources scoped to a region of
the program.
The interpreter for a resource scoping effect, aptly named Scoped, should:
- Allocate a resource (the
MVar) whose lifetime is restricted to the region in which the effect is used - Allow multiple resource allocations within one interpreter
- Hide as much of the implementation from the use site as possible
- Be sound, i.e. not require exceptions for incorrect use
- Allow the business logic to explicitly specify where the resource is used, without knowledge of its implementation details
In the previous section the job of the outer interpreter, interpretSync, was precisely to acquire the resource and
pass it to the inner interpreter, interpretSyncWithMVar, which executes the effect-specific logic.
Consequently, the generalized version of it takes a resource acquisition action and a parameterized interpreter:
interpretScoped ::
Sem r resource ->
(resource -> InterpreterFor effect r) ->
InterpreterFor (Scoped resource effect) r
interpretScoped acquireResource scopedInterpreter = ...This already looks better – the second parameter’s type has the exact shape of
interpretSyncWithMVar.
The implementation now has to acquire the resource with the first argument and use the second one to interpret the
higher-order region.
Note that interpretScoped is an interpreter for Scoped resource effect. You should understand a program with effect Scoped resource effect as a program which can use effect under the condition that
it has acquired a resource. The inner region (stored in a Use in
our example), on the other hand, does actually use effect
directly. So the inner interpreter is an interpreter for effect itself.
We will also need something to play the role of Use: a function
to allocate a resource for a region. The region uses effect, but it
is used in a program that uses Scoped resource effect. In Polysemy, a
function that changes the effects available to a region is written as
an interpreter, which we will be calling scoped.
scoped ::
Member (Scoped resource effect) r =>
InterpreterFor effect rIn our concrete example, the effect parameter is Sync,
but the resource parameter must stay polymorphic, because the concrete implementation should remain hidden from the
business logic.
The hard part, however, is figuring out the implementation of scoped, and this requires some knowledge about
Polysemy’s internals.
Here Be Dragons: The Full Implementation
The Scoped resource effect effect must perform two tasks:
- Store the region in which the scope should be active
- Interpret effects of type
effectin a scope where aresourceexists
This suggests these two constructors for Scoped:
data Scoped (resource :: Type) (effect :: Effect) :: Effect where
InScope :: (resource -> m a) -> Scoped resource effect m a
Run :: resource -> effect m a -> Scoped resource effect m a
scoped ::
Member (Scoped resource effect) r =>
InterpreterFor effect r
scoped region =
send $ InScope \resource -> transform (Run resource) regionWe can store a region with InScope. Regions are stored as functions
resource -> m a so that the interpreter will be able to create and
inject a scoped resource. We then store the resource in the Run
constructor, which simply pairs up an effect with the scoped
resource. The implementation of scoped uses the transform combinator
from Polysemy, which converts an effect type into another.
The implementation of the interpreter, interpretScoped, follows:
interpretScoped ::
Sem r resource ->
(resource -> InterpreterFor effect r) ->
InterpreterFor (Scoped resource effect) r
interpretScoped acquireResource scopedInterpreter =
interpretH \case
Run resource action ->
scopedInterpreter resource (send action)
InScope region -> do
resource <- acquireResource
interpretScoped (region resource)Now Sync can be interpreted in terms of Scoped with all its benefits:
data Sync :: Effect where
Wait :: Sync m ()
Signal :: Sync m ()
interpretSync ::
Member (Embed IO) r =>
MVar () ->
InterpreterFor Sync r
interpretSync mv =
interpret \case
Wait -> embed (takeMVar mv)
Signal -> embed (putMVar mv)
program :: Sem [Scoped resource Sync, Async, Output Text] ()
program = do
scoped do
async do
output "background thread"
signal
wait
output "main thread"
main :: IO ()
main = do
log <- (runFinal . asyncToIOFinal . runOutputList . interpretScoped (embed newEmptyMVar) interpretSync) do
async program
program
traverse_ putStrLn logThe resource parameter stays polymorphic in program, to be instantiated as MVar only when interpretSync is
called in main, thereby hiding the implementation from the logic, while providing the mechanism by which GHC connects
the resource to the use site.
Wrapping Up
I’ve worked with Polysemy quite intensely, but when I started using this pattern I was surprised at the ergonomics it
displays in practice.
I already built several useful effects with it, like a publish/subscribe mechanism built on unagi channels that
duplicates the channel for each subscriber:
program = do
async do
Events.subscribe do
assertEqual 1 =<< Events.consume
async do
Events.subscribe do
assertEqual 1 =<< Events.consume
Events.publish 1Finally, I’d like to acknowledge the brilliant people who made this possible: Love Waern, whose genius manifested the magic of the implementation, Georgi Lyubenov, who stated the problem that motivated it, and Sandy Maguire, the creator of the amazing Polysemy.
Behind the scenes
Torsten is a Haskell architect with a passion for creating complex tools and libraries with cutting-edge technologies, seamless user experience, and detailed, accessible documentation.
If you enjoyed this article, you might be interested in joining the Tweag team.