kiroku-store-0.12.0.0: test/Test/SubscriptionNamespace.hs
module Test.SubscriptionNamespace (spec) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM (atomically, newTVarIO, readTVarIO)
import Control.Exception (bracket, fromException)
import Control.Lens ((^.))
import Control.Monad (forM_, replicateM_, when)
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.Int (Int32, Int64)
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Hasql.Statement (preparable)
import Hasql.Transaction qualified as Transaction
import Hasql.Transaction.Sessions qualified as Tx
import Kiroku.Store
import Kiroku.Store.Notification qualified as Notify
import Kiroku.Store.SQL qualified as SQL
import Kiroku.Store.Subscription.Fsm qualified as Fsm
import Kiroku.Store.Subscription.Worker (withNamespaceSafetyPollForTest, withNamespaceWindowHookForTest, withSaveCheckpointHookForTest)
import System.Timeout (timeout)
import Test.Helpers
import Test.Hspec
spec :: Spec
spec = describe "namespace subscriptions" $ do
forM_ [1, 3] $ \width ->
it ("catches up across empty windows, includes future categories, and restarts (W=" <> show width <> ")") $
withTestStore $ \store -> do
replicateM_ 9 (append store "noise-1")
append store "pm-1"
append store "pm:fulfillment-1"
append store "pm:billing-1"
append store "orders-1"
seen <- newIORef []
let config = (cfg "restart" "pm" (record seen)) {batchSize = batch width}
withSubscription store config $ \handle -> do
awaitLive handle
positions seen `shouldReturn` [10, 11, 12]
checkpoint store "restart" 0 `shouldReturn` Just 13
append store "pm:new-1"
await (positions seen) (== [10, 11, 12, 14])
await (checkpoint store "restart" 0) (== Just 14)
Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True
restarted <- subscribe store ((cfg "restart" "pm" (\event -> record seen event >> pure Stop)) {batchSize = batch width})
append store "pm:newer-1"
clean restarted
positions seen `shouldReturn` [10, 11, 12, 14, 15]
binding store "restart" `shouldReturn` ("category_namespace", Just "pm")
forM_
[ ("pm", ["pm", "pm:a", "pm:a:b"], ["pmm", "pmish", "orders"]),
("pm:a", ["pm:a", "pm:a:b"], ["pm", "pm:ab"]),
("%_\\,雪", ["%_\\,雪", "%_\\,雪:子"], ["xx雪", "%X\\,雪"]),
("", ["", ":", ":a"], ["pm", "orders"]),
("pm:", ["pm:", "pm::child"], ["pm", "pm:child"]),
("pm-x", [], ["pm", "pm:x", "pm-x"])
]
$ \(namespace, included, excluded) ->
it ("matches literal category boundaries for " <> show namespace) $
withTestStore $ \store -> do
forM_ (included <> excluded) $ \category -> append store (category <> "-1")
seen <- newIORef []
withSubscription store (cfg "literal" namespace (record seen)) $ \handle -> do
awaitLive handle
positions seen `shouldReturn` [1 .. fromIntegral (length included)]
it "keeps Stop at the stopping event, then replays the rest of a prefetched window" $
withTestStore $ \store -> do
forM_ ["pm-1", "pm:a-1", "pm:b-1", "noise-1"] (append store)
first <- subscribe store (cfg "stop" "pm" (\_ -> pure Stop))
clean first
checkpoint store "stop" 0 `shouldReturn` Just 1
seen <- newIORef []
withSubscription store (cfg "stop" "pm" (record seen)) $ \handle -> do
awaitLive handle
positions seen `shouldReturn` [2, 3]
checkpoint store "stop" 0 `shouldReturn` Just 4
it "does not publish speculative completion while a handler is blocked or cancelled" $
withTestStore $ \store -> do
append store "pm-1"
append store "noise-1"
entered <- newEmptyMVar
blocked <- newEmptyMVar
withSubscription store (cfg "cancel" "pm" (\_ -> putMVar entered () >> takeMVar blocked >> pure Continue)) $ \handle -> do
bounded (takeMVar entered)
checkpoint store "cancel" 0 `shouldReturn` Just 0
currentState handle >>= \case
Just (Fsm.CatchingUp (GlobalPosition 0) _) -> pure ()
other -> expectationFailure (show other)
seen <- newIORef []
withSubscription store (cfg "cancel" "pm" (record seen)) $ \handle -> do
awaitLive handle
positions seen `shouldReturn` [1]
checkpoint store "cancel" 0 `shouldReturn` Just 2
it "resolves Retry, DeadLetter, filtering and completed trailing rows with one tail save" $
withTestStore $ \store -> do
append store "pm-1"
append store "pm:a-1"
append store "noise-1"
attempts <- newIORef (0 :: Int)
saves <- newIORef []
let handler' event
| event ^. #globalPosition == GlobalPosition 1 = do
attempt <- atomicModifyIORef' attempts (\n -> (n + 1, n))
pure (if attempt == 0 then Retry (RetryDelay 0) else Continue)
| otherwise = pure (DeadLetter (DeadLetterPoison "test"))
withSaveCheckpointHookForTest (\_ p -> atomicModifyIORef' saves (\ps -> (p : ps, ()))) $
withSubscription store (cfg "dispositions" "pm" handler') awaitLive
readIORef attempts `shouldReturn` 2
readIORef saves `shouldReturn` [GlobalPosition 3]
checkpoint store "dispositions" 0 `shouldReturn` Just 3
binding store "dispositions" `shouldReturn` ("category_namespace", Just "pm")
let filtered = (cfg "filter" "pm" (\_ -> expectationFailure "filtered handler ran" >> pure Continue)) {selector = Just (const False)}
withSubscription store filtered awaitLive
checkpoint store "filter" 0 `shouldReturn` Just 3
it "coalesces empty-window saves at 32 windows and the head" $
withTestStore $ \store -> do
replicateM_ 70 (append store "noise-1")
saves <- newIORef []
withSaveCheckpointHookForTest (\_ p -> atomicModifyIORef' saves (\ps -> (ps <> [p], ()))) $
withSubscription store ((cfg "coalesce" "pm" (\_ -> pure Continue)) {batchSize = batch 1}) awaitLive
readIORef saves `shouldReturn` map GlobalPosition [32, 64, 70]
it "does not mistake hard-deleted tail gaps or an empty window for pending delivery" $
withTestStore $ \store -> do
append store "pm-1"
append store "gone-1"
Right _ <- runStoreIO store (hardDeleteStream (StreamName "gone-1"))
seen <- newIORef []
withSubscription store ((cfg "gaps" "pm" (record seen)) {batchSize = batch 1}) awaitLive
positions seen `shouldReturn` [1]
checkpoint store "gaps" 0 `shouldReturn` Just 2
it "partitions plain and grouped namespaces completely with per-member empty windows" $
withTestStore $ \store -> do
forM_ [1 .. 25 :: Int] $ \n -> do
append store "noise-1"
append store ("pm:" <> T.pack (show n) <> "-1")
members <- mapM (const (newIORef [])) [0 .. 2 :: Int32]
forM_ (zip [0 ..] members) $ \(index, seen) ->
withSubscription store ((cfg "group" "pm" (record seen)) {consumerGroup = Just (validConsumerGroup index 3), batchSize = batch 1}) $ \handle -> do
awaitLive handle
checkpoint store "group" index `shouldReturn` Just 50
delivered <- concat <$> mapM positions members
sort delivered `shouldBe` [2, 4 .. 50]
it "retries catch-up and live fetch failures without skipping matching rows" $
withTestStore $ \store -> do
append store "pm-1"
remaining <- newIORef True
seen <- newIORef []
let inject _ _ _ = do
failNow <- atomicModifyIORef' remaining (\b -> (False, b))
pure (if failNow then Just (Left Pool.AcquisitionTimeoutUsageError) else Nothing)
withNamespaceWindowHookForTest inject $
withSubscription store (cfg "fetch-fault" "pm" (record seen)) $ \handle -> do
awaitLive handle
writeIORef remaining True
append store "pm:new-1"
await (positions seen) (== [1, 2])
await (checkpoint store "fetch-fault" 0) (== Just 2)
it "replays after ordinary save failure and never persists an unfinished atomic dead letter" $
withTestStore $ \store -> do
append store "pm-1"
append store "noise-1"
-- A database trigger injects actual ordinary/atomic SQL failures, rather
-- than throwing from a seam before attempting a save.
sql store "CREATE FUNCTION fail_namespace_save() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.last_seen > 0 THEN RAISE EXCEPTION 'injected save failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER fail_namespace_save BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION fail_namespace_save()"
withSubscription store (cfg "save-fault" "pm" (\_ -> pure Continue)) awaitLive
checkpoint store "save-fault" 0 `shouldReturn` Just 0
failed <- subscribe store (cfg "atomic-fault" "pm" (\_ -> pure (DeadLetter (DeadLetterPoison "fail"))))
waitWithTimeout 5_000_000 failed >>= \case
Right (Left _) -> pure ()
other -> expectationFailure (show other)
checkpoint store "atomic-fault" 0 `shouldReturn` Just 0
sql store "DROP TRIGGER fail_namespace_save ON subscriptions"
seen <- newIORef []
withSubscription store (cfg "save-fault" "pm" (record seen)) awaitLive
positions seen `shouldReturn` [1]
checkpoint store "save-fault" 0 `shouldReturn` Just 2
it "preserves namespace identity through rebind, resize, adoption and strict startup" $
withTestStore $ \store -> do
withSubscription store (cfg "binding" "pm" (\_ -> pure Continue)) awaitLive
mismatch <- subscribe store (defaultSubscriptionConfig (SubscriptionName "binding") (Category (CategoryName "pm")) (\_ -> pure Stop))
waitWithTimeout 5_000_000 mismatch >>= \case
Right (Left e) -> (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)
other -> expectationFailure (show other)
let tx :: Transaction.Transaction a -> IO a
tx action = Pool.use (store ^. #pool) (Tx.transaction Tx.ReadCommitted Tx.Write action) >>= either (fail . show) pure
report <- tx (rebindSubscriptionTargetTx (SubscriptionName "binding") (CategoryNamespace "pm:a") (GlobalPosition 0))
previousBindings report `shouldBe` V.singleton (Just (CategoryNamespace "pm"))
_ <- tx (resizeConsumerGroupTx (SubscriptionName "binding") (either (error . show) Prelude.id (mkConsumerGroupSize 2)))
withSubscription store ((cfg "binding" "pm:a" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}) awaitLive
_ <- tx (resizeConsumerGroupTx (SubscriptionName "adopt") (either (error . show) Prelude.id (mkConsumerGroupSize 2)))
withSubscription store ((cfg "adopt" "pm" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 0 2)}) awaitLive
withSubscription store ((cfg "adopt" "pm" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}) awaitLive
it "routes only exact/ancestor namespace cells and releases shared registrations idempotently" $ do
registry <- newTVarIO Map.empty
(pm, release1) <- atomically (Notify.registerNamespace registry "pm")
(same, release2) <- atomically (Notify.registerNamespace registry "pm")
(nested, release3) <- atomically (Notify.registerNamespace registry "pm:a")
(empty, release4) <- atomically (Notify.registerNamespace registry "")
(colon, release5) <- atomically (Notify.registerNamespace registry "pm:")
(pm == same) `shouldBe` True
atomically (Notify.notifyNamespace registry "pm:a:b")
mapM readTVarIO [pm, nested, empty, colon] `shouldReturn` [1, 1, 0, 0]
atomically (Notify.notifyNamespace registry "pm::child" >> Notify.notifyNamespace registry ":child")
mapM readTVarIO [pm, nested, empty, colon] `shouldReturn` [2, 1, 1, 1]
atomically (release1 >> release1)
Map.size <$> readTVarIO registry `shouldReturn` 4
atomically (release2 >> release3 >> release4 >> release5)
Map.null <$> readTVarIO registry `shouldReturn` True
(replacement, release6) <- atomically (Notify.registerNamespace registry "pm")
atomically (release2 >> Notify.notifyNamespace registry "pm")
readTVarIO replacement `shouldReturn` 1
atomically release6
-- Empty registry path must not even evaluate a category.
atomically (Notify.notifyNamespace registry (error "empty registry evaluated category"))
it "suppresses unrelated live fetches and recovers a lost notification at the safety tick" $
withNamespaceSafetyPollForTest 200_000 $
withTestStore $ \store -> do
fetches <- newIORef (0 :: Int)
seen <- newIORef []
withNamespaceWindowHookForTest (\_ _ _ -> atomicModifyIORef' fetches (\n -> (n + 1, Nothing))) $
withSubscription store (cfg "idle" "pm" (record seen)) $ \handle -> do
awaitLive handle
append store "noise-1"
-- Wait for the actual listener to process the unrelated notification.
await (readTVarIO (Notify.categoryGenerations (store ^. #notifier))) (Map.member "noise")
readIORef fetches `shouldReturn` 0
await (readIORef fetches) (> 0)
-- Temporarily remove the sole registry entry to simulate a lost wake.
sql store "ALTER TABLE streams DISABLE TRIGGER stream_events_notify_insert; ALTER TABLE streams DISABLE TRIGGER stream_events_notify_update"
append store "pm:new-1"
sql store "ALTER TABLE streams ENABLE TRIGGER stream_events_notify_insert; ALTER TABLE streams ENABLE TRIGGER stream_events_notify_update"
await (positions seen) (== [2])
it "keeps a captured head finite and does not lose a notification during its drain" $
withTestStore $ \store -> do
append store "pm-1"
captured <- newIORef False
seen <- newIORef []
let inject _ _ headPosition = do
first <- atomicModifyIORef' captured (\done -> (True, not done))
when first $ do
headPosition `shouldBe` GlobalPosition 1
append store "pm:new-1"
await (readTVarIO (Notify.categoryGenerations (store ^. #notifier))) (Map.member "pm:new")
pure Nothing
withNamespaceWindowHookForTest inject $
withSubscription store ((cfg "race" "pm" (record seen)) {batchSize = batch 1}) $ \handle -> do
awaitLive handle
await (positions seen) (== [1, 2])
await (checkpoint store "race" 0) (== Just 2)
it "never advances past an unhandled decode failure, and ignores unrelated decode failures" $ do
let reject event = pure (Left (DecodeFailure (event ^. #eventId) "test refusal"))
broken :: ConnectionSettings -> ConnectionSettings
broken settings = settings {storeSettings = defaultStoreSettings {decodeHook = Just reject}}
withTestStoreSettings broken $ \store -> do
append store "noise-1"
withSubscription store (cfg "unrelated-decode" "pm" (\_ -> expectationFailure "unrelated handler" >> pure Stop)) awaitLive
checkpoint store "unrelated-decode" 0 `shouldReturn` Just 1
append store "pm-1"
append store "noise-1"
handle <- subscribe store ((cfg "decode" "pm" (\_ -> expectationFailure "undecodable handler" >> pure Stop)) {retryPolicy = RetryPolicy 1})
waitWithTimeout 5_000_000 handle >>= \case
Right (Left err) -> (fromException err :: Maybe SubscriptionUndecodable) `shouldSatisfy` maybe False (const True)
other -> expectationFailure (show other)
checkpoint store "decode" 0 `shouldReturn` Just 0
-- The alternate callback resolves only matching undecodable events.
withSubscription store ((cfg "decode" "pm" (\_ -> pure Stop)) {undecodableHandler = Just (\_ _ -> pure Continue)}) awaitLive
checkpoint store "decode" 0 `shouldReturn` Just 3
it "shares live namespace wakes across grouped members and reconnects the listener" $
withNamespaceSafetyPollForTest 100_000 $
withTestStore $ \store -> do
members <- mapM (const (newIORef [])) [0 .. 2 :: Int32]
let configs = [(cfg "live-group" "pm" (record seen)) {consumerGroup = Just (validConsumerGroup index 3), batchSize = batch 1} | (index, seen) <- zip [0 ..] members]
bracket (mapM (subscribe store) configs) (mapM_ cancel) $ \handles -> do
mapM_ awaitLive handles
forM_ [1 .. 6 :: Int] $ \n -> append store ("pm:new:" <> T.pack (show n) <> "-1")
await (concat <$> mapM positions members) ((== [1 .. 6]) . sort)
pid <- waitForListenerPid (store ^. #pool) 5_000_000
terminateBackend (store ^. #pool) pid
append store "pm:reconnect-1"
await (concat <$> mapM positions members) ((== [1 .. 7]) . sort)
_ <- waitForListenerPidNotEqual (store ^. #pool) pid 5_000_000
pure ()
Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True
it "retains a failed empty frontier and retries its flush without another append" $
withNamespaceSafetyPollForTest 50_000 $
withTestStore $ \store -> do
append store "noise-1"
sql store "CREATE FUNCTION fail_empty_save() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected flush failure'; END $$; CREATE TRIGGER fail_empty_save BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION fail_empty_save()"
withSubscription store (cfg "flush-fault" "pm" (\_ -> pure Continue)) $ \handle -> do
awaitLive handle
checkpoint store "flush-fault" 0 `shouldReturn` Just 0
sql store "DROP TRIGGER fail_empty_save ON subscriptions"
await (checkpoint store "flush-fault" 0) (== Just 1)
it "releases namespace registrations when startup fails" $
withTestStore $ \store -> do
handle <- subscribe store ((cfg "missing" "pm" (\_ -> pure Continue)) {missingCheckpointPolicy = FailIfMissing})
waitWithTimeout 5_000_000 handle >>= \case
Right (Left _) -> pure ()
other -> expectationFailure (show other)
Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True
cfg :: Text -> Text -> (RecordedEvent -> IO SubscriptionResult) -> SubscriptionConfig
cfg name namespace = defaultSubscriptionConfig (SubscriptionName name) (CategoryNamespace namespace)
batch :: Int32 -> BatchSize
batch = either (error . show) Prelude.id . mkBatchSize
record :: IORef [Int64] -> RecordedEvent -> IO SubscriptionResult
record ref event = do
let GlobalPosition pos = event ^. #globalPosition
atomicModifyIORef' ref (\xs -> (xs <> [pos], ()))
pure Continue
positions :: IORef [Int64] -> IO [Int64]
positions = readIORef
append :: KirokuStore -> Text -> IO ()
append store stream = do
Right _ <- runStoreIO store (appendToStream (StreamName stream) AnyVersion [makeEvent "E" (Aeson.object [])])
pure ()
await :: IO a -> (a -> Bool) -> IO ()
await action predicate = bounded go
where
go = action >>= \value -> if predicate value then pure () else threadDelay 1000 >> go
bounded :: IO a -> IO a
bounded action = timeout 5_000_000 action >>= maybe (fail "namespace test timed out") pure
awaitLive :: SubscriptionHandle -> IO ()
awaitLive handle = await (currentState handle) (\case Just Fsm.Live {} -> True; _ -> False)
clean :: SubscriptionHandle -> IO ()
clean handle =
waitWithTimeout 5_000_000 handle >>= \case
Right (Right ()) -> pure ()
other -> expectationFailure (show other)
checkpoint :: KirokuStore -> Text -> Int32 -> IO (Maybe Int64)
checkpoint store name index = session store (Session.statement (name, index) SQL.getCheckpointMemberStmt)
binding :: KirokuStore -> Text -> IO (Text, Maybe Text)
binding store name = session store (Session.statement name (preparable "SELECT target_kind,target_category FROM subscriptions WHERE subscription_name=$1 AND consumer_group_member=0" (E.param (E.nonNullable E.text)) (D.singleRow ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))))
sql :: KirokuStore -> Text -> IO ()
sql store = session store . Session.script
session :: KirokuStore -> Session.Session a -> IO a
session store action = Pool.use (store ^. #pool) action >>= either (fail . show) pure