packages feed

shibuya-kiroku-adapter 0.6.0.1 → 0.7.0.0

raw patch · 8 files changed

+2669/−2676 lines, 8 filesdep ~kiroku-storedep ~shibuya-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: kiroku-store, shibuya-core

API changes (from Hackage documentation)

+ Shibuya.Adapter.Kiroku: CategoryNamespace :: !Text -> SubscriptionTarget

Files

CHANGELOG.md view
@@ -1,5 +1,29 @@ # Changelog +## 0.7.0.0 — 2026-10-26++### Breaking Changes++- Require `kiroku-store ^>=0.12.0.0`. Re-exported `SubscriptionTarget` gains+  `CategoryNamespace Text`; update exhaustive matches.+- Require [shibuya-core](mori://shinzui/shibuya/packages/shibuya-core)+  `>=0.12 && <0.13` across the library, tests, lifecycle fixture, and benchmarks.+  Downstream direct `Ingested` record constructors must supply Shibuya's new+  `deliveryStatus` field.++### New Features++- Support namespace targets in single and grouped configurations through the+  underlying store worker. Namespace checkpoints may advance beyond the last+  matching delivery; each member scans bounded global windows. Apply migration+  0016 from kiroku-store-migrations 0.7.2.0 before starting namespace workers.++### Other Changes++- Initialize `deliveryStatus` to `pure DeliveryCurrent` for ack-coupled Kiroku+  deliveries. Adapter-specific function signatures and configuration fields are+  unchanged.+ ## 0.6.0.1 — 2026-10-11  ### Other Changes
app/LifecycleLive.hs view
@@ -24,15 +24,15 @@ import Kiroku.Store import Kiroku.Test.Postgres (ephemeralConfig, migrateTestDatabase) import Shibuya.Adapter.Kiroku (defaultKirokuAdapterConfig, kirokuAdapter)-import Shibuya.App (-    ProcessorId (..),+import Shibuya.App+  ( ProcessorId (..),     ShutdownConfig (drainTimeout, totalShutdownTimeout),     defaultAppConfig,     defaultShutdownConfig,     mkProcessor,     runApp,     stopAppGracefully,- )+  ) import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..)) import Shibuya.Core.Ingested (Message (..)) import Shibuya.Core.Types (Envelope (..))@@ -44,86 +44,86 @@ import Text.Read (readMaybe)  data Config = Config-    { durationSecs :: !Int-    , messagesPerSecond :: !Int-    , sampleIntervalSecs :: !Int-    , outputCsv :: !FilePath-    , outputLedger :: !FilePath-    , runId :: !String-    , restartAtSecs :: !Int-    , shutdownDrainSecs :: !Int-    , shutdownTotalSecs :: !Int-    }+  { durationSecs :: !Int,+    messagesPerSecond :: !Int,+    sampleIntervalSecs :: !Int,+    outputCsv :: !FilePath,+    outputLedger :: !FilePath,+    runId :: !String,+    restartAtSecs :: !Int,+    shutdownDrainSecs :: !Int,+    shutdownTotalSecs :: !Int+  }  data Sample = Sample-    { timestamp :: !UTCTime-    , elapsedSecs :: !Int-    , messagesProduced :: !Int-    , messagesProcessed :: !Int-    , messagesFailed :: !Int-    , queueDepth :: !Int64-    , retainedBytes :: !Word64-    , maxLiveBytes :: !Word64-    }+  { timestamp :: !UTCTime,+    elapsedSecs :: !Int,+    messagesProduced :: !Int,+    messagesProcessed :: !Int,+    messagesFailed :: !Int,+    queueDepth :: !Int64,+    retainedBytes :: !Word64,+    maxLiveBytes :: !Word64+  }  data DeliveryLedger = DeliveryLedger-    { producedHandle :: !Handle-    , processedHandle :: !Handle-    , producedPath :: !FilePath-    , processedPath :: !FilePath-    , malformedDeliveries :: !(IORef Int)-    }+  { producedHandle :: !Handle,+    processedHandle :: !Handle,+    producedPath :: !FilePath,+    processedPath :: !FilePath,+    malformedDeliveries :: !(IORef Int)+  }  main :: IO () main = do-    config <- loadConfig-    let suffix = Text.pack (filter validResourceChar config.runId)-        streamName = StreamName ("ep45-" <> suffix)-        subscriptionName = SubscriptionName ("ep45-" <> suffix)-    putStrLn "=== Shibuya Kiroku lifecycle live fixture ==="-    putStrLn $ "Duration: " <> show config.durationSecs <> " seconds"-    putStrLn $ "Target rate: " <> show config.messagesPerSecond <> " msg/s"-    putStrLn $ "Restart at: " <> show config.restartAtSecs <> " seconds"-    pgConfig <- ephemeralConfig-    result <- Pg.withCachedConfig pgConfig Pg.defaultCacheConfig $ \database -> do-        let connectionString = Pg.connectionString database-        migrateTestDatabase connectionString-        withStore (defaultConnectionSettings connectionString) $ \store ->-            runFixture config store streamName subscriptionName-    case result of-        Left err -> error $ "Failed to start ephemeral PostgreSQL: " <> show err-        Right () -> pure ()+  config <- loadConfig+  let suffix = Text.pack (filter validResourceChar config.runId)+      streamName = StreamName ("ep45-" <> suffix)+      subscriptionName = SubscriptionName ("ep45-" <> suffix)+  putStrLn "=== Shibuya Kiroku lifecycle live fixture ==="+  putStrLn $ "Duration: " <> show config.durationSecs <> " seconds"+  putStrLn $ "Target rate: " <> show config.messagesPerSecond <> " msg/s"+  putStrLn $ "Restart at: " <> show config.restartAtSecs <> " seconds"+  pgConfig <- ephemeralConfig+  result <- Pg.withCachedConfig pgConfig Pg.defaultCacheConfig $ \database -> do+    let connectionString = Pg.connectionString database+    migrateTestDatabase connectionString+    withStore (defaultConnectionSettings connectionString) $ \store ->+      runFixture config store streamName subscriptionName+  case result of+    Left err -> error $ "Failed to start ephemeral PostgreSQL: " <> show err+    Right () -> pure ()   where     validResourceChar char =-        ('a' <= char && char <= 'z')-            || ('A' <= char && char <= 'Z')-            || ('0' <= char && char <= '9')-            || char == '-'+      ('a' <= char && char <= 'z')+        || ('A' <= char && char <= 'Z')+        || ('0' <= char && char <= '9')+        || char == '-'  loadConfig :: IO Config loadConfig = do-    duration <- envInt "DURATION_SECS" 1800-    rate <- envInt "MESSAGES_PER_SECOND" 100-    interval <- envInt "SAMPLE_INTERVAL_SECS" 30-    output <- envString "OUTPUT_CSV" "kiroku-lifecycle.csv"-    ledger <- envString "OUTPUT_LEDGER" (output <> ".ledger.json")-    now <- getCurrentTime-    identifier <- envString "LIFECYCLE_RUN_ID" (formatTime defaultTimeLocale "%Y%m%d%H%M%S" now)-    restart <- envInt "RESTART_AT_SECS" (duration `div` 2)-    shutdownDrain <- envInt "SHUTDOWN_DRAIN_SECS" 30-    shutdownTotal <- envInt "SHUTDOWN_TOTAL_SECS" 60-    pure-        Config-            { durationSecs = duration-            , messagesPerSecond = rate-            , sampleIntervalSecs = interval-            , outputCsv = output-            , outputLedger = ledger-            , runId = identifier-            , restartAtSecs = max 1 (min (duration - 1) restart)-            , shutdownDrainSecs = shutdownDrain-            , shutdownTotalSecs = shutdownTotal-            }+  duration <- envInt "DURATION_SECS" 1800+  rate <- envInt "MESSAGES_PER_SECOND" 100+  interval <- envInt "SAMPLE_INTERVAL_SECS" 30+  output <- envString "OUTPUT_CSV" "kiroku-lifecycle.csv"+  ledger <- envString "OUTPUT_LEDGER" (output <> ".ledger.json")+  now <- getCurrentTime+  identifier <- envString "LIFECYCLE_RUN_ID" (formatTime defaultTimeLocale "%Y%m%d%H%M%S" now)+  restart <- envInt "RESTART_AT_SECS" (duration `div` 2)+  shutdownDrain <- envInt "SHUTDOWN_DRAIN_SECS" 30+  shutdownTotal <- envInt "SHUTDOWN_TOTAL_SECS" 60+  pure+    Config+      { durationSecs = duration,+        messagesPerSecond = rate,+        sampleIntervalSecs = interval,+        outputCsv = output,+        outputLedger = ledger,+        runId = identifier,+        restartAtSecs = max 1 (min (duration - 1) restart),+        shutdownDrainSecs = shutdownDrain,+        shutdownTotalSecs = shutdownTotal+      }  envString :: String -> String -> IO String envString key fallback = maybe fallback (\value -> value) <$> lookupEnv key@@ -133,114 +133,114 @@  runFixture :: Config -> KirokuStore -> StreamName -> SubscriptionName -> IO () runFixture config store streamName subscriptionName = do-    producedVar <- newTVarIO (0 :: Int)-    processedRef <- newIORef (0 :: Int)-    failedRef <- newIORef (0 :: Int)-    stopVar <- newTVarIO False-    startTime <- getCurrentTime+  producedVar <- newTVarIO (0 :: Int)+  processedRef <- newIORef (0 :: Int)+  failedRef <- newIORef (0 :: Int)+  stopVar <- newTVarIO False+  startTime <- getCurrentTime -    withDeliveryLedger config $ \ledger ->-        withFile config.outputCsv WriteMode $ \handle -> do-            hPutStrLn handle csvHeader-            producerThread <- Async.async $ runProducer config store streamName producedVar failedRef ledger stopVar-            samplerThread <- Async.async $ runSampler config store subscriptionName startTime producedVar processedRef failedRef handle+  withDeliveryLedger config $ \ledger ->+    withFile config.outputCsv WriteMode $ \handle -> do+      hPutStrLn handle csvHeader+      producerThread <- Async.async $ runProducer config store streamName producedVar failedRef ledger stopVar+      samplerThread <- Async.async $ runSampler config store subscriptionName startTime producedVar processedRef failedRef handle -            runConsumerSegment config store subscriptionName processedRef failedRef ledger $ do-                threadDelay (config.restartAtSecs * 1_000_000)-                putStrLn "Graceful midpoint stop"+      runConsumerSegment config store subscriptionName processedRef failedRef ledger $ do+        threadDelay (config.restartAtSecs * 1_000_000)+        putStrLn "Graceful midpoint stop" -            putStrLn "Restarting with the same durable subscription"-            runConsumerSegment config store subscriptionName processedRef failedRef ledger $ do-                threadDelay ((config.durationSecs - config.restartAtSecs) * 1_000_000)-                atomically $ writeTVar stopVar True-                Async.wait producerThread-                waitForDrain producedVar processedRef 60-                waitForCheckpointDrain store subscriptionName 30+      putStrLn "Restarting with the same durable subscription"+      runConsumerSegment config store subscriptionName processedRef failedRef ledger $ do+        threadDelay ((config.durationSecs - config.restartAtSecs) * 1_000_000)+        atomically $ writeTVar stopVar True+        Async.wait producerThread+        waitForDrain producedVar processedRef 60+        waitForCheckpointDrain store subscriptionName 30 -            Async.cancel samplerThread-            finalSample <- sampleMetrics store subscriptionName startTime producedVar processedRef failedRef-            hPutStrLn handle $ sampleToCsv finalSample-            hFlush handle+      Async.cancel samplerThread+      finalSample <- sampleMetrics store subscriptionName startTime producedVar processedRef failedRef+      hPutStrLn handle $ sampleToCsv finalSample+      hFlush handle -            ledgerPassed <- writeDeliveryLedger config ledger+      ledgerPassed <- writeDeliveryLedger config ledger -            let passed =-                    finalSample.messagesFailed == 0-                        && finalSample.messagesProcessed == finalSample.messagesProduced-                        && finalSample.queueDepth == 0-                        && ledgerPassed-            putStrLn $ "Produced: " <> show finalSample.messagesProduced-            putStrLn $ "Processed: " <> show finalSample.messagesProcessed-            putStrLn $ "Durable backlog: " <> show finalSample.queueDepth-            unless passed exitFailure+      let passed =+            finalSample.messagesFailed == 0+              && finalSample.messagesProcessed == finalSample.messagesProduced+              && finalSample.queueDepth == 0+              && ledgerPassed+      putStrLn $ "Produced: " <> show finalSample.messagesProduced+      putStrLn $ "Processed: " <> show finalSample.messagesProcessed+      putStrLn $ "Durable backlog: " <> show finalSample.queueDepth+      unless passed exitFailure  runProducer :: Config -> KirokuStore -> StreamName -> TVar Int -> IORef Int -> DeliveryLedger -> TVar Bool -> IO () runProducer config store streamName producedVar failedRef ledger stopVar = loop (0 :: Int)   where     delayMicros = 1_000_000 `div` max 1 config.messagesPerSecond     loop index = do-        shouldStop <- readTVarIO stopVar-        unless shouldStop $ do-            let event =-                    EventData-                        { eventId = Nothing-                        , eventType = EventType "Ep45Lifecycle"-                        , payload = object ["sequence" .= index]-                        , metadata = Nothing-                        , causationId = Nothing-                        , correlationId = Nothing-                        }-            result <- runStoreIO store $ appendToStream streamName AnyVersion [event]-            case result of-                Left _ -> atomicModifyIORef' failedRef $ \count -> (count + 1, ())-                Right _ -> do-                    atomically $ modifyTVar' producedVar (+ 1)-                    recordProduced ledger index-            when (delayMicros > 0) $ threadDelay delayMicros-            loop (index + 1)+      shouldStop <- readTVarIO stopVar+      unless shouldStop $ do+        let event =+              EventData+                { eventId = Nothing,+                  eventType = EventType "Ep45Lifecycle",+                  payload = object ["sequence" .= index],+                  metadata = Nothing,+                  causationId = Nothing,+                  correlationId = Nothing+                }+        result <- runStoreIO store $ appendToStream streamName AnyVersion [event]+        case result of+          Left _ -> atomicModifyIORef' failedRef $ \count -> (count + 1, ())+          Right _ -> do+            atomically $ modifyTVar' producedVar (+ 1)+            recordProduced ledger index+        when (delayMicros > 0) $ threadDelay delayMicros+        loop (index + 1)  runConsumerSegment :: Config -> KirokuStore -> SubscriptionName -> IORef Int -> IORef Int -> DeliveryLedger -> IO () -> IO () runConsumerSegment config store subscriptionName processedRef failedRef ledger action =-    runEff $ runTracingNoop $ do-        adapter <- kirokuAdapter store (defaultKirokuAdapterConfig subscriptionName AllStreams)-        result <- runApp defaultAppConfig [(ProcessorId "kiroku-ep45", mkProcessor adapter handler)]-        case result of-            Left err -> liftIO $ error $ "runApp failed: " <> show err-            Right appHandle -> do-                liftIO action-                let shutdownConfig =-                        defaultShutdownConfig-                            { drainTimeout = fromIntegral config.shutdownDrainSecs-                            , totalShutdownTimeout = fromIntegral config.shutdownTotalSecs-                            }-                drained <- stopAppGracefully shutdownConfig appHandle-                unless drained $ liftIO $ error "Shibuya application required forced shutdown"+  runEff $ runTracingNoop $ do+    adapter <- kirokuAdapter store (defaultKirokuAdapterConfig subscriptionName AllStreams)+    result <- runApp defaultAppConfig [(ProcessorId "kiroku-ep45", mkProcessor adapter handler)]+    case result of+      Left err -> liftIO $ error $ "runApp failed: " <> show err+      Right appHandle -> do+        liftIO action+        let shutdownConfig =+              defaultShutdownConfig+                { drainTimeout = fromIntegral config.shutdownDrainSecs,+                  totalShutdownTimeout = fromIntegral config.shutdownTotalSecs+                }+        drained <- stopAppGracefully shutdownConfig appHandle+        unless drained $ liftIO $ error "Shibuya application required forced shutdown"   where     handler message = do-        let Message{envelope = Envelope{payload = recorded}} = message-            sequenceNumber = parseMaybe (withObject "EP-45 event" (.: "sequence")) recorded.payload-        case sequenceNumber of-            Nothing -> do-                liftIO $ do-                    atomicModifyIORef' failedRef $ \count -> (count + 1, ())-                    atomicModifyIORef' ledger.malformedDeliveries $ \count -> (count + 1, ())-                pure $ AckDeadLetter (InvalidPayload "EP-45 ledger sequence missing")-            Just value -> do-                liftIO $ do-                    atomicModifyIORef' processedRef $ \count -> (count + 1, ())-                    recordProcessed ledger value-                pure AckOk+      let Message {envelope = Envelope {payload = recorded}} = message+          sequenceNumber = parseMaybe (withObject "EP-45 event" (.: "sequence")) recorded.payload+      case sequenceNumber of+        Nothing -> do+          liftIO $ do+            atomicModifyIORef' failedRef $ \count -> (count + 1, ())+            atomicModifyIORef' ledger.malformedDeliveries $ \count -> (count + 1, ())+          pure $ AckDeadLetter (InvalidPayload "EP-45 ledger sequence missing")+        Just value -> do+          liftIO $ do+            atomicModifyIORef' processedRef $ \count -> (count + 1, ())+            recordProcessed ledger value+          pure AckOk  withDeliveryLedger :: Config -> (DeliveryLedger -> IO a) -> IO a withDeliveryLedger config action =-    let producedPath = config.outputLedger <> ".produced.ids"-        processedPath = config.outputLedger <> ".processed.ids"-     in withFile producedPath ReadWriteMode $ \producedHandle ->-            withFile processedPath ReadWriteMode $ \processedHandle -> do-                hSetFileSize producedHandle 0-                hSetFileSize processedHandle 0-                malformedDeliveries <- newIORef 0-                action DeliveryLedger{producedHandle, processedHandle, producedPath, processedPath, malformedDeliveries}+  let producedPath = config.outputLedger <> ".produced.ids"+      processedPath = config.outputLedger <> ".processed.ids"+   in withFile producedPath ReadWriteMode $ \producedHandle ->+        withFile processedPath ReadWriteMode $ \processedHandle -> do+          hSetFileSize producedHandle 0+          hSetFileSize processedHandle 0+          malformedDeliveries <- newIORef 0+          action DeliveryLedger {producedHandle, processedHandle, producedPath, processedPath, malformedDeliveries}  recordProduced :: DeliveryLedger -> Int -> IO () recordProduced ledger value = hPutStrLn ledger.producedHandle (show value)@@ -250,154 +250,154 @@  writeDeliveryLedger :: Config -> DeliveryLedger -> IO Bool writeDeliveryLedger config ledger = do-    producedValues <- readIdentityHandle ledger.producedPath ledger.producedHandle-    processedValues <- readIdentityHandle ledger.processedPath ledger.processedHandle-    malformed <- readIORef ledger.malformedDeliveries-    let produced = Set.fromList producedValues-        processed = Set.fromList processedValues-        duplicates = duplicateValues processedValues-    let missing = produced `Set.difference` processed-        unexpected = processed `Set.difference` produced-        passed = Set.null missing && Set.null unexpected && Set.null duplicates && malformed == 0-        artifact =-            object-                [ "schemaVersion" .= (1 :: Int)-                , "adapter" .= ("kiroku" :: String)-                , "runId" .= config.runId-                , "status" .= if passed then ("pass" :: String) else "fail"-                , "producedIds" .= Set.toAscList produced-                , "processedIds" .= Set.toAscList processed-                , "duplicateIds" .= Set.toAscList duplicates-                , "missingIds" .= Set.toAscList missing-                , "unexpectedIds" .= Set.toAscList unexpected-                , "malformedDeliveries" .= malformed-                ]-    LBS.writeFile config.outputLedger (encode artifact)-    putStrLn $ "Delivery ledger: " <> config.outputLedger <> " (" <> if passed then "pass)" else "fail)"-    pure passed+  producedValues <- readIdentityHandle ledger.producedPath ledger.producedHandle+  processedValues <- readIdentityHandle ledger.processedPath ledger.processedHandle+  malformed <- readIORef ledger.malformedDeliveries+  let produced = Set.fromList producedValues+      processed = Set.fromList processedValues+      duplicates = duplicateValues processedValues+  let missing = produced `Set.difference` processed+      unexpected = processed `Set.difference` produced+      passed = Set.null missing && Set.null unexpected && Set.null duplicates && malformed == 0+      artifact =+        object+          [ "schemaVersion" .= (1 :: Int),+            "adapter" .= ("kiroku" :: String),+            "runId" .= config.runId,+            "status" .= if passed then ("pass" :: String) else "fail",+            "producedIds" .= Set.toAscList produced,+            "processedIds" .= Set.toAscList processed,+            "duplicateIds" .= Set.toAscList duplicates,+            "missingIds" .= Set.toAscList missing,+            "unexpectedIds" .= Set.toAscList unexpected,+            "malformedDeliveries" .= malformed+          ]+  LBS.writeFile config.outputLedger (encode artifact)+  putStrLn $ "Delivery ledger: " <> config.outputLedger <> " (" <> if passed then "pass)" else "fail)"+  pure passed  readIdentityHandle :: FilePath -> Handle -> IO [Int] readIdentityHandle path handle = do-    hFlush handle-    hSeek handle AbsoluteSeek 0-    contents <- BS.hGetContents handle-    traverse parseIdentity (filter (not . BS.null) (BS.lines contents))+  hFlush handle+  hSeek handle AbsoluteSeek 0+  contents <- BS.hGetContents handle+  traverse parseIdentity (filter (not . BS.null) (BS.lines contents))   where     parseIdentity raw =-        maybe (ioError $ userError $ "Invalid delivery identity in " <> path) pure (readMaybe $ BS.unpack raw)+      maybe (ioError $ userError $ "Invalid delivery identity in " <> path) pure (readMaybe $ BS.unpack raw)  duplicateValues :: [Int] -> Set Int duplicateValues = snd . foldl' step (Set.empty, Set.empty)   where     step (seen, duplicates) value-        | Set.member value seen = (seen, Set.insert value duplicates)-        | otherwise = (Set.insert value seen, duplicates)+      | Set.member value seen = (seen, Set.insert value duplicates)+      | otherwise = (Set.insert value seen, duplicates)  runSampler :: Config -> KirokuStore -> SubscriptionName -> UTCTime -> TVar Int -> IORef Int -> IORef Int -> Handle -> IO () runSampler config store subscriptionName startTime producedVar processedRef failedRef handle = forever $ do-    threadDelay (config.sampleIntervalSecs * 1_000_000)-    sample <- sampleMetrics store subscriptionName startTime producedVar processedRef failedRef-    hPutStrLn handle $ sampleToCsv sample-    hFlush handle-    putStrLn $-        "["-            <> show sample.elapsedSecs-            <> "s] produced="-            <> show sample.messagesProduced-            <> " processed="-            <> show sample.messagesProcessed-            <> " backlog="-            <> show sample.queueDepth-            <> " retained="-            <> show sample.retainedBytes+  threadDelay (config.sampleIntervalSecs * 1_000_000)+  sample <- sampleMetrics store subscriptionName startTime producedVar processedRef failedRef+  hPutStrLn handle $ sampleToCsv sample+  hFlush handle+  putStrLn $+    "["+      <> show sample.elapsedSecs+      <> "s] produced="+      <> show sample.messagesProduced+      <> " processed="+      <> show sample.messagesProcessed+      <> " backlog="+      <> show sample.queueDepth+      <> " retained="+      <> show sample.retainedBytes  sampleMetrics :: KirokuStore -> SubscriptionName -> UTCTime -> TVar Int -> IORef Int -> IORef Int -> IO Sample sampleMetrics store subscriptionName startTime producedVar processedRef failedRef = do-    now <- getCurrentTime-    produced <- readTVarIO producedVar-    processed <- readIORef processedRef-    failed <- readIORef failedRef-    backlog <- checkpointBacklog store subscriptionName-    (retained, highWater) <- getMemoryBytes-    pure-        Sample-            { timestamp = now-            , elapsedSecs = round $ diffUTCTime now startTime-            , messagesProduced = produced-            , messagesProcessed = processed-            , messagesFailed = failed-            , queueDepth = backlog-            , retainedBytes = retained-            , maxLiveBytes = highWater-            }+  now <- getCurrentTime+  produced <- readTVarIO producedVar+  processed <- readIORef processedRef+  failed <- readIORef failedRef+  backlog <- checkpointBacklog store subscriptionName+  (retained, highWater) <- getMemoryBytes+  pure+    Sample+      { timestamp = now,+        elapsedSecs = round $ diffUTCTime now startTime,+        messagesProduced = produced,+        messagesProcessed = processed,+        messagesFailed = failed,+        queueDepth = backlog,+        retainedBytes = retained,+        maxLiveBytes = highWater+      }  checkpointBacklog :: KirokuStore -> SubscriptionName -> IO Int64 checkpointBacklog store subscriptionName = do-    result <- runStoreIO store subscriptionCheckpointInventory-    inventory <- case result of-        Left err -> error $ "Checkpoint inventory failed: " <> show err-        Right value -> pure value-    let GlobalPosition storePosition = inventory.storePosition-        checkpointPosition =-            case Vector.find isTargetCheckpoint inventory.checkpoints of-                Nothing -> 0-                Just checkpoint ->-                    let GlobalPosition position = checkpoint.checkpointPosition-                     in position-    pure $ max 0 (storePosition - checkpointPosition)+  result <- runStoreIO store subscriptionCheckpointInventory+  inventory <- case result of+    Left err -> error $ "Checkpoint inventory failed: " <> show err+    Right value -> pure value+  let GlobalPosition storePosition = inventory.storePosition+      checkpointPosition =+        case Vector.find isTargetCheckpoint inventory.checkpoints of+          Nothing -> 0+          Just checkpoint ->+            let GlobalPosition position = checkpoint.checkpointPosition+             in position+  pure $ max 0 (storePosition - checkpointPosition)   where     isTargetCheckpoint (SubscriptionCheckpoint name member _ _) =-        name == subscriptionName && member == 0+      name == subscriptionName && member == 0  getMemoryBytes :: IO (Word64, Word64) getMemoryBytes = do-    enabled <- getRTSStatsEnabled-    if enabled-        then do-            performMajorGC-            stats <- getRTSStats-            pure (gcdetails_live_bytes stats.gc, max_live_bytes stats)-        else pure (0, 0)+  enabled <- getRTSStatsEnabled+  if enabled+    then do+      performMajorGC+      stats <- getRTSStats+      pure (gcdetails_live_bytes stats.gc, max_live_bytes stats)+    else pure (0, 0)  waitForDrain :: TVar Int -> IORef Int -> Int -> IO () waitForDrain producedVar processedRef timeoutSecs = loop (timeoutSecs * 10)   where     loop remaining = do-        produced <- readTVarIO producedVar-        processed <- readIORef processedRef-        if processed >= produced-            then pure ()-            else-                if remaining > 0-                    then threadDelay 100_000 >> loop (remaining - 1)-                    else error $ "Timed out draining produced events: produced=" <> show produced <> " processed=" <> show processed+      produced <- readTVarIO producedVar+      processed <- readIORef processedRef+      if processed >= produced+        then pure ()+        else+          if remaining > 0+            then threadDelay 100_000 >> loop (remaining - 1)+            else error $ "Timed out draining produced events: produced=" <> show produced <> " processed=" <> show processed  waitForCheckpointDrain :: KirokuStore -> SubscriptionName -> Int -> IO () waitForCheckpointDrain store subscriptionName timeoutSecs = loop (timeoutSecs * 10)   where     loop remaining = do-        backlog <- checkpointBacklog store subscriptionName-        if backlog <= 0-            then pure ()-            else-                if remaining > 0-                    then threadDelay 100_000 >> loop (remaining - 1)-                    else error $ "Timed out draining Kiroku checkpoint backlog: backlog=" <> show backlog+      backlog <- checkpointBacklog store subscriptionName+      if backlog <= 0+        then pure ()+        else+          if remaining > 0+            then threadDelay 100_000 >> loop (remaining - 1)+            else error $ "Timed out draining Kiroku checkpoint backlog: backlog=" <> show backlog  csvHeader :: String csvHeader = "timestamp,elapsed_secs,produced,processed,failed,queue_depth,retained_bytes,max_live_bytes"  sampleToCsv :: Sample -> String sampleToCsv sample =-    Text.unpack $-        Text.intercalate-            ","-            [ Text.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" sample.timestamp-            , Text.pack $ show sample.elapsedSecs-            , Text.pack $ show sample.messagesProduced-            , Text.pack $ show sample.messagesProcessed-            , Text.pack $ show sample.messagesFailed-            , Text.pack $ show sample.queueDepth-            , Text.pack $ show sample.retainedBytes-            , Text.pack $ show sample.maxLiveBytes-            ]+  Text.unpack $+    Text.intercalate+      ","+      [ Text.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" sample.timestamp,+        Text.pack $ show sample.elapsedSecs,+        Text.pack $ show sample.messagesProduced,+        Text.pack $ show sample.messagesProcessed,+        Text.pack $ show sample.messagesFailed,+        Text.pack $ show sample.queueDepth,+        Text.pack $ show sample.retainedBytes,+        Text.pack $ show sample.maxLiveBytes+      ]
bench/WriteProbe.hs view
@@ -1,10 +1,9 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedRecordDot #-} -{- | MP-12's durable append/subscription comparison probe. Build this identical-workload against the original control and candidate; only the legacy group-constructor needs a compatibility branch. No production instrumentation.--}+-- | MP-12's durable append/subscription comparison probe. Build this identical+-- workload against the original control and candidate; only the legacy group+-- constructor needs a compatibility branch. No production instrumentation. module Main (main) where  import Contravariant.Extras (contrazip2)@@ -43,69 +42,69 @@ import Text.Read (readMaybe)  data Workload = Workload-    { seconds :: !Int-    , mode :: !String-    , width :: !Int-    , appendBatch :: !Int-    , checkpointBatch :: !Int32-    , fresh :: !Bool-    , offered :: !Int-    }+  { seconds :: !Int,+    mode :: !String,+    width :: !Int,+    appendBatch :: !Int,+    checkpointBatch :: !Int32,+    fresh :: !Bool,+    offered :: !Int+  }  main :: IO () main = do-    args <- getArgs-    workload <- case args of-        [duration, mode, width, batch, checkpoint, fresh, offered] ->-            Workload <$> number duration <*> pure mode <*> number width <*> number batch <*> number checkpoint <*> number fresh <*> number offered-        _ -> fail "usage: write-probe SECONDS MODE WIDTH APPEND_BATCH CHECKPOINT_BATCH FRESH OFFERED_CALLS_PER_SEC (MODE=none|all|category|group|adapter)"-    unless (workload.seconds > 0 && workload.width > 0 && workload.appendBatch > 0 && workload.checkpointBatch > 0 && workload.offered >= 0 && workload.mode `elem` ["none", "all", "category", "group", "adapter"]) (fail "invalid workload")-    original <- ephemeralConfig-    let overridden = ["fsync", "full_page_writes", "synchronous_commit", "shared_buffers", "wal_level"]-        durable = original{Pg.postgresSettings = filter (\(key, _) -> key `notElem` overridden) original.postgresSettings ++ [("fsync", "on"), ("synchronous_commit", "on"), ("full_page_writes", "on"), ("shared_buffers", "128MB"), ("wal_level", "replica")]}-    result <- Pg.withCachedConfig durable Pg.defaultCacheConfig $ \database -> do-        migrateTestDatabase (Pg.connectionString database)-        live <- newIORef (0 :: Int)-        failures <- newIORef (0 :: Int)-        batches <- newIORef (0 :: Int)-        let observe = \case-                KirokuEventSubscriptionCaughtUp{} -> atomicModifyIORef' live (\n -> (n + 1, ()))-                KirokuEventSubscriptionDbError{} -> atomicModifyIORef' failures (\n -> (n + 1, ()))-                KirokuEventSubscriptionDelivered{} -> atomicModifyIORef' batches (\n -> (n + 1, ()))-                _ -> pure ()-            settings = (defaultConnectionSettings (Pg.connectionString database)){poolSize = 10, eventHandler = Just observe}-        withStore settings $ \store -> do-            let event = EventData Nothing (EventType "Probe") (object ["body" .= Text.replicate 512 "x"]) Nothing Nothing Nothing-            -- Identical existing-stream fixtures and explicit future-only live entry.-            forM_ [0 .. 3] $ \writer -> forM_ [0 .. workload.width - 1] $ \slot ->-                void $ append store [(stream writer slot 0 False, AnyVersion, [event])]-            delivered <- newIORef (0 :: Int)-            let handler _ = atomicModifyIORef' delivered (\n -> (n + 1, ())) >> pure Continue-                config m =-                    (defaultSubscriptionConfig (SubscriptionName "probe") (workloadTarget workload) handler)-                        { missingCheckpointPolicy = FromCurrentHead-                        , batchSize = either (error . show) Prelude.id (mkBatchSize workload.checkpointBatch)-                        , consumerGroup = if workload.mode == "group" then Just (membership m) else Nothing-                        }-                members = if workload.mode == "group" then [0, 1, 2, 3] else [0]-                native = bracket (mapM (subscribe store . config) members) (mapM_ cancel) $ \_ -> measure workload store event delivered live failures batches (length members)-            case workload.mode of-                "none" -> measure workload store event delivered live failures batches 0-                "adapter" -> runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            (defaultKirokuAdapterConfig (SubscriptionName "probe") AllStreams)-                                { Adapter.missingCheckpointPolicy = FromCurrentHead-                                , Adapter.batchSize = either (error . show) Prelude.id (mkBatchSize workload.checkpointBatch)-                                }-                    app <- runApp defaultAppConfig [(ProcessorId "probe", mkProcessor adapter (\_ -> liftIO (atomicModifyIORef' delivered (\n -> (n + 1, ()))) >> pure AckOk))]-                    case app of-                        Left err -> liftIO $ fail (show err)-                        Right handle -> do-                            liftIO $ measure workload store event delivered live failures batches 1-                            stopApp handle-                _ -> native-    either (fail . show) pure result+  args <- getArgs+  workload <- case args of+    [duration, mode, width, batch, checkpoint, fresh, offered] ->+      Workload <$> number duration <*> pure mode <*> number width <*> number batch <*> number checkpoint <*> number fresh <*> number offered+    _ -> fail "usage: write-probe SECONDS MODE WIDTH APPEND_BATCH CHECKPOINT_BATCH FRESH OFFERED_CALLS_PER_SEC (MODE=none|all|category|group|adapter)"+  unless (workload.seconds > 0 && workload.width > 0 && workload.appendBatch > 0 && workload.checkpointBatch > 0 && workload.offered >= 0 && workload.mode `elem` ["none", "all", "category", "group", "adapter"]) (fail "invalid workload")+  original <- ephemeralConfig+  let overridden = ["fsync", "full_page_writes", "synchronous_commit", "shared_buffers", "wal_level"]+      durable = original {Pg.postgresSettings = filter (\(key, _) -> key `notElem` overridden) original.postgresSettings ++ [("fsync", "on"), ("synchronous_commit", "on"), ("full_page_writes", "on"), ("shared_buffers", "128MB"), ("wal_level", "replica")]}+  result <- Pg.withCachedConfig durable Pg.defaultCacheConfig $ \database -> do+    migrateTestDatabase (Pg.connectionString database)+    live <- newIORef (0 :: Int)+    failures <- newIORef (0 :: Int)+    batches <- newIORef (0 :: Int)+    let observe = \case+          KirokuEventSubscriptionCaughtUp {} -> atomicModifyIORef' live (\n -> (n + 1, ()))+          KirokuEventSubscriptionDbError {} -> atomicModifyIORef' failures (\n -> (n + 1, ()))+          KirokuEventSubscriptionDelivered {} -> atomicModifyIORef' batches (\n -> (n + 1, ()))+          _ -> pure ()+        settings = (defaultConnectionSettings (Pg.connectionString database)) {poolSize = 10, eventHandler = Just observe}+    withStore settings $ \store -> do+      let event = EventData Nothing (EventType "Probe") (object ["body" .= Text.replicate 512 "x"]) Nothing Nothing Nothing+      -- Identical existing-stream fixtures and explicit future-only live entry.+      forM_ [0 .. 3] $ \writer -> forM_ [0 .. workload.width - 1] $ \slot ->+        void $ append store [(stream writer slot 0 False, AnyVersion, [event])]+      delivered <- newIORef (0 :: Int)+      let handler _ = atomicModifyIORef' delivered (\n -> (n + 1, ())) >> pure Continue+          config m =+            (defaultSubscriptionConfig (SubscriptionName "probe") (workloadTarget workload) handler)+              { missingCheckpointPolicy = FromCurrentHead,+                batchSize = either (error . show) Prelude.id (mkBatchSize workload.checkpointBatch),+                consumerGroup = if workload.mode == "group" then Just (membership m) else Nothing+              }+          members = if workload.mode == "group" then [0, 1, 2, 3] else [0]+          native = bracket (mapM (subscribe store . config) members) (mapM_ cancel) $ \_ -> measure workload store event delivered live failures batches (length members)+      case workload.mode of+        "none" -> measure workload store event delivered live failures batches 0+        "adapter" -> runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              (defaultKirokuAdapterConfig (SubscriptionName "probe") AllStreams)+                { Adapter.missingCheckpointPolicy = FromCurrentHead,+                  Adapter.batchSize = either (error . show) Prelude.id (mkBatchSize workload.checkpointBatch)+                }+          app <- runApp defaultAppConfig [(ProcessorId "probe", mkProcessor adapter (\_ -> liftIO (atomicModifyIORef' delivered (\n -> (n + 1, ()))) >> pure AckOk))]+          case app of+            Left err -> liftIO $ fail (show err)+            Right handle -> do+              liftIO $ measure workload store event delivered live failures batches 1+              stopApp handle+        _ -> native+  either (fail . show) pure result   where     number text = maybe (fail ("invalid argument " <> text)) pure (readMaybe text) @@ -127,98 +126,98 @@  measure :: Workload -> KirokuStore -> EventData -> IORef Int -> IORef Int -> IORef Int -> IORef Int -> Int -> IO () measure workload store event delivered live failures batches members = do-    when (members > 0) $ await "live entry" (fmap (>= members) (readIORef live))-    -- Warm up the same append shape for two seconds, then fully drain before-    -- measuring. The output contains only the declared measurement interval.-    void $ writers 2-    when (members > 0) $ await "warmup checkpoint drain" durable-    flushStats store-    writeIORef delivered 0-    writeIORef batches 0-    performMajorGC-    stats0 <- getRTSStats-    wal0 <- scalar store "SELECT pg_current_wal_insert_lsn()::text"-    tables0 <- tableStats store-    start <- getMonotonicTimeNSec-    samples <- writers workload.seconds-    finish <- getMonotonicTimeNSec-    let calls = sum [length latencies | (latencies, _) <- samples]-        events = calls * workload.width * workload.appendBatch-        elapsed = secondsBetween start finish-        sorted = sort (concat [latencies | (latencies, _) <- samples])-    when (members > 0) $ do-        await "delivery drain" (fmap (== events) (readIORef delivered))-        await "durable progress drain" durable-    performMajorGC-    stats1 <- getRTSStats-    errors <- readIORef failures-    unless (errors == 0) (fail "database errors during workload")-    flushStats store-    wal1 <- scalar store "SELECT pg_current_wal_insert_lsn()::text"-    walBytes <- scalarInt store ("SELECT pg_wal_lsn_diff('" <> wal1 <> "'::pg_lsn, '" <> wal0 <> "'::pg_lsn)::bigint")-    tables1 <- tableStats store-    count <- readIORef delivered-    batchCount <- readIORef batches-    server <- scalar store "SELECT version()"-    durability <- scalar store "SELECT current_setting('fsync') || ',' || current_setting('synchronous_commit') || ',' || current_setting('full_page_writes')"-    let (saves0, hot0) = tables0-        (saves1, hot1) = tables1-    Bytes.putStrLn $-        encode $-            object-                [ "workload" .= object ["mode" .= workload.mode, "width" .= workload.width, "append_batch" .= workload.appendBatch, "checkpoint_batch" .= workload.checkpointBatch, "fresh" .= workload.fresh, "offered" .= workload.offered, "seconds" .= workload.seconds]-                , "server" .= server-                , "durability" .= durability-                , "calls" .= calls-                , "events" .= events-                , "elapsed" .= elapsed-                , "events_per_second" .= (fromIntegral events / elapsed :: Double)-                , "append_p50_ms" .= percentile sorted 0.50-                , "append_p95_ms" .= percentile sorted 0.95-                , "append_p99_ms" .= percentile sorted 0.99-                , "delivered" .= count-                , "delivery_batches" .= batchCount-                , "durable_drained" .= True-                , "wal_bytes" .= walBytes-                , "checkpoint_updates" .= (saves1 - saves0)-                , "checkpoint_hot_updates" .= (hot1 - hot0)-                , "allocated_bytes" .= (stats1.allocated_bytes - stats0.allocated_bytes)-                , "gc_cpu_ns" .= (stats1.gc_cpu_ns - stats0.gc_cpu_ns)-                , "gc_elapsed_ns" .= (stats1.gc_elapsed_ns - stats0.gc_elapsed_ns)-                , "max_live_bytes" .= stats1.max_live_bytes-                ]+  when (members > 0) $ await "live entry" (fmap (>= members) (readIORef live))+  -- Warm up the same append shape for two seconds, then fully drain before+  -- measuring. The output contains only the declared measurement interval.+  void $ writers 2+  when (members > 0) $ await "warmup checkpoint drain" durable+  flushStats store+  writeIORef delivered 0+  writeIORef batches 0+  performMajorGC+  stats0 <- getRTSStats+  wal0 <- scalar store "SELECT pg_current_wal_insert_lsn()::text"+  tables0 <- tableStats store+  start <- getMonotonicTimeNSec+  samples <- writers workload.seconds+  finish <- getMonotonicTimeNSec+  let calls = sum [length latencies | (latencies, _) <- samples]+      events = calls * workload.width * workload.appendBatch+      elapsed = secondsBetween start finish+      sorted = sort (concat [latencies | (latencies, _) <- samples])+  when (members > 0) $ do+    await "delivery drain" (fmap (== events) (readIORef delivered))+    await "durable progress drain" durable+  performMajorGC+  stats1 <- getRTSStats+  errors <- readIORef failures+  unless (errors == 0) (fail "database errors during workload")+  flushStats store+  wal1 <- scalar store "SELECT pg_current_wal_insert_lsn()::text"+  walBytes <- scalarInt store ("SELECT pg_wal_lsn_diff('" <> wal1 <> "'::pg_lsn, '" <> wal0 <> "'::pg_lsn)::bigint")+  tables1 <- tableStats store+  count <- readIORef delivered+  batchCount <- readIORef batches+  server <- scalar store "SELECT version()"+  durability <- scalar store "SELECT current_setting('fsync') || ',' || current_setting('synchronous_commit') || ',' || current_setting('full_page_writes')"+  let (saves0, hot0) = tables0+      (saves1, hot1) = tables1+  Bytes.putStrLn $+    encode $+      object+        [ "workload" .= object ["mode" .= workload.mode, "width" .= workload.width, "append_batch" .= workload.appendBatch, "checkpoint_batch" .= workload.checkpointBatch, "fresh" .= workload.fresh, "offered" .= workload.offered, "seconds" .= workload.seconds],+          "server" .= server,+          "durability" .= durability,+          "calls" .= calls,+          "events" .= events,+          "elapsed" .= elapsed,+          "events_per_second" .= (fromIntegral events / elapsed :: Double),+          "append_p50_ms" .= percentile sorted 0.50,+          "append_p95_ms" .= percentile sorted 0.95,+          "append_p99_ms" .= percentile sorted 0.99,+          "delivered" .= count,+          "delivery_batches" .= batchCount,+          "durable_drained" .= True,+          "wal_bytes" .= walBytes,+          "checkpoint_updates" .= (saves1 - saves0),+          "checkpoint_hot_updates" .= (hot1 - hot0),+          "allocated_bytes" .= (stats1.allocated_bytes - stats0.allocated_bytes),+          "gc_cpu_ns" .= (stats1.gc_cpu_ns - stats0.gc_cpu_ns),+          "gc_elapsed_ns" .= (stats1.gc_elapsed_ns - stats0.gc_elapsed_ns),+          "max_live_bytes" .= stats1.max_live_bytes+        ]   where     durable = do-        Right inventory <- runStoreIO store subscriptionCheckpointInventory-        -- Group members have sparse partitions, so their own final event may-        -- precede the global head; verify against the last matching fetch below.-        if workload.mode /= "group"-            then pure (all (\row -> row.checkpointPosition >= inventory.storePosition) (Vector.toList inventory.checkpoints))-            else-                and-                    <$> mapM-                        ( \row -> do-                            n <- runSession store $ Session.statement (let { GlobalPosition p = row.checkpointPosition } in p, row.consumerGroupMember) groupRemaining-                            pure (n == 0)-                        )-                        (Vector.toList inventory.checkpoints)+      Right inventory <- runStoreIO store subscriptionCheckpointInventory+      -- Group members have sparse partitions, so their own final event may+      -- precede the global head; verify against the last matching fetch below.+      if workload.mode /= "group"+        then pure (all (\row -> row.checkpointPosition >= inventory.storePosition) (Vector.toList inventory.checkpoints))+        else+          and+            <$> mapM+              ( \row -> do+                  n <- runSession store $ Session.statement (let { GlobalPosition p = row.checkpointPosition } in p, row.consumerGroupMember) groupRemaining+                  pure (n == 0)+              )+              (Vector.toList inventory.checkpoints)     writers duration = do-        begin <- getMonotonicTimeNSec-        let end = begin + fromIntegral duration * 1_000_000_000-        Async.mapConcurrently (writer begin end) [0 .. 3]+      begin <- getMonotonicTimeNSec+      let end = begin + fromIntegral duration * 1_000_000_000+      Async.mapConcurrently (writer begin end) [0 .. 3]     writer begin end writerId = loop 0 []       where         spacing = if workload.offered == 0 then 0 else 4_000_000_000 `div` fromIntegral workload.offered         loop i collected = do-            now <- getMonotonicTimeNSec-            let scheduled = if spacing == 0 then now else begin + fromIntegral i * spacing-            if now >= end || scheduled >= end-                then pure (collected, ())-                else do-                    when (scheduled > now) (threadDelay (fromIntegral ((scheduled - now) `div` 1000)))-                    void $ append store [(stream writerId slot (i + fromIntegral begin) workload.fresh, AnyVersion, replicate workload.appendBatch event) | slot <- [0 .. workload.width - 1]]-                    completed <- getMonotonicTimeNSec-                    loop (i + 1) (fromIntegral (completed - scheduled) / 1_000_000 : collected)+          now <- getMonotonicTimeNSec+          let scheduled = if spacing == 0 then now else begin + fromIntegral i * spacing+          if now >= end || scheduled >= end+            then pure (collected, ())+            else do+              when (scheduled > now) (threadDelay (fromIntegral ((scheduled - now) `div` 1000)))+              void $ append store [(stream writerId slot (i + fromIntegral begin) workload.fresh, AnyVersion, replicate workload.appendBatch event) | slot <- [0 .. workload.width - 1]]+              completed <- getMonotonicTimeNSec+              loop (i + 1) (fromIntegral (completed - scheduled) / 1_000_000 : collected)  secondsBetween :: Word64 -> Word64 -> Double secondsBetween start finish = fromIntegral (finish - start) / 1_000_000_000@@ -246,10 +245,10 @@  groupRemaining :: Statement (Int64, Int32) Int64 groupRemaining =-    preparable-        "SELECT count(*) FROM kiroku.stream_events WHERE stream_id = 0 AND category = 'probe' AND stream_version > $1 AND (((hashtextextended(original_stream_id::text, 0) % 4) + 4) % 4) = $2"-        (contrazip2 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    "SELECT count(*) FROM kiroku.stream_events WHERE stream_id = 0 AND category = 'probe' AND stream_version > $1 AND (((hashtextextended(original_stream_id::text, 0) % 4) + 4) % 4) = $2"+    (contrazip2 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))+    (D.singleRow (D.column (D.nonNullable D.int8)))  -- Flush backend-local table statistics outside the timed interval. Ten -- concurrent sessions occupy the ten pool slots, including idle writer and@@ -257,6 +256,6 @@ -- into the measured HOT-update deltas. flushStats :: KirokuStore -> IO () flushStats store =-    Async.replicateConcurrently_ 10 $-        runSession store $-            Session.script "SELECT pg_stat_force_next_flush(); SELECT pg_sleep(0.2)"+  Async.replicateConcurrently_ 10 $+    runSession store $+      Session.script "SELECT pg_stat_force_next_flush(); SELECT pg_sleep(0.2)"
internal/Shibuya/Adapter/Kiroku/Internal.hs view
@@ -1,58 +1,58 @@-module Shibuya.Adapter.Kiroku.Internal (-    acquireAllAndTransfer,-) where+module Shibuya.Adapter.Kiroku.Internal+  ( acquireAllAndTransfer,+  )+where  import Control.Exception (SomeException) import Data.Int (Int32) import Effectful (Eff, IOE, (:>)) import Effectful.Exception qualified as Exception -{- | Acquire a fixed set of resources and atomically transfer their ownership.--The acquisition itself is restored to the caller's masking state because it may-perform interruptible startup work. As soon as an acquisition returns, the new-resource is added to the masked ownership ledger before the next interruptible-operation can run. If acquisition or transfer fails, every owned resource is-given one release attempt in reverse acquisition order and the original-exception is rethrown even when a release also fails.--The post-acquire hook runs while the new resource is already in the ledger. It-exists so tests can stop at the exact cancellation boundary without adding a-hook to the public adapter API; production callers pass a no-op.--}+-- | Acquire a fixed set of resources and atomically transfer their ownership.+--+-- The acquisition itself is restored to the caller's masking state because it may+-- perform interruptible startup work. As soon as an acquisition returns, the new+-- resource is added to the masked ownership ledger before the next interruptible+-- operation can run. If acquisition or transfer fails, every owned resource is+-- given one release attempt in reverse acquisition order and the original+-- exception is rethrown even when a release also fails.+--+-- The post-acquire hook runs while the new resource is already in the ledger. It+-- exists so tests can stop at the exact cancellation boundary without adding a+-- hook to the public adapter API; production callers pass a no-op. acquireAllAndTransfer ::-    (IOE :> es) =>-    Int32 ->-    (Int32 -> Eff es resource) ->-    (resource -> Eff es ()) ->-    (Int32 -> resource -> Eff es ()) ->-    ([resource] -> Eff es result) ->-    Eff es result+  (IOE :> es) =>+  Int32 ->+  (Int32 -> Eff es resource) ->+  (resource -> Eff es ()) ->+  (Int32 -> resource -> Eff es ()) ->+  ([resource] -> Eff es result) ->+  Eff es result acquireAllAndTransfer count acquire release afterAcquire transfer =-    Exception.mask $ \restore -> go restore [] 0+  Exception.mask $ \restore -> go restore [] 0   where     go restore owned member-        | member >= count = do-            outcome <- tryAny (transfer (reverse owned))-            either (`cleanupAndRethrow` owned) pure outcome-        | otherwise = do-            acquisition <- tryAny (restore (acquire member))-            case acquisition of-                Left primary -> cleanupAndRethrow primary owned-                Right resource -> do-                    let owned' = resource : owned-                    handoff <- tryAny (afterAcquire member resource)-                    case handoff of-                        Left primary -> cleanupAndRethrow primary owned'-                        Right () -> go restore owned' (member + 1)+      | member >= count = do+          outcome <- tryAny (transfer (reverse owned))+          either (`cleanupAndRethrow` owned) pure outcome+      | otherwise = do+          acquisition <- tryAny (restore (acquire member))+          case acquisition of+            Left primary -> cleanupAndRethrow primary owned+            Right resource -> do+              let owned' = resource : owned+              handoff <- tryAny (afterAcquire member resource)+              case handoff of+                Left primary -> cleanupAndRethrow primary owned'+                Right () -> go restore owned' (member + 1)      cleanupAndRethrow primary owned = do-        mapM_ releaseIgnoringFailure owned-        Exception.throwIO primary+      mapM_ releaseIgnoringFailure owned+      Exception.throwIO primary      releaseIgnoringFailure resource = do-        _ <- tryAny (release resource)-        pure ()+      _ <- tryAny (release resource)+      pure ()  tryAny :: Eff es a -> Eff es (Either SomeException a) tryAny = Exception.try
shibuya-kiroku-adapter.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            shibuya-kiroku-adapter-version:         0.6.0.1+version:         0.7.0.0 synopsis:   Kiroku event store adapter for the Shibuya queue processing framework @@ -65,8 +65,8 @@     , effectful-core                         >=2.6.1     && <2.7  || >=2.7.1.1 && <2.8     , hs-opentelemetry-api                   ^>=1.0     , hs-opentelemetry-semantic-conventions  ^>=1.40-    , kiroku-store                           ^>=0.11.0.0-    , shibuya-core                           >=0.10      && <0.11+    , kiroku-store                           ^>=0.12.0.0+    , shibuya-core                           >=0.12      && <0.13     , shibuya-kiroku-adapter-internal     , stm                                    >=2.5       && <2.6     , streamly-core                          >=0.3       && <0.4@@ -96,10 +96,10 @@     , hasql-pool                       >=1.2       && <1.5     , hs-opentelemetry-api             ^>=1.0     , hspec                            >=2.10      && <2.12-    , kiroku-store                     ^>=0.11.0.0+    , kiroku-store                     ^>=0.12.0.0     , kiroku-test-support              ^>=0.1.0.0     , lens                             >=5.2       && <5.4-    , shibuya-core                     >=0.10      && <0.11+    , shibuya-core                     >=0.12      && <0.13     , shibuya-kiroku-adapter     , shibuya-kiroku-adapter-internal     , stm                              >=2.5       && <2.6@@ -130,9 +130,9 @@     , containers              >=0.6       && <0.8     , effectful-core          >=2.6.1     && <2.7  || >=2.7.1.1 && <2.8     , ephemeral-pg            >=0.3.1     && <0.4-    , kiroku-store            ^>=0.11.0.0+    , kiroku-store            ^>=0.12.0.0     , kiroku-test-support     ^>=0.1.0.0-    , shibuya-core            >=0.10      && <0.11+    , shibuya-core            >=0.12      && <0.13     , shibuya-kiroku-adapter     , stm                     >=2.5       && <2.6     , text                    >=2.0       && <2.2@@ -148,18 +148,18 @@   default-extensions: OverloadedRecordDot   ghc-options:        -threaded -rtsopts "-with-rtsopts=-N4 -T -A32m" -O2   build-depends:-    , aeson                   >=2.1   && <2.3-    , async                   >=2.2   && <2.3-    , base                    >=4.18  && <5-    , bytestring              >=0.11  && <0.13+    , aeson                   >=2.1       && <2.3+    , async                   >=2.2       && <2.3+    , base                    >=4.18      && <5+    , bytestring              >=0.11      && <0.13     , contravariant-extras    >=0.3-    , effectful-core          >=2.6.1 && <2.7  || >=2.7.1.1 && <2.8-    , ephemeral-pg            >=0.3.1 && <0.4-    , hasql                   >=1.10  && <1.11-    , hasql-pool              >=1.2   && <1.5-    , kiroku-store+    , effectful-core          >=2.6.1     && <2.7  || >=2.7.1.1 && <2.8+    , ephemeral-pg            >=0.3.1     && <0.4+    , hasql                   >=1.10      && <1.11+    , hasql-pool              >=1.2       && <1.5+    , kiroku-store            ^>=0.12.0.0     , kiroku-test-support-    , shibuya-core            >=0.10  && <0.11+    , shibuya-core            >=0.12      && <0.13     , shibuya-kiroku-adapter-    , text                    >=2.0   && <2.2-    , vector                  >=0.13  && <0.14+    , text                    >=2.0       && <2.2+    , vector                  >=0.13      && <0.14
src/Shibuya/Adapter/Kiroku.hs view
@@ -1,125 +1,124 @@-{- | Kiroku event store adapter for the Shibuya queue processing framework.--This adapter wraps Kiroku's push-based subscriptions into Shibuya's-pull-based 'Adapter' interface. Events are bridged through a bounded-'TBQueue' (via @kiroku-store@'s 'subscriptionStream') and lifted into the-effectful stack with @Stream.morphInner@.--== Example--@-import Effectful (runEff)-import Kiroku.Store (withStore, defaultConnectionSettings)-import Shibuya.Adapter.Kiroku-import Shibuya.App-import Shibuya.Core.Ack (AckDecision (..))-import Shibuya.Telemetry.Effect (runTracingNoop)--main :: IO ()-main = withStore settings $ \\store ->-    runEff $ runTracingNoop $ do-        let cfg = defaultKirokuAdapterConfig (SubscriptionName \"my-projection\") AllStreams-        adapter <- kirokuAdapter store cfg--        let handler ingested = do-                -- process ingested.envelope.payload :: RecordedEvent-                pure AckOk--        Right appHandle <- runApp defaultAppConfig-            [(ProcessorId \"my-projection\", kirokuProcessor adapter handler)]--        waitApp appHandle-@--== Consumer-Group Example (size 4)--A consumer group splits one logical subscription across @N@ members. Each-originating stream is deterministically assigned to exactly one member (by a-hash computed in PostgreSQL), so same-stream events stay ordered while distinct-streams are processed in parallel. To run a whole group in one process, use-'kirokuConsumerGroupProcessors': one call yields @N@ named processors, each a-member adapter pinned to the group-level @('PartitionedInOrder', 'Serial')@-policy — no manual @[0..N-1]@ wiring.--@-main :: IO ()-main = withStore settings $ \\store ->-    runEff $ runTracingNoop $ do-        groupSize <- either (fail . show) pure (mkConsumerGroupSize 4)-        let cfg = defaultConsumerGroupConfig-                (SubscriptionName \"orders-projection\")-                (Category (CategoryName \"orders\"))-                groupSize--        Right processors <- kirokuConsumerGroupProcessors store cfg handler-        Right appHandle <- runApp defaultAppConfig processors-        waitApp appHandle-  where-    handler ingested = do-        -- process ingested.envelope.payload :: RecordedEvent-        pure AckOk-@--To run members across separate processes instead, give each process one-'kirokuAdapter' with its own 'member' index and the same 'subscriptionName'.-Kiroku's per-member checkpoint (keyed by @(subscriptionName, member)@) lets each-process resume from its own position after a restart. Exactly one live process-must own each member index at a time.--== Ack Semantics--The adapter bridges through @kiroku-store@'s __ack-coupled__ stream-('subscriptionAckStream'): for each event the Kiroku worker blocks until the-Shibuya handler's 'AckDecision' is finalized, then acts on it. The handler's-decision therefore drives Kiroku checkpointing per event:--* 'AckOk' — the worker checkpoints past the event (the normal case).-* 'AckRetry' @delay@ — the worker redelivers the /same/ event after @delay@,-  bounded by the subscription's retry policy-  ('Kiroku.Store.Subscription.Types.RetryPolicy', default five attempts); on-  exhaustion the event is dead-lettered with-  'Kiroku.Store.Subscription.Types.DeadLetterMaxAttempts'.-* 'AckDeadLetter' @reason@ — the worker records the event in-  @kiroku.dead_letters@ (with the reason translated to a Kiroku-native-  'Kiroku.Store.Subscription.Types.DeadLetterReason') and atomically advances-  the checkpoint past it.-* 'AckHalt' — cancels the underlying Kiroku subscription (no checkpoint advance,-  so the halting event replays on restart).--The envelope's @attempt@ reports the zero-based redelivery count, so a handler-can observe how many times Kiroku has redelivered an event.--== Backpressure and Handler Exceptions--Because delivery is ack-coupled, the Kiroku subscription worker blocks on each-event until the Shibuya handler finalizes its decision, providing natural-backpressure. The @bufferSize@ field is only the capacity of the bridge queue-between the worker and the Shibuya stream consumer; for this adapter the-effective depth is at most one event because the worker waits for an ack before-delivering the next event, and @bufferSize@ must be at least 1.--The @queueCapacity@ field is the publisher-side burst knob: it is the number of-publisher batches, up to 1000 events each, that can be buffered before Kiroku-pauses this subscriber. The adapter uses Kiroku's lossless @PauseAndResume@-overflow policy, so a paused subscriber catches up from its checkpoint instead-of being killed.--Shibuya's supervised runner converts a synchronous handler exception to an-immediate 'AckRetry' and finalizes it, so the ack-coupled Kiroku worker cannot be-left blocked by an abandoned reply. 'kirokuProcessor' applies the one-second-paced guard for a single processor. 'guardKirokuHandlerWith' remains useful when-the application wants a different exception disposition, and-'kirokuConsumerGroupProcessors' applies the adapter's default guard-automatically. Asynchronous cancellation is never converted into an ack.--Raw consumers of @adapter.source@ must finalize every item. Leaving one pending-blocks delivery and checkpoint advancement. Opt in with a positive-@handlerStallWarnAfter@ to receive advisory store handler-stall events; warnings-never finalize, retry or checkpoint the item. @retryPolicy@ controls total-deliveries independently of the delay chosen by 'AckRetry'.--}-module Shibuya.Adapter.Kiroku (-    -- * Adapter+-- | Kiroku event store adapter for the Shibuya queue processing framework.+--+-- This adapter wraps Kiroku's push-based subscriptions into Shibuya's+-- pull-based 'Adapter' interface. Events are bridged through a bounded+-- 'TBQueue' (via @kiroku-store@'s 'subscriptionStream') and lifted into the+-- effectful stack with @Stream.morphInner@.+--+-- == Example+--+-- @+-- import Effectful (runEff)+-- import Kiroku.Store (withStore, defaultConnectionSettings)+-- import Shibuya.Adapter.Kiroku+-- import Shibuya.App+-- import Shibuya.Core.Ack (AckDecision (..))+-- import Shibuya.Telemetry.Effect (runTracingNoop)+--+-- main :: IO ()+-- main = withStore settings $ \\store ->+--     runEff $ runTracingNoop $ do+--         let cfg = defaultKirokuAdapterConfig (SubscriptionName \"my-projection\") AllStreams+--         adapter <- kirokuAdapter store cfg+--+--         let handler ingested = do+--                 -- process ingested.envelope.payload :: RecordedEvent+--                 pure AckOk+--+--         Right appHandle <- runApp defaultAppConfig+--             [(ProcessorId \"my-projection\", kirokuProcessor adapter handler)]+--+--         waitApp appHandle+-- @+--+-- == Consumer-Group Example (size 4)+--+-- A consumer group splits one logical subscription across @N@ members. Each+-- originating stream is deterministically assigned to exactly one member (by a+-- hash computed in PostgreSQL), so same-stream events stay ordered while distinct+-- streams are processed in parallel. To run a whole group in one process, use+-- 'kirokuConsumerGroupProcessors': one call yields @N@ named processors, each a+-- member adapter pinned to the group-level @('PartitionedInOrder', 'Serial')@+-- policy — no manual @[0..N-1]@ wiring.+--+-- @+-- main :: IO ()+-- main = withStore settings $ \\store ->+--     runEff $ runTracingNoop $ do+--         groupSize <- either (fail . show) pure (mkConsumerGroupSize 4)+--         let cfg = defaultConsumerGroupConfig+--                 (SubscriptionName \"orders-projection\")+--                 (Category (CategoryName \"orders\"))+--                 groupSize+--+--         Right processors <- kirokuConsumerGroupProcessors store cfg handler+--         Right appHandle <- runApp defaultAppConfig processors+--         waitApp appHandle+--   where+--     handler ingested = do+--         -- process ingested.envelope.payload :: RecordedEvent+--         pure AckOk+-- @+--+-- To run members across separate processes instead, give each process one+-- 'kirokuAdapter' with its own 'member' index and the same 'subscriptionName'.+-- Kiroku's per-member checkpoint (keyed by @(subscriptionName, member)@) lets each+-- process resume from its own position after a restart. Exactly one live process+-- must own each member index at a time.+--+-- == Ack Semantics+--+-- The adapter bridges through @kiroku-store@'s __ack-coupled__ stream+-- ('subscriptionAckStream'): for each event the Kiroku worker blocks until the+-- Shibuya handler's 'AckDecision' is finalized, then acts on it. The handler's+-- decision therefore drives Kiroku checkpointing per event:+--+-- * 'AckOk' — the worker checkpoints past the event (the normal case).+-- * 'AckRetry' @delay@ — the worker redelivers the /same/ event after @delay@,+--   bounded by the subscription's retry policy+--   ('Kiroku.Store.Subscription.Types.RetryPolicy', default five attempts); on+--   exhaustion the event is dead-lettered with+--   'Kiroku.Store.Subscription.Types.DeadLetterMaxAttempts'.+-- * 'AckDeadLetter' @reason@ — the worker records the event in+--   @kiroku.dead_letters@ (with the reason translated to a Kiroku-native+--   'Kiroku.Store.Subscription.Types.DeadLetterReason') and atomically advances+--   the checkpoint past it.+-- * 'AckHalt' — cancels the underlying Kiroku subscription (no checkpoint advance,+--   so the halting event replays on restart).+--+-- The envelope's @attempt@ reports the zero-based redelivery count, so a handler+-- can observe how many times Kiroku has redelivered an event.+--+-- == Backpressure and Handler Exceptions+--+-- Because delivery is ack-coupled, the Kiroku subscription worker blocks on each+-- event until the Shibuya handler finalizes its decision, providing natural+-- backpressure. The @bufferSize@ field is only the capacity of the bridge queue+-- between the worker and the Shibuya stream consumer; for this adapter the+-- effective depth is at most one event because the worker waits for an ack before+-- delivering the next event, and @bufferSize@ must be at least 1.+--+-- The @queueCapacity@ field is the publisher-side burst knob: it is the number of+-- publisher batches, up to 1000 events each, that can be buffered before Kiroku+-- pauses this subscriber. The adapter uses Kiroku's lossless @PauseAndResume@+-- overflow policy, so a paused subscriber catches up from its checkpoint instead+-- of being killed.+--+-- Shibuya's supervised runner converts a synchronous handler exception to an+-- immediate 'AckRetry' and finalizes it, so the ack-coupled Kiroku worker cannot be+-- left blocked by an abandoned reply. 'kirokuProcessor' applies the one-second+-- paced guard for a single processor. 'guardKirokuHandlerWith' remains useful when+-- the application wants a different exception disposition, and+-- 'kirokuConsumerGroupProcessors' applies the adapter's default guard+-- automatically. Asynchronous cancellation is never converted into an ack.+--+-- Raw consumers of @adapter.source@ must finalize every item. Leaving one pending+-- blocks delivery and checkpoint advancement. Opt in with a positive+-- @handlerStallWarnAfter@ to receive advisory store handler-stall events; warnings+-- never finalize, retry or checkpoint the item. @retryPolicy@ controls total+-- deliveries independently of the delay chosen by 'AckRetry'.+module Shibuya.Adapter.Kiroku+  ( -- * Adapter     kirokuAdapter,     kirokuProcessor,     guardKirokuHandlerWith,@@ -148,7 +147,8 @@     size,     EventTypeFilter (..),     MissingCheckpointPolicy (..),-) where+  )+where  import Control.Exception (SomeException) import Data.Int (Int32)@@ -159,8 +159,8 @@ import GHC.Generics (Generic) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Subscription.Stream (StreamBufferSize, defaultStreamBufferSize, subscriptionAckStream)-import Kiroku.Store.Subscription.Types (-    ConsumerGroup,+import Kiroku.Store.Subscription.Types+  ( ConsumerGroup,     ConsumerGroupSize,     EventTypeFilter (..),     MissingCheckpointPolicy (..),@@ -174,7 +174,7 @@     mkConsumerGroup,     mkConsumerGroupSize,     size,- )+  ) import Kiroku.Store.Subscription.Types qualified as Sub import Kiroku.Store.Types (RecordedEvent) import Numeric.Natural (Natural)@@ -188,395 +188,377 @@ import Shibuya.Policy (Concurrency (..), OrderingPolicy (..), validatePolicy) import Streamly.Data.Stream qualified as Stream -{- | Configuration for creating a Kiroku adapter.--@subscriptionName@ must be unique across all active subscriptions — it-identifies the checkpoint row in the @subscriptions@ table.--@bufferSize@ is the bridge queue capacity and must be at least 1. With this-ack-coupled adapter the effective depth is at most one event, because the-worker blocks until the handler's decision is finalized.--@queueCapacity@ is the publisher-side burst capacity in batches. When a burst-exceeds it, the adapter relies on Kiroku's default @PauseAndResume@ policy:-Kiroku pauses the subscriber and later resumes losslessly from its checkpoint.--}+-- | Configuration for creating a Kiroku adapter.+--+-- @subscriptionName@ must be unique across all active subscriptions — it+-- identifies the checkpoint row in the @subscriptions@ table.+--+-- @bufferSize@ is the bridge queue capacity and must be at least 1. With this+-- ack-coupled adapter the effective depth is at most one event, because the+-- worker blocks until the handler's decision is finalized.+--+-- @queueCapacity@ is the publisher-side burst capacity in batches. When a burst+-- exceeds it, the adapter relies on Kiroku's default @PauseAndResume@ policy:+-- Kiroku pauses the subscriber and later resumes losslessly from its checkpoint. data KirokuAdapterConfig = KirokuAdapterConfig-    { subscriptionName :: !SubscriptionName-    -- ^ Unique subscription identifier (checkpoint key)-    , subscriptionTarget :: !SubscriptionTarget-    -- ^ 'AllStreams' or @'Category' categoryName@-    , batchSize :: !Sub.BatchSize-    -- ^ Events per database fetch during catch-up-    , bufferSize :: !StreamBufferSize-    -- ^ Bridge 'TBQueue' capacity; must be at least 1.-    , retryPolicy :: !Sub.RetryPolicy-    -- ^ Total delivery attempts, default five; each AckRetry chooses its delay.-    , handlerStallWarnAfter :: !(Maybe NominalDiffTime)-    -- ^ Optional positive advisory warning interval, disabled by default.-    , queueCapacity :: !Natural-    {- ^ Publisher-side capacity in batches, where each batch contains up to-    Kiroku's publisher batch size (currently 1000 events). When this fills,-    Kiroku pauses and later resumes the subscriber losslessly.-    -}-    , consumerGroup :: !(Maybe ConsumerGroup)-    {- ^ Optional consumer-group membership for this adapter instance.-    'Nothing' (the default) = ordinary single-consumer subscription.-    @'Just' cg@ (built with 'mkConsumerGroup') = this adapter is-    member @m@ of a group of size @n@, receiving only the events whose-    originating stream hashes to slot @m@ (in global-position order). To run a-    full size-@n@ group, create @n@ adapters with the same 'subscriptionName'-    and distinct 'member' indices, each backed by its own Shibuya processor.--    'mkConsumerGroupSize' and 'mkConsumerGroup' validate this invariant before-    an adapter can be configured.-    -}-    , missingCheckpointPolicy :: !MissingCheckpointPolicy-    {- ^ What the underlying Kiroku worker does when this adapter's exact-    @(subscriptionName, consumer-group member)@ checkpoint row is absent.-    'FromBeginning' is the compatibility default; use 'FromCurrentHead' for a-    future-only processor or 'FailIfMissing' when prior provisioning is-    mandatory. Existing checkpoints always win.-    -}-    , eventTypeFilter :: !EventTypeFilter-    {- ^ Which event types this adapter delivers. Pass 'AllEventTypes' (deliver-    everything) or @'OnlyEventTypes' s@ to receive only events whose type is in-    @s@. Forwarded into the underlying subscription; filtering is worker-side-    (before the ack-coupled bridge), so a filtered-out event never reaches the-    Shibuya handler, is never retried or dead-lettered, and the checkpoint still-    advances past it. 'Shibuya.Adapter.Kiroku.Convert' and the 'AckHandle' are-    unaffected.-    -}-    , selector :: !(Maybe (RecordedEvent -> Bool))-    {- ^ Optional opaque per-event predicate, the escape hatch for filtering this-    adapter's stream on a property 'eventTypeFilter' cannot express (e.g.-    payload, metadata, or correlation\/causation ids). Default 'Nothing' (no-    extra filtering). Forwarded into the underlying subscription and composed-    with 'eventTypeFilter' as a logical AND: an event reaches the Shibuya handler-    only when it passes both. Like 'eventTypeFilter' it is applied worker-side-    before the ack-coupled bridge, so a rejected event is never retried or-    dead-lettered and the checkpoint still advances past it. See-    'Kiroku.Store.Subscription.Types.selector' for when to prefer it over the-    introspectable 'eventTypeFilter'.-    -}-    }-    deriving stock (Generic)--{- | A 'KirokuAdapterConfig' with sensible defaults: @batchSize = Sub.defaultBatchSize@,-@bufferSize = defaultStreamBufferSize@, @queueCapacity = 16@, @consumerGroup = 'Nothing'@-(ordinary single-consumer subscription), @missingCheckpointPolicy =-'FromBeginning'@, @eventTypeFilter = 'AllEventTypes'@-(deliver every type), and @selector = 'Nothing'@ (no extra predicate-filtering). Supply the subscription name and target; override individual fields-with record-update syntax.--Prefer this over a full record literal so that any field added to-'KirokuAdapterConfig' later is inherited at its default automatically:+  { -- | Unique subscription identifier (checkpoint key)+    subscriptionName :: !SubscriptionName,+    -- | 'AllStreams', exact @'Category' categoryName@, or literal+    -- @'CategoryNamespace' "pm"@ (category @pm@ and its @pm:*@ descendants).+    -- Groups use the same source-stream partitioning for every target.+    -- Namespace checkpoints can advance beyond the last delivered match;+    -- each member scans bounded global windows and periodically checks for+    -- missed notifications. Requires migration 0016 before starting workers.+    subscriptionTarget :: !SubscriptionTarget,+    -- | Events per database fetch during catch-up+    batchSize :: !Sub.BatchSize,+    -- | Bridge 'TBQueue' capacity; must be at least 1.+    bufferSize :: !StreamBufferSize,+    -- | Total delivery attempts, default five; each AckRetry chooses its delay.+    retryPolicy :: !Sub.RetryPolicy,+    -- | Optional positive advisory warning interval, disabled by default.+    handlerStallWarnAfter :: !(Maybe NominalDiffTime),+    -- | Publisher-side capacity in batches, where each batch contains up to+    --     Kiroku's publisher batch size (currently 1000 events). When this fills,+    --     Kiroku pauses and later resumes the subscriber losslessly.+    queueCapacity :: !Natural,+    -- | Optional consumer-group membership for this adapter instance.+    --     'Nothing' (the default) = ordinary single-consumer subscription.+    --     @'Just' cg@ (built with 'mkConsumerGroup') = this adapter is+    --     member @m@ of a group of size @n@, receiving only the events whose+    --     originating stream hashes to slot @m@ (in global-position order). To run a+    --     full size-@n@ group, create @n@ adapters with the same 'subscriptionName'+    --     and distinct 'member' indices, each backed by its own Shibuya processor.+    --+    --     'mkConsumerGroupSize' and 'mkConsumerGroup' validate this invariant before+    --     an adapter can be configured.+    consumerGroup :: !(Maybe ConsumerGroup),+    -- | What the underlying Kiroku worker does when this adapter's exact+    --     @(subscriptionName, consumer-group member)@ checkpoint row is absent.+    --     'FromBeginning' is the compatibility default; use 'FromCurrentHead' for a+    --     future-only processor or 'FailIfMissing' when prior provisioning is+    --     mandatory. Existing checkpoints always win.+    missingCheckpointPolicy :: !MissingCheckpointPolicy,+    -- | Which event types this adapter delivers. Pass 'AllEventTypes' (deliver+    --     everything) or @'OnlyEventTypes' s@ to receive only events whose type is in+    --     @s@. Forwarded into the underlying subscription; filtering is worker-side+    --     (before the ack-coupled bridge), so a filtered-out event never reaches the+    --     Shibuya handler, is never retried or dead-lettered, and the checkpoint still+    --     advances past it. 'Shibuya.Adapter.Kiroku.Convert' and the 'AckHandle' are+    --     unaffected.+    eventTypeFilter :: !EventTypeFilter,+    -- | Optional opaque per-event predicate, the escape hatch for filtering this+    --     adapter's stream on a property 'eventTypeFilter' cannot express (e.g.+    --     payload, metadata, or correlation\/causation ids). Default 'Nothing' (no+    --     extra filtering). Forwarded into the underlying subscription and composed+    --     with 'eventTypeFilter' as a logical AND: an event reaches the Shibuya handler+    --     only when it passes both. Like 'eventTypeFilter' it is applied worker-side+    --     before the ack-coupled bridge, so a rejected event is never retried or+    --     dead-lettered and the checkpoint still advances past it. See+    --     'Kiroku.Store.Subscription.Types.selector' for when to prefer it over the+    --     introspectable 'eventTypeFilter'.+    selector :: !(Maybe (RecordedEvent -> Bool))+  }+  deriving stock (Generic) -@-let cfg =-        (defaultKirokuAdapterConfig "my-projection" 'AllStreams')-            { eventTypeFilter = 'OnlyEventTypes' (Set.fromList [EventType "OrderPlaced"]) }-adapter <- kirokuAdapter store cfg-@--}+-- | A 'KirokuAdapterConfig' with sensible defaults: @batchSize = Sub.defaultBatchSize@,+-- @bufferSize = defaultStreamBufferSize@, @queueCapacity = 16@, @consumerGroup = 'Nothing'@+-- (ordinary single-consumer subscription), @missingCheckpointPolicy =+-- 'FromBeginning'@, @eventTypeFilter = 'AllEventTypes'@+-- (deliver every type), and @selector = 'Nothing'@ (no extra predicate+-- filtering). Supply the subscription name and target; override individual fields+-- with record-update syntax.+--+-- Prefer this over a full record literal so that any field added to+-- 'KirokuAdapterConfig' later is inherited at its default automatically:+--+-- @+-- let cfg =+--         (defaultKirokuAdapterConfig "my-projection" 'AllStreams')+--             { eventTypeFilter = 'OnlyEventTypes' (Set.fromList [EventType "OrderPlaced"]) }+-- adapter <- kirokuAdapter store cfg+-- @ defaultKirokuAdapterConfig ::-    SubscriptionName -> SubscriptionTarget -> KirokuAdapterConfig+  SubscriptionName -> SubscriptionTarget -> KirokuAdapterConfig defaultKirokuAdapterConfig name target =-    KirokuAdapterConfig-        { subscriptionName = name-        , subscriptionTarget = target-        , batchSize = Sub.defaultBatchSize-        , bufferSize = defaultStreamBufferSize-        , retryPolicy = Sub.defaultRetryPolicy-        , handlerStallWarnAfter = Nothing-        , queueCapacity = 16-        , consumerGroup = Nothing-        , missingCheckpointPolicy = FromBeginning-        , eventTypeFilter = AllEventTypes-        , selector = Nothing-        }--{- | Convert any synchronous exception thrown by a Shibuya handler into an-'AckDecision'.+  KirokuAdapterConfig+    { subscriptionName = name,+      subscriptionTarget = target,+      batchSize = Sub.defaultBatchSize,+      bufferSize = defaultStreamBufferSize,+      retryPolicy = Sub.defaultRetryPolicy,+      handlerStallWarnAfter = Nothing,+      queueCapacity = 16,+      consumerGroup = Nothing,+      missingCheckpointPolicy = FromBeginning,+      eventTypeFilter = AllEventTypes,+      selector = Nothing+    } -This ensures Shibuya still finalizes the ack. Asynchronous exceptions such as-thread cancellation are not caught.--}+-- | Convert any synchronous exception thrown by a Shibuya handler into an+-- 'AckDecision'.+--+-- This ensures Shibuya still finalizes the ack. Asynchronous exceptions such as+-- thread cancellation are not caught. guardKirokuHandlerWith ::-    (SomeException -> AckDecision) ->-    Handler es msg ->-    Handler es msg+  (SomeException -> AckDecision) ->+  Handler es msg ->+  Handler es msg guardKirokuHandlerWith handleException h ingested =-    h ingested `catchSync` (pure . handleException)--{- | Recommended handler guard for this adapter.+  h ingested `catchSync` (pure . handleException) -A synchronous exception becomes @'AckRetry' ('RetryDelay' 1)@. Kiroku then-redelivers after one second and eventually dead-letters persistent failures-according to the subscription retry policy.--}+-- | Recommended handler guard for this adapter.+--+-- A synchronous exception becomes @'AckRetry' ('RetryDelay' 1)@. Kiroku then+-- redelivers after one second and eventually dead-letters persistent failures+-- according to the subscription retry policy. guardKirokuHandler :: Handler es msg -> Handler es msg guardKirokuHandler = guardKirokuHandlerWith (const (AckRetry (RetryDelay 1))) -{- | Recommended single-processor constructor. Like mkProcessor, defaults to-unordered policy and serial concurrency, with the paced exception guard.--}+-- | Recommended single-processor constructor. Like mkProcessor, defaults to+-- unordered policy and serial concurrency, with the paced exception guard. kirokuProcessor :: Adapter es RecordedEvent -> Handler es RecordedEvent -> QueueProcessor es kirokuProcessor adapter handler = mkProcessor adapter (guardKirokuHandler handler) -{- | Create a Shibuya 'Adapter' backed by a Kiroku subscription.--The adapter:--1. Calls 'subscriptionStream' to start a Kiroku subscription with a-   ack-coupled bounded bridge.-2. Lifts the @Stream IO RecordedEvent@ to @Stream (Eff es)@ via-   @Stream.morphInner liftIO@.-3. Wraps each 'RecordedEvent' into an 'Ingested' value with an-   'Envelope' (mapping event ID → message ID, global position → cursor)-   and an 'AckHandle' whose finalized decision drives Kiroku checkpointing,-   retries, dead-lettering, or halt.--The returned adapter's @shutdown@ action cancels the underlying-subscription and wakes any blocked stream reader. If the subscription worker-dies with an exception, @source@ terminates with that exception.--}+-- | Create a Shibuya 'Adapter' backed by a Kiroku subscription.+--+-- The adapter:+--+-- 1. Calls 'subscriptionStream' to start a Kiroku subscription with a+--    ack-coupled bounded bridge.+-- 2. Lifts the @Stream IO RecordedEvent@ to @Stream (Eff es)@ via+--    @Stream.morphInner liftIO@.+-- 3. Wraps each 'RecordedEvent' into an 'Ingested' value with an+--    'Envelope' (mapping event ID → message ID, global position → cursor)+--    and an 'AckHandle' whose finalized decision drives Kiroku checkpointing,+--    retries, dead-lettering, or halt.+--+-- The returned adapter's @shutdown@ action cancels the underlying+-- subscription and wakes any blocked stream reader. If the subscription worker+-- dies with an exception, @source@ terminates with that exception. kirokuAdapter ::-    (IOE :> es) =>-    KirokuStore ->-    KirokuAdapterConfig ->-    Eff es (Adapter es RecordedEvent)-kirokuAdapter store KirokuAdapterConfig{subscriptionName = subName, subscriptionTarget = subTarget, batchSize = bs, bufferSize = buf, queueCapacity = qCap, retryPolicy = attempts, handlerStallWarnAfter = stallWarn, consumerGroup = cg, missingCheckpointPolicy = checkpointPolicy, eventTypeFilter = etf, selector = sel} = do-    -- Build from 'defaultSubscriptionConfig' and override only the non-default-    -- fields. Using the smart constructor (rather than a full record literal)-    -- means any future field added to 'SubscriptionConfigM' is inherited at its-    -- default automatically — e.g. EP-2's 'consumerGroupGuard', left 'False' here.-    let subConfig :: SubscriptionConfig-        subConfig =-            (defaultSubscriptionConfig subName subTarget (\_ -> pure Continue))-                { Sub.batchSize = bs-                , Sub.retryPolicy = attempts-                , Sub.handlerStallWarnAfter = stallWarn-                , Sub.queueCapacity = qCap-                , Sub.consumerGroup = cg-                , Sub.missingCheckpointPolicy = checkpointPolicy-                , Sub.eventTypeFilter = etf-                , Sub.selector = sel-                }--    (ioStream, cancelAction) <- liftIO $ subscriptionAckStream store subConfig buf--    -- The subscription name and consumer-group member are known only here (not on-    -- the RecordedEvent), so thread them into the conversion as OTel attributes-    -- that ride onto Shibuya's per-message span (EP-5 M2).-    let SubscriptionName subNameText = subName-        -- Precompute the constant kiroku.* attributes once per adapter (not per-        -- event): kirokuEnvelopeAttrs builds the base map, and the per-event-        -- conversion only inserts the event type and global position.-        envAttrs =-            kirokuEnvelopeAttrs-                subNameText-                (fmap (fromIntegral . member) cg)-        ingestedStream = fmap (toIngestedAck envAttrs cancelAction) (Stream.morphInner liftIO ioStream)+  (IOE :> es) =>+  KirokuStore ->+  KirokuAdapterConfig ->+  Eff es (Adapter es RecordedEvent)+kirokuAdapter store KirokuAdapterConfig {subscriptionName = subName, subscriptionTarget = subTarget, batchSize = bs, bufferSize = buf, queueCapacity = qCap, retryPolicy = attempts, handlerStallWarnAfter = stallWarn, consumerGroup = cg, missingCheckpointPolicy = checkpointPolicy, eventTypeFilter = etf, selector = sel} = do+  -- Build from 'defaultSubscriptionConfig' and override only the non-default+  -- fields. Using the smart constructor (rather than a full record literal)+  -- means any future field added to 'SubscriptionConfigM' is inherited at its+  -- default automatically — e.g. EP-2's 'consumerGroupGuard', left 'False' here.+  let subConfig :: SubscriptionConfig+      subConfig =+        (defaultSubscriptionConfig subName subTarget (\_ -> pure Continue))+          { Sub.batchSize = bs,+            Sub.retryPolicy = attempts,+            Sub.handlerStallWarnAfter = stallWarn,+            Sub.queueCapacity = qCap,+            Sub.consumerGroup = cg,+            Sub.missingCheckpointPolicy = checkpointPolicy,+            Sub.eventTypeFilter = etf,+            Sub.selector = sel+          } -    pure-        Adapter-            { adapterName = "kiroku"-            , source = ingestedStream-            , shutdown = liftIO cancelAction-            }+  (ioStream, cancelAction) <- liftIO $ subscriptionAckStream store subConfig buf -{- | Configuration for a whole kiroku consumer group presented as a single-Shibuya partitioned-ordering unit.+  -- The subscription name and consumer-group member are known only here (not on+  -- the RecordedEvent), so thread them into the conversion as OTel attributes+  -- that ride onto Shibuya's per-message span (EP-5 M2).+  let SubscriptionName subNameText = subName+      -- Precompute the constant kiroku.* attributes once per adapter (not per+      -- event): kirokuEnvelopeAttrs builds the base map, and the per-event+      -- conversion only inserts the event type and global position.+      envAttrs =+        kirokuEnvelopeAttrs+          subNameText+          (fmap (fromIntegral . member) cg)+      ingestedStream = fmap (toIngestedAck envAttrs cancelAction) (Stream.morphInner liftIO ioStream) -Unlike 'KirokuAdapterConfig' (which describes one member), this describes the-__entire__ group: 'groupSize' members of one subscription, each receiving the-streams whose originating-stream hash maps to its slot. Hand to-'kirokuConsumerGroupProcessors' to obtain @groupSize@ ready-to-run Shibuya-processors with no manual @[0..N-1]@ wiring.+  pure+    Adapter+      { adapterName = "kiroku",+        source = ingestedStream,+        shutdown = liftIO cancelAction+      } -@memberConcurrency@ is the per-member concurrency. Because kiroku delivers each-member a single strictly global-position-ordered stream, only 'Serial' honestly-preserves per-stream ordering; any 'Ahead'/'Async' is rejected by-'consumerGroupPolicy' before any subscription opens. The /group/ as a whole is-'PartitionedInOrder' (ordered within each member's partition, parallel across-members).--}+-- | Configuration for a whole kiroku consumer group presented as a single+-- Shibuya partitioned-ordering unit.+--+-- Unlike 'KirokuAdapterConfig' (which describes one member), this describes the+-- __entire__ group: 'groupSize' members of one subscription, each receiving the+-- streams whose originating-stream hash maps to its slot. Hand to+-- 'kirokuConsumerGroupProcessors' to obtain @groupSize@ ready-to-run Shibuya+-- processors with no manual @[0..N-1]@ wiring.+--+-- @memberConcurrency@ is the per-member concurrency. Because kiroku delivers each+-- member a single strictly global-position-ordered stream, only 'Serial' honestly+-- preserves per-stream ordering; any 'Ahead'/'Async' is rejected by+-- 'consumerGroupPolicy' before any subscription opens. The /group/ as a whole is+-- 'PartitionedInOrder' (ordered within each member's partition, parallel across+-- members). data KirokuConsumerGroupConfig = KirokuConsumerGroupConfig-    { subscriptionName :: !SubscriptionName-    {- ^ Shared subscription identifier; each member checkpoints under-    @(subscriptionName, member)@.-    -}-    , subscriptionTarget :: !SubscriptionTarget-    {- ^ 'AllStreams' or @'Category' categoryName@ — the same source for every-    member; kiroku partitions it across members in SQL.-    -}-    , groupSize :: !ConsumerGroupSize-    {- ^ @N@ members; must be @>= 1@ (enforced by the underlying-    'mkConsumerGroupSize' at construction).-    -}-    , batchSize :: !Sub.BatchSize-    -- ^ Events per database fetch during catch-up (per member).-    , bufferSize :: !StreamBufferSize-    -- ^ Per-member bridge 'TBQueue' capacity; must be at least 1.-    , retryPolicy :: !Sub.RetryPolicy-    -- ^ Total delivery attempts, default five; each AckRetry chooses its delay.-    , handlerStallWarnAfter :: !(Maybe NominalDiffTime)-    -- ^ Optional positive advisory warning interval, disabled by default.-    , queueCapacity :: !Natural-    {- ^ Per-member publisher-side capacity in batches. When this fills, Kiroku-    pauses and later resumes the member losslessly.-    -}-    , memberConcurrency :: !Concurrency-    -- ^ Per-member concurrency; must be 'Serial' (validated).-    , missingCheckpointPolicy :: !MissingCheckpointPolicy-    {- ^ Missing-checkpoint policy applied independently to every member key.-    Existing member rows always win; a new 'FromCurrentHead' group seeds every-    member at the head each member observes during startup.-    -}-    , eventTypeFilter :: !EventTypeFilter-    {- ^ Event-type filter applied to /every/ member (the same filter on each).-    'AllEventTypes' delivers everything; @'OnlyEventTypes' s@ delivers only the-    named types. Forwarded into each per-member 'KirokuAdapterConfig', so a-    filtered partitioned group behaves like a filtered single subscription:-    filtering is worker-side and per member, the checkpoint still advances past-    filtered events, and the partition's completeness is preserved over the-    delivered types.-    -}-    , selector :: !(Maybe (RecordedEvent -> Bool))-    {- ^ Optional opaque per-event predicate applied to /every/ member (the same-    predicate on each), the escape hatch for filtering a property-    'eventTypeFilter' cannot express. Default 'Nothing'. Forwarded into each-    per-member 'KirokuAdapterConfig' and composed with 'eventTypeFilter' as a-    logical AND, so a selector-filtered partitioned group behaves like a-    selector-filtered single subscription (worker-side, per member, checkpoint-    still advances past rejected events). See-    'Kiroku.Store.Subscription.Types.selector'.-    -}-    }-    deriving stock (Generic)+  { -- | Shared subscription identifier; each member checkpoints under+    --     @(subscriptionName, member)@.+    subscriptionName :: !SubscriptionName,+    -- | 'AllStreams' or @'Category' categoryName@ — the same source for every+    --     member; kiroku partitions it across members in SQL.+    subscriptionTarget :: !SubscriptionTarget,+    -- | @N@ members; must be @>= 1@ (enforced by the underlying+    --     'mkConsumerGroupSize' at construction).+    groupSize :: !ConsumerGroupSize,+    -- | Events per database fetch during catch-up (per member).+    batchSize :: !Sub.BatchSize,+    -- | Per-member bridge 'TBQueue' capacity; must be at least 1.+    bufferSize :: !StreamBufferSize,+    -- | Total delivery attempts, default five; each AckRetry chooses its delay.+    retryPolicy :: !Sub.RetryPolicy,+    -- | Optional positive advisory warning interval, disabled by default.+    handlerStallWarnAfter :: !(Maybe NominalDiffTime),+    -- | Per-member publisher-side capacity in batches. When this fills, Kiroku+    --     pauses and later resumes the member losslessly.+    queueCapacity :: !Natural,+    -- | Per-member concurrency; must be 'Serial' (validated).+    memberConcurrency :: !Concurrency,+    -- | Missing-checkpoint policy applied independently to every member key.+    --     Existing member rows always win; a new 'FromCurrentHead' group seeds every+    --     member at the head each member observes during startup.+    missingCheckpointPolicy :: !MissingCheckpointPolicy,+    -- | Event-type filter applied to /every/ member (the same filter on each).+    --     'AllEventTypes' delivers everything; @'OnlyEventTypes' s@ delivers only the+    --     named types. Forwarded into each per-member 'KirokuAdapterConfig', so a+    --     filtered partitioned group behaves like a filtered single subscription:+    --     filtering is worker-side and per member, the checkpoint still advances past+    --     filtered events, and the partition's completeness is preserved over the+    --     delivered types.+    eventTypeFilter :: !EventTypeFilter,+    -- | Optional opaque per-event predicate applied to /every/ member (the same+    --     predicate on each), the escape hatch for filtering a property+    --     'eventTypeFilter' cannot express. Default 'Nothing'. Forwarded into each+    --     per-member 'KirokuAdapterConfig' and composed with 'eventTypeFilter' as a+    --     logical AND, so a selector-filtered partitioned group behaves like a+    --     selector-filtered single subscription (worker-side, per member, checkpoint+    --     still advances past rejected events). See+    --     'Kiroku.Store.Subscription.Types.selector'.+    selector :: !(Maybe (RecordedEvent -> Bool))+  }+  deriving stock (Generic) -{- | A 'KirokuConsumerGroupConfig' with sensible defaults: @memberConcurrency =-'Serial'@ (the only legal per-member concurrency), @batchSize = Sub.defaultBatchSize@,-@bufferSize = defaultStreamBufferSize@, @queueCapacity = 16@, @missingCheckpointPolicy =-'FromBeginning'@, @eventTypeFilter = 'AllEventTypes'@-(deliver every type), @selector = 'Nothing'@ (no extra predicate filtering).-Supply the subscription name, target, and group size.--}+-- | A 'KirokuConsumerGroupConfig' with sensible defaults: @memberConcurrency =+-- 'Serial'@ (the only legal per-member concurrency), @batchSize = Sub.defaultBatchSize@,+-- @bufferSize = defaultStreamBufferSize@, @queueCapacity = 16@, @missingCheckpointPolicy =+-- 'FromBeginning'@, @eventTypeFilter = 'AllEventTypes'@+-- (deliver every type), @selector = 'Nothing'@ (no extra predicate filtering).+-- Supply the subscription name, target, and group size. defaultConsumerGroupConfig ::-    SubscriptionName -> SubscriptionTarget -> ConsumerGroupSize -> KirokuConsumerGroupConfig+  SubscriptionName -> SubscriptionTarget -> ConsumerGroupSize -> KirokuConsumerGroupConfig defaultConsumerGroupConfig name target n =-    KirokuConsumerGroupConfig-        { subscriptionName = name-        , subscriptionTarget = target-        , groupSize = n-        , batchSize = Sub.defaultBatchSize-        , bufferSize = defaultStreamBufferSize-        , retryPolicy = Sub.defaultRetryPolicy-        , handlerStallWarnAfter = Nothing-        , queueCapacity = 16-        , memberConcurrency = Serial-        , missingCheckpointPolicy = FromBeginning-        , eventTypeFilter = AllEventTypes-        , selector = Nothing-        }--{- | Map a requested per-member concurrency onto the group's validated Shibuya-@('OrderingPolicy', 'Concurrency')@.+  KirokuConsumerGroupConfig+    { subscriptionName = name,+      subscriptionTarget = target,+      groupSize = n,+      batchSize = Sub.defaultBatchSize,+      bufferSize = defaultStreamBufferSize,+      retryPolicy = Sub.defaultRetryPolicy,+      handlerStallWarnAfter = Nothing,+      queueCapacity = 16,+      memberConcurrency = Serial,+      missingCheckpointPolicy = FromBeginning,+      eventTypeFilter = AllEventTypes,+      selector = Nothing+    } -The group's ordering contract is always 'PartitionedInOrder'; a member's own-ordered stream must be processed serially. This reuses Shibuya's own-'validatePolicy' rule (@'StrictInOrder' => 'Serial'@) so the adapter never-invents its own legality check: 'Ahead'/'Async' yield-@'Left' ('InvalidPolicyCombo' ...)@ and 'Serial' yields-@'Right' ('PartitionedInOrder', 'Serial')@. The returned-@('PartitionedInOrder', 'Serial')@ also passes 'validatePolicy', so 'runApp'-will not reject it later.--}+-- | Map a requested per-member concurrency onto the group's validated Shibuya+-- @('OrderingPolicy', 'Concurrency')@.+--+-- The group's ordering contract is always 'PartitionedInOrder'; a member's own+-- ordered stream must be processed serially. This reuses Shibuya's own+-- 'validatePolicy' rule (@'StrictInOrder' => 'Serial'@) so the adapter never+-- invents its own legality check: 'Ahead'/'Async' yield+-- @'Left' ('InvalidPolicyCombo' ...)@ and 'Serial' yields+-- @'Right' ('PartitionedInOrder', 'Serial')@. The returned+-- @('PartitionedInOrder', 'Serial')@ also passes 'validatePolicy', so 'runApp'+-- will not reject it later. consumerGroupPolicy :: Concurrency -> Either PolicyError (OrderingPolicy, Concurrency) consumerGroupPolicy conc = do-    -- A member delivers one strictly-ordered stream; only Serial is honest.-    validatePolicy StrictInOrder conc -- rejects Ahead/Async with PolicyError-    pure (PartitionedInOrder, conc)--{- | Present a whole kiroku consumer group as a single 'PartitionedInOrder' unit:-one call yields @groupSize@ named 'QueueProcessor's, each backed by its own-member adapter and each pinned to @('PartitionedInOrder', 'Serial')@.-The supplied handler is wrapped in 'guardKirokuHandler' automatically so a-synchronous handler exception finalizes a retry disposition instead of-abandoning Kiroku's ack reply.--This replaces the manual @mapM mkMemberAdapter [0 .. N-1]@ boilerplate. The-member→policy mapping is validated once up front via 'consumerGroupPolicy'; if-the caller requests a 'memberConcurrency' kiroku cannot honor per member-('Ahead'/'Async'), the result is @'Left' ('InvalidPolicyCombo' ...)@ and __no-kiroku subscription is opened__.+  -- A member delivers one strictly-ordered stream; only Serial is honest.+  validatePolicy StrictInOrder conc -- rejects Ahead/Async with PolicyError+  pure (PartitionedInOrder, conc) -Each processor's 'ProcessorId' is-@\"\<subscriptionName\>-member-\<m\>\"@ so member identity is readable off the id-and two members never collide. The group size is validated by 'mkConsumerGroupSize' at construction.--}+-- | Present a whole kiroku consumer group as a single 'PartitionedInOrder' unit:+-- one call yields @groupSize@ named 'QueueProcessor's, each backed by its own+-- member adapter and each pinned to @('PartitionedInOrder', 'Serial')@.+-- The supplied handler is wrapped in 'guardKirokuHandler' automatically so a+-- synchronous handler exception finalizes a retry disposition instead of+-- abandoning Kiroku's ack reply.+--+-- This replaces the manual @mapM mkMemberAdapter [0 .. N-1]@ boilerplate. The+-- member→policy mapping is validated once up front via 'consumerGroupPolicy'; if+-- the caller requests a 'memberConcurrency' kiroku cannot honor per member+-- ('Ahead'/'Async'), the result is @'Left' ('InvalidPolicyCombo' ...)@ and __no+-- kiroku subscription is opened__.+--+-- Each processor's 'ProcessorId' is+-- @\"\<subscriptionName\>-member-\<m\>\"@ so member identity is readable off the id+-- and two members never collide. The group size is validated by 'mkConsumerGroupSize' at construction. kirokuConsumerGroupProcessors ::-    (IOE :> es) =>-    KirokuStore ->-    KirokuConsumerGroupConfig ->-    Handler es RecordedEvent ->-    Eff es (Either PolicyError [(ProcessorId, QueueProcessor es)])-kirokuConsumerGroupProcessors store cfg@KirokuConsumerGroupConfig{subscriptionName = subName, subscriptionTarget = subTarget, groupSize = n, batchSize = bs, bufferSize = buf, queueCapacity = qCap, retryPolicy = attempts, handlerStallWarnAfter = stallWarn, missingCheckpointPolicy = checkpointPolicy, eventTypeFilter = etf, selector = sel} handler =-    kirokuConsumerGroupProcessorsWith mkMemberAdapter cfg handler+  (IOE :> es) =>+  KirokuStore ->+  KirokuConsumerGroupConfig ->+  Handler es RecordedEvent ->+  Eff es (Either PolicyError [(ProcessorId, QueueProcessor es)])+kirokuConsumerGroupProcessors store cfg@KirokuConsumerGroupConfig {subscriptionName = subName, subscriptionTarget = subTarget, groupSize = n, batchSize = bs, bufferSize = buf, queueCapacity = qCap, retryPolicy = attempts, handlerStallWarnAfter = stallWarn, missingCheckpointPolicy = checkpointPolicy, eventTypeFilter = etf, selector = sel} handler =+  kirokuConsumerGroupProcessorsWith mkMemberAdapter cfg handler   where     mkMemberAdapter m =-        kirokuAdapter-            store-            KirokuAdapterConfig-                { subscriptionName = subName-                , subscriptionTarget = subTarget-                , batchSize = bs-                , bufferSize = buf-                , retryPolicy = attempts-                , handlerStallWarnAfter = stallWarn-                , queueCapacity = qCap-                , consumerGroup = Just (either (error . show) Prelude.id (mkConsumerGroup m n))-                , missingCheckpointPolicy = checkpointPolicy-                , eventTypeFilter = etf-                , selector = sel-                }--{- | Factory-parameterized core for 'kirokuConsumerGroupProcessors'.+      kirokuAdapter+        store+        KirokuAdapterConfig+          { subscriptionName = subName,+            subscriptionTarget = subTarget,+            batchSize = bs,+            bufferSize = buf,+            retryPolicy = attempts,+            handlerStallWarnAfter = stallWarn,+            queueCapacity = qCap,+            consumerGroup = Just (either (error . show) Prelude.id (mkConsumerGroup m n)),+            missingCheckpointPolicy = checkpointPolicy,+            eventTypeFilter = etf,+            selector = sel+          } -This is exported primarily for tests and advanced integration points. It-validates the group configuration, creates each member adapter with the-provided factory, and shuts down already-created adapters if a later factory-call throws before ownership can be returned to the caller.--}+-- | Factory-parameterized core for 'kirokuConsumerGroupProcessors'.+--+-- This is exported primarily for tests and advanced integration points. It+-- validates the group configuration, creates each member adapter with the+-- provided factory, and shuts down already-created adapters if a later factory+-- call throws before ownership can be returned to the caller. kirokuConsumerGroupProcessorsWith ::-    (IOE :> es) =>-    (Int32 -> Eff es (Adapter es RecordedEvent)) ->-    KirokuConsumerGroupConfig ->-    Handler es RecordedEvent ->-    Eff es (Either PolicyError [(ProcessorId, QueueProcessor es)])+  (IOE :> es) =>+  (Int32 -> Eff es (Adapter es RecordedEvent)) ->+  KirokuConsumerGroupConfig ->+  Handler es RecordedEvent ->+  Eff es (Either PolicyError [(ProcessorId, QueueProcessor es)]) kirokuConsumerGroupProcessorsWith-    mkMemberAdapter-    KirokuConsumerGroupConfig-        { subscriptionName = subName-        , groupSize = n-        , memberConcurrency = mc-        }-    handler =-        case consumerGroupPolicy mc of-            Left e -> pure (Left e)-            Right (ordering, conc) -> do-                let SubscriptionName name = subName-                acquireAllAndTransfer-                    (consumerGroupSizeValue n)-                    mkMemberAdapter-                    (\Adapter{shutdown = shutdownAction} -> shutdownAction)-                    (\_ _ -> pure ())-                    ( \adapters ->-                        pure . Right $-                            [ let pid = ProcessorId (name <> "-member-" <> T.pack (show m))-                               in (pid, QueueProcessor adapter (guardKirokuHandler handler) ordering conc)-                            | (m, adapter) <- zip [0 .. consumerGroupSizeValue n - 1] adapters-                            ]-                    )+  mkMemberAdapter+  KirokuConsumerGroupConfig+    { subscriptionName = subName,+      groupSize = n,+      memberConcurrency = mc+    }+  handler =+    case consumerGroupPolicy mc of+      Left e -> pure (Left e)+      Right (ordering, conc) -> do+        let SubscriptionName name = subName+        acquireAllAndTransfer+          (consumerGroupSizeValue n)+          mkMemberAdapter+          (\Adapter {shutdown = shutdownAction} -> shutdownAction)+          (\_ _ -> pure ())+          ( \adapters ->+              pure . Right $+                [ let pid = ProcessorId (name <> "-member-" <> T.pack (show m))+                   in (pid, QueueProcessor adapter (guardKirokuHandler handler) ordering conc)+                | (m, adapter) <- zip [0 .. consumerGroupSizeValue n - 1] adapters+                ]+          )
src/Shibuya/Adapter/Kiroku/Convert.hs view
@@ -1,25 +1,24 @@-{- | Conversion from Kiroku's 'RecordedEvent' to Shibuya's 'Ingested' and-'Envelope' types.--== Envelope Mapping--@-RecordedEvent field   →  Envelope field-─────────────────────────────────────────-eventId (UUID)        →  messageId (Text)-globalPosition        →  cursor (CursorInt)-createdAt             →  enqueuedAt-metadata.traceparent  →  traceContext-(the event itself)    →  payload-(none)                →  partition = Nothing-@--The adapter preserves W3C trace-context metadata when @metadata@ is a JSON-object containing a string @traceparent@ key. A string @tracestate@ key is-included when present.--}-module Shibuya.Adapter.Kiroku.Convert (-    -- * Conversion+-- | Conversion from Kiroku's 'RecordedEvent' to Shibuya's 'Ingested' and+-- 'Envelope' types.+--+-- == Envelope Mapping+--+-- @+-- RecordedEvent field   →  Envelope field+-- ─────────────────────────────────────────+-- eventId (UUID)        →  messageId (Text)+-- globalPosition        →  cursor (CursorInt)+-- createdAt             →  enqueuedAt+-- metadata.traceparent  →  traceContext+-- (the event itself)    →  payload+-- (none)                →  partition = Nothing+-- @+--+-- The adapter preserves W3C trace-context metadata when @metadata@ is a JSON+-- object containing a string @traceparent@ key. A string @tracestate@ key is+-- included when present.+module Shibuya.Adapter.Kiroku.Convert+  ( -- * Conversion     toIngestedAck,     toEnvelope, @@ -30,7 +29,8 @@     -- * Ack-decision translation     toKirokuResult,     toKirokuDeadLetterReason,-) where+  )+where  import Control.Concurrent.STM (atomically, tryPutTMVar) import Control.Monad (void)@@ -46,168 +46,162 @@ import Data.UUID qualified as UUID import Effectful (IOE, liftIO, (:>)) import Kiroku.Store.Subscription.Stream (AckItem (..))-import Kiroku.Store.Subscription.Types (-    DeadLetterReason (..),+import Kiroku.Store.Subscription.Types+  ( DeadLetterReason (..),     RetryDelay (..),     SubscriptionResult (..),- )-import Kiroku.Store.Types (-    EventId (..),+  )+import Kiroku.Store.Types+  ( EventId (..),     EventType (..),     GlobalPosition (..),     RecordedEvent (..),- )+  ) import OpenTelemetry.Attributes (Attribute, AttributeKey (..), toAttribute) import OpenTelemetry.SemanticConventions qualified as Sem import Shibuya.Core.Ack (AckDecision (..)) import Shibuya.Core.Ack qualified as Ack import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..)) import Shibuya.Core.Types (Attempt (..), Cursor (..), Envelope (..), MessageId (..), TraceHeaders) -{- | The kiroku identity stamped onto each Shibuya 'Envelope' as OpenTelemetry-attributes, so the kiroku subscription is visible on Shibuya's per-message span.--Construct it once per adapter with 'kirokuEnvelopeAttrs'. It holds the-/constant/ per-subscription attributes — the subscription name and, for a-grouped subscription, the consumer-group member — already built into an-attribute map, so the per-event conversion path only inserts this event's type-and global position rather than rebuilding the whole map for every event. The-subscription name and member are not carried on a 'RecordedEvent' (they are-known only at the adapter-config level), so the adapter threads them in through-this value; the event type and global position come from the 'RecordedEvent'-itself.--}+-- | The kiroku identity stamped onto each Shibuya 'Envelope' as OpenTelemetry+-- attributes, so the kiroku subscription is visible on Shibuya's per-message span.+--+-- Construct it once per adapter with 'kirokuEnvelopeAttrs'. It holds the+-- /constant/ per-subscription attributes — the subscription name and, for a+-- grouped subscription, the consumer-group member — already built into an+-- attribute map, so the per-event conversion path only inserts this event's type+-- and global position rather than rebuilding the whole map for every event. The+-- subscription name and member are not carried on a 'RecordedEvent' (they are+-- known only at the adapter-config level), so the adapter threads them in through+-- this value; the event type and global position come from the 'RecordedEvent'+-- itself. newtype KirokuEnvelopeAttrs = KirokuEnvelopeAttrs-    { baseAttributes :: HashMap Text Attribute-    -- ^ The constant per-subscription attributes, precomputed once.-    }+  { -- | The constant per-subscription attributes, precomputed once.+    baseAttributes :: HashMap Text Attribute+  } -{- | Build a 'KirokuEnvelopeAttrs' from a subscription name and an optional-consumer-group member index (@'Nothing'@ for a non-grouped subscription),-precomputing the constant @kiroku.*@ attribute map once. The member attribute is-included only when a member index is given.--}+-- | Build a 'KirokuEnvelopeAttrs' from a subscription name and an optional+-- consumer-group member index (@'Nothing'@ for a non-grouped subscription),+-- precomputing the constant @kiroku.*@ attribute map once. The member attribute is+-- included only when a member index is given. kirokuEnvelopeAttrs :: Text -> Maybe Int -> KirokuEnvelopeAttrs kirokuEnvelopeAttrs subscriptionName member =-    KirokuEnvelopeAttrs $-        HashMap.fromList $-            (attrKirokuSubscriptionName, toAttribute subscriptionName)-                : (attrMessagingSystem, toAttribute ("kiroku" :: Text))-                : (attrMessagingDestinationName, toAttribute subscriptionName)-                : maybe-                    []-                    (\m -> [(attrKirokuConsumerGroupMember, toAttribute (fromIntegral m :: Int64))])-                    member--{- | Wrap an ack-coupled 'AckItem' (from @kiroku-store@'s 'subscriptionAckStream')-into an 'Ingested' value suitable for Shibuya handlers.--The underlying Kiroku worker is blocked waiting for this item's reply, so the-'AckHandle.finalize' translates the Shibuya 'AckDecision' into a Kiroku-'SubscriptionResult' and writes it back — driving the worker's checkpointing:--* 'AckOk' — reply 'Continue'; the worker checkpoints past the event.-* 'AckRetry' @delay@ — reply 'Retry'; the worker redelivers the same event after-  @delay@, bounded by the subscription's retry policy, then dead-letters it.-* 'AckDeadLetter' @reason@ — reply 'DeadLetter'; the worker records the event in-  @kiroku.dead_letters@ and advances past it.-* 'AckHalt' — cancels the underlying Kiroku subscription (no checkpoint advance,-  so the halting event replays on restart), preserving the prior adapter-  behavior; the blocked worker is interrupted by the cancellation.--'finalize' is idempotent: the reply is written with 'tryPutTMVar' so a second-call is a no-op.+  KirokuEnvelopeAttrs $+    HashMap.fromList $+      (attrKirokuSubscriptionName, toAttribute subscriptionName)+        : (attrMessagingSystem, toAttribute ("kiroku" :: Text))+        : (attrMessagingDestinationName, toAttribute subscriptionName)+        : maybe+          []+          (\m -> [(attrKirokuConsumerGroupMember, toAttribute (fromIntegral m :: Int64))])+          member -The envelope's @attempt@ is set from the item's redelivery counter so a Shibuya-handler can observe how many times Kiroku has redelivered the event.--}+-- | Wrap an ack-coupled 'AckItem' (from @kiroku-store@'s 'subscriptionAckStream')+-- into an 'Ingested' value suitable for Shibuya handlers.+--+-- The underlying Kiroku worker is blocked waiting for this item's reply, so the+-- 'AckHandle.finalize' translates the Shibuya 'AckDecision' into a Kiroku+-- 'SubscriptionResult' and writes it back — driving the worker's checkpointing:+--+-- * 'AckOk' — reply 'Continue'; the worker checkpoints past the event.+-- * 'AckRetry' @delay@ — reply 'Retry'; the worker redelivers the same event after+--   @delay@, bounded by the subscription's retry policy, then dead-letters it.+-- * 'AckDeadLetter' @reason@ — reply 'DeadLetter'; the worker records the event in+--   @kiroku.dead_letters@ and advances past it.+-- * 'AckHalt' — cancels the underlying Kiroku subscription (no checkpoint advance,+--   so the halting event replays on restart), preserving the prior adapter+--   behavior; the blocked worker is interrupted by the cancellation.+--+-- 'finalize' is idempotent: the reply is written with 'tryPutTMVar' so a second+-- call is a no-op.+--+-- The envelope's @attempt@ is set from the item's redelivery counter so a Shibuya+-- handler can observe how many times Kiroku has redelivered the event. toIngestedAck :: (IOE :> es) => KirokuEnvelopeAttrs -> IO () -> AckItem -> Ingested es RecordedEvent toIngestedAck attrs cancelAction (AckItem event attempt reply) =-    Ingested-        { envelope = (toEnvelope attrs event){attempt = Just (Attempt attempt)}-        , ack =-            AckHandle-                { finalize = \case-                    AckHalt _ -> liftIO cancelAction-                    decision ->-                        liftIO $-                            atomically $-                                void $-                                    tryPutTMVar reply (toKirokuResult attempt decision)-                }-        , lease = Nothing-        }+  Ingested+    { envelope = (toEnvelope attrs event) {attempt = Just (Attempt attempt)},+      ack =+        AckHandle+          { finalize = \case+              AckHalt _ -> liftIO cancelAction+              decision ->+                liftIO $+                  atomically $+                    void $+                      tryPutTMVar reply (toKirokuResult attempt decision)+          },+      lease = Nothing,+      deliveryStatus = pure DeliveryCurrent+    } -{- | Translate a non-halt Shibuya 'AckDecision' into a Kiroku-'SubscriptionResult'. 'AckHalt' is handled separately (it cancels the-subscription) and maps to 'Continue' here only defensively. The 'Word' is the-event's current redelivery attempt, used to annotate a 'MaxRetriesExceeded'-reason.--}+-- | Translate a non-halt Shibuya 'AckDecision' into a Kiroku+-- 'SubscriptionResult'. 'AckHalt' is handled separately (it cancels the+-- subscription) and maps to 'Continue' here only defensively. The 'Word' is the+-- event's current redelivery attempt, used to annotate a 'MaxRetriesExceeded'+-- reason. toKirokuResult :: Word -> AckDecision -> SubscriptionResult toKirokuResult attempt = \case-    AckOk -> Continue-    AckRetry (Ack.RetryDelay d) -> Retry (RetryDelay d)-    AckDeadLetter reason -> DeadLetter (toKirokuDeadLetterReason attempt reason)-    AckHalt _ -> Continue--{- | Translate a Shibuya 'Ack.DeadLetterReason' into a Kiroku 'DeadLetterReason'.--The three framework-owned reasons keep their dedicated Kiroku constructors, so-their @reason_summary@ and @reason@ JSONB encodings are unchanged.+  AckOk -> Continue+  AckRetry (Ack.RetryDelay d) -> Retry (RetryDelay d)+  AckDeadLetter reason -> DeadLetter (toKirokuDeadLetterReason attempt reason)+  AckHalt _ -> Continue -'Ack.ApplicationFailure' (added in @shibuya-core@ 0.9) has no Kiroku-counterpart — Kiroku deliberately knows nothing about application-owned-dead-letter codes — so it maps to 'DeadLetterOther'. The summary is Shibuya's-canonical @code: detail@ rendering, and the structured JSON keeps the code in-its own field so operators can query @reason->'detail'->>'code'@ without-parsing the summary text.--}+-- | Translate a Shibuya 'Ack.DeadLetterReason' into a Kiroku 'DeadLetterReason'.+--+-- The three framework-owned reasons keep their dedicated Kiroku constructors, so+-- their @reason_summary@ and @reason@ JSONB encodings are unchanged.+--+-- 'Ack.ApplicationFailure' (added in @shibuya-core@ 0.9) has no Kiroku+-- counterpart — Kiroku deliberately knows nothing about application-owned+-- dead-letter codes — so it maps to 'DeadLetterOther'. The summary is Shibuya's+-- canonical @code: detail@ rendering, and the structured JSON keeps the code in+-- its own field so operators can query @reason->'detail'->>'code'@ without+-- parsing the summary text. toKirokuDeadLetterReason :: Word -> Ack.DeadLetterReason -> DeadLetterReason toKirokuDeadLetterReason attempt = \case-    Ack.PoisonPill detail -> DeadLetterPoison detail-    Ack.InvalidPayload detail -> DeadLetterInvalid detail-    Ack.MaxRetriesExceeded -> DeadLetterMaxAttempts (fromIntegral attempt)-    reason@(Ack.ApplicationFailure code detail) ->-        DeadLetterOther-            (Ack.renderDeadLetterReason reason)-            (object ["code" .= Ack.deadLetterCodeText code, "detail" .= detail])--{- | Convert a 'RecordedEvent' to a Shibuya 'Envelope'.+  Ack.PoisonPill detail -> DeadLetterPoison detail+  Ack.InvalidPayload detail -> DeadLetterInvalid detail+  Ack.MaxRetriesExceeded -> DeadLetterMaxAttempts (fromIntegral attempt)+  reason@(Ack.ApplicationFailure code detail) ->+    DeadLetterOther+      (Ack.renderDeadLetterReason reason)+      (object ["code" .= Ack.deadLetterCodeText code, "detail" .= detail]) -The event's UUID is formatted as text for the 'MessageId', and the-global position is used as an integer 'Cursor' for ordering. The 'Envelope's-@attributes@ are populated with the kiroku identity (subscription name,-consumer-group member, event type, global position) from 'KirokuEnvelopeAttrs'-and the event, so Shibuya's per-message span carries the kiroku context.--}+-- | Convert a 'RecordedEvent' to a Shibuya 'Envelope'.+--+-- The event's UUID is formatted as text for the 'MessageId', and the+-- global position is used as an integer 'Cursor' for ordering. The 'Envelope's+-- @attributes@ are populated with the kiroku identity (subscription name,+-- consumer-group member, event type, global position) from 'KirokuEnvelopeAttrs'+-- and the event, so Shibuya's per-message span carries the kiroku context. toEnvelope :: KirokuEnvelopeAttrs -> RecordedEvent -> Envelope RecordedEvent toEnvelope attrs event =-    let RecordedEvent{eventId = EventId uuid, eventType = EventType etype, globalPosition = GlobalPosition pos, createdAt = ts, metadata = meta} = event-     in Envelope-            { messageId = MessageId (T.pack (UUID.toString uuid))-            , cursor = Just (CursorInt (fromIntegral pos))-            , partition = Nothing-            , enqueuedAt = Just ts-            , traceContext = metadataTraceContext meta-            , headers = Nothing-            , attempt = Nothing-            , attributes = eventAttributes attrs etype pos-            , payload = event-            }+  let RecordedEvent {eventId = EventId uuid, eventType = EventType etype, globalPosition = GlobalPosition pos, createdAt = ts, metadata = meta} = event+   in Envelope+        { messageId = MessageId (T.pack (UUID.toString uuid)),+          cursor = Just (CursorInt (fromIntegral pos)),+          partition = Nothing,+          enqueuedAt = Just ts,+          traceContext = metadataTraceContext meta,+          headers = Nothing,+          attempt = Nothing,+          attributes = eventAttributes attrs etype pos,+          payload = event+        } -{- | The per-event @kiroku.*@ attribute map: the precomputed constant attributes-('baseAttributes') plus this event's type and global position. Only the two-per-event keys are inserted, so the constant attributes are not rebuilt for-every event. Keys mirror the native-span attribute keys in-@Kiroku.Otel.Subscription@ so a trace reads consistently across the kiroku and-Shibuya sides.--}+-- | The per-event @kiroku.*@ attribute map: the precomputed constant attributes+-- ('baseAttributes') plus this event's type and global position. Only the two+-- per-event keys are inserted, so the constant attributes are not rebuilt for+-- every event. Keys mirror the native-span attribute keys in+-- @Kiroku.Otel.Subscription@ so a trace reads consistently across the kiroku and+-- Shibuya sides. eventAttributes :: KirokuEnvelopeAttrs -> Text -> Int64 -> HashMap Text Attribute eventAttributes attrs etype pos =-    HashMap.insert attrKirokuEventType (toAttribute etype) $-        HashMap.insert attrKirokuEventGlobalPosition (toAttribute pos) (baseAttributes attrs)+  HashMap.insert attrKirokuEventType (toAttribute etype) $+    HashMap.insert attrKirokuEventGlobalPosition (toAttribute pos) (baseAttributes attrs)  attrMessagingSystem :: Text attrMessagingSystem = unkey Sem.messaging_system@@ -229,11 +223,11 @@  metadataTraceContext :: Maybe Value -> Maybe TraceHeaders metadataTraceContext (Just (Object metadata)) = do-    String traceparent <- KM.lookup (Key.fromString "traceparent") metadata-    let traceparentHeader = ("traceparent", TE.encodeUtf8 traceparent)-        traceHeaders =-            case KM.lookup (Key.fromString "tracestate") metadata of-                Just (String tracestate) -> [traceparentHeader, ("tracestate", TE.encodeUtf8 tracestate)]-                _ -> [traceparentHeader]-    pure traceHeaders+  String traceparent <- KM.lookup (Key.fromString "traceparent") metadata+  let traceparentHeader = ("traceparent", TE.encodeUtf8 traceparent)+      traceHeaders =+        case KM.lookup (Key.fromString "tracestate") metadata of+          Just (String tracestate) -> [traceparentHeader, ("tracestate", TE.encodeUtf8 tracestate)]+          _ -> [traceparentHeader]+  pure traceHeaders metadataTraceContext _ = Nothing
test/Main.hs view
@@ -37,1547 +37,1541 @@ import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres) import OpenTelemetry.Attributes (toAttribute) import Shibuya.Adapter (Adapter (..))-import Shibuya.Adapter.Kiroku (-    consumerGroupPolicy,-    defaultConsumerGroupConfig,-    defaultKirokuAdapterConfig,-    guardKirokuHandlerWith,-    kirokuAdapter,-    kirokuConsumerGroupProcessors,-    kirokuConsumerGroupProcessorsWith,-    kirokuProcessor,- )-import Shibuya.Adapter.Kiroku.Convert (-    KirokuEnvelopeAttrs,-    kirokuEnvelopeAttrs,-    toEnvelope,-    toIngestedAck,-    toKirokuDeadLetterReason,- )-import Shibuya.Adapter.Kiroku.Internal (acquireAllAndTransfer)-import Shibuya.App (-    ProcessorId (..),-    QueueProcessor (..),-    defaultAppConfig,-    getAppMetrics,-    mkProcessor,-    runApp,-    stopApp,-    stopAppGracefully,-    waitApp,- )-import Shibuya.App qualified as Shibuya-import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..))-import Shibuya.Core.Ack qualified as Ack-import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Error (PolicyError (..))-import Shibuya.Core.Ingested (Ingested (..), Message (..))-import Shibuya.Core.Metrics (ProcessorState (..))-import Shibuya.Core.Types (Attempt (..), Envelope (..))-import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))-import Shibuya.Telemetry.Effect (runTracingNoop)-import Streamly.Data.Fold qualified as Fold-import Streamly.Data.Stream qualified as Stream-import System.Timeout (timeout)-import Test.Hspec--main :: IO ()-main = withSharedMigratedPostgres $ hspec $ do-    describe "toEnvelope" $ do-        it "copies W3C trace metadata into Shibuya trace headers" $ do-            let traceparent :: Text-                traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"-                tracestate :: Text-                tracestate = "rojo=00f067aa0ba902b7"-                Envelope{traceContext} =-                    toEnvelope-                        sampleEnvelopeAttrs-                        ( makeRecordedEvent-                            ( Just $-                                Aeson.object-                                    [ "traceparent" Aeson..= traceparent-                                    , "tracestate" Aeson..= tracestate-                                    , "other" Aeson..= ("preserved" :: Text)-                                    ]-                            )-                        )--            traceContext-                `shouldBe` Just-                    [ ("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")-                    , ("tracestate", "rojo=00f067aa0ba902b7")-                    ]--        it "omits trace headers when traceparent is absent or not a string" $ do-            let Envelope{traceContext = missingTraceparent} =-                    toEnvelope sampleEnvelopeAttrs (makeRecordedEvent (Just (Aeson.object ["tracestate" Aeson..= ("state" :: Text)])))-                Envelope{traceContext = nonStringTraceparent} =-                    toEnvelope sampleEnvelopeAttrs (makeRecordedEvent (Just (Aeson.object ["traceparent" Aeson..= Aeson.Number 1])))--            missingTraceparent `shouldBe` Nothing-            nonStringTraceparent `shouldBe` Nothing--        it "does not surface broker headers (headers is Nothing), even with trace metadata" $ do-            let Envelope{headers = noMeta} =-                    toEnvelope sampleEnvelopeAttrs (makeRecordedEvent Nothing)-                Envelope{headers = withTraceMeta} =-                    toEnvelope-                        sampleEnvelopeAttrs-                        (makeRecordedEvent (Just (Aeson.object ["traceparent" Aeson..= ("00-abc-def-01" :: Text)])))--            noMeta `shouldBe` Nothing-            withTraceMeta `shouldBe` Nothing--        it "stamps kiroku identity attributes for a non-grouped subscription (EP-5 M2)" $ do-            let attrs = kirokuEnvelopeAttrs "orders-proj" Nothing-                Envelope{attributes} = toEnvelope attrs (makeRecordedEvent Nothing)-            -- makeRecordedEvent: eventType "TraceEvent", globalPosition 1.-            HashMap.lookup "kiroku.subscription.name" attributes-                `shouldBe` Just (toAttribute ("orders-proj" :: Text))-            HashMap.lookup "messaging.system" attributes-                `shouldBe` Just (toAttribute ("kiroku" :: Text))-            HashMap.lookup "messaging.destination.name" attributes-                `shouldBe` Just (toAttribute ("orders-proj" :: Text))-            HashMap.lookup "kiroku.event.type" attributes-                `shouldBe` Just (toAttribute ("TraceEvent" :: Text))-            HashMap.lookup "kiroku.event.global_position" attributes-                `shouldBe` Just (toAttribute (1 :: Int64))-            -- No member key for a non-grouped subscription.-            HashMap.lookup "kiroku.consumer_group.member" attributes `shouldBe` Nothing--        it "stamps the consumer-group member attribute for a grouped subscription (EP-5 M2)" $ do-            let attrs = kirokuEnvelopeAttrs "orders-proj" (Just 2)-                Envelope{attributes} = toEnvelope attrs (makeRecordedEvent Nothing)-            HashMap.lookup "kiroku.subscription.name" attributes-                `shouldBe` Just (toAttribute ("orders-proj" :: Text))-            HashMap.lookup "kiroku.consumer_group.member" attributes-                `shouldBe` Just (toAttribute (2 :: Int64))--    describe "toKirokuDeadLetterReason" $ do-        it "keeps the framework reasons on their dedicated Kiroku constructors" $ do-            toKirokuDeadLetterReason 3 (PoisonPill "bad event")-                `shouldBe` KTypes.DeadLetterPoison "bad event"-            toKirokuDeadLetterReason 3 (InvalidPayload "not json")-                `shouldBe` KTypes.DeadLetterInvalid "not json"-            toKirokuDeadLetterReason 3 MaxRetriesExceeded-                `shouldBe` KTypes.DeadLetterMaxAttempts 3--        it "maps an application failure to DeadLetterOther with a queryable code (shibuya-core 0.9)" $ do-            code <--                either (E.throwIO . userError . T.unpack) pure $-                    Ack.mkDeadLetterCode "billing.card_declined"-            let reason =-                    toKirokuDeadLetterReason 1 (Ack.ApplicationFailure code "card declined by issuer")--            KTypes.deadLetterSummary reason-                `shouldBe` "billing.card_declined: card declined by issuer"-            KTypes.deadLetterReasonJson reason-                `shouldBe` Aeson.object-                    [ "kind" Aeson..= ("other" :: Text)-                    , "summary" Aeson..= ("billing.card_declined: card declined by issuer" :: Text)-                    , "detail"-                        Aeson..= Aeson.object-                            [ "code" Aeson..= ("billing.card_declined" :: Text)-                            , "detail" Aeson..= ("card declined by issuer" :: Text)-                            ]-                    ]--    describe "consumer group policy" $ do-        it "accepts Serial member concurrency as (PartitionedInOrder, Serial)" $-            consumerGroupPolicy Serial `shouldBe` Right (PartitionedInOrder, Serial)--        it "rejects Ahead member concurrency with a PolicyError" $-            consumerGroupPolicy (Ahead 4)-                `shouldBe` Left (InvalidPolicyCombo "StrictInOrder requires Serial concurrency")--        it "rejects Async member concurrency with a PolicyError" $-            consumerGroupPolicy (Async 4)-                `shouldBe` Left (InvalidPolicyCombo "StrictInOrder requires Serial concurrency")--    acknowledgementSpec-    around withTestStore $ do-        describe "kirokuAdapter" $ do-            it "delivers only matching event types when an eventTypeFilter is set (EP-43)" $ \store -> do-                -- A, B, A, B, A on one stream (global positions 1..5). The adapter-                -- is filtered to type A; the Shibuya handler must see only the As.-                let events =-                        [ makeEvent "A" (Aeson.object [])-                        , makeEvent "B" (Aeson.object [])-                        , makeEvent "A" (Aeson.object [])-                        , makeEvent "B" (Aeson.object [])-                        , makeEvent "A" (Aeson.object [])-                        ]-                Right _ <- runStoreIO store $ appendToStream (StreamName "etf-adapter-1") NoStream events-                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)-                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "etf-adapter") AllStreams-                                & #eventTypeFilter .~ OnlyEventTypes (Set.fromList [EventType "A"])-                    let handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                            pure AckOk-                    res <- runApp defaultAppConfig [(ProcessorId "etf", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 3 10_000_000-                            stopApp appHandle--                collected <- readIORef ref-                -- Only the three A events reached the handler; no Bs.-                map (^. #eventType) collected `shouldBe` replicate 3 (EventType "A")-                map globalPos (reverse collected) `shouldBe` [1, 3, 5]--            it "delivers only matching types across a filtered consumer group (EP-43)" $ \store -> do-                -- 20 streams in category "etfg", each [A, B]; A at odd global-                -- positions 1,3,..,39. A size-4 group filtered to type A must-                -- deliver exactly the 20 As, disjoint and complete.-                let streams = [0 .. 19 :: Int]-                mapM_-                    ( \i -> do-                        let sn = StreamName ("etfg-" <> T.pack (show i))-                        Right _ <--                            runStoreIO store $-                                appendToStream sn NoStream [makeEvent "A" (Aeson.object []), makeEvent "B" (Aeson.object [])]-                        pure ()-                    )-                    streams-                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)-                runEff $ runTracingNoop $ do-                    let cfg =-                            defaultConsumerGroupConfig (SubscriptionName "etfg") (Category (CategoryName "etfg")) (validGroupSize 4)-                                & #eventTypeFilter .~ OnlyEventTypes (Set.fromList [EventType "A"])-                        handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                            pure AckOk-                    res <- kirokuConsumerGroupProcessors store cfg handler-                    case res of-                        Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)-                        Right processors -> do-                            appRes <- runApp defaultAppConfig processors-                            case appRes of-                                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                Right appHandle -> do-                                    liftIO $ waitForCount countVar 20 15_000_000-                                    stopApp appHandle--                collected <- readIORef ref-                -- Every delivered event is an A (filter honored per member)...-                map (^. #eventType) collected `shouldBe` replicate 20 (EventType "A")-                -- ...and the union over members is the complete, disjoint set of A-                -- positions [1,3,..,39] — no A dropped, none delivered twice.-                sort (map globalPos collected) `shouldBe` [1, 3 .. 39]--            it "delivers only events matching an opaque selector (EP-43 follow-up)" $ \store -> do-                -- All one type "A" (so eventTypeFilter cannot distinguish them);-                -- only the payload tag {keep} differs. keep, skip, keep, skip, keep-                -- at positions 1..5. The selector admits only keep=True.-                let keepObj = Aeson.object [("keep", Aeson.Bool True)]-                    skipObj = Aeson.object [("keep", Aeson.Bool False)]-                    events =-                        [ makeEvent "A" keepObj-                        , makeEvent "A" skipObj-                        , makeEvent "A" keepObj-                        , makeEvent "A" skipObj-                        , makeEvent "A" keepObj-                        ]-                Right _ <- runStoreIO store $ appendToStream (StreamName "sel-adapter-1") NoStream events-                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)-                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "sel-adapter") AllStreams-                                & #selector .~ Just (\e -> (e ^. #payload) == keepObj)-                    let handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                            pure AckOk-                    res <- runApp defaultAppConfig [(ProcessorId "sel", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 3 10_000_000-                            stopApp appHandle--                collected <- readIORef ref-                -- Only the three keep events reached the handler (positions 1,3,5);-                -- the two skip events were filtered worker-side and never delivered.-                map globalPos (reverse collected) `shouldBe` [1, 3, 5]--            it "delivers catch-up events through Shibuya pipeline" $ \store -> do-                let events = map (\i -> makeEvent ("CU" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-catchup-1") NoStream events-                threadDelay 200_000--                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "shibuya-catchup-test") AllStreams--                    let handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                            pure AckOk--                    res <- runApp defaultAppConfig [(ProcessorId "catchup", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 10 10_000_000-                            stopApp appHandle--                collected <- readIORef ref-                length collected `shouldBe` 10--            it "supports a future-only FromCurrentHead adapter" $ \store -> do-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "shibuya-future-only-events")-                            NoStream-                            (map (\i -> makeEvent ("Historical" <> T.pack (show i)) (Aeson.object [])) [1 .. 4 :: Int])-                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "shibuya-future-only") AllStreams-                                & #missingCheckpointPolicy .~ FromCurrentHead-                    liftIO $-                        waitForCheckpointPosition-                            store-                            (SubscriptionCheckpointKey (SubscriptionName "shibuya-future-only") 0)-                            (GlobalPosition 4)-                    liftIO $ do-                        Right _ <--                            runStoreIO store $-                                appendToStream-                                    (StreamName "shibuya-future-only-events")-                                    StreamExists-                                    [makeEvent "Future" (Aeson.object [])]-                        pure ()-                    let handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    count <- readTVar countVar-                                    writeTVar countVar (count + 1)-                            pure AckOk-                    result <- runApp defaultAppConfig [(ProcessorId "future-only", mkProcessor adapter handler)]-                    case result of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 1 10_000_000-                            stopApp appHandle--                collected <- reverse <$> readIORef ref-                map globalPos collected `shouldBe` [5]--            it "preserves an existing checkpoint when restarted with FromCurrentHead" $ \store -> do-                let subscriptionName = SubscriptionName "shibuya-existing-checkpoint"-                    checkpointKey = SubscriptionCheckpointKey subscriptionName 0-                    config policy =-                        defaultKirokuAdapterConfig subscriptionName AllStreams-                            & #missingCheckpointPolicy .~ policy-                Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-existing-checkpoint-events") NoStream [makeEvent "First" (Aeson.object [])]-                firstSeen <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <- kirokuAdapter store (config FromBeginning)-                    result <--                        runApp-                            defaultAppConfig-                            [-                                ( ProcessorId "existing-checkpoint-first"-                                , mkProcessor adapter $ \_ -> do-                                    liftIO $ atomically $ writeTVar firstSeen 1-                                    pure AckOk-                                )-                            ]-                    case result of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount firstSeen 1 10_000_000-                            liftIO $ waitForCheckpointPosition store checkpointKey (GlobalPosition 1)-                            stopApp appHandle--                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "shibuya-existing-checkpoint-events")-                            StreamExists-                            [makeEvent "Second" (Aeson.object [])]-                replayed <- newIORef ([] :: [Int64])-                within "existing checkpoint restart" $-                    runEff $-                        runTracingNoop $ do-                            adapter <- kirokuAdapter store (config FromCurrentHead)-                            result <--                                runApp-                                    defaultAppConfig-                                    [-                                        ( ProcessorId "existing-checkpoint-restart"-                                        , mkProcessor adapter $ \ingested -> do-                                            liftIO $ modifyIORef' replayed (globalPos (envelopePayload ingested) :)-                                            pure (AckHalt (HaltFatal "existing checkpoint observed"))-                                        )-                                    ]-                            case result of-                                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                Right appHandle -> waitApp appHandle--                readIORef replayed `shouldReturn` [2]--            it "surfaces FailIfMissing without leaving a subscription registered" $ \store -> do-                let subscriptionName = SubscriptionName "shibuya-required-checkpoint"-                    checkpointKey = SubscriptionCheckpointKey subscriptionName 0-                    config =-                        defaultKirokuAdapterConfig subscriptionName AllStreams-                            & #missingCheckpointPolicy .~ FailIfMissing-                outcome <--                    E.try $-                        within "FailIfMissing adapter refusal" $-                            runEff $-                                runTracingNoop $ do-                                    adapter <- kirokuAdapter store config-                                    let Adapter{source = sourceStream} = adapter-                                    Stream.fold Fold.drain sourceStream-                case outcome of-                    Left exception-                        | Just (SubscriptionCheckpointMissing actual) <- E.fromException (exception :: E.SomeException) ->-                            actual `shouldBe` checkpointKey-                    Left exception -> expectationFailure ("expected SubscriptionCheckpointMissing, got: " <> show exception)-                    Right () -> expectationFailure "expected a missing-checkpoint refusal"-                Map.null <$> subscriptionStates store `shouldReturn` True--            it "delivers live events through Shibuya pipeline" $ \store -> do-                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "shibuya-live-test") AllStreams--                    let handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                            pure AckOk--                    res <- runApp defaultAppConfig [(ProcessorId "live", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ threadDelay 200_000-                            liftIO $ do-                                let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]-                                Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-live-1") NoStream events-                                pure ()-                            liftIO $ waitForCount countVar 5 10_000_000-                            stopApp appHandle--                collected <- readIORef ref-                length collected `shouldBe` 5--            it "survives an append burst while the handler is paused" $ \store -> do-                firstSeen <- newEmptyMVar-                release <- newEmptyMVar-                countVar <- newTVarIO (0 :: Int)-                seenRef <- newIORef ([] :: [RecordedEvent])--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "burst-pause-resume") AllStreams-                                & #queueCapacity .~ 1--                    let handler ingested = do-                            n <- liftIO $ do-                                modifyIORef' seenRef (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    let c' = c + 1-                                    writeTVar countVar c'-                                    pure c'-                            if n == 1-                                then liftIO $ putMVar firstSeen () >> takeMVar release-                                else pure ()-                            pure AckOk--                    res <- runApp defaultAppConfig [(ProcessorId "burst", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ do-                                let appendOne i =-                                        runStoreIO store $-                                            appendToStream-                                                (StreamName ("burst-" <> T.pack (show (i :: Int))))-                                                NoStream-                                                [makeEvent ("Burst" <> T.pack (show i)) (Aeson.object [])]-                                Right _ <- appendOne 1-                                takeMVar firstSeen-                                mapM_ (\i -> appendOne i >>= (`shouldSatisfy` either (const False) (const True))) [2 .. 40 :: Int]-                                putMVar release ()-                                waitForCount countVar 40 15_000_000-                            stopApp appHandle--                seen <- readIORef seenRef-                sort (map globalPos seen) `shouldBe` [1 .. 40]--            it "ends adapter.source cleanly after shutdown" $ \store -> do-                within "adapter source clean shutdown" $-                    runEff $-                        runTracingNoop $ do-                            adapter <- kirokuAdapter store $ defaultKirokuAdapterConfig (SubscriptionName "source-clean-end") AllStreams-                            let Adapter{source = sourceStream, shutdown = shutdownAction} = adapter-                            shutdownAction-                            Stream.fold Fold.drain sourceStream--            it "rethrows worker crashes through adapter.source" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "source-crash-1") NoStream [makeEvent "SourceCrash" (Aeson.object [])]-                threadDelay 200_000-                let injectBoom _ _ = E.throwIO (userError "adapter source worker boom")-                result <--                    withFetchBatchHookForTest injectBoom $-                        E.try $-                            within "adapter source worker crash" $-                                runEff $-                                    runTracingNoop $ do-                                        adapter <- kirokuAdapter store $ defaultKirokuAdapterConfig (SubscriptionName "source-crash") AllStreams-                                        let Adapter{source = sourceStream} = adapter-                                        Stream.fold Fold.drain sourceStream-                case result of-                    Left (e :: E.SomeException) -> show e `shouldContain` "adapter source worker boom"-                    Right _ -> expectationFailure "expected adapter.source to rethrow the worker exception"--            it "runs multiple category subscriptions concurrently" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "orders-s1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "orders-s2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "payments-s1") NoStream [makeEvent "PaymentReceived" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "inventory-s1") NoStream [makeEvent "StockUpdated" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "inventory-s2") NoStream [makeEvent "StockDepleted" (Aeson.object [])]-                threadDelay 200_000--                ordersRef <- newIORef ([] :: [RecordedEvent])-                paymentsRef <- newIORef ([] :: [RecordedEvent])-                inventoryRef <- newIORef ([] :: [RecordedEvent])-                ordersCount <- newTVarIO (0 :: Int)-                paymentsCount <- newTVarIO (0 :: Int)-                inventoryCount <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    let mkCatAdapter nm cat =-                            kirokuAdapter store $-                                defaultKirokuAdapterConfig (SubscriptionName nm) (Category (CategoryName cat))-                    let mkHandler ref' cVar ingested = do-                            liftIO $ do-                                modifyIORef' ref' (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar cVar-                                    writeTVar cVar (c + 1)-                            pure AckOk--                    ordersAdapter <- mkCatAdapter "orders-proj" "orders"-                    paymentsAdapter <- mkCatAdapter "payments-proj" "payments"-                    inventoryAdapter <- mkCatAdapter "inventory-proj" "inventory"--                    res <--                        runApp-                            defaultAppConfig-                            [ (ProcessorId "orders", mkProcessor ordersAdapter (mkHandler ordersRef ordersCount))-                            , (ProcessorId "payments", mkProcessor paymentsAdapter (mkHandler paymentsRef paymentsCount))-                            , (ProcessorId "inventory", mkProcessor inventoryAdapter (mkHandler inventoryRef inventoryCount))-                            ]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ do-                                waitForCount ordersCount 2 10_000_000-                                waitForCount paymentsCount 1 10_000_000-                                waitForCount inventoryCount 2 10_000_000-                            stopApp appHandle--                ordersCollected <- readIORef ordersRef-                paymentsCollected <- readIORef paymentsRef-                inventoryCollected <- readIORef inventoryRef-                length ordersCollected `shouldBe` 2-                length paymentsCollected `shouldBe` 1-                length inventoryCollected `shouldBe` 2--            it "isolates a failing subscription from healthy ones" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "good-s1") NoStream (map (\i -> makeEvent ("Good" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int])-                Right _ <- runStoreIO store $ appendToStream (StreamName "bad-s1") NoStream [makeEvent "Bad1" (Aeson.object []), makeEvent "Bad2" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "other-s1") NoStream [makeEvent "Other1" (Aeson.object []), makeEvent "Other2" (Aeson.object [])]-                threadDelay 200_000--                goodRef <- newIORef ([] :: [RecordedEvent])-                otherRef <- newIORef ([] :: [RecordedEvent])-                goodCount <- newTVarIO (0 :: Int)-                otherCount <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    goodAdapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "good-proj") (Category (CategoryName "good"))-                    badAdapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "bad-proj") (Category (CategoryName "bad"))-                    otherAdapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "other-proj") (Category (CategoryName "other"))--                    let goodHandler ingested = do-                            liftIO $ do-                                modifyIORef' goodRef (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar goodCount-                                    writeTVar goodCount (c + 1)-                            pure AckOk--                    let badHandler _ingested = do-                            liftIO $ error "handler crash!"--                    let otherHandler ingested = do-                            liftIO $ do-                                modifyIORef' otherRef (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar otherCount-                                    writeTVar otherCount (c + 1)-                            pure AckOk--                    res <--                        runApp-                            defaultAppConfig-                            [ (ProcessorId "good", mkProcessor goodAdapter goodHandler)-                            , (ProcessorId "bad", mkProcessor badAdapter badHandler)-                            , (ProcessorId "other", mkProcessor otherAdapter otherHandler)-                            ]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ do-                                waitForCount goodCount 3 10_000_000-                                waitForCount otherCount 2 10_000_000-                                threadDelay 500_000-                            metrics <- getAppMetrics appHandle-                            case Map.lookup (ProcessorId "bad") metrics of-                                Just m -> case m ^. #state of-                                    Failed _ _ -> pure ()-                                    other -> liftIO $ expectationFailure ("Expected Failed state, got: " <> show other)-                                Nothing -> liftIO $ expectationFailure "No metrics for bad processor"-                            stopApp appHandle--                goodCollected <- readIORef goodRef-                otherCollected <- readIORef otherRef-                length goodCollected `shouldBe` 3-                length otherCollected `shouldBe` 2--            it "shuts down all subscriptions coordinately" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "shut-a") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "shut-b") NoStream [makeEvent "B" (Aeson.object [])]-                threadDelay 200_000--                countA <- newTVarIO (0 :: Int)-                countB <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapterA <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "shut-a-proj") (Category (CategoryName "shut"))-                    adapterB <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "shut-b-proj") (Category (CategoryName "shut"))--                    let handlerA _ingested = do-                            liftIO $ atomically $ do-                                c <- readTVar countA-                                writeTVar countA (c + 1)-                            pure AckOk--                    let handlerB _ingested = do-                            liftIO $ atomically $ do-                                c <- readTVar countB-                                writeTVar countB (c + 1)-                            pure AckOk--                    res <--                        runApp-                            defaultAppConfig-                            [ (ProcessorId "a", mkProcessor adapterA handlerA)-                            , (ProcessorId "b", mkProcessor adapterB handlerB)-                            ]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ do-                                waitForCount countA 2 10_000_000-                                waitForCount countB 2 10_000_000-                            drained <- stopAppGracefully Shibuya.defaultShutdownConfig appHandle-                            liftIO $ drained `shouldBe` True--        describe "ack dispositions" $ do-            it "keeps the first Kiroku reply when an ack is finalized twice" $ \_store -> do-                reply <- newEmptyTMVarIO-                cancellations <- newIORef (0 :: Int)-                let Ingested{ack = AckHandle{finalize}} =-                        toIngestedAck-                            sampleEnvelopeAttrs-                            (modifyIORef' cancellations (+ 1))-                            (AckItem (makeRecordedEvent Nothing) 0 reply)--                runEff $ do-                    finalize AckOk-                    finalize (AckDeadLetter (PoisonPill "late duplicate"))--                atomically (readTMVar reply) `shouldReturn` Continue-                readIORef cancellations `shouldReturn` 0--            it "AckRetry redelivers the same event, then AckOk advances (EP-40 M3)" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "ackretry-1") NoStream [makeEvent "R1" (Aeson.object [])]-                threadDelay 200_000--                deliveries <- newIORef (0 :: Int)-                countVar <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "ackretry-proj") AllStreams--                    let handler _ingested = do-                            n <- liftIO $ do-                                modifyIORef' deliveries (+ 1)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                                    pure (c + 1)-                            -- Retry the first delivery, accept the second.-                            pure (if n == 1 then AckRetry (Ack.RetryDelay 0) else AckOk)--                    res <- runApp defaultAppConfig [(ProcessorId "ackretry", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 2 10_000_000-                            stopApp appHandle--                -- The one event was delivered twice (initial + one retry).-                n <- readIORef deliveries-                n `shouldBe` 2-                -- It was not dead-lettered (the retry succeeded).-                dls <- readDeadLetters store "ackretry-proj"-                length dls `shouldBe` 0--            it "AckHalt leaves the checkpoint behind the event and restart replays it" $ \store -> do-                let subscriptionName = SubscriptionName "ackhalt-replay-proj"-                    checkpointKey = SubscriptionCheckpointKey subscriptionName 0-                Right _ <- runStoreIO store $ appendToStream (StreamName "ackhalt-replay-1") NoStream [makeEvent "H1" (Aeson.object [])]-                threadDelay 200_000--                deliveries <- newIORef ([] :: [Int64])-                let runHaltingProcessor label =-                        within label $-                            runEff $-                                runTracingNoop $ do-                                    adapter <--                                        kirokuAdapter store $-                                            defaultKirokuAdapterConfig subscriptionName AllStreams-                                    result <--                                        runApp-                                            defaultAppConfig-                                            [-                                                ( ProcessorId "ackhalt-replay"-                                                , mkProcessor adapter $ \ingested -> do-                                                    liftIO $ modifyIORef' deliveries (globalPos (envelopePayload ingested) :)-                                                    pure (AckHalt (HaltFatal "intentional halt"))-                                                )-                                            ]-                                    case result of-                                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                        Right appHandle -> waitApp appHandle--                runHaltingProcessor "first AckHalt"-                readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)--                runHaltingProcessor "AckHalt restart"-                reverse <$> readIORef deliveries `shouldReturn` [1, 1]-                readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)--            it "shutdown after an ack but before checkpoint persistence replays without leaking" $ \store -> do-                let subscriptionName = SubscriptionName "checkpoint-cancel-replay-proj"-                    checkpointKey = SubscriptionCheckpointKey subscriptionName 0-                Right _ <- runStoreIO store $ appendToStream (StreamName "checkpoint-cancel-replay-1") NoStream [makeEvent "C1" (Aeson.object [])]-                threadDelay 200_000--                saveStarted <- newEmptyMVar-                holdSave <- newEmptyMVar-                firstDelivery <- newTVarIO (0 :: Int)-                let pauseSave config _-                        | KTypes.name config == subscriptionName = putMVar saveStarted () >> takeMVar holdSave-                        | otherwise = pure ()--                withSaveCheckpointHookForTest pauseSave $-                    within "shutdown during checkpoint persistence" $-                        runEff $-                            runTracingNoop $ do-                                adapter <--                                    kirokuAdapter store $-                                        defaultKirokuAdapterConfig subscriptionName AllStreams-                                result <--                                    runApp-                                        defaultAppConfig-                                        [-                                            ( ProcessorId "checkpoint-cancel"-                                            , mkProcessor adapter $ \_ -> do-                                                liftIO $ atomically $ do-                                                    count <- readTVar firstDelivery-                                                    writeTVar firstDelivery (count + 1)-                                                pure AckOk-                                            )-                                        ]-                                case result of-                                    Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                    Right appHandle -> do-                                        liftIO $ waitForCount firstDelivery 1 10_000_000-                                        liftIO $ takeMVar saveStarted-                                        stopApp appHandle--                readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)-                Map.null <$> subscriptionStates store `shouldReturn` True--                replayed <- newIORef ([] :: [Int64])-                within "restart after interrupted checkpoint" $-                    runEff $-                        runTracingNoop $ do-                            adapter <--                                kirokuAdapter store $-                                    defaultKirokuAdapterConfig subscriptionName AllStreams-                            result <--                                runApp-                                    defaultAppConfig-                                    [-                                        ( ProcessorId "checkpoint-replay"-                                        , mkProcessor adapter $ \ingested -> do-                                            liftIO $ modifyIORef' replayed (globalPos (envelopePayload ingested) :)-                                            pure (AckHalt (HaltFatal "replay observed"))-                                        )-                                    ]-                            case result of-                                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                Right appHandle -> waitApp appHandle--                readIORef replayed `shouldReturn` [1]-                readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)-                Map.null <$> subscriptionStates store `shouldReturn` True--            it "AckDeadLetter records the event and the next event continues (EP-40 M3)" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "ackdl-1") NoStream [makeEvent "D1" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "ackdl-2") NoStream [makeEvent "D2" (Aeson.object [])]-                threadDelay 200_000--                okPayloads <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "ackdl-proj") AllStreams--                    let handler ingested = do-                            n <- liftIO $ atomically $ do-                                c <- readTVar countVar-                                writeTVar countVar (c + 1)-                                pure (c + 1)-                            -- Dead-letter the first event; accept the second.-                            if n == 1-                                then pure (AckDeadLetter (PoisonPill "poison"))-                                else do-                                    liftIO $ modifyIORef' okPayloads (envelopePayload ingested :)-                                    pure AckOk--                    res <- runApp defaultAppConfig [(ProcessorId "ackdl", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 2 10_000_000-                            stopApp appHandle--                -- The first event (global position 1) is recorded as dead-letter.-                dls <- readDeadLetters store "ackdl-proj"-                map SQL.deadLetterGlobalPosition dls `shouldBe` [1]-                -- The second event was delivered and accepted.-                oks <- readIORef okPayloads-                map globalPos oks `shouldBe` [2]--            it "guardKirokuHandlerWith retries a transient handler exception and preserves attempts" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "guardretry-1") NoStream [makeEvent "GR1" (Aeson.object [])]-                threadDelay 200_000--                deliveries <- newIORef (0 :: Int)-                attempts <- newIORef ([] :: [Maybe Attempt])-                countVar <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "guardretry-proj") AllStreams--                    let throwingOnce ingested = do-                            let Message{envelope = Envelope{attempt = deliveryAttempt}} = ingested-                            n <- liftIO $ do-                                modifyIORef' attempts (deliveryAttempt :)-                                modifyIORef' deliveries (+ 1)-                                atomically $ do-                                    c <- readTVar countVar-                                    let c' = c + 1-                                    writeTVar countVar c'-                                    pure c'-                            if n == 1-                                then liftIO $ E.throwIO (userError "transient projection failure")-                                else pure AckOk-                        handler = guardKirokuHandlerWith (const (AckRetry (Ack.RetryDelay 0))) throwingOnce--                    res <- runApp defaultAppConfig [(ProcessorId "guardretry", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount countVar 2 10_000_000-                            stopApp appHandle--                n <- readIORef deliveries-                n `shouldBe` 2-                reverse <$> readIORef attempts `shouldReturn` [Just (Attempt 0), Just (Attempt 1)]-                dls <- readDeadLetters store "guardretry-proj"-                length dls `shouldBe` 0--            it "guardKirokuHandlerWith dead-letters a persistent handler exception and continues" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "guarddl-1") NoStream [makeEvent "GDL1" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "guarddl-2") NoStream [makeEvent "GDL2" (Aeson.object [])]-                threadDelay 200_000--                nextSeen <- newTVarIO (0 :: Int)--                runEff $ runTracingNoop $ do-                    adapter <--                        kirokuAdapter store $-                            defaultKirokuAdapterConfig (SubscriptionName "guarddl-proj") AllStreams--                    let alwaysThrowFirst ingested =-                            if globalPos (envelopePayload ingested) == 1-                                then liftIO $ E.throwIO (userError "persistent projection failure")-                                else do-                                    liftIO $ atomically $ do-                                        c <- readTVar nextSeen-                                        writeTVar nextSeen (c + 1)-                                    pure AckOk-                        handler = guardKirokuHandlerWith (const (AckRetry (Ack.RetryDelay 0))) alwaysThrowFirst--                    res <- runApp defaultAppConfig [(ProcessorId "guarddl", mkProcessor adapter handler)]-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForCount nextSeen 1 15_000_000-                            stopApp appHandle--                dls <- readDeadLetters store "guarddl-proj"-                map SQL.deadLetterGlobalPosition dls `shouldBe` [1]--        describe "consumer groups" $ do-            it "four-member group delivers a disjoint, complete partition of the stream" $ \store -> do-                -- 20 streams × 2 events = 40 events, global positions 1..40, category "cg".-                let streams = ["cg-" <> T.pack (show i) | i <- [1 .. 20 :: Int]]-                mapM_-                    ( \sn -> do-                        let evs = [makeEvent ("EV" <> T.pack (show k)) (Aeson.object []) | k <- [1 .. 2 :: Int]]-                        Right _ <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs-                        pure ()-                    )-                    streams-                threadDelay 200_000--                -- One IORef + one TVar counter per member.-                refs <- mapM (const (newIORef ([] :: [RecordedEvent]))) [0 .. 3 :: Int]-                cvars <- mapM (const (newTVarIO (0 :: Int))) [0 .. 3 :: Int]--                runEff $ runTracingNoop $ do-                    -- Build one adapter per member index; each carries the same-                    -- subscriptionName and a distinct member of a size-4 group.-                    adapters <--                        mapM-                            ( \m ->-                                kirokuAdapter store $-                                    defaultKirokuAdapterConfig (SubscriptionName "cg-shibuya-group") (Category (CategoryName "cg"))-                                        & #consumerGroup .~ Just (validConsumerGroup m 4)-                            )-                            [0, 1, 2, 3]--                    let mkHandler ref' cvar ingested = do-                            liftIO $ do-                                modifyIORef' ref' (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar cvar-                                    writeTVar cvar (c + 1)-                            pure AckOk--                        processors =-                            [ ( ProcessorId ("cg-member-" <> T.pack (show m))-                              , mkProcessor (adapters !! m) (mkHandler (refs !! m) (cvars !! m))-                              )-                            | m <- [0 .. 3 :: Int]-                            ]--                    res <- runApp defaultAppConfig processors-                    case res of-                        Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                        Right appHandle -> do-                            liftIO $ waitForTotal cvars 40 15_000_000-                            stopApp appHandle--                collected <- mapM readIORef refs-                -- Disjoint + complete: the union of every member's delivered global-                -- positions is exactly [1..40] — no event dropped, none delivered twice.-                let allPositions = sort (concatMap (map globalPos) collected)-                allPositions `shouldBe` [1 .. 40]-                -- No member starved: 20 streams over 4 members gives each ≥ 1 stream.-                mapM_ (\c -> length c `shouldSatisfy` (>= 1)) collected--        describe "consumer group policy" $ do-            it "rejects an invalid member concurrency before opening any subscription" $ \store -> do-                -- A request the helper cannot honor per member returns Left without-                -- starting any worker. The (unused) store proves no subscription opens.-                result <--                    runEff $-                        runTracingNoop $-                            kirokuConsumerGroupProcessors-                                store-                                ( defaultConsumerGroupConfig (SubscriptionName "cgp-reject") (Category (CategoryName "cgp-reject")) (validGroupSize 4)-                                    & #memberConcurrency .~ Async 4-                                )-                                ( \ingested -> do-                                    let _ = envelopePayload ingested-                                    pure AckOk-                                )-                case result of-                    Left err -> err `shouldBe` InvalidPolicyCombo "StrictInOrder requires Serial concurrency"-                    Right _ -> expectationFailure "expected Left PolicyError for Async member concurrency"--                withResult <--                    runEff $-                        runTracingNoop $-                            kirokuConsumerGroupProcessorsWith-                                ( \_ -> do-                                    liftIO $ expectationFailure "factory should not be called for an invalid policy"-                                    pure stubAdapter-                                )-                                ( defaultConsumerGroupConfig (SubscriptionName "cgp-reject-with") (Category (CategoryName "cgp-reject-with")) (validGroupSize 4)-                                    & #memberConcurrency .~ Async 4-                                )-                                ( \ingested -> do-                                    let _ = envelopePayload ingested-                                    pure AckOk-                                )-                case withResult of-                    Left err -> err `shouldBe` InvalidPolicyCombo "StrictInOrder requires Serial concurrency"-                    Right _ -> expectationFailure "expected Left PolicyError for Async member concurrency"--            it "rejects non-positive group sizes at construction" $ \_store -> do-                mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)-                mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))--            it "shuts down every created member and preserves the factory failure when cleanup throws" $ \_store -> do-                shutdowns <- newIORef ([] :: [Int32])-                let sentinel = userError "member 2 failed"-                    factory m-                        | m == 2 = liftIO $ E.throwIO sentinel-                        | otherwise =-                            pure-                                Adapter-                                    { adapterName = "stub-kiroku"-                                    , source = Stream.nil-                                    , shutdown = liftIO $ do-                                        modifyIORef' shutdowns (<> [m])-                                        if m == 1-                                            then E.throwIO (userError "member 1 cleanup failed")-                                            else pure ()-                                    }-                    handler ingested = do-                        let _ = envelopePayload ingested-                        pure AckOk-                result <--                    E.try $-                        runEff $-                            runTracingNoop $-                                kirokuConsumerGroupProcessorsWith-                                    factory-                                    (defaultConsumerGroupConfig (SubscriptionName "cgp-partial-cleanup") AllStreams (validGroupSize 3))-                                    handler-                case result of-                    Left (e :: E.SomeException) -> show e `shouldContain` "member 2 failed"-                    Right _ -> expectationFailure "expected the member factory failure to rethrow"-                readIORef shutdowns `shouldReturn` [1, 0]--            it "owns a returned member before cancellation can interrupt construction" $ \_store -> do-                acquired <- newEmptyMVar-                holdTransfer <- newEmptyMVar-                finished <- newEmptyMVar-                shutdowns <- newIORef ([] :: [Int32])-                maskingStates <- newIORef ([] :: [E.MaskingState])-                worker <--                    forkIO $ do-                        outcome <--                            E.try $-                                runEff $-                                    acquireAllAndTransfer-                                        2-                                        pure-                                        (\member -> liftIO $ modifyIORef' shutdowns (<> [member]))-                                        ( \member _ -> liftIO $ do-                                            state <- E.getMaskingState-                                            modifyIORef' maskingStates (<> [state])-                                            if member == 0-                                                then putMVar acquired () >> takeMVar holdTransfer-                                                else pure ()-                                        )-                                        pure-                        putMVar finished (outcome :: Either E.SomeException [Int32])--                takeMVar acquired-                killThread worker-                outcome <- takeMVar finished-                case outcome of-                    Left exception ->-                        E.fromException exception `shouldBe` Just E.ThreadKilled-                    Right _ -> expectationFailure "expected cancellation during ownership transfer"-                readIORef maskingStates `shouldReturn` [E.MaskedInterruptible]-                readIORef shutdowns `shouldReturn` [0]--            it "wraps consumer-group handlers so exceptions finalize a retry decision" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "cgp-guard-1") NoStream [makeEvent "CGGuard" (Aeson.object [])]-                threadDelay 200_000--                countVar <- newTVarIO (0 :: Int)-                runEff $-                    runTracingNoop $ do-                        result <--                            kirokuConsumerGroupProcessors-                                store-                                (defaultConsumerGroupConfig (SubscriptionName "cgp-guard") AllStreams (validGroupSize 1))-                                ( \_ -> do-                                    n <- liftIO $ atomically $ do-                                        c <- readTVar countVar-                                        let c' = c + 1-                                        writeTVar countVar c'-                                        pure c'-                                    if n == 1-                                        then liftIO $ E.throwIO (userError "guard me")-                                        else pure AckOk-                                )-                        case result of-                            Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)-                            Right processors -> do-                                appRes <- runApp defaultAppConfig processors-                                case appRes of-                                    Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                    Right appHandle -> do-                                        liftIO $ waitForCount countVar 2 15_000_000-                                        stopApp appHandle--            it "one call yields N PartitionedInOrder processors; members partition the stream disjointly" $ \store -> do-                -- 20 streams × 2 events = 40 events, global positions 1..40, category "cgp".-                let streams = ["cgp-" <> T.pack (show i) | i <- [1 .. 20 :: Int]]-                mapM_-                    ( \sn -> do-                        let evs = [makeEvent ("EV" <> T.pack (show k)) (Aeson.object []) | k <- [1 .. 2 :: Int]]-                        Right _ <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs-                        pure ()-                    )-                    streams-                threadDelay 200_000--                -- A single shared handler records every delivery (consumer-group-                -- members all run the same handler). One counter for the whole group.-                ref <- newIORef ([] :: [RecordedEvent])-                countVar <- newTVarIO (0 :: Int)--                let cfg =-                        defaultConsumerGroupConfig-                            (SubscriptionName "cgp-shibuya-group")-                            (Category (CategoryName "cgp"))-                            (validGroupSize 4)--                runEff $ runTracingNoop $ do-                    let handler ingested = do-                            liftIO $ do-                                modifyIORef' ref (envelopePayload ingested :)-                                atomically $ do-                                    c <- readTVar countVar-                                    writeTVar countVar (c + 1)-                            pure AckOk--                    -- One call replaces the manual `mapM mkMemberAdapter [0..3]` wiring.-                    res <- kirokuConsumerGroupProcessors store cfg handler-                    case res of-                        Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)-                        Right processors -> do-                            -- The group is presented as N processors, each pinned to the-                            -- group-level PartitionedInOrder contract + per-member Serial.-                            liftIO $ length processors `shouldBe` 4-                            -- 'Nothing' marks a batching processor, which the-                            -- consumer group must never produce.-                            let policies =-                                    map-                                        ( \(_, p) -> case p of-                                            QueueProcessor{ordering, concurrency} -> Just (ordering, concurrency)-                                            BatchingProcessor{} -> Nothing-                                        )-                                        processors-                            liftIO $ policies `shouldBe` replicate 4 (Just (PartitionedInOrder, Serial))-                            -- The member index is readable off the ProcessorId.-                            let pids = map (\(ProcessorId p, _) -> p) processors-                            liftIO $-                                pids-                                    `shouldBe` ["cgp-shibuya-group-member-" <> T.pack (show m) | m <- [0 .. 3 :: Int]]--                            appRes <- runApp defaultAppConfig processors-                            case appRes of-                                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)-                                Right appHandle -> do-                                    liftIO $ waitForCount countVar 40 15_000_000-                                    stopApp appHandle--                collected <- readIORef ref-                -- Disjoint + complete through the helper: the union of every member's-                -- delivered global positions is exactly [1..40] — no event dropped, none-                -- delivered twice. No duplicates ⇒ the member partitions are disjoint;-                -- ==[1..40] ⇒ the union is the complete source. This is the same-                -- disjoint-complete property MasterPlan 4 used, now asserted through the-                -- single-call helper and the Shibuya pipeline.-                sort (map globalPos collected) `shouldBe` [1 .. 40]-                -- Every one of the 20 originating streams was covered.-                length (nub (map origStreamId collected)) `shouldBe` 20---- Helpers--envelopePayload :: Message es RecordedEvent -> RecordedEvent-envelopePayload ing = let Message{envelope = env} = ing in env ^. #payload--stubAdapter :: Adapter es RecordedEvent-stubAdapter =-    Adapter-        { adapterName = "stub-kiroku"-        , source = Stream.nil-        , shutdown = pure ()-        }---- | Read the dead letters recorded for a non-group subscription (member 0).-readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord]-readDeadLetters store subName = readMemberDeadLetters store subName 0--readMemberDeadLetters :: KirokuStore -> Text -> Int32 -> IO [SQL.DeadLetterRecord]-readMemberDeadLetters store subName memberIndex = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, memberIndex) SQL.readDeadLettersStmt)-    case result of-        Left err -> error ("readDeadLetters failed: " <> show err)-        Right v -> pure (toList v)---- | The raw global position of a recorded event (for disjoint/complete checks).-globalPos :: RecordedEvent -> Int64-globalPos e = case e ^. #globalPosition of GlobalPosition p -> p---- | The originating stream's surrogate id (for stream-coverage checks).-origStreamId :: RecordedEvent -> Int64-origStreamId e = case e ^. #originalStreamId of StreamId p -> p---- | Wait until the sum of all TVar counts reaches the target or the timeout fires.-waitForTotal :: [STM.TVar Int] -> Int -> Int -> IO ()-waitForTotal vars target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    result <--        atomically $-            ( do-                total <- sum <$> mapM readTVar vars-                STM.check (total >= target)-                pure True-            )-                `STM.orElse` ( do-                                t <- readTVar timeoutVar-                                STM.check t-                                pure False-                             )-    unless result $ do-        actual <- atomically $ sum <$> mapM readTVar vars-        expectationFailure ("Timed out waiting for total " <> show target <> ", got " <> show actual)--waitForCount :: STM.TVar Int -> Int -> Int -> IO ()-waitForCount countVar target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    result <--        atomically $-            ( do-                c <- readTVar countVar-                STM.check (c >= target)-                pure True-            )-                `STM.orElse` ( do-                                t <- readTVar timeoutVar-                                STM.check t-                                pure False-                             )-    unless result $ do-        actual <- atomically $ readTVar countVar-        expectationFailure ("Timed out waiting for count " <> show target <> ", got " <> show actual)--within :: String -> IO a -> IO a-within label action = do-    result <- timeout 10_000_000 action-    case result of-        Nothing -> fail ("Timed out waiting for " <> label)-        Just a -> pure a--waitForCheckpointPosition :: KirokuStore -> SubscriptionCheckpointKey -> GlobalPosition -> IO ()-waitForCheckpointPosition store expectedKey expectedPosition =-    within "subscription checkpoint initialization" loop-  where-    loop = do-        Right (SubscriptionCheckpointInventory _ checkpoints) <--            runStoreIO store subscriptionCheckpointInventory-        let positions =-                [ (SubscriptionCheckpointKey name member, position)-                | SubscriptionCheckpoint name member position _ <- toList checkpoints-                ]-        if (expectedKey, expectedPosition) `elem` positions-            then pure ()-            else threadDelay 20_000 >> loop--readCheckpointPosition :: KirokuStore -> SubscriptionCheckpointKey -> IO (Maybe GlobalPosition)-readCheckpointPosition store expectedKey = do-    Right (SubscriptionCheckpointInventory _ checkpoints) <--        runStoreIO store subscriptionCheckpointInventory-    pure $ case [ position-                | SubscriptionCheckpoint name member position _ <- toList checkpoints-                , SubscriptionCheckpointKey name member == expectedKey-                ] of-        position : _ -> Just position-        [] -> Nothing--makeEvent :: Text -> Value -> EventData-makeEvent typ p =-    EventData-        { eventId = Nothing-        , eventType = EventType typ-        , payload = p-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }--{- | A throwaway attribute source for the trace-header tests, whose assertions-do not depend on the kiroku identity attributes.--}-sampleEnvelopeAttrs :: KirokuEnvelopeAttrs-sampleEnvelopeAttrs = kirokuEnvelopeAttrs "test-sub" Nothing--makeRecordedEvent :: Maybe Value -> RecordedEvent-makeRecordedEvent meta =-    RecordedEvent-        { eventId = EventId UUID.nil-        , eventType = EventType "TraceEvent"-        , streamVersion = StreamVersion 1-        , globalPosition = GlobalPosition 1-        , originalStreamId = StreamId 1-        , originalVersion = StreamVersion 1-        , payload = Aeson.object []-        , metadata = meta-        , causationId = Nothing-        , correlationId = Nothing-        , createdAt = UTCTime (fromGregorian 2026 5 16) 0-        }--withTestStore :: (KirokuStore -> IO ()) -> IO ()-withTestStore action =-    withMigratedTestDatabase $ \connStr ->-        withStore (defaultConnectionSettings connStr) action--validGroupSize :: Int32 -> ConsumerGroupSize-validGroupSize = either (error . show) Prelude.id . mkConsumerGroupSize--validConsumerGroup :: Int32 -> Int32 -> ConsumerGroup-validConsumerGroup m n = either (error . show) Prelude.id (mkConsumerGroup m (validGroupSize n))--acknowledgementSpec :: Spec-acknowledgementSpec = do-    describe "acknowledgement liveness" $ do-        forM_ [False, True] $ \guarded ->-            it (if guarded then "guarded single processor retries with pacing and continues" else "standard Shibuya handler exceptions are finalized") $-                withTestStore $ \store -> do-                    Right _ <--                        runStoreIO store $-                            appendToStream-                                (StreamName "ack-liveness")-                                NoStream-                                [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]-                    attempts <- newIORef ([] :: [(Int64, Maybe Attempt, Word64)])-                    count <- newTVarIO (0 :: Int)-                    within "runner finalization" $ runEff $ runTracingNoop $ do-                        adapter <- kirokuAdapter store (defaultKirokuAdapterConfig (SubscriptionName "ack-liveness") AllStreams)-                        let handler ingested = do-                                now <- liftIO getMonotonicTimeNSec-                                let pos = globalPos (envelopePayload ingested)-                                    Message{envelope = Envelope{attempt = attempt'}} = ingested-                                n <- liftIO $ do-                                    modifyIORef' attempts ((pos, attempt', now) :)-                                    atomically $ do-                                        old <- readTVar count-                                        writeTVar count (old + 1)-                                        pure old-                                if n == 0 then liftIO (E.throwIO (userError "one-shot handler failure")) else pure AckOk-                            processor = if guarded then kirokuProcessor adapter handler else mkProcessor adapter handler-                        app <- runApp defaultAppConfig [(ProcessorId "ack-liveness", processor)] >>= either (liftIO . fail . show) pure-                        liftIO $ waitForCount count 3 5_000_000-                        liftIO $ waitForCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-liveness") 0) (GlobalPosition 2)-                        stopApp app-                    recorded <- reverse <$> readIORef attempts-                    map (\(pos, attempt', _) -> (pos, attempt')) recorded-                        `shouldBe` [(1, Just (Attempt 0)), (1, Just (Attempt 1)), (2, Just (Attempt 0))]-                    case recorded of-                        (_, _, firstAt) : (_, _, retriedAt) : _ ->-                            if guarded-                                then retriedAt - firstAt `shouldSatisfy` (>= 900_000_000)-                                else retriedAt - firstAt `shouldSatisfy` (< 900_000_000)-                        _ -> expectationFailure "expected three finalized deliveries"--        forM_ [Nothing, Just 0.05] $ \warnAfter ->-            it ("keeps a raw unfinalized item pending and forwards warning policy " <> show warnAfter) $ do-                warnings <- newTVarIO (0 :: Int)-                warning <- STM.newEmptyTMVarIO-                let observe event = case event of-                        KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> atomically $ do-                            STM.modifyTVar' warnings (+ 1)-                            _ <- STM.tryPutTMVar warning (pos, elapsed)-                            pure ()-                        _ -> pure ()-                withMigratedTestDatabase $ \connStr ->-                    withStore (defaultConnectionSettings connStr & #eventHandler .~ Just observe) $ \store -> do-                        Right _ <--                            runStoreIO store $-                                appendToStream-                                    (StreamName "ack-raw")-                                    NoStream-                                    [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]-                        within "raw acknowledgement ownership" $ runEff $ runTracingNoop $ Timeout.runTimeout $ do-                            adapter <--                                kirokuAdapter-                                    store-                                    ( (defaultKirokuAdapterConfig (SubscriptionName "ack-raw") AllStreams)-                                        & #handlerStallWarnAfter .~ warnAfter-                                    )-                            let Adapter{source = sourceStream, shutdown = shutdownAction} = adapter-                            first <- Stream.uncons sourceStream-                            case first of-                                Nothing -> liftIO (expectationFailure "expected first item")-                                Just (item, rest) -> do-                                    -- The raw consumer intentionally leaves item unfinalized.-                                    secondRead <- Timeout.timeout 150_000 (Stream.uncons rest)-                                    liftIO (isNothing secondRead `shouldBe` True)-                                    liftIO $ readCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-raw") 0) `shouldReturn` Just (GlobalPosition 0)-                                    case warnAfter of-                                        Nothing -> liftIO $ STM.readTVarIO warnings `shouldReturn` 0-                                        Just threshold -> liftIO $ do-                                            (pos, elapsed) <- within "raw ack warning" (atomically (STM.readTMVar warning))-                                            pos `shouldBe` GlobalPosition 1-                                            elapsed `shouldSatisfy` (>= threshold)-                                    -- Finalization remains explicitly owned by this consumer.-                                    let Ingested{ack = AckHandle{finalize = finalizeFirst}} = item-                                    finalizeFirst AckOk-                                    (second, _) <- Stream.uncons rest >>= maybe (liftIO (fail "expected second item")) pure-                                    let Ingested{envelope = Envelope{payload = secondEvent}} = second-                                    liftIO $ globalPos secondEvent `shouldBe` 2-                                    let Ingested{ack = AckHandle{finalize = finalizeSecond}} = second-                                    finalizeSecond AckOk-                                    liftIO $ waitForCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-raw") 0) (GlobalPosition 2)-                            shutdownAction-                        stoppedCount <- STM.readTVarIO warnings-                        threadDelay 100_000-                        STM.readTVarIO warnings `shouldReturn` stoppedCount--    describe "retry policy" $ do-        forM_ [Nothing, Just 2] $ \attemptLimit ->-            it ("honors single-adapter total delivery limit " <> show attemptLimit) $ withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "ack-policy") NoStream [makeEvent "Retry" (Aeson.object [])]-                count <- newTVarIO (0 :: Int)-                runEff $ runTracingNoop $ do-                    let config =-                            (defaultKirokuAdapterConfig (SubscriptionName "ack-policy") AllStreams)-                                & #retryPolicy .~ maybe defaultRetryPolicy RetryPolicy attemptLimit-                    adapter <- kirokuAdapter store config-                    let handler _ = liftIO (atomically (STM.modifyTVar' count (+ 1))) >> pure (AckRetry (Ack.RetryDelay 0))-                    app <- runApp defaultAppConfig [(ProcessorId "ack-policy", kirokuProcessor adapter handler)] >>= either (liftIO . fail . show) pure-                    liftIO $ waitForCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-policy") 0) (GlobalPosition 1)-                    stopApp app-                STM.readTVarIO count `shouldReturn` maybe 5 Prelude.id attemptLimit-                letters <- readDeadLetters store "ack-policy"-                map SQL.deadLetterAttemptCount letters `shouldBe` [fromIntegral (maybe 5 Prelude.id attemptLimit)]--        it "forwards two attempts and stall policy to both consumer-group members" $ do-            warned <- newTVarIO Set.empty-            release <- STM.newEmptyTMVarIO-            let observe event = case event of-                    KirokuEventSubscriptionHandlerStalled _ _ _ _ (GroupMember member' 2) -> atomically (STM.modifyTVar' warned (Set.insert member'))-                    _ -> pure ()-            withMigratedTestDatabase $ \connStr ->-                withStore (defaultConnectionSettings connStr & #eventHandler .~ Just observe) $ \store -> do-                    -- Enough distinct streams to exercise both PostgreSQL hash slots;-                    -- require both warnings before release so an empty slot fails.-                    forM_ [1 .. 20 :: Int] $ \i -> do-                        Right _ <- runStoreIO store $ appendToStream (StreamName ("ack-group-" <> T.pack (show i))) NoStream [makeEvent "Retry" (Aeson.object [])]-                        pure ()-                    seen <- newIORef Map.empty-                    runEff $ runTracingNoop $ do-                        let config =-                                (defaultConsumerGroupConfig (SubscriptionName "ack-policy-group") AllStreams (validGroupSize 2))-                                    & #retryPolicy .~ RetryPolicy 2-                                    & #handlerStallWarnAfter .~ Just 0.05-                            handler ingested = do-                                liftIO $ atomically (STM.readTMVar release)-                                liftIO $ atomicModifyIORef' seen (\old -> (Map.insertWith (+) (globalPos (envelopePayload ingested)) (1 :: Int) old, ()))-                                pure (AckRetry (Ack.RetryDelay 0))-                        processors <- kirokuConsumerGroupProcessors store config handler >>= either (liftIO . fail . show) pure-                        app <- runApp defaultAppConfig processors >>= either (liftIO . fail . show) pure-                        liftIO $ within "both group members stalled" $ atomically $ readTVar warned >>= STM.check . (== Set.fromList [0, 1])-                        liftIO $ atomically (STM.putTMVar release ())-                        liftIO $ within "all group dead letters" $ do-                            let loop = do-                                    letters <- concat <$> mapM (readMemberDeadLetters store "ack-policy-group") [0, 1]-                                    if length letters == 20 then pure () else threadDelay 10_000 >> loop-                            loop-                        stopApp app-                    Map.elems <$> readIORef seen `shouldReturn` replicate 20 2-                    letters <- concat <$> mapM (readMemberDeadLetters store "ack-policy-group") [0, 1]-                    sort (map SQL.deadLetterGlobalPosition letters) `shouldBe` [1 .. 20]-                    map SQL.deadLetterAttemptCount letters `shouldBe` replicate 20 2+import Shibuya.Adapter.Kiroku+  ( consumerGroupPolicy,+    defaultConsumerGroupConfig,+    defaultKirokuAdapterConfig,+    guardKirokuHandlerWith,+    kirokuAdapter,+    kirokuConsumerGroupProcessors,+    kirokuConsumerGroupProcessorsWith,+    kirokuProcessor,+  )+import Shibuya.Adapter.Kiroku.Convert+  ( KirokuEnvelopeAttrs,+    kirokuEnvelopeAttrs,+    toEnvelope,+    toIngestedAck,+    toKirokuDeadLetterReason,+  )+import Shibuya.Adapter.Kiroku.Internal (acquireAllAndTransfer)+import Shibuya.App+  ( ProcessorId (..),+    QueueProcessor (..),+    defaultAppConfig,+    getAppMetrics,+    mkProcessor,+    runApp,+    stopApp,+    stopAppGracefully,+    waitApp,+  )+import Shibuya.App qualified as Shibuya+import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..))+import Shibuya.Core.Ack qualified as Ack+import Shibuya.Core.AckHandle (AckHandle (..))+import Shibuya.Core.Error (PolicyError (..))+import Shibuya.Core.Ingested (Ingested (..), Message (..))+import Shibuya.Core.Metrics (ProcessorState (..))+import Shibuya.Core.Types (Attempt (..), Envelope (..))+import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))+import Shibuya.Telemetry.Effect (runTracingNoop)+import Streamly.Data.Fold qualified as Fold+import Streamly.Data.Stream qualified as Stream+import System.Timeout (timeout)+import Test.Hspec++main :: IO ()+main = withSharedMigratedPostgres $ hspec $ do+  describe "toEnvelope" $ do+    it "copies W3C trace metadata into Shibuya trace headers" $ do+      let traceparent :: Text+          traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"+          tracestate :: Text+          tracestate = "rojo=00f067aa0ba902b7"+          Envelope {traceContext} =+            toEnvelope+              sampleEnvelopeAttrs+              ( makeRecordedEvent+                  ( Just $+                      Aeson.object+                        [ "traceparent" Aeson..= traceparent,+                          "tracestate" Aeson..= tracestate,+                          "other" Aeson..= ("preserved" :: Text)+                        ]+                  )+              )++      traceContext+        `shouldBe` Just+          [ ("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),+            ("tracestate", "rojo=00f067aa0ba902b7")+          ]++    it "omits trace headers when traceparent is absent or not a string" $ do+      let Envelope {traceContext = missingTraceparent} =+            toEnvelope sampleEnvelopeAttrs (makeRecordedEvent (Just (Aeson.object ["tracestate" Aeson..= ("state" :: Text)])))+          Envelope {traceContext = nonStringTraceparent} =+            toEnvelope sampleEnvelopeAttrs (makeRecordedEvent (Just (Aeson.object ["traceparent" Aeson..= Aeson.Number 1])))++      missingTraceparent `shouldBe` Nothing+      nonStringTraceparent `shouldBe` Nothing++    it "does not surface broker headers (headers is Nothing), even with trace metadata" $ do+      let Envelope {headers = noMeta} =+            toEnvelope sampleEnvelopeAttrs (makeRecordedEvent Nothing)+          Envelope {headers = withTraceMeta} =+            toEnvelope+              sampleEnvelopeAttrs+              (makeRecordedEvent (Just (Aeson.object ["traceparent" Aeson..= ("00-abc-def-01" :: Text)])))++      noMeta `shouldBe` Nothing+      withTraceMeta `shouldBe` Nothing++    it "stamps kiroku identity attributes for a non-grouped subscription (EP-5 M2)" $ do+      let attrs = kirokuEnvelopeAttrs "orders-proj" Nothing+          Envelope {attributes} = toEnvelope attrs (makeRecordedEvent Nothing)+      -- makeRecordedEvent: eventType "TraceEvent", globalPosition 1.+      HashMap.lookup "kiroku.subscription.name" attributes+        `shouldBe` Just (toAttribute ("orders-proj" :: Text))+      HashMap.lookup "messaging.system" attributes+        `shouldBe` Just (toAttribute ("kiroku" :: Text))+      HashMap.lookup "messaging.destination.name" attributes+        `shouldBe` Just (toAttribute ("orders-proj" :: Text))+      HashMap.lookup "kiroku.event.type" attributes+        `shouldBe` Just (toAttribute ("TraceEvent" :: Text))+      HashMap.lookup "kiroku.event.global_position" attributes+        `shouldBe` Just (toAttribute (1 :: Int64))+      -- No member key for a non-grouped subscription.+      HashMap.lookup "kiroku.consumer_group.member" attributes `shouldBe` Nothing++    it "stamps the consumer-group member attribute for a grouped subscription (EP-5 M2)" $ do+      let attrs = kirokuEnvelopeAttrs "orders-proj" (Just 2)+          Envelope {attributes} = toEnvelope attrs (makeRecordedEvent Nothing)+      HashMap.lookup "kiroku.subscription.name" attributes+        `shouldBe` Just (toAttribute ("orders-proj" :: Text))+      HashMap.lookup "kiroku.consumer_group.member" attributes+        `shouldBe` Just (toAttribute (2 :: Int64))++  describe "toKirokuDeadLetterReason" $ do+    it "keeps the framework reasons on their dedicated Kiroku constructors" $ do+      toKirokuDeadLetterReason 3 (PoisonPill "bad event")+        `shouldBe` KTypes.DeadLetterPoison "bad event"+      toKirokuDeadLetterReason 3 (InvalidPayload "not json")+        `shouldBe` KTypes.DeadLetterInvalid "not json"+      toKirokuDeadLetterReason 3 MaxRetriesExceeded+        `shouldBe` KTypes.DeadLetterMaxAttempts 3++    it "maps an application failure to DeadLetterOther with a queryable code (shibuya-core 0.9)" $ do+      code <-+        either (E.throwIO . userError . T.unpack) pure $+          Ack.mkDeadLetterCode "billing.card_declined"+      let reason =+            toKirokuDeadLetterReason 1 (Ack.ApplicationFailure code "card declined by issuer")++      KTypes.deadLetterSummary reason+        `shouldBe` "billing.card_declined: card declined by issuer"+      KTypes.deadLetterReasonJson reason+        `shouldBe` Aeson.object+          [ "kind" Aeson..= ("other" :: Text),+            "summary" Aeson..= ("billing.card_declined: card declined by issuer" :: Text),+            "detail"+              Aeson..= Aeson.object+                [ "code" Aeson..= ("billing.card_declined" :: Text),+                  "detail" Aeson..= ("card declined by issuer" :: Text)+                ]+          ]++  describe "consumer group policy" $ do+    it "accepts Serial member concurrency as (PartitionedInOrder, Serial)" $+      consumerGroupPolicy Serial `shouldBe` Right (PartitionedInOrder, Serial)++    it "rejects Ahead member concurrency with a PolicyError" $+      consumerGroupPolicy (Ahead 4)+        `shouldBe` Left (InvalidPolicyCombo "StrictInOrder requires Serial concurrency")++    it "rejects Async member concurrency with a PolicyError" $+      consumerGroupPolicy (Async 4)+        `shouldBe` Left (InvalidPolicyCombo "StrictInOrder requires Serial concurrency")++  acknowledgementSpec+  around withTestStore $ do+    describe "kirokuAdapter" $ do+      it "delivers only matching event types when an eventTypeFilter is set (EP-43)" $ \store -> do+        -- A, B, A, B, A on one stream (global positions 1..5). The adapter+        -- is filtered to type A; the Shibuya handler must see only the As.+        let events =+              [ makeEvent "A" (Aeson.object []),+                makeEvent "B" (Aeson.object []),+                makeEvent "A" (Aeson.object []),+                makeEvent "B" (Aeson.object []),+                makeEvent "A" (Aeson.object [])+              ]+        Right _ <- runStoreIO store $ appendToStream (StreamName "etf-adapter-1") NoStream events+        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)+        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "etf-adapter") AllStreams+                & #eventTypeFilter .~ OnlyEventTypes (Set.fromList [EventType "A"])+          let handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                pure AckOk+          res <- runApp defaultAppConfig [(ProcessorId "etf", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 3 10_000_000+              stopApp appHandle++        collected <- readIORef ref+        -- Only the three A events reached the handler; no Bs.+        map (^. #eventType) collected `shouldBe` replicate 3 (EventType "A")+        map globalPos (reverse collected) `shouldBe` [1, 3, 5]++      it "delivers only matching types across a filtered consumer group (EP-43)" $ \store -> do+        -- 20 streams in category "etfg", each [A, B]; A at odd global+        -- positions 1,3,..,39. A size-4 group filtered to type A must+        -- deliver exactly the 20 As, disjoint and complete.+        let streams = [0 .. 19 :: Int]+        mapM_+          ( \i -> do+              let sn = StreamName ("etfg-" <> T.pack (show i))+              Right _ <-+                runStoreIO store $+                  appendToStream sn NoStream [makeEvent "A" (Aeson.object []), makeEvent "B" (Aeson.object [])]+              pure ()+          )+          streams+        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)+        runEff $ runTracingNoop $ do+          let cfg =+                defaultConsumerGroupConfig (SubscriptionName "etfg") (Category (CategoryName "etfg")) (validGroupSize 4)+                  & #eventTypeFilter .~ OnlyEventTypes (Set.fromList [EventType "A"])+              handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                pure AckOk+          res <- kirokuConsumerGroupProcessors store cfg handler+          case res of+            Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)+            Right processors -> do+              appRes <- runApp defaultAppConfig processors+              case appRes of+                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                Right appHandle -> do+                  liftIO $ waitForCount countVar 20 15_000_000+                  stopApp appHandle++        collected <- readIORef ref+        -- Every delivered event is an A (filter honored per member)...+        map (^. #eventType) collected `shouldBe` replicate 20 (EventType "A")+        -- ...and the union over members is the complete, disjoint set of A+        -- positions [1,3,..,39] — no A dropped, none delivered twice.+        sort (map globalPos collected) `shouldBe` [1, 3 .. 39]++      it "delivers only events matching an opaque selector (EP-43 follow-up)" $ \store -> do+        -- All one type "A" (so eventTypeFilter cannot distinguish them);+        -- only the payload tag {keep} differs. keep, skip, keep, skip, keep+        -- at positions 1..5. The selector admits only keep=True.+        let keepObj = Aeson.object [("keep", Aeson.Bool True)]+            skipObj = Aeson.object [("keep", Aeson.Bool False)]+            events =+              [ makeEvent "A" keepObj,+                makeEvent "A" skipObj,+                makeEvent "A" keepObj,+                makeEvent "A" skipObj,+                makeEvent "A" keepObj+              ]+        Right _ <- runStoreIO store $ appendToStream (StreamName "sel-adapter-1") NoStream events+        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)+        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "sel-adapter") AllStreams+                & #selector .~ Just (\e -> (e ^. #payload) == keepObj)+          let handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                pure AckOk+          res <- runApp defaultAppConfig [(ProcessorId "sel", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 3 10_000_000+              stopApp appHandle++        collected <- readIORef ref+        -- Only the three keep events reached the handler (positions 1,3,5);+        -- the two skip events were filtered worker-side and never delivered.+        map globalPos (reverse collected) `shouldBe` [1, 3, 5]++      it "delivers catch-up events through Shibuya pipeline" $ \store -> do+        let events = map (\i -> makeEvent ("CU" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-catchup-1") NoStream events+        threadDelay 200_000++        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "shibuya-catchup-test") AllStreams++          let handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                pure AckOk++          res <- runApp defaultAppConfig [(ProcessorId "catchup", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 10 10_000_000+              stopApp appHandle++        collected <- readIORef ref+        length collected `shouldBe` 10++      it "supports a future-only FromCurrentHead adapter" $ \store -> do+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "shibuya-future-only-events")+              NoStream+              (map (\i -> makeEvent ("Historical" <> T.pack (show i)) (Aeson.object [])) [1 .. 4 :: Int])+        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "shibuya-future-only") AllStreams+                & #missingCheckpointPolicy .~ FromCurrentHead+          liftIO $+            waitForCheckpointPosition+              store+              (SubscriptionCheckpointKey (SubscriptionName "shibuya-future-only") 0)+              (GlobalPosition 4)+          liftIO $ do+            Right _ <-+              runStoreIO store $+                appendToStream+                  (StreamName "shibuya-future-only-events")+                  StreamExists+                  [makeEvent "Future" (Aeson.object [])]+            pure ()+          let handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    count <- readTVar countVar+                    writeTVar countVar (count + 1)+                pure AckOk+          result <- runApp defaultAppConfig [(ProcessorId "future-only", mkProcessor adapter handler)]+          case result of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 1 10_000_000+              stopApp appHandle++        collected <- reverse <$> readIORef ref+        map globalPos collected `shouldBe` [5]++      it "preserves an existing checkpoint when restarted with FromCurrentHead" $ \store -> do+        let subscriptionName = SubscriptionName "shibuya-existing-checkpoint"+            checkpointKey = SubscriptionCheckpointKey subscriptionName 0+            config policy =+              defaultKirokuAdapterConfig subscriptionName AllStreams+                & #missingCheckpointPolicy .~ policy+        Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-existing-checkpoint-events") NoStream [makeEvent "First" (Aeson.object [])]+        firstSeen <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <- kirokuAdapter store (config FromBeginning)+          result <-+            runApp+              defaultAppConfig+              [ ( ProcessorId "existing-checkpoint-first",+                  mkProcessor adapter $ \_ -> do+                    liftIO $ atomically $ writeTVar firstSeen 1+                    pure AckOk+                )+              ]+          case result of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount firstSeen 1 10_000_000+              liftIO $ waitForCheckpointPosition store checkpointKey (GlobalPosition 1)+              stopApp appHandle++        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "shibuya-existing-checkpoint-events")+              StreamExists+              [makeEvent "Second" (Aeson.object [])]+        replayed <- newIORef ([] :: [Int64])+        within "existing checkpoint restart" $+          runEff $+            runTracingNoop $ do+              adapter <- kirokuAdapter store (config FromCurrentHead)+              result <-+                runApp+                  defaultAppConfig+                  [ ( ProcessorId "existing-checkpoint-restart",+                      mkProcessor adapter $ \ingested -> do+                        liftIO $ modifyIORef' replayed (globalPos (envelopePayload ingested) :)+                        pure (AckHalt (HaltFatal "existing checkpoint observed"))+                    )+                  ]+              case result of+                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                Right appHandle -> waitApp appHandle++        readIORef replayed `shouldReturn` [2]++      it "surfaces FailIfMissing without leaving a subscription registered" $ \store -> do+        let subscriptionName = SubscriptionName "shibuya-required-checkpoint"+            checkpointKey = SubscriptionCheckpointKey subscriptionName 0+            config =+              defaultKirokuAdapterConfig subscriptionName AllStreams+                & #missingCheckpointPolicy .~ FailIfMissing+        outcome <-+          E.try $+            within "FailIfMissing adapter refusal" $+              runEff $+                runTracingNoop $ do+                  adapter <- kirokuAdapter store config+                  let Adapter {source = sourceStream} = adapter+                  Stream.fold Fold.drain sourceStream+        case outcome of+          Left exception+            | Just (SubscriptionCheckpointMissing actual) <- E.fromException (exception :: E.SomeException) ->+                actual `shouldBe` checkpointKey+          Left exception -> expectationFailure ("expected SubscriptionCheckpointMissing, got: " <> show exception)+          Right () -> expectationFailure "expected a missing-checkpoint refusal"+        Map.null <$> subscriptionStates store `shouldReturn` True++      it "delivers live events through Shibuya pipeline" $ \store -> do+        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "shibuya-live-test") AllStreams++          let handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                pure AckOk++          res <- runApp defaultAppConfig [(ProcessorId "live", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ threadDelay 200_000+              liftIO $ do+                let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+                Right _ <- runStoreIO store $ appendToStream (StreamName "shibuya-live-1") NoStream events+                pure ()+              liftIO $ waitForCount countVar 5 10_000_000+              stopApp appHandle++        collected <- readIORef ref+        length collected `shouldBe` 5++      it "survives an append burst while the handler is paused" $ \store -> do+        firstSeen <- newEmptyMVar+        release <- newEmptyMVar+        countVar <- newTVarIO (0 :: Int)+        seenRef <- newIORef ([] :: [RecordedEvent])++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "burst-pause-resume") AllStreams+                & #queueCapacity .~ 1++          let handler ingested = do+                n <- liftIO $ do+                  modifyIORef' seenRef (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    let c' = c + 1+                    writeTVar countVar c'+                    pure c'+                if n == 1+                  then liftIO $ putMVar firstSeen () >> takeMVar release+                  else pure ()+                pure AckOk++          res <- runApp defaultAppConfig [(ProcessorId "burst", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ do+                let appendOne i =+                      runStoreIO store $+                        appendToStream+                          (StreamName ("burst-" <> T.pack (show (i :: Int))))+                          NoStream+                          [makeEvent ("Burst" <> T.pack (show i)) (Aeson.object [])]+                Right _ <- appendOne 1+                takeMVar firstSeen+                mapM_ (\i -> appendOne i >>= (`shouldSatisfy` either (const False) (const True))) [2 .. 40 :: Int]+                putMVar release ()+                waitForCount countVar 40 15_000_000+              stopApp appHandle++        seen <- readIORef seenRef+        sort (map globalPos seen) `shouldBe` [1 .. 40]++      it "ends adapter.source cleanly after shutdown" $ \store -> do+        within "adapter source clean shutdown" $+          runEff $+            runTracingNoop $ do+              adapter <- kirokuAdapter store $ defaultKirokuAdapterConfig (SubscriptionName "source-clean-end") AllStreams+              let Adapter {source = sourceStream, shutdown = shutdownAction} = adapter+              shutdownAction+              Stream.fold Fold.drain sourceStream++      it "rethrows worker crashes through adapter.source" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "source-crash-1") NoStream [makeEvent "SourceCrash" (Aeson.object [])]+        threadDelay 200_000+        let injectBoom _ _ = E.throwIO (userError "adapter source worker boom")+        result <-+          withFetchBatchHookForTest injectBoom $+            E.try $+              within "adapter source worker crash" $+                runEff $+                  runTracingNoop $ do+                    adapter <- kirokuAdapter store $ defaultKirokuAdapterConfig (SubscriptionName "source-crash") AllStreams+                    let Adapter {source = sourceStream} = adapter+                    Stream.fold Fold.drain sourceStream+        case result of+          Left (e :: E.SomeException) -> show e `shouldContain` "adapter source worker boom"+          Right _ -> expectationFailure "expected adapter.source to rethrow the worker exception"++      it "runs multiple category subscriptions concurrently" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "orders-s1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "orders-s2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "payments-s1") NoStream [makeEvent "PaymentReceived" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "inventory-s1") NoStream [makeEvent "StockUpdated" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "inventory-s2") NoStream [makeEvent "StockDepleted" (Aeson.object [])]+        threadDelay 200_000++        ordersRef <- newIORef ([] :: [RecordedEvent])+        paymentsRef <- newIORef ([] :: [RecordedEvent])+        inventoryRef <- newIORef ([] :: [RecordedEvent])+        ordersCount <- newTVarIO (0 :: Int)+        paymentsCount <- newTVarIO (0 :: Int)+        inventoryCount <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          let mkCatAdapter nm cat =+                kirokuAdapter store $+                  defaultKirokuAdapterConfig (SubscriptionName nm) (Category (CategoryName cat))+          let mkHandler ref' cVar ingested = do+                liftIO $ do+                  modifyIORef' ref' (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar cVar+                    writeTVar cVar (c + 1)+                pure AckOk++          ordersAdapter <- mkCatAdapter "orders-proj" "orders"+          paymentsAdapter <- mkCatAdapter "payments-proj" "payments"+          inventoryAdapter <- mkCatAdapter "inventory-proj" "inventory"++          res <-+            runApp+              defaultAppConfig+              [ (ProcessorId "orders", mkProcessor ordersAdapter (mkHandler ordersRef ordersCount)),+                (ProcessorId "payments", mkProcessor paymentsAdapter (mkHandler paymentsRef paymentsCount)),+                (ProcessorId "inventory", mkProcessor inventoryAdapter (mkHandler inventoryRef inventoryCount))+              ]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ do+                waitForCount ordersCount 2 10_000_000+                waitForCount paymentsCount 1 10_000_000+                waitForCount inventoryCount 2 10_000_000+              stopApp appHandle++        ordersCollected <- readIORef ordersRef+        paymentsCollected <- readIORef paymentsRef+        inventoryCollected <- readIORef inventoryRef+        length ordersCollected `shouldBe` 2+        length paymentsCollected `shouldBe` 1+        length inventoryCollected `shouldBe` 2++      it "isolates a failing subscription from healthy ones" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "good-s1") NoStream (map (\i -> makeEvent ("Good" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int])+        Right _ <- runStoreIO store $ appendToStream (StreamName "bad-s1") NoStream [makeEvent "Bad1" (Aeson.object []), makeEvent "Bad2" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "other-s1") NoStream [makeEvent "Other1" (Aeson.object []), makeEvent "Other2" (Aeson.object [])]+        threadDelay 200_000++        goodRef <- newIORef ([] :: [RecordedEvent])+        otherRef <- newIORef ([] :: [RecordedEvent])+        goodCount <- newTVarIO (0 :: Int)+        otherCount <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          goodAdapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "good-proj") (Category (CategoryName "good"))+          badAdapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "bad-proj") (Category (CategoryName "bad"))+          otherAdapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "other-proj") (Category (CategoryName "other"))++          let goodHandler ingested = do+                liftIO $ do+                  modifyIORef' goodRef (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar goodCount+                    writeTVar goodCount (c + 1)+                pure AckOk++          let badHandler _ingested = do+                liftIO $ error "handler crash!"++          let otherHandler ingested = do+                liftIO $ do+                  modifyIORef' otherRef (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar otherCount+                    writeTVar otherCount (c + 1)+                pure AckOk++          res <-+            runApp+              defaultAppConfig+              [ (ProcessorId "good", mkProcessor goodAdapter goodHandler),+                (ProcessorId "bad", mkProcessor badAdapter badHandler),+                (ProcessorId "other", mkProcessor otherAdapter otherHandler)+              ]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ do+                waitForCount goodCount 3 10_000_000+                waitForCount otherCount 2 10_000_000+                threadDelay 500_000+              metrics <- getAppMetrics appHandle+              case Map.lookup (ProcessorId "bad") metrics of+                Just m -> case m ^. #state of+                  Failed _ _ -> pure ()+                  other -> liftIO $ expectationFailure ("Expected Failed state, got: " <> show other)+                Nothing -> liftIO $ expectationFailure "No metrics for bad processor"+              stopApp appHandle++        goodCollected <- readIORef goodRef+        otherCollected <- readIORef otherRef+        length goodCollected `shouldBe` 3+        length otherCollected `shouldBe` 2++      it "shuts down all subscriptions coordinately" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "shut-a") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "shut-b") NoStream [makeEvent "B" (Aeson.object [])]+        threadDelay 200_000++        countA <- newTVarIO (0 :: Int)+        countB <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapterA <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "shut-a-proj") (Category (CategoryName "shut"))+          adapterB <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "shut-b-proj") (Category (CategoryName "shut"))++          let handlerA _ingested = do+                liftIO $ atomically $ do+                  c <- readTVar countA+                  writeTVar countA (c + 1)+                pure AckOk++          let handlerB _ingested = do+                liftIO $ atomically $ do+                  c <- readTVar countB+                  writeTVar countB (c + 1)+                pure AckOk++          res <-+            runApp+              defaultAppConfig+              [ (ProcessorId "a", mkProcessor adapterA handlerA),+                (ProcessorId "b", mkProcessor adapterB handlerB)+              ]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ do+                waitForCount countA 2 10_000_000+                waitForCount countB 2 10_000_000+              drained <- stopAppGracefully Shibuya.defaultShutdownConfig appHandle+              liftIO $ drained `shouldBe` True++    describe "ack dispositions" $ do+      it "keeps the first Kiroku reply when an ack is finalized twice" $ \_store -> do+        reply <- newEmptyTMVarIO+        cancellations <- newIORef (0 :: Int)+        let Ingested {ack = AckHandle {finalize}} =+              toIngestedAck+                sampleEnvelopeAttrs+                (modifyIORef' cancellations (+ 1))+                (AckItem (makeRecordedEvent Nothing) 0 reply)++        runEff $ do+          finalize AckOk+          finalize (AckDeadLetter (PoisonPill "late duplicate"))++        atomically (readTMVar reply) `shouldReturn` Continue+        readIORef cancellations `shouldReturn` 0++      it "AckRetry redelivers the same event, then AckOk advances (EP-40 M3)" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "ackretry-1") NoStream [makeEvent "R1" (Aeson.object [])]+        threadDelay 200_000++        deliveries <- newIORef (0 :: Int)+        countVar <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "ackretry-proj") AllStreams++          let handler _ingested = do+                n <- liftIO $ do+                  modifyIORef' deliveries (+ 1)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                    pure (c + 1)+                -- Retry the first delivery, accept the second.+                pure (if n == 1 then AckRetry (Ack.RetryDelay 0) else AckOk)++          res <- runApp defaultAppConfig [(ProcessorId "ackretry", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 2 10_000_000+              stopApp appHandle++        -- The one event was delivered twice (initial + one retry).+        n <- readIORef deliveries+        n `shouldBe` 2+        -- It was not dead-lettered (the retry succeeded).+        dls <- readDeadLetters store "ackretry-proj"+        length dls `shouldBe` 0++      it "AckHalt leaves the checkpoint behind the event and restart replays it" $ \store -> do+        let subscriptionName = SubscriptionName "ackhalt-replay-proj"+            checkpointKey = SubscriptionCheckpointKey subscriptionName 0+        Right _ <- runStoreIO store $ appendToStream (StreamName "ackhalt-replay-1") NoStream [makeEvent "H1" (Aeson.object [])]+        threadDelay 200_000++        deliveries <- newIORef ([] :: [Int64])+        let runHaltingProcessor label =+              within label $+                runEff $+                  runTracingNoop $ do+                    adapter <-+                      kirokuAdapter store $+                        defaultKirokuAdapterConfig subscriptionName AllStreams+                    result <-+                      runApp+                        defaultAppConfig+                        [ ( ProcessorId "ackhalt-replay",+                            mkProcessor adapter $ \ingested -> do+                              liftIO $ modifyIORef' deliveries (globalPos (envelopePayload ingested) :)+                              pure (AckHalt (HaltFatal "intentional halt"))+                          )+                        ]+                    case result of+                      Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                      Right appHandle -> waitApp appHandle++        runHaltingProcessor "first AckHalt"+        readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)++        runHaltingProcessor "AckHalt restart"+        reverse <$> readIORef deliveries `shouldReturn` [1, 1]+        readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)++      it "shutdown after an ack but before checkpoint persistence replays without leaking" $ \store -> do+        let subscriptionName = SubscriptionName "checkpoint-cancel-replay-proj"+            checkpointKey = SubscriptionCheckpointKey subscriptionName 0+        Right _ <- runStoreIO store $ appendToStream (StreamName "checkpoint-cancel-replay-1") NoStream [makeEvent "C1" (Aeson.object [])]+        threadDelay 200_000++        saveStarted <- newEmptyMVar+        holdSave <- newEmptyMVar+        firstDelivery <- newTVarIO (0 :: Int)+        let pauseSave config _+              | KTypes.name config == subscriptionName = putMVar saveStarted () >> takeMVar holdSave+              | otherwise = pure ()++        withSaveCheckpointHookForTest pauseSave $+          within "shutdown during checkpoint persistence" $+            runEff $+              runTracingNoop $ do+                adapter <-+                  kirokuAdapter store $+                    defaultKirokuAdapterConfig subscriptionName AllStreams+                result <-+                  runApp+                    defaultAppConfig+                    [ ( ProcessorId "checkpoint-cancel",+                        mkProcessor adapter $ \_ -> do+                          liftIO $ atomically $ do+                            count <- readTVar firstDelivery+                            writeTVar firstDelivery (count + 1)+                          pure AckOk+                      )+                    ]+                case result of+                  Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                  Right appHandle -> do+                    liftIO $ waitForCount firstDelivery 1 10_000_000+                    liftIO $ takeMVar saveStarted+                    stopApp appHandle++        readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)+        Map.null <$> subscriptionStates store `shouldReturn` True++        replayed <- newIORef ([] :: [Int64])+        within "restart after interrupted checkpoint" $+          runEff $+            runTracingNoop $ do+              adapter <-+                kirokuAdapter store $+                  defaultKirokuAdapterConfig subscriptionName AllStreams+              result <-+                runApp+                  defaultAppConfig+                  [ ( ProcessorId "checkpoint-replay",+                      mkProcessor adapter $ \ingested -> do+                        liftIO $ modifyIORef' replayed (globalPos (envelopePayload ingested) :)+                        pure (AckHalt (HaltFatal "replay observed"))+                    )+                  ]+              case result of+                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                Right appHandle -> waitApp appHandle++        readIORef replayed `shouldReturn` [1]+        readCheckpointPosition store checkpointKey `shouldReturn` Just (GlobalPosition 0)+        Map.null <$> subscriptionStates store `shouldReturn` True++      it "AckDeadLetter records the event and the next event continues (EP-40 M3)" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "ackdl-1") NoStream [makeEvent "D1" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "ackdl-2") NoStream [makeEvent "D2" (Aeson.object [])]+        threadDelay 200_000++        okPayloads <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "ackdl-proj") AllStreams++          let handler ingested = do+                n <- liftIO $ atomically $ do+                  c <- readTVar countVar+                  writeTVar countVar (c + 1)+                  pure (c + 1)+                -- Dead-letter the first event; accept the second.+                if n == 1+                  then pure (AckDeadLetter (PoisonPill "poison"))+                  else do+                    liftIO $ modifyIORef' okPayloads (envelopePayload ingested :)+                    pure AckOk++          res <- runApp defaultAppConfig [(ProcessorId "ackdl", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 2 10_000_000+              stopApp appHandle++        -- The first event (global position 1) is recorded as dead-letter.+        dls <- readDeadLetters store "ackdl-proj"+        map SQL.deadLetterGlobalPosition dls `shouldBe` [1]+        -- The second event was delivered and accepted.+        oks <- readIORef okPayloads+        map globalPos oks `shouldBe` [2]++      it "guardKirokuHandlerWith retries a transient handler exception and preserves attempts" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "guardretry-1") NoStream [makeEvent "GR1" (Aeson.object [])]+        threadDelay 200_000++        deliveries <- newIORef (0 :: Int)+        attempts <- newIORef ([] :: [Maybe Attempt])+        countVar <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "guardretry-proj") AllStreams++          let throwingOnce ingested = do+                let Message {envelope = Envelope {attempt = deliveryAttempt}} = ingested+                n <- liftIO $ do+                  modifyIORef' attempts (deliveryAttempt :)+                  modifyIORef' deliveries (+ 1)+                  atomically $ do+                    c <- readTVar countVar+                    let c' = c + 1+                    writeTVar countVar c'+                    pure c'+                if n == 1+                  then liftIO $ E.throwIO (userError "transient projection failure")+                  else pure AckOk+              handler = guardKirokuHandlerWith (const (AckRetry (Ack.RetryDelay 0))) throwingOnce++          res <- runApp defaultAppConfig [(ProcessorId "guardretry", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount countVar 2 10_000_000+              stopApp appHandle++        n <- readIORef deliveries+        n `shouldBe` 2+        reverse <$> readIORef attempts `shouldReturn` [Just (Attempt 0), Just (Attempt 1)]+        dls <- readDeadLetters store "guardretry-proj"+        length dls `shouldBe` 0++      it "guardKirokuHandlerWith dead-letters a persistent handler exception and continues" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "guarddl-1") NoStream [makeEvent "GDL1" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "guarddl-2") NoStream [makeEvent "GDL2" (Aeson.object [])]+        threadDelay 200_000++        nextSeen <- newTVarIO (0 :: Int)++        runEff $ runTracingNoop $ do+          adapter <-+            kirokuAdapter store $+              defaultKirokuAdapterConfig (SubscriptionName "guarddl-proj") AllStreams++          let alwaysThrowFirst ingested =+                if globalPos (envelopePayload ingested) == 1+                  then liftIO $ E.throwIO (userError "persistent projection failure")+                  else do+                    liftIO $ atomically $ do+                      c <- readTVar nextSeen+                      writeTVar nextSeen (c + 1)+                    pure AckOk+              handler = guardKirokuHandlerWith (const (AckRetry (Ack.RetryDelay 0))) alwaysThrowFirst++          res <- runApp defaultAppConfig [(ProcessorId "guarddl", mkProcessor adapter handler)]+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForCount nextSeen 1 15_000_000+              stopApp appHandle++        dls <- readDeadLetters store "guarddl-proj"+        map SQL.deadLetterGlobalPosition dls `shouldBe` [1]++    describe "consumer groups" $ do+      it "four-member group delivers a disjoint, complete partition of the stream" $ \store -> do+        -- 20 streams × 2 events = 40 events, global positions 1..40, category "cg".+        let streams = ["cg-" <> T.pack (show i) | i <- [1 .. 20 :: Int]]+        mapM_+          ( \sn -> do+              let evs = [makeEvent ("EV" <> T.pack (show k)) (Aeson.object []) | k <- [1 .. 2 :: Int]]+              Right _ <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs+              pure ()+          )+          streams+        threadDelay 200_000++        -- One IORef + one TVar counter per member.+        refs <- mapM (const (newIORef ([] :: [RecordedEvent]))) [0 .. 3 :: Int]+        cvars <- mapM (const (newTVarIO (0 :: Int))) [0 .. 3 :: Int]++        runEff $ runTracingNoop $ do+          -- Build one adapter per member index; each carries the same+          -- subscriptionName and a distinct member of a size-4 group.+          adapters <-+            mapM+              ( \m ->+                  kirokuAdapter store $+                    defaultKirokuAdapterConfig (SubscriptionName "cg-shibuya-group") (Category (CategoryName "cg"))+                      & #consumerGroup .~ Just (validConsumerGroup m 4)+              )+              [0, 1, 2, 3]++          let mkHandler ref' cvar ingested = do+                liftIO $ do+                  modifyIORef' ref' (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar cvar+                    writeTVar cvar (c + 1)+                pure AckOk++              processors =+                [ ( ProcessorId ("cg-member-" <> T.pack (show m)),+                    mkProcessor (adapters !! m) (mkHandler (refs !! m) (cvars !! m))+                  )+                | m <- [0 .. 3 :: Int]+                ]++          res <- runApp defaultAppConfig processors+          case res of+            Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+            Right appHandle -> do+              liftIO $ waitForTotal cvars 40 15_000_000+              stopApp appHandle++        collected <- mapM readIORef refs+        -- Disjoint + complete: the union of every member's delivered global+        -- positions is exactly [1..40] — no event dropped, none delivered twice.+        let allPositions = sort (concatMap (map globalPos) collected)+        allPositions `shouldBe` [1 .. 40]+        -- No member starved: 20 streams over 4 members gives each ≥ 1 stream.+        mapM_ (\c -> length c `shouldSatisfy` (>= 1)) collected++    describe "consumer group policy" $ do+      it "rejects an invalid member concurrency before opening any subscription" $ \store -> do+        -- A request the helper cannot honor per member returns Left without+        -- starting any worker. The (unused) store proves no subscription opens.+        result <-+          runEff $+            runTracingNoop $+              kirokuConsumerGroupProcessors+                store+                ( defaultConsumerGroupConfig (SubscriptionName "cgp-reject") (Category (CategoryName "cgp-reject")) (validGroupSize 4)+                    & #memberConcurrency .~ Async 4+                )+                ( \ingested -> do+                    let _ = envelopePayload ingested+                    pure AckOk+                )+        case result of+          Left err -> err `shouldBe` InvalidPolicyCombo "StrictInOrder requires Serial concurrency"+          Right _ -> expectationFailure "expected Left PolicyError for Async member concurrency"++        withResult <-+          runEff $+            runTracingNoop $+              kirokuConsumerGroupProcessorsWith+                ( \_ -> do+                    liftIO $ expectationFailure "factory should not be called for an invalid policy"+                    pure stubAdapter+                )+                ( defaultConsumerGroupConfig (SubscriptionName "cgp-reject-with") (Category (CategoryName "cgp-reject-with")) (validGroupSize 4)+                    & #memberConcurrency .~ Async 4+                )+                ( \ingested -> do+                    let _ = envelopePayload ingested+                    pure AckOk+                )+        case withResult of+          Left err -> err `shouldBe` InvalidPolicyCombo "StrictInOrder requires Serial concurrency"+          Right _ -> expectationFailure "expected Left PolicyError for Async member concurrency"++      it "rejects non-positive group sizes at construction" $ \_store -> do+        mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)+        mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))++      it "shuts down every created member and preserves the factory failure when cleanup throws" $ \_store -> do+        shutdowns <- newIORef ([] :: [Int32])+        let sentinel = userError "member 2 failed"+            factory m+              | m == 2 = liftIO $ E.throwIO sentinel+              | otherwise =+                  pure+                    Adapter+                      { adapterName = "stub-kiroku",+                        source = Stream.nil,+                        shutdown = liftIO $ do+                          modifyIORef' shutdowns (<> [m])+                          if m == 1+                            then E.throwIO (userError "member 1 cleanup failed")+                            else pure ()+                      }+            handler ingested = do+              let _ = envelopePayload ingested+              pure AckOk+        result <-+          E.try $+            runEff $+              runTracingNoop $+                kirokuConsumerGroupProcessorsWith+                  factory+                  (defaultConsumerGroupConfig (SubscriptionName "cgp-partial-cleanup") AllStreams (validGroupSize 3))+                  handler+        case result of+          Left (e :: E.SomeException) -> show e `shouldContain` "member 2 failed"+          Right _ -> expectationFailure "expected the member factory failure to rethrow"+        readIORef shutdowns `shouldReturn` [1, 0]++      it "owns a returned member before cancellation can interrupt construction" $ \_store -> do+        acquired <- newEmptyMVar+        holdTransfer <- newEmptyMVar+        finished <- newEmptyMVar+        shutdowns <- newIORef ([] :: [Int32])+        maskingStates <- newIORef ([] :: [E.MaskingState])+        worker <-+          forkIO $ do+            outcome <-+              E.try $+                runEff $+                  acquireAllAndTransfer+                    2+                    pure+                    (\member -> liftIO $ modifyIORef' shutdowns (<> [member]))+                    ( \member _ -> liftIO $ do+                        state <- E.getMaskingState+                        modifyIORef' maskingStates (<> [state])+                        if member == 0+                          then putMVar acquired () >> takeMVar holdTransfer+                          else pure ()+                    )+                    pure+            putMVar finished (outcome :: Either E.SomeException [Int32])++        takeMVar acquired+        killThread worker+        outcome <- takeMVar finished+        case outcome of+          Left exception ->+            E.fromException exception `shouldBe` Just E.ThreadKilled+          Right _ -> expectationFailure "expected cancellation during ownership transfer"+        readIORef maskingStates `shouldReturn` [E.MaskedInterruptible]+        readIORef shutdowns `shouldReturn` [0]++      it "wraps consumer-group handlers so exceptions finalize a retry decision" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "cgp-guard-1") NoStream [makeEvent "CGGuard" (Aeson.object [])]+        threadDelay 200_000++        countVar <- newTVarIO (0 :: Int)+        runEff $+          runTracingNoop $ do+            result <-+              kirokuConsumerGroupProcessors+                store+                (defaultConsumerGroupConfig (SubscriptionName "cgp-guard") AllStreams (validGroupSize 1))+                ( \_ -> do+                    n <- liftIO $ atomically $ do+                      c <- readTVar countVar+                      let c' = c + 1+                      writeTVar countVar c'+                      pure c'+                    if n == 1+                      then liftIO $ E.throwIO (userError "guard me")+                      else pure AckOk+                )+            case result of+              Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)+              Right processors -> do+                appRes <- runApp defaultAppConfig processors+                case appRes of+                  Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                  Right appHandle -> do+                    liftIO $ waitForCount countVar 2 15_000_000+                    stopApp appHandle++      it "one call yields N PartitionedInOrder processors; members partition the stream disjointly" $ \store -> do+        -- 20 streams × 2 events = 40 events, global positions 1..40, category "cgp".+        let streams = ["cgp-" <> T.pack (show i) | i <- [1 .. 20 :: Int]]+        mapM_+          ( \sn -> do+              let evs = [makeEvent ("EV" <> T.pack (show k)) (Aeson.object []) | k <- [1 .. 2 :: Int]]+              Right _ <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs+              pure ()+          )+          streams+        threadDelay 200_000++        -- A single shared handler records every delivery (consumer-group+        -- members all run the same handler). One counter for the whole group.+        ref <- newIORef ([] :: [RecordedEvent])+        countVar <- newTVarIO (0 :: Int)++        let cfg =+              defaultConsumerGroupConfig+                (SubscriptionName "cgp-shibuya-group")+                (Category (CategoryName "cgp"))+                (validGroupSize 4)++        runEff $ runTracingNoop $ do+          let handler ingested = do+                liftIO $ do+                  modifyIORef' ref (envelopePayload ingested :)+                  atomically $ do+                    c <- readTVar countVar+                    writeTVar countVar (c + 1)+                pure AckOk++          -- One call replaces the manual `mapM mkMemberAdapter [0..3]` wiring.+          res <- kirokuConsumerGroupProcessors store cfg handler+          case res of+            Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)+            Right processors -> do+              -- The group is presented as N processors, each pinned to the+              -- group-level PartitionedInOrder contract + per-member Serial.+              liftIO $ length processors `shouldBe` 4+              -- 'Nothing' marks a batching processor, which the+              -- consumer group must never produce.+              let policies =+                    map+                      ( \(_, p) -> case p of+                          QueueProcessor {ordering, concurrency} -> Just (ordering, concurrency)+                          BatchingProcessor {} -> Nothing+                      )+                      processors+              liftIO $ policies `shouldBe` replicate 4 (Just (PartitionedInOrder, Serial))+              -- The member index is readable off the ProcessorId.+              let pids = map (\(ProcessorId p, _) -> p) processors+              liftIO $+                pids+                  `shouldBe` ["cgp-shibuya-group-member-" <> T.pack (show m) | m <- [0 .. 3 :: Int]]++              appRes <- runApp defaultAppConfig processors+              case appRes of+                Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)+                Right appHandle -> do+                  liftIO $ waitForCount countVar 40 15_000_000+                  stopApp appHandle++        collected <- readIORef ref+        -- Disjoint + complete through the helper: the union of every member's+        -- delivered global positions is exactly [1..40] — no event dropped, none+        -- delivered twice. No duplicates ⇒ the member partitions are disjoint;+        -- ==[1..40] ⇒ the union is the complete source. This is the same+        -- disjoint-complete property MasterPlan 4 used, now asserted through the+        -- single-call helper and the Shibuya pipeline.+        sort (map globalPos collected) `shouldBe` [1 .. 40]+        -- Every one of the 20 originating streams was covered.+        length (nub (map origStreamId collected)) `shouldBe` 20++-- Helpers++envelopePayload :: Message es RecordedEvent -> RecordedEvent+envelopePayload ing = let Message {envelope = env} = ing in env ^. #payload++stubAdapter :: Adapter es RecordedEvent+stubAdapter =+  Adapter+    { adapterName = "stub-kiroku",+      source = Stream.nil,+      shutdown = pure ()+    }++-- | Read the dead letters recorded for a non-group subscription (member 0).+readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord]+readDeadLetters store subName = readMemberDeadLetters store subName 0++readMemberDeadLetters :: KirokuStore -> Text -> Int32 -> IO [SQL.DeadLetterRecord]+readMemberDeadLetters store subName memberIndex = do+  result <- Pool.use (store ^. #pool) (Session.statement (subName, memberIndex) SQL.readDeadLettersStmt)+  case result of+    Left err -> error ("readDeadLetters failed: " <> show err)+    Right v -> pure (toList v)++-- | The raw global position of a recorded event (for disjoint/complete checks).+globalPos :: RecordedEvent -> Int64+globalPos e = case e ^. #globalPosition of GlobalPosition p -> p++-- | The originating stream's surrogate id (for stream-coverage checks).+origStreamId :: RecordedEvent -> Int64+origStreamId e = case e ^. #originalStreamId of StreamId p -> p++-- | Wait until the sum of all TVar counts reaches the target or the timeout fires.+waitForTotal :: [STM.TVar Int] -> Int -> Int -> IO ()+waitForTotal vars target timeoutMicros = do+  timeoutVar <- registerDelay timeoutMicros+  result <-+    atomically $+      ( do+          total <- sum <$> mapM readTVar vars+          STM.check (total >= target)+          pure True+      )+        `STM.orElse` ( do+                         t <- readTVar timeoutVar+                         STM.check t+                         pure False+                     )+  unless result $ do+    actual <- atomically $ sum <$> mapM readTVar vars+    expectationFailure ("Timed out waiting for total " <> show target <> ", got " <> show actual)++waitForCount :: STM.TVar Int -> Int -> Int -> IO ()+waitForCount countVar target timeoutMicros = do+  timeoutVar <- registerDelay timeoutMicros+  result <-+    atomically $+      ( do+          c <- readTVar countVar+          STM.check (c >= target)+          pure True+      )+        `STM.orElse` ( do+                         t <- readTVar timeoutVar+                         STM.check t+                         pure False+                     )+  unless result $ do+    actual <- atomically $ readTVar countVar+    expectationFailure ("Timed out waiting for count " <> show target <> ", got " <> show actual)++within :: String -> IO a -> IO a+within label action = do+  result <- timeout 10_000_000 action+  case result of+    Nothing -> fail ("Timed out waiting for " <> label)+    Just a -> pure a++waitForCheckpointPosition :: KirokuStore -> SubscriptionCheckpointKey -> GlobalPosition -> IO ()+waitForCheckpointPosition store expectedKey expectedPosition =+  within "subscription checkpoint initialization" loop+  where+    loop = do+      Right (SubscriptionCheckpointInventory _ checkpoints) <-+        runStoreIO store subscriptionCheckpointInventory+      let positions =+            [ (SubscriptionCheckpointKey name member, position)+            | SubscriptionCheckpoint name member position _ <- toList checkpoints+            ]+      if (expectedKey, expectedPosition) `elem` positions+        then pure ()+        else threadDelay 20_000 >> loop++readCheckpointPosition :: KirokuStore -> SubscriptionCheckpointKey -> IO (Maybe GlobalPosition)+readCheckpointPosition store expectedKey = do+  Right (SubscriptionCheckpointInventory _ checkpoints) <-+    runStoreIO store subscriptionCheckpointInventory+  pure $ case [ position+              | SubscriptionCheckpoint name member position _ <- toList checkpoints,+                SubscriptionCheckpointKey name member == expectedKey+              ] of+    position : _ -> Just position+    [] -> Nothing++makeEvent :: Text -> Value -> EventData+makeEvent typ p =+  EventData+    { eventId = Nothing,+      eventType = EventType typ,+      payload = p,+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }++-- | A throwaway attribute source for the trace-header tests, whose assertions+-- do not depend on the kiroku identity attributes.+sampleEnvelopeAttrs :: KirokuEnvelopeAttrs+sampleEnvelopeAttrs = kirokuEnvelopeAttrs "test-sub" Nothing++makeRecordedEvent :: Maybe Value -> RecordedEvent+makeRecordedEvent meta =+  RecordedEvent+    { eventId = EventId UUID.nil,+      eventType = EventType "TraceEvent",+      streamVersion = StreamVersion 1,+      globalPosition = GlobalPosition 1,+      originalStreamId = StreamId 1,+      originalVersion = StreamVersion 1,+      payload = Aeson.object [],+      metadata = meta,+      causationId = Nothing,+      correlationId = Nothing,+      createdAt = UTCTime (fromGregorian 2026 5 16) 0+    }++withTestStore :: (KirokuStore -> IO ()) -> IO ()+withTestStore action =+  withMigratedTestDatabase $ \connStr ->+    withStore (defaultConnectionSettings connStr) action++validGroupSize :: Int32 -> ConsumerGroupSize+validGroupSize = either (error . show) Prelude.id . mkConsumerGroupSize++validConsumerGroup :: Int32 -> Int32 -> ConsumerGroup+validConsumerGroup m n = either (error . show) Prelude.id (mkConsumerGroup m (validGroupSize n))++acknowledgementSpec :: Spec+acknowledgementSpec = do+  describe "acknowledgement liveness" $ do+    forM_ [False, True] $ \guarded ->+      it (if guarded then "guarded single processor retries with pacing and continues" else "standard Shibuya handler exceptions are finalized") $+        withTestStore $ \store -> do+          Right _ <-+            runStoreIO store $+              appendToStream+                (StreamName "ack-liveness")+                NoStream+                [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]+          attempts <- newIORef ([] :: [(Int64, Maybe Attempt, Word64)])+          count <- newTVarIO (0 :: Int)+          within "runner finalization" $ runEff $ runTracingNoop $ do+            adapter <- kirokuAdapter store (defaultKirokuAdapterConfig (SubscriptionName "ack-liveness") AllStreams)+            let handler ingested = do+                  now <- liftIO getMonotonicTimeNSec+                  let pos = globalPos (envelopePayload ingested)+                      Message {envelope = Envelope {attempt = attempt'}} = ingested+                  n <- liftIO $ do+                    modifyIORef' attempts ((pos, attempt', now) :)+                    atomically $ do+                      old <- readTVar count+                      writeTVar count (old + 1)+                      pure old+                  if n == 0 then liftIO (E.throwIO (userError "one-shot handler failure")) else pure AckOk+                processor = if guarded then kirokuProcessor adapter handler else mkProcessor adapter handler+            app <- runApp defaultAppConfig [(ProcessorId "ack-liveness", processor)] >>= either (liftIO . fail . show) pure+            liftIO $ waitForCount count 3 5_000_000+            liftIO $ waitForCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-liveness") 0) (GlobalPosition 2)+            stopApp app+          recorded <- reverse <$> readIORef attempts+          map (\(pos, attempt', _) -> (pos, attempt')) recorded+            `shouldBe` [(1, Just (Attempt 0)), (1, Just (Attempt 1)), (2, Just (Attempt 0))]+          case recorded of+            (_, _, firstAt) : (_, _, retriedAt) : _ ->+              if guarded+                then retriedAt - firstAt `shouldSatisfy` (>= 900_000_000)+                else retriedAt - firstAt `shouldSatisfy` (< 900_000_000)+            _ -> expectationFailure "expected three finalized deliveries"++    forM_ [Nothing, Just 0.05] $ \warnAfter ->+      it ("keeps a raw unfinalized item pending and forwards warning policy " <> show warnAfter) $ do+        warnings <- newTVarIO (0 :: Int)+        warning <- STM.newEmptyTMVarIO+        let observe event = case event of+              KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> atomically $ do+                STM.modifyTVar' warnings (+ 1)+                _ <- STM.tryPutTMVar warning (pos, elapsed)+                pure ()+              _ -> pure ()+        withMigratedTestDatabase $ \connStr ->+          withStore (defaultConnectionSettings connStr & #eventHandler .~ Just observe) $ \store -> do+            Right _ <-+              runStoreIO store $+                appendToStream+                  (StreamName "ack-raw")+                  NoStream+                  [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]+            within "raw acknowledgement ownership" $ runEff $ runTracingNoop $ Timeout.runTimeout $ do+              adapter <-+                kirokuAdapter+                  store+                  ( (defaultKirokuAdapterConfig (SubscriptionName "ack-raw") AllStreams)+                      & #handlerStallWarnAfter .~ warnAfter+                  )+              let Adapter {source = sourceStream, shutdown = shutdownAction} = adapter+              first <- Stream.uncons sourceStream+              case first of+                Nothing -> liftIO (expectationFailure "expected first item")+                Just (item, rest) -> do+                  -- The raw consumer intentionally leaves item unfinalized.+                  secondRead <- Timeout.timeout 150_000 (Stream.uncons rest)+                  liftIO (isNothing secondRead `shouldBe` True)+                  liftIO $ readCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-raw") 0) `shouldReturn` Just (GlobalPosition 0)+                  case warnAfter of+                    Nothing -> liftIO $ STM.readTVarIO warnings `shouldReturn` 0+                    Just threshold -> liftIO $ do+                      (pos, elapsed) <- within "raw ack warning" (atomically (STM.readTMVar warning))+                      pos `shouldBe` GlobalPosition 1+                      elapsed `shouldSatisfy` (>= threshold)+                  -- Finalization remains explicitly owned by this consumer.+                  let Ingested {ack = AckHandle {finalize = finalizeFirst}} = item+                  finalizeFirst AckOk+                  (second, _) <- Stream.uncons rest >>= maybe (liftIO (fail "expected second item")) pure+                  let Ingested {envelope = Envelope {payload = secondEvent}} = second+                  liftIO $ globalPos secondEvent `shouldBe` 2+                  let Ingested {ack = AckHandle {finalize = finalizeSecond}} = second+                  finalizeSecond AckOk+                  liftIO $ waitForCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-raw") 0) (GlobalPosition 2)+              shutdownAction+            stoppedCount <- STM.readTVarIO warnings+            threadDelay 100_000+            STM.readTVarIO warnings `shouldReturn` stoppedCount++  describe "retry policy" $ do+    forM_ [Nothing, Just 2] $ \attemptLimit ->+      it ("honors single-adapter total delivery limit " <> show attemptLimit) $ withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "ack-policy") NoStream [makeEvent "Retry" (Aeson.object [])]+        count <- newTVarIO (0 :: Int)+        runEff $ runTracingNoop $ do+          let config =+                (defaultKirokuAdapterConfig (SubscriptionName "ack-policy") AllStreams)+                  & #retryPolicy .~ maybe defaultRetryPolicy RetryPolicy attemptLimit+          adapter <- kirokuAdapter store config+          let handler _ = liftIO (atomically (STM.modifyTVar' count (+ 1))) >> pure (AckRetry (Ack.RetryDelay 0))+          app <- runApp defaultAppConfig [(ProcessorId "ack-policy", kirokuProcessor adapter handler)] >>= either (liftIO . fail . show) pure+          liftIO $ waitForCheckpointPosition store (SubscriptionCheckpointKey (SubscriptionName "ack-policy") 0) (GlobalPosition 1)+          stopApp app+        STM.readTVarIO count `shouldReturn` maybe 5 Prelude.id attemptLimit+        letters <- readDeadLetters store "ack-policy"+        map SQL.deadLetterAttemptCount letters `shouldBe` [fromIntegral (maybe 5 Prelude.id attemptLimit)]++    it "forwards two attempts and stall policy to both consumer-group members" $ do+      warned <- newTVarIO Set.empty+      release <- STM.newEmptyTMVarIO+      let observe event = case event of+            KirokuEventSubscriptionHandlerStalled _ _ _ _ (GroupMember member' 2) -> atomically (STM.modifyTVar' warned (Set.insert member'))+            _ -> pure ()+      withMigratedTestDatabase $ \connStr ->+        withStore (defaultConnectionSettings connStr & #eventHandler .~ Just observe) $ \store -> do+          -- Enough distinct streams to exercise both PostgreSQL hash slots;+          -- require both warnings before release so an empty slot fails.+          forM_ [1 .. 20 :: Int] $ \i -> do+            Right _ <- runStoreIO store $ appendToStream (StreamName ("ack-group-" <> T.pack (show i))) NoStream [makeEvent "Retry" (Aeson.object [])]+            pure ()+          seen <- newIORef Map.empty+          runEff $ runTracingNoop $ do+            let config =+                  (defaultConsumerGroupConfig (SubscriptionName "ack-policy-group") AllStreams (validGroupSize 2))+                    & #retryPolicy .~ RetryPolicy 2+                    & #handlerStallWarnAfter .~ Just 0.05+                handler ingested = do+                  liftIO $ atomically (STM.readTMVar release)+                  liftIO $ atomicModifyIORef' seen (\old -> (Map.insertWith (+) (globalPos (envelopePayload ingested)) (1 :: Int) old, ()))+                  pure (AckRetry (Ack.RetryDelay 0))+            processors <- kirokuConsumerGroupProcessors store config handler >>= either (liftIO . fail . show) pure+            app <- runApp defaultAppConfig processors >>= either (liftIO . fail . show) pure+            liftIO $ within "both group members stalled" $ atomically $ readTVar warned >>= STM.check . (== Set.fromList [0, 1])+            liftIO $ atomically (STM.putTMVar release ())+            liftIO $ within "all group dead letters" $ do+              let loop = do+                    letters <- concat <$> mapM (readMemberDeadLetters store "ack-policy-group") [0, 1]+                    if length letters == 20 then pure () else threadDelay 10_000 >> loop+              loop+            stopApp app+          Map.elems <$> readIORef seen `shouldReturn` replicate 20 2+          letters <- concat <$> mapM (readMemberDeadLetters store "ack-policy-group") [0, 1]+          sort (map SQL.deadLetterGlobalPosition letters) `shouldBe` [1 .. 20]+          map SQL.deadLetterAttemptCount letters `shouldBe` replicate 20 2