packages feed

kiroku-store 0.13.0.0 → 0.13.0.1

raw patch · 8 files changed

+232/−12 lines, 8 filesPVP: minor bump suggested

API additions: PVP suggests at least a minor version bump

API changes (from Hackage documentation)

+ Kiroku.Store.Notification: withListenHookForTest :: (IO () -> IO ()) -> IO a -> IO a

Files

CHANGELOG.md view
@@ -1,5 +1,20 @@ # Changelog +## 0.13.0.1 — 2026-10-30++### Bug Fixes++- Preserve asynchronous cancellation through observability callbacks, publisher+  recovery and initial notifier LISTEN setup, including concrete exception payloads.+- Release the acquired notifier connection when startup LISTEN is cancelled;+  retain synchronous callback suppression and publisher recovery behavior.++### Other Changes++- Export `withListenHookForTest` from `Kiroku.Store.Notification` for sequential,+  process-local startup LISTEN injection. The previous hook is restored on exit;+  production leaves it unset.+ ## 0.13.0.0 — 2026-10-28  ### Breaking Changes
kiroku-store.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-store-version:         0.13.0.0+version:         0.13.0.1 synopsis:        High-performance PostgreSQL event store description:   Kiroku is a PostgreSQL-backed event store for Haskell applications. It@@ -108,6 +108,7 @@   hs-source-dirs:  test   other-modules:     Paths_kiroku_store+    Test.AsyncCancellation     Test.BrowseQueryPlans     Test.BrowseReads     Test.BrowseReadsMock
src/Kiroku/Store/Notification.hs view
@@ -3,6 +3,7 @@     NotifierStartError (..),     startNotifier,     stopNotifier,+    withListenHookForTest,     NamespaceRegistry,     registerNamespace,     notifyNamespace,@@ -13,11 +14,12 @@ import Control.Concurrent.Async (Async) import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM (STM, TChan, TVar, atomically, modifyTVar', newBroadcastTChanIO, newTVar, newTVarIO, readTVar, readTVarIO, writeTChan, writeTVar)-import Control.Exception (Exception, SomeAsyncException, SomeException, asyncExceptionFromException, bracketOnError, catch, throwIO, toException)+import Control.Exception (Exception, SomeAsyncException, SomeException, asyncExceptionFromException, bracket, bracketOnError, catch, fromException, throwIO, toException) import Control.Monad (forM_, unless, void) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.ByteString (ByteString) import Data.ByteString.Char8 qualified as BC+import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text)@@ -36,6 +38,7 @@ import Hasql.Statement (Statement, unpreparable) import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop) import Kiroku.Store.Types (CategoryName (..), StreamName (..), categoryName)+import System.IO.Unsafe (unsafePerformIO)  -- | A Notifier manages a dedicated PostgreSQL connection for LISTEN/NOTIFY. -- It writes a @()@ tick to a broadcast 'TChan' on every notification,@@ -128,6 +131,28 @@   | n <= 0 = 1_000_000   | otherwise = min maxReconnectDelayMicros (1_000_000 * 2 ^ (min (n - 1) 30)) +{-# NOINLINE listenHookRef #-}+listenHookRef :: IORef (Maybe (IO () -> IO ()))+listenHookRef = unsafePerformIO (newIORef Nothing)++-- | Test-only process-local startup LISTEN hook. Use sequentially; the previous+-- hook is restored even if the test throws. Production leaves it unset.+withListenHookForTest :: (IO () -> IO ()) -> IO a -> IO a+withListenHookForTest hook action =+  bracket+    ( do+        previous <- readIORef listenHookRef+        writeIORef listenHookRef (Just hook)+        pure previous+    )+    (writeIORef listenHookRef)+    (const action)++startupListen :: IO () -> IO ()+startupListen action = do+  hook <- readIORef listenHookRef+  maybe action ($ action) hook+ -- | Start a Notifier. Acquires a dedicated connection, sets -- @application_name@ to @kiroku-listener@ for operator visibility, issues -- LISTEN on the @\<schema\>.events@ channel, and spawns a thread that@@ -148,7 +173,8 @@ -- -- On 'Async.AsyncCancelled' (from cancellation), exits cleanly. ----- Initial acquire or LISTEN failure raises 'NotifierStartError'.+-- Synchronous initial acquire or LISTEN failure raises 'NotifierStartError'.+-- Asynchronous LISTEN exceptions propagate unchanged after releasing the connection. startNotifier ::   (MonadIO m) =>   -- | libpq connection string@@ -164,10 +190,10 @@   namespaces <- newTVarIO Map.empty   let channel = toPgIdentifier (schema <> ".events")   bracketOnError (acquireOrThrow connString) Connection.release $ \conn -> do-    Notifications.listen conn channel+    startupListen (Notifications.listen conn channel)       `catch` \(e :: SomeException) ->-        case asyncExceptionFromException e of-          Just (ae :: SomeAsyncException) -> throwIO ae+        case fromException e of+          Just (_ :: SomeAsyncException) -> throwIO e           Nothing -> throwIO (NotifierListenError e)     connRef <- newTVarIO conn     thread <- Async.async (listenerLoop chan catGenVar namespaces connRef channel connString mHandler)
src/Kiroku/Store/Observability.hs view
@@ -51,7 +51,7 @@   ) where -import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO)+import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO) import Data.Foldable (for_) import Data.Int (Int32, Int64) import Data.Time (NominalDiffTime)@@ -246,8 +246,8 @@ emitOrDrop mHandler evt =   for_ mHandler $ \handler ->     handler evt `catch` \(e :: SomeException) ->-      case asyncExceptionFromException e of-        Just (ae :: SomeAsyncException) -> throwIO ae+      case fromException e of+        Just (_ :: SomeAsyncException) -> throwIO e         Nothing -> pure ()  -- | Which database phase a 'KirokuEventSubscriptionDbError' fired in.
src/Kiroku/Store/Subscription/EventPublisher.hs view
@@ -53,7 +53,7 @@     writeTVar,   ) import Control.Concurrent.STM qualified as STM-import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO)+import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO) import Control.Monad (when) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Foldable (for_)@@ -232,8 +232,8 @@       -- partial delivery, lastPublished is not advanced, so the next tick       -- re-fetches under Kiroku's at-least-once delivery contract.       fetchAndBroadcast `catch` \(e :: SomeException) ->-        case asyncExceptionFromException e of-          Just (ae :: SomeAsyncException) -> throwIO ae+        case fromException e of+          Just (_ :: SomeAsyncException) -> throwIO e           Nothing -> emitOrDrop mHandler (KirokuEventPublisherLoopError e)       loop 
test/Main.hs view
@@ -25,6 +25,7 @@ import Kiroku.Store.Subscription.EventPublisher (publisherPosition) import Kiroku.Store.Subscription.Fsm qualified as Fsm import Kiroku.Test.Postgres (withMigratedTestDatabase)+import Test.AsyncCancellation qualified as AsyncCancellation import Test.BrowseQueryPlans qualified as BrowseQueryPlans import Test.BrowseReads qualified as BrowseReads import Test.BrowseReadsMock qualified as BrowseReadsMock@@ -138,6 +139,7 @@     StreamNameLookup.noOpSpec   CategoryIdleNoSpin.spec   HandlerStall.spec+  AsyncCancellation.spec   PublisherCallbackResilience.spec   PublisherDropCounter.spec   PublisherIdleAdvance.spec
+ test/Test/AsyncCancellation.hs view
@@ -0,0 +1,172 @@+module Test.AsyncCancellation (spec, publisherSpec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar, tryReadMVar)+import Control.Concurrent.STM (atomically)+import Control.Exception (AsyncException (ThreadKilled, UserInterrupt), Exception (..), SomeException, asyncExceptionFromException, asyncExceptionToException, bracket, finally, throwIO, throwTo, try)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (forM_, void)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Int (Int64)+import Data.Text (Text)+import Kiroku.Store+import Kiroku.Store.Notification qualified as N+import Kiroku.Store.Observability (emitOrDrop)+import Kiroku.Store.Subscription.EventPublisher qualified as P+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import System.Timeout (timeout)+import Test.Helpers (listenerCount, makeEvent, withTestStoreSettings)+import Test.Hspec++-- The payload ensures rethrowing preserves a consumer-defined exception too.+data PayloadCancellation = PayloadCancellation Int deriving stock (Eq, Show)++instance Exception PayloadCancellation where+  toException = asyncExceptionToException+  fromException = asyncExceptionFromException++data SyncFailure = SyncFailure deriving stock (Eq, Show)++instance Exception SyncFailure++cases :: [(String, SomeException, SomeException -> Bool)]+cases =+  [ ("AsyncCancelled", toException Async.AsyncCancelled, matches Async.AsyncCancelled),+    ("ThreadKilled", toException ThreadKilled, matches ThreadKilled),+    ("UserInterrupt", toException UserInterrupt, matches UserInterrupt),+    ("custom payload", toException (PayloadCancellation 73), matches (PayloadCancellation 73))+  ]+  where+    matches :: (Exception e, Eq e) => e -> SomeException -> Bool+    matches expected e = fromException e == Just expected++bounded :: IO a -> IO a+bounded action = timeout 10_000_000 action >>= maybe (fail "asynchronous boundary timed out") pure++expectException :: (SomeException -> Bool) -> Either SomeException a -> Expectation+expectException matches = \case+  Left e -> e `shouldSatisfy` matches+  Right _ -> expectationFailure "expected asynchronous exception; action returned normally"++emit :: IO () -> IO ()+emit action = emitOrDrop (Just (const action)) KirokuEventNotifierReconnected++-- Release the blocked action before the scoped async finalizer on every exit.+-- This also makes the original, cancellation-swallowing implementation safe to test.+crossThread :: (IO () -> IO ()) -> (Async.Async () -> IO ()) -> (SomeException -> Bool) -> IO ()+crossThread boundary send matches = do+  entered <- newEmptyMVar+  release <- newEmptyMVar+  Async.withAsync (boundary (putMVar entered () >> takeMVar release)) $ \target ->+    ( do+        bounded (takeMVar entered)+        bounded (send target)+        bounded (Async.waitCatch target) >>= expectException matches+    )+      `finally` void (tryPutMVar release ())++awaitNoListeners :: Text -> Int64 -> IO ()+awaitNoListeners conn baseline = bounded loop+  where+    loop = do+      n <- listenerCount conn+      if n == baseline then pure () else threadDelay 1_000 >> loop++spec :: Spec+spec = describe "asynchronous cancellation" $ do+  it "allows absent and successful observation callbacks" $ do+    emitOrDrop Nothing KirokuEventNotifierReconnected+    called <- newIORef False+    emit (writeIORef called True)+    readIORef called `shouldReturn` True+  it "suppresses synchronous observation failures" $ emit (throwIO SyncFailure)+  forM_ cases $ \(label, exception, matches) -> do+    it ("preserves observation " <> label) $+      try (emit (throwIO exception)) >>= expectException matches+    it ("preserves notifier startup " <> label <> " and releases its connection") $+      withMigratedTestDatabase $ \conn -> do+        baseline <- listenerCount conn+        baseline `shouldBe` 0+        N.withListenHookForTest+          ( \_ -> do+              listenerCount conn `shouldReturn` (baseline + 1)+              throwIO exception+          )+          $ do+            result <- try (N.startNotifier conn "kiroku" Nothing)+            -- Even an incorrect successful startup must be cleaned up.+            either (const (pure ())) N.stopNotifier result+            expectException matches result+        awaitNoListeners conn baseline+  it "wraps synchronous notifier startup failure with its original cause and releases the connection" $+    withMigratedTestDatabase $ \conn -> do+      baseline <- listenerCount conn+      N.withListenHookForTest (\_ -> throwIO SyncFailure) $ do+        result <- try (N.startNotifier conn "kiroku" Nothing)+        either (const (pure ())) N.stopNotifier result+        case result of+          Left e+            | Just (N.NotifierListenError cause) <- fromException e ->+                fromException cause `shouldBe` Just SyncFailure+          _ -> expectationFailure "expected NotifierListenError with synchronous cause"+      awaitNoListeners conn baseline+  forM_ [False, True] $ \custom -> do+    let send target = if custom then throwTo (Async.asyncThreadId target) (PayloadCancellation 73) else Async.cancel target+        matches e = if custom then fromException e == Just (PayloadCancellation 73) else fromException e == Just Async.AsyncCancelled+    it ("terminates a blocked observation callback, custom=" <> show custom) $+      crossThread emit send matches+    it ("terminates blocked notifier startup and releases its connection, custom=" <> show custom) $+      withMigratedTestDatabase $ \conn -> do+        baseline <- listenerCount conn+        let boundary action =+              N.withListenHookForTest (\_ -> (listenerCount conn `shouldReturn` (baseline + 1)) >> action) $+                bracket (N.startNotifier conn "kiroku" Nothing) N.stopNotifier (const (pure ()))+        crossThread boundary send matches+        awaitNoListeners conn baseline++publisherSpec :: Spec+publisherSpec = describe "asynchronous publisher recovery" $ do+  forM_ [False, True] $ \callback -> do+    forM_ cases $ \(label, exception, matches) ->+      it ("preserves " <> label <> ", callback=" <> show callback) $+        publisherCase callback (throwIO exception) Nothing (pure ()) matches+    forM_ [False, True] $ \custom ->+      it ("terminates blocked action, callback=" <> show callback <> ", custom=" <> show custom) $ do+        entered <- newEmptyMVar+        release <- newEmptyMVar+        let action = putMVar entered () >> takeMVar release+            send target = do+              bounded (takeMVar entered)+              bounded (if custom then throwTo (Async.asyncThreadId target) (PayloadCancellation 73) else Async.cancel target)+            matches e = if custom then fromException e == Just (PayloadCancellation 73) else fromException e == Just Async.AsyncCancelled+        publisherCase callback action (Just send) (void (tryPutMVar release ())) matches++publisherCase :: Bool -> IO () -> Maybe (Async.Async () -> IO ()) -> IO () -> (SomeException -> Bool) -> IO ()+publisherCase callback action sender cleanup matches = do+  loopError <- newEmptyMVar+  let hook event =+        if callback+          then pure (Left (DecodeFailure (event ^. #eventId) "injected decode failure"))+          else action >> pure (Right event)+      observe event = case event of+        KirokuEventPublisherDecodeFailed {} | callback -> action+        KirokuEventPublisherLoopError {} -> void (tryPutMVar loopError ())+        _ -> pure ()+      tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook} & #eventHandler .~ Just observe+  withTestStoreSettings tweak $ \store -> do+    let pub = store ^. #publisher+        target = P.publisherThread pub+    ( bracket (atomically (P.subscribePublisher pub 1 PauseAndResume)) (\(_, _, unsubscribe) -> unsubscribe) $ \_ -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "async-boundary") NoStream [makeEvent "Cancel" (Aeson.object [])]+        forM_ sender ($ target)+        outcome <- bounded $ Async.race (takeMVar loopError) (Async.waitCatch target)+        case outcome of+          Left () -> expectationFailure "asynchronous exception became a publisher loop error"+          Right result -> expectException matches result+        tryReadMVar loopError `shouldReturn` Nothing+        atomically (P.publisherPosition pub) `shouldReturn` GlobalPosition 0+      )+      `finally` cleanup
test/Test/PublisherCallbackResilience.hs view
@@ -29,6 +29,7 @@ import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Subscription.Effect qualified as SubEff import Kiroku.Store.Subscription.Fsm (SubscriptionState (..))+import Test.AsyncCancellation qualified as AsyncCancellation import Test.Helpers (caughtUpEventHandler, makeEvent, validConsumerGroup, waitForPublisher, waitForSubscriptionLive, waitWithTimeout, withTestStoreSettings) import Test.Hspec @@ -90,6 +91,7 @@  spec :: Spec spec = describe "publisher callback resilience" $ do+  AsyncCancellation.publisherSpec   it "stops only the default subscriber while a sibling dead-letters and continues live" $ do     defaultLive <- newEmptyMVar     siblingLive <- newEmptyMVar@@ -553,6 +555,8 @@         Right (Right ()) -> pure ()         Left timeout -> expectationFailure timeout         Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)+      awaitCheckpoint store "publisher-decode-hook-resilience" 2+      waitForPublisher store (GlobalPosition 2)      seen <- reverse <$> readIORef delivered     seen `shouldBe` [EventType "Boom", EventType "Ok"]