shibuya-core-0.10.0.0: test/Shibuya/App/LifecycleSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Shibuya.App.LifecycleSpec (spec) where
import Control.Concurrent (newEmptyMVar, putMVar, threadDelay)
import Control.Concurrent.NQE.Supervisor (Strategy (..))
import Control.Concurrent.STM (atomically, check, readTVar, readTVarIO, retry)
import Control.Exception (SomeException, mask, try)
import Control.Monad (forM_, join, void)
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Time (UTCTime (..), diffUTCTime, fromGregorian, getCurrentTime)
import Effectful (Eff, IOE, Limit (..), Persistence (..), UnliftStrategy (..), liftIO, runEff, withEffToIO, (:>))
import Effectful.Exception qualified as Exception
import OpenTelemetry.Attributes qualified as OTelAttributes
import OpenTelemetry.Trace.Core qualified as OTel
import Shibuya.Adapter (Adapter (..))
import Shibuya.App
( AppConfig (..),
QueueProcessor (..),
ShutdownConfig (..),
SupervisionStrategy (..),
defaultAppConfig,
mkBatchProcessor,
mkProcessor,
runApp,
stopAppGracefully,
waitApp,
)
import Shibuya.Batch (BatchConfig (..), ackAll, defaultBatchConfig)
import Shibuya.Core.Ack (AckDecision (..), HaltReason (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested, mkIngested)
import Shibuya.Core.Metrics (ProcessorId (..), ProcessorMetrics (..), ProcessorState (..), sampleMetrics)
import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..), mkEnvelope)
import Shibuya.Internal.App
( AppHandle (..),
OwnershipFailure (..),
acquireOwned,
)
import Shibuya.Internal.Runner.Master
( ProcessorLifecycle (..),
getLifecycleSnapshot,
startMaster,
stopMaster,
)
import Shibuya.Internal.Runner.Supervised (SupervisedProcessor (..), runSupervised)
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (Tracing, runTracing, runTracingNoop)
import Streamly.Data.Stream qualified as Stream
import Test.Hspec
import UnliftIO qualified as UIO
spec :: Spec
spec = describe "Shibuya.App lifecycle" $ do
it "cleans an acquired startup owner when cancellation lands at the transfer barrier" $ do
transferReached <- newEmptyMVar
cleanupCalled <- newEmptyMVar
worker <-
UIO.async $
runEff $
acquireOwned
(pure ())
(const $ liftIO $ putMVar cleanupCalled ())
(\() -> liftIO (putMVar transferReached ()) >> liftIO (atomically retry))
UIO.takeMVar transferReached
UIO.cancel worker
result <- UIO.wait worker
cleaned <- UIO.timeout 1_000_000 (UIO.takeMVar cleanupCalled)
case result of
Left (OwnedActionFailed failure Nothing) ->
Exception.isAsyncException failure `shouldBe` True
Left other -> expectationFailure $ "unexpected ownership failure: " <> show other
Right () -> expectationFailure "startup transfer unexpectedly completed"
cleaned `shouldBe` Just ()
it "waitApp returns after a handler halts" $ do
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
messages <- createTestMessages 10
let handler _ = pure $ AckHalt (HaltFatal "stop")
processor = mkProcessor (testAdapter messages) handler
app <- runAppOrFail IgnoreFailures 10 [(ProcessorId "halt", processor)]
waitApp app
_ <- stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app
pure ()
result `shouldBe` Just ()
it "stopAppGracefully returns True promptly after halt" $ do
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
messages <- createTestMessages 10
let handler _ = pure $ AckHalt (HaltFatal "stop")
processor = mkProcessor (testAdapter messages) handler
app <- runAppOrFail IgnoreFailures 10 [(ProcessorId "halt-stop", processor)]
liftIO $ threadDelay 200_000
startedAt <- liftIO getCurrentTime
drained <- stopAppGracefully (ShutdownConfig {drainTimeout = 5, totalShutdownTimeout = 6}) app
finishedAt <- liftIO getCurrentTime
pure (drained, diffUTCTime finishedAt startedAt)
case result of
Just (drained, elapsed) -> do
drained `shouldBe` True
elapsed `shouldSatisfy` (< 2)
Nothing -> expectationFailure "stopAppGracefully timed out"
it "cancellation sets done" $ do
doneAfterStop <- runEff $ runTracingNoop $ do
master <- startMaster IgnoreAll
sp <- runSupervised master 10 (ProcessorId "cancelled") Unordered Serial infiniteAdapter alwaysAckOk
liftIO $ threadDelay 50_000
stopMaster master
liftIO $ readTVarIO sp.done
doneAfterStop `shouldBe` True
it "waitApp returns after a batch handler halts" $ do
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
messages <- createTestMessages 10
let handler _info _msgs = pure $ ackAll (AckHalt (HaltFatal "stop"))
config = defaultBatchConfig {batchSize = 2, batchTimeout = 0.1}
processor = mkBatchProcessor (testAdapter messages) handler config
app <- runAppOrFail IgnoreFailures 10 [(ProcessorId "batch-halt", processor)]
waitApp app
_ <- stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app
pure ()
result `shouldBe` Just ()
it "halt wakes idle intake in every queue strategy and batch mode" $ do
let queue concurrency ordering =
QueueProcessor oneThenIdleAdapter (const (pure (AckHalt (HaltFatal "idle halt")))) ordering concurrency
batch =
mkBatchProcessor
oneThenIdleAdapter
(\_ _ -> pure (ackAll (AckHalt (HaltFatal "idle batch halt"))))
defaultBatchConfig {batchSize = 10, batchTimeout = 0.05}
processors =
[ (ProcessorId "idle-serial", queue Serial Unordered),
(ProcessorId "idle-ahead", queue (Ahead 2) Unordered),
(ProcessorId "idle-async", queue (Async 2) Unordered),
(ProcessorId "idle-keyed", queue (Async 2) PartitionedInOrder),
(ProcessorId "idle-batch", batch)
]
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
app <- runAppOrFail IgnoreFailures 10 processors
waitApp app
getLifecycleSnapshot app.master
case result of
Nothing -> expectationFailure "idle halt did not wake every processor"
Just snapshot ->
Map.elems snapshot `shouldSatisfy` all (== LifecycleStopped)
it "IgnoreFailures retains finalizer failure and message identity after waitApp" $ do
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
let pid = ProcessorId "retained-finalizer-failure"
processor = mkProcessor permanentFinalizerFailureAdapter alwaysAckOk
app <- runAppOrFail IgnoreFailures 10 [(pid, processor)]
waitApp app
snapshot <- getLifecycleSnapshot app.master
stopMaster app.master
pure (Map.lookup pid snapshot)
case result of
Just (Just (LifecycleFailed message (Just messageId))) -> do
message `shouldSatisfy` Text.isInfixOf "finalization failed"
messageId `shouldBe` MessageId "finalizer-failure"
other -> expectationFailure $ "expected retained finalizer failure, got: " <> show other
it "retains finalizer failure with tracing enabled" $ do
provider <- OTel.createTracerProvider [] OTel.emptyTracerProviderOptions
let instrumentation =
OTel.InstrumentationLibrary
{ OTel.libraryName = "shibuya-lifecycle-test",
OTel.libraryVersion = "",
OTel.librarySchemaUrl = "",
OTel.libraryAttributes = OTelAttributes.emptyAttributes
}
tracer = OTel.makeTracer provider instrumentation OTel.tracerOptions
pid = ProcessorId "traced-finalizer-failure"
result <-
UIO.timeout 5_000_000 $
runEff $
runTracing tracer $ do
app <- runAppOrFail IgnoreFailures 10 [(pid, mkProcessor permanentFinalizerFailureAdapter alwaysAckOk)]
waitApp app
snapshot <- getLifecycleSnapshot app.master
stopMaster app.master
pure (Map.lookup pid snapshot)
case result of
Just (Just (LifecycleFailed _ (Just (MessageId "finalizer-failure")))) -> pure ()
other -> expectationFailure $ "expected traced finalizer failure, got: " <> show other
it "StopAllOnFailure delivers exhausted finalization exactly once" $ do
let failing = mkProcessor permanentFinalizerFailureAdapter alwaysAckOk
result <-
UIO.timeout 15_000_000 $
UIO.withAsync (countLinkedDeliveries [(ProcessorId "finalizer-single-delivery", failing)]) UIO.wait
result `shouldBe` Just 1
it "attempts every adapter shutdown and stops all children when one throws" $ do
secondShutdownRef <- newIORef False
(shutdownResult, secondCalled, waited) <-
runEff $
runTracingNoop $ do
let throwing =
infiniteAdapter
{ adapterName = "test:throwing-shutdown",
shutdown = liftIO $ ioError (userError "first shutdown failed")
}
observing =
infiniteAdapter
{ adapterName = "test:observing-shutdown",
shutdown = liftIO $ writeIORef secondShutdownRef True
}
app <-
runAppOrFail
IgnoreFailures
10
[ (ProcessorId "a-throwing-shutdown", mkProcessor throwing alwaysAckOk),
(ProcessorId "z-observing-shutdown", mkProcessor observing alwaysAckOk)
]
result <-
Exception.try @SomeException $
stopAppGracefully
(ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2})
app
didWait <- liftIO $ UIO.timeout 1_000_000 (waitForDoneIO app)
observed <- liftIO $ readIORef secondShutdownRef
pure (result, observed, didWait)
shutdownResult `shouldSatisfy` isLeft
secondCalled `shouldBe` True
waited `shouldBe` Just ()
it "bounds a never-returning adapter shutdown with the total deadline" $ do
result <-
UIO.timeout 2_000_000 $
runEff $
runTracingNoop $ do
let blocking =
infiniteAdapter
{ adapterName = "test:blocking-shutdown",
shutdown = liftIO $ atomically retry
}
app <- runAppOrFail IgnoreFailures 10 [(ProcessorId "blocking-shutdown", mkProcessor blocking alwaysAckOk)]
drained <-
stopAppGracefully
(ShutdownConfig {drainTimeout = 10, totalShutdownTimeout = 0.1})
app
waitApp app
pure drained
result `shouldBe` Just False
it "runs adapter shutdown once for concurrent and repeated stop calls" $ do
shutdownCountRef <- newIORef (0 :: Int)
((first, second, repeated), shutdownCount) <-
runEff $
runTracingNoop $ do
let adapter =
infiniteAdapter
{ adapterName = "test:coordinated-shutdown",
shutdown = liftIO $ modifyIORef' shutdownCountRef (+ 1)
}
config = ShutdownConfig {drainTimeout = 0.05, totalShutdownTimeout = 1}
app <- runAppOrFail IgnoreFailures 10 [(ProcessorId "coordinated-shutdown", mkProcessor adapter alwaysAckOk)]
(firstResult, secondResult) <-
withEffToIO (ConcUnlift Persistent Unlimited) $ \runInIO ->
UIO.concurrently
(runInIO $ stopAppGracefully config app)
(runInIO $ stopAppGracefully config app)
repeatedResult <- stopAppGracefully config app
count <- liftIO $ readIORef shutdownCountRef
pure ((firstResult, secondResult, repeatedResult), count)
first `shouldBe` False
second `shouldBe` first
repeated `shouldBe` first
shutdownCount `shouldBe` 1
it "stops the master when the shutdown caller is cancelled during drain" $ do
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
handlerStarted <- liftIO newEmptyMVar
let handler _ = do
liftIO $ putMVar handlerStarted ()
liftIO $ atomically retry
processor = mkProcessor infiniteAdapter handler
config = ShutdownConfig {drainTimeout = 10, totalShutdownTimeout = 20}
app <- runAppOrFail IgnoreFailures 10 [(ProcessorId "cancel-drain", processor)]
liftIO $ UIO.takeMVar handlerStarted
withEffToIO (ConcUnlift Persistent Unlimited) $ \runInIO -> do
stopper <- UIO.async (runInIO $ stopAppGracefully config app)
threadDelay 20_000
UIO.cancel stopper
stopResult <- UIO.waitCatch stopper
completed <- UIO.timeout 1_000_000 (waitForDoneIO app)
pure (isLeft stopResult, completed)
result `shouldBe` Just (True, Just ())
it "graceful completion under StopAllOnFailure does not kill siblings" $ do
countBRef <- newIORef (0 :: Int)
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
messagesA <- createTestMessages 3
messagesB <- createTestMessages 30
let handlerA _ = pure AckOk
handlerB _ = do
liftIO $ threadDelay 10_000
liftIO $ modifyIORef' countBRef (+ 1)
pure AckOk
procA = mkProcessor (testAdapter messagesA) handlerA
procB = mkProcessor (testAdapter messagesB) handlerB
app <-
runAppOrFail
StopAllOnFailure
10
[(ProcessorId "complete-A", procA), (ProcessorId "complete-B", procB)]
waitApp app
_ <- stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app
liftIO $ readIORef countBRef
result `shouldBe` Just 30
it "halt under StopAllOnFailure does not kill siblings" $ do
countARef <- newIORef (0 :: Int)
countBRef <- newIORef (0 :: Int)
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
messagesA <- createTestMessages 10
messagesB <- createTestMessages 30
let handlerA _ = do
count <- liftIO $ readIORef countARef
liftIO $ modifyIORef' countARef (+ 1)
if count >= 1
then pure $ AckHalt (HaltFatal "A stops")
else pure AckOk
handlerB _ = do
liftIO $ threadDelay 10_000
liftIO $ modifyIORef' countBRef (+ 1)
pure AckOk
procA = mkProcessor (testAdapter messagesA) handlerA
procB = mkProcessor (testAdapter messagesB) handlerB
app <-
runAppOrFail
StopAllOnFailure
10
[(ProcessorId "halt-A", procA), (ProcessorId "halt-B", procB)]
waitApp app
_ <- stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app
liftIO $ readIORef countBRef
result `shouldBe` Just 30
it "failure under StopAllOnFailure kills siblings and propagates" $ do
countBRef <- newIORef (0 :: Int)
result <-
UIO.withAsync
( runEff $
runTracingNoop $ do
messagesB <- createTestMessages 50
let handlerA _ = pure AckOk
handlerB _ = do
liftIO $ threadDelay 20_000
liftIO $ modifyIORef' countBRef (+ 1)
pure AckOk
procA = mkProcessor (failingAfterAdapter 3 "Adapter A source failed!") handlerA
procB = mkProcessor (testAdapter messagesB) handlerB
app <-
runAppOrFail
StopAllOnFailure
10
[(ProcessorId "fail-A", procA), (ProcessorId "fail-B", procB)]
liftIO $ threadDelay 500_000
_ <- stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app
pure ()
)
UIO.waitCatch
case result of
Left err ->
Text.pack (show err) `shouldSatisfy` Text.isInfixOf "Adapter A source failed"
Right () ->
expectationFailure "Expected adapter A source failure to propagate"
countB <- readIORef countBRef
countB `shouldSatisfy` (< 50)
it "StopAllOnFailure delivers one processor failure to the caller exactly once" $ do
let failing = mkProcessor (failingAfterAdapter 0 "single-delivery failure") alwaysAckOk
result <-
UIO.timeout 15_000_000 $
UIO.withAsync (countLinkedDeliveries [(ProcessorId "single-delivery", failing)]) UIO.wait
result `shouldBe` Just 1
it "StopAllOnFailure delivers one failure exactly once while cancelling busy siblings" $ do
-- Siblings of every runner shape are cancelled by the supervisor; their
-- cancellation must not surface as further exceptions in the caller.
let failing = mkProcessor (failingAfterAdapter 3 "sibling-delivery failure") alwaysAckOk
serialSibling = mkProcessor infiniteAdapter alwaysAckOk
asyncSibling = (mkProcessor infiniteAdapter alwaysAckOk) {ordering = Unordered, concurrency = Async 4}
keyedSibling = (mkProcessor infiniteAdapter alwaysAckOk) {ordering = PartitionedInOrder, concurrency = Ahead 4}
batchSibling =
mkBatchProcessor
infiniteAdapter
(\_info _msgs -> pure (ackAll AckOk))
defaultBatchConfig {batchSize = 2, batchTimeout = 0.1}
result <-
UIO.timeout 15_000_000 $
UIO.withAsync
( countLinkedDeliveries
[ (ProcessorId "sibling-delivery", failing),
(ProcessorId "serial-sibling", serialSibling),
(ProcessorId "async-sibling", asyncSibling),
(ProcessorId "keyed-sibling", keyedSibling),
(ProcessorId "batch-sibling", batchSibling)
]
)
UIO.wait
result `shouldBe` Just 1
it "IgnoreFailures isolates a failing processor" $ do
countBRef <- newIORef (0 :: Int)
result <-
UIO.timeout 5_000_000 $
runEff $
runTracingNoop $ do
messagesB <- createTestMessages 20
let handlerA _ = pure AckOk
handlerB _ = do
liftIO $ threadDelay 10_000
liftIO $ modifyIORef' countBRef (+ 1)
pure AckOk
procA = mkProcessor (failingAfterAdapter 3 "Adapter A source failed!") handlerA
procB = mkProcessor (testAdapter messagesB) handlerB
app <-
runAppOrFail
IgnoreFailures
10
[(ProcessorId "ignore-fail-A", procA), (ProcessorId "ignore-fail-B", procB)]
waitApp app
metricsA <- processorMetrics app (ProcessorId "ignore-fail-A")
_ <- stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app
countB <- liftIO $ readIORef countBRef
pure (metricsA, countB)
case result of
Nothing -> expectationFailure "IgnoreFailures run timed out"
Just (metricsA, countB) -> do
countB `shouldBe` 20
case metricsA.state of
Failed msg _ -> msg `shouldSatisfy` Text.isInfixOf "Adapter A source failed"
other -> expectationFailure $ "Expected failed processor metrics, got: " ++ show other
runAppOrFail ::
(IOE :> es, Tracing :> es) =>
SupervisionStrategy ->
Int ->
[(ProcessorId, QueueProcessor es)] ->
Eff es (AppHandle es)
runAppOrFail strategy inboxSize processors = do
result <- runApp defaultAppConfig {strategy = strategy, inboxSize = inboxSize} processors
case result of
Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err) >> error "unreachable"
Right app -> pure app
-- | Run the processors under 'StopAllOnFailure' on the calling thread and count
-- the linked-thread exceptions that thread receives.
--
-- The deliveries are asynchronous exceptions, so one that lands between two
-- handlers escapes both. Everything therefore runs under 'mask' and waits only
-- inside 'restore', wrapped in base's 'try' (UnliftIO's deliberately ignores
-- asynchronous exceptions): none can arrive between iterations, and the count is
-- exact. The handle is retained until the end so garbage collection plays no part.
--
-- The first delivery gets a generous deadline, because a loaded machine may be
-- slow to schedule the failing processor; only the search for a /second/
-- delivery uses a short quiet window. A duplicate follows the first within
-- milliseconds, since both come from the same failure.
countLinkedDeliveries :: [(ProcessorId, QueueProcessor '[Tracing, IOE])] -> IO Int
countLinkedDeliveries processors =
mask $ \restore -> do
stopRef <- newIORef (pure ())
started <-
try @SomeException $
restore $
runEff $
runTracingNoop $ do
app <- runAppOrFail StopAllOnFailure 10 processors
liftIO $
writeIORef stopRef $
runEff $
runTracingNoop $
void (stopAppGracefully (ShutdownConfig {drainTimeout = 1, totalShutdownTimeout = 2}) app)
let arrivesWithin :: Int -> IO Bool
arrivesWithin micros = either (const True) (const False) <$> try @SomeException (restore (threadDelay micros))
countExtra :: Int -> IO Int
countExtra n = do
another <- arrivesWithin 500_000
if another then countExtra (n + 1) else pure n
first <- either (const (pure True)) (const (arrivesWithin 10_000_000)) started
deliveries <- if first then countExtra 1 else pure 0
join (readIORef stopRef)
pure deliveries
processorMetrics ::
(IOE :> es) =>
AppHandle es ->
ProcessorId ->
Eff es ProcessorMetrics
processorMetrics app pid =
case app of
AppHandle {processors = processorsMap} ->
case Map.lookup pid processorsMap of
Nothing -> liftIO $ expectationFailure ("missing processor: " <> show pid) >> error "unreachable"
Just (SupervisedProcessor {metrics = metricsHandle}, _) -> liftIO $ sampleMetrics metricsHandle
waitForDoneIO :: AppHandle es -> IO ()
waitForDoneIO app =
case app of
AppHandle {processors = processorsMap} ->
atomically $
forM_ (Map.elems processorsMap) $
\(sp, _) -> readTVar sp.done >>= check
alwaysAckOk :: (Applicative f) => a -> f AckDecision
alwaysAckOk _ = pure AckOk
testTime :: UTCTime
testTime = UTCTime (fromGregorian 2024 1 1) 0
createTestMessages :: (IOE :> es) => Int -> Eff es [Ingested es String]
createTestMessages n = traverse createTestMessage [1 .. n]
createTestMessage :: (IOE :> es) => Int -> Eff es (Ingested es String)
createTestMessage i = do
let msgId = MessageId $ "msg-" <> (if i < 10 then "0" else "") <> Text.pack (show i)
env =
(mkEnvelope msgId ("message-" <> show i))
{ cursor = Just (CursorInt i),
enqueuedAt = Just testTime
}
pure $ mkIngested env (AckHandle $ \_ -> pure ())
testAdapter :: [Ingested es String] -> Adapter es String
testAdapter messages =
Adapter
{ adapterName = "test:list",
source = Stream.fromList messages,
shutdown = pure ()
}
failingAfterAdapter :: (IOE :> es) => Int -> Text.Text -> Adapter es String
failingAfterAdapter goodCount failureText =
Adapter
{ adapterName = "test:failing",
source = Stream.unfoldrM step (0 :: Int),
shutdown = pure ()
}
where
step n
| n < goodCount = do
msg <- createTestMessage (n + 1)
pure (Just (msg, n + 1))
| otherwise = error (Text.unpack failureText)
infiniteAdapter :: (IOE :> es) => Adapter es String
infiniteAdapter =
Adapter
{ adapterName = "test:infinite",
source = Stream.unfoldrM step (1 :: Int),
shutdown = pure ()
}
where
step n = do
liftIO $ threadDelay 5_000
msg <- createTestMessage n
pure (Just (msg, n + 1))
oneThenIdleAdapter :: (IOE :> es) => Adapter es String
oneThenIdleAdapter =
Adapter
{ adapterName = "test:one-then-idle",
source = Stream.unfoldrM step False,
shutdown = pure ()
}
where
step False = do
msg <- createTestMessage 1
pure (Just (msg, True))
step True = liftIO $ atomically retry
permanentFinalizerFailureAdapter :: (IOE :> es) => Adapter es String
permanentFinalizerFailureAdapter =
Adapter
{ adapterName = "test:permanent-finalizer-failure",
source = Stream.fromEffect (pure ingested),
shutdown = pure ()
}
where
envelope = mkEnvelope (MessageId "finalizer-failure") "message"
ingested =
mkIngested
envelope
(AckHandle $ \_ -> liftIO $ ioError (userError "permanent finalizer failure"))
isLeft :: Either a b -> Bool
isLeft (Left _) = True
isLeft (Right _) = False