packages feed

shibuya-kiroku-adapter 0.2.0.0 → 0.4.0.0

raw patch · 5 files changed

+508/−119 lines, 5 filesdep ~effectful-coredep ~kiroku-storedep ~shibuya-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: effectful-core, kiroku-store, shibuya-core

API changes (from Hackage documentation)

+ Shibuya.Adapter.Kiroku: [queueCapacity] :: KirokuConsumerGroupConfig -> !Natural
+ Shibuya.Adapter.Kiroku: guardKirokuHandler :: forall (es :: [Effect]) msg. Handler es msg -> Handler es msg
+ Shibuya.Adapter.Kiroku: guardKirokuHandlerWith :: forall (es :: [Effect]) msg. (SomeException -> AckDecision) -> Handler es msg -> Handler es msg
+ Shibuya.Adapter.Kiroku: kirokuConsumerGroupProcessorsWith :: forall (es :: [Effect]). IOE :> es => (Int32 -> Eff es (Adapter es RecordedEvent)) -> KirokuConsumerGroupConfig -> Handler es RecordedEvent -> Eff es (Either PolicyError [(ProcessorId, QueueProcessor es)])
- Shibuya.Adapter.Kiroku: KirokuAdapterConfig :: !SubscriptionName -> !SubscriptionTarget -> !Int32 -> !Natural -> !Maybe ConsumerGroup -> !EventTypeFilter -> !Maybe (RecordedEvent -> Bool) -> KirokuAdapterConfig
+ Shibuya.Adapter.Kiroku: KirokuAdapterConfig :: !SubscriptionName -> !SubscriptionTarget -> !Int32 -> !Natural -> !Natural -> !Maybe ConsumerGroup -> !EventTypeFilter -> !Maybe (RecordedEvent -> Bool) -> KirokuAdapterConfig
- Shibuya.Adapter.Kiroku: KirokuConsumerGroupConfig :: !SubscriptionName -> !SubscriptionTarget -> !Int32 -> !Int32 -> !Natural -> !Concurrency -> !EventTypeFilter -> !Maybe (RecordedEvent -> Bool) -> KirokuConsumerGroupConfig
+ Shibuya.Adapter.Kiroku: KirokuConsumerGroupConfig :: !SubscriptionName -> !SubscriptionTarget -> !Int32 -> !Int32 -> !Natural -> !Natural -> !Concurrency -> !EventTypeFilter -> !Maybe (RecordedEvent -> Bool) -> KirokuConsumerGroupConfig
- Shibuya.Adapter.Kiroku: consumerGroupPolicy :: Concurrency -> Either PolicyError (Ordering, Concurrency)
+ Shibuya.Adapter.Kiroku: consumerGroupPolicy :: Concurrency -> Either PolicyError (OrderingPolicy, Concurrency)

Files

CHANGELOG.md view
@@ -2,6 +2,48 @@  ## Unreleased +## 0.4.0.0 — 2026-07-11++### Breaking Changes++* `KirokuAdapterConfig` and `KirokuConsumerGroupConfig` gain a+  `queueCapacity` field, defaulting to 16 in the smart constructors.+* Requires `shibuya-core >=0.8 && <0.9` (was `>=0.7`) and `kiroku-store ^>=0.3`.++### Fixed++* Adapter subscriptions now rely on Kiroku's default lossless+  `PauseAndResume` overflow policy instead of pinning fail-fast+  `DropSubscription`.+* `guardKirokuHandlerWith` and `guardKirokuHandler` convert synchronous handler+  exceptions into finalized ack decisions so Kiroku workers are not abandoned+  mid-ack. `kirokuConsumerGroupProcessors` applies the default guard+  automatically.+* `Adapter.source` now has regression coverage for EP-1's termination contract:+  clean shutdown ends the stream normally, while a worker crash is rethrown to+  the stream consumer.+* `kirokuConsumerGroupProcessors` now throws `InvalidConsumerGroup` for+  non-positive group sizes before opening subscriptions, and cleans up already+  created member adapters if a later member fails during construction.+* The package description and Haddocks describe the ack-coupled bridge instead+  of the obsolete no-op ack behavior.++### Known Limitations++* `shibuya-core` still needs upstream fixes for finalize-on-exception in its+  supervised processor and for propagating ingester stream failures from the+  supervised runner. The adapter-side guard is defensive and remains correct+  after those fixes land.++## 0.3.0.0 — 2026-06-05++### Changed++- Require `shibuya-core >=0.7 && <0.8`. `Envelope` now carries a+  `headers :: Maybe Headers` field; `toEnvelope` sets it to `Nothing`,+  because Kiroku events carry no ordered, raw broker headers. W3C trace+  context continues to be surfaced via `traceContext` from event metadata.+ ## 0.2.0.0 — 2026-05-31  ### Breaking Changes
shibuya-kiroku-adapter.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            shibuya-kiroku-adapter-version:         0.2.0.0+version:         0.4.0.0 synopsis:   Kiroku event store adapter for the Shibuya queue processing framework @@ -10,12 +10,10 @@   interface, enabling supervised multi-subscription processing with failure   isolation, per-subscription metrics, and coordinated graceful shutdown.   .-  Events flow through a bounded TBQueue bridge (provided by-  @kiroku-store@'s @subscriptionStream@) and are lifted into Shibuya's-  effect stack via @Stream.morphInner@. Checkpoint management is handled-  internally by Kiroku's subscription worker — the adapter's ack semantics-  are no-op for AckOk\/AckRetry\/AckDeadLetter and trigger subscription-  cancellation for AckHalt.+  Events flow through @kiroku-store@'s ack-coupled @subscriptionAckStream@+  bridge and are lifted into Shibuya's effect stack via @Stream.morphInner@.+  Each finalized Shibuya @AckDecision@ drives Kiroku checkpointing, retry,+  dead-lettering, or halt before the worker moves to the next event.  author:          Nadeem Bitar maintainer:      nadeem@gmail.com@@ -49,11 +47,11 @@   build-depends:     , aeson                                  >=2.1   && <2.3     , base                                   >=4.18  && <5-    , effectful-core                         >=2.4   && <2.7+    , effectful-core                         >=2.5   && <2.7     , hs-opentelemetry-api                   ^>=1.0     , hs-opentelemetry-semantic-conventions  ^>=1.40-    , kiroku-store                           ^>=0.2-    , shibuya-core                           >=0.6   && <0.7+    , kiroku-store                           ^>=0.3+    , shibuya-core                           >=0.8   && <0.9     , stm                                    >=2.5   && <2.6     , streamly-core                          >=0.3   && <0.4     , text                                   >=2.0   && <2.2@@ -80,12 +78,13 @@     , hasql-pool              >=1.2  && <1.5     , hs-opentelemetry-api    ^>=1.0     , hspec                   >=2.10 && <2.12-    , kiroku-store            ^>=0.2+    , kiroku-store            ^>=0.3     , kiroku-test-support     , lens                    >=5.2  && <5.4-    , shibuya-core            >=0.6  && <0.7+    , shibuya-core            >=0.8  && <0.9     , shibuya-kiroku-adapter     , stm                     >=2.5  && <2.6+    , streamly-core           >=0.3  && <0.4     , text                    >=2.0  && <2.2     , time                    >=1.12 && <1.15     , unordered-containers    >=0.2  && <0.3
src/Shibuya/Adapter/Kiroku.hs view
@@ -18,21 +18,14 @@ main :: IO () main = withStore settings $ \\store ->     runEff $ runTracingNoop $ do-        adapter <- kirokuAdapter store-            KirokuAdapterConfig-                { subscriptionName = SubscriptionName \"my-projection\"-                , subscriptionTarget = AllStreams-                , batchSize = 100-                , bufferSize = 256-                , consumerGroup = Nothing-                , eventTypeFilter = AllEventTypes-                }+        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 IgnoreFailures 100+        Right appHandle <- runApp defaultAppConfig             [(ProcessorId \"my-projection\", mkProcessor adapter handler)]          waitApp appHandle@@ -58,7 +51,7 @@                 4   -- group size          Right processors <- kirokuConsumerGroupProcessors store cfg handler-        Right appHandle <- runApp IgnoreFailures 100 processors+        Right appHandle <- runApp defaultAppConfig processors         waitApp appHandle   where     handler ingested = do@@ -95,16 +88,33 @@ The envelope's @attempt@ reports the zero-based redelivery count, so a handler can observe how many times Kiroku has redelivered an event. -== Backpressure+== Backpressure and Handler Exceptions -The @bufferSize@ field in 'KirokuAdapterConfig' controls the 'TBQueue'-capacity. Because delivery is ack-coupled, the Kiroku subscription worker blocks-on each event until the Shibuya handler finalizes its decision, providing natural-backpressure.+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.++A Shibuya handler used with this adapter must not let synchronous exceptions+escape. Shibuya's supervised runner records handler exceptions without+finalizing the ack; with Kiroku's ack-coupled bridge, an unfinalized ack blocks+the Kiroku worker forever. Direct 'mkProcessor' users should wrap handlers in+'guardKirokuHandler' or handle exceptions themselves.+'kirokuConsumerGroupProcessors' applies 'guardKirokuHandler' automatically. -} module Shibuya.Adapter.Kiroku (     -- * Adapter     kirokuAdapter,+    guardKirokuHandlerWith,+    guardKirokuHandler,      -- * Configuration     KirokuAdapterConfig (..),@@ -115,6 +125,7 @@     defaultConsumerGroupConfig,     consumerGroupPolicy,     kirokuConsumerGroupProcessors,+    kirokuConsumerGroupProcessorsWith,      -- * Re-exports from kiroku-store     SubscriptionName (..),@@ -123,41 +134,48 @@     EventTypeFilter (..), ) where +import Control.Exception (SomeException) import Data.Int (Int32) import Data.Text qualified as T import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Exception (catchSync, onException, throwIO) import GHC.Generics (Generic) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Subscription.Stream (subscriptionAckStream) import Kiroku.Store.Subscription.Types (     ConsumerGroup (..),     EventTypeFilter (..),-    OverflowPolicy (..),-    SubscriptionConfigM (..),+    InvalidConsumerGroup (..),+    SubscriptionConfig,     SubscriptionName (..),     SubscriptionResult (..),     SubscriptionTarget (..),     defaultSubscriptionConfig,  )+import Kiroku.Store.Subscription.Types qualified as Sub import Kiroku.Store.Types (RecordedEvent) import Numeric.Natural (Natural) import Shibuya.Adapter (Adapter (..)) import Shibuya.Adapter.Kiroku.Convert (kirokuEnvelopeAttrs, toIngestedAck) import Shibuya.App (ProcessorId (..), QueueProcessor (..))+import Shibuya.Core.Ack (AckDecision (..), RetryDelay (..)) import Shibuya.Core.Error (PolicyError (..)) import Shibuya.Handler (Handler)-import Shibuya.Policy (Concurrency (..), Ordering (..), validatePolicy)+import Shibuya.Policy (Concurrency (..), OrderingPolicy (..), validatePolicy) import Streamly.Data.Stream qualified as Stream-import Prelude hiding (Ordering)  {- | Configuration for creating a Kiroku adapter.  @subscriptionName@ must be unique across all active subscriptions — it identifies the checkpoint row in the @subscriptions@ table. -@bufferSize@ controls backpressure: the Kiroku worker blocks when the-internal queue is full, throttling database polling to match the handler's-consumption rate.+@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@@ -167,7 +185,12 @@     , batchSize :: !Int32     -- ^ Events per database fetch during catch-up     , bufferSize :: !Natural-    -- ^ TBQueue capacity (backpressure threshold)+    -- ^ Bridge 'TBQueue' capacity; must be at least 1.+    , 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.@@ -206,10 +229,11 @@     deriving stock (Generic)  {- | A 'KirokuAdapterConfig' with sensible defaults: @batchSize = 100@,-@bufferSize = 256@, @consumerGroup = 'Nothing'@ (ordinary single-consumer-subscription), @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.+@bufferSize = 256@, @queueCapacity = 16@, @consumerGroup = 'Nothing'@+(ordinary single-consumer subscription), @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:@@ -229,46 +253,69 @@         , subscriptionTarget = target         , batchSize = 100         , bufferSize = 256+        , queueCapacity = 16         , consumerGroup = Nothing         , eventTypeFilter = AllEventTypes         , selector = Nothing         } +{- | 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+guardKirokuHandlerWith handleException h ingested =+    h ingested `catchSync` (pure . handleException)++{- | 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)))+ {- | Create a Shibuya 'Adapter' backed by a Kiroku subscription.  The adapter:  1. Calls 'subscriptionStream' to start a Kiroku subscription with a-   bounded 'TBQueue' bridge.+   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 a no-op 'AckHandle' (except 'AckHalt' which cancels the-   subscription).+   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 flushes the sentinel through the queue so any-blocked stream reader terminates.+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 subName subTarget bs buf cg etf sel) = do+kirokuAdapter store KirokuAdapterConfig{subscriptionName = subName, subscriptionTarget = subTarget, batchSize = bs, bufferSize = buf, queueCapacity = qCap, consumerGroup = cg, 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 =+    let subConfig :: SubscriptionConfig+        subConfig =             (defaultSubscriptionConfig subName subTarget (\_ -> pure Continue))-                { batchSize = bs-                , queueCapacity = 16-                , overflowPolicy = DropSubscription-                , consumerGroup = cg-                , eventTypeFilter = etf-                , selector = sel+                { Sub.batchSize = bs+                , Sub.queueCapacity = qCap+                , Sub.consumerGroup = cg+                , Sub.eventTypeFilter = etf+                , Sub.selector = sel                 }      (ioStream, cancelAction) <- liftIO $ subscriptionAckStream store subConfig buf@@ -326,7 +373,11 @@     , batchSize :: !Int32     -- ^ Events per database fetch during catch-up (per member).     , bufferSize :: !Natural-    -- ^ Per-member 'TBQueue' capacity (backpressure threshold).+    -- ^ Per-member bridge 'TBQueue' capacity; must be at least 1.+    , 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).     , eventTypeFilter :: !EventTypeFilter@@ -353,9 +404,9 @@  {- | A 'KirokuConsumerGroupConfig' with sensible defaults: @memberConcurrency = 'Serial'@ (the only legal per-member concurrency), @batchSize = 100@,-@bufferSize = 256@, @eventTypeFilter = 'AllEventTypes'@ (deliver every type),-@selector = 'Nothing'@ (no extra predicate filtering). Supply the subscription-name, target, and group size.+@bufferSize = 256@, @queueCapacity = 16@, @eventTypeFilter = 'AllEventTypes'@+(deliver every type), @selector = 'Nothing'@ (no extra predicate filtering).+Supply the subscription name, target, and group size. -} defaultConsumerGroupConfig ::     SubscriptionName -> SubscriptionTarget -> Int32 -> KirokuConsumerGroupConfig@@ -366,13 +417,14 @@         , groupSize = n         , batchSize = 100         , bufferSize = 256+        , queueCapacity = 16         , memberConcurrency = Serial         , eventTypeFilter = AllEventTypes         , selector = Nothing         }  {- | Map a requested per-member concurrency onto the group's validated Shibuya-@('Ordering', 'Concurrency')@.+@('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@@ -383,7 +435,7 @@ @('PartitionedInOrder', 'Serial')@ also passes 'validatePolicy', so 'runApp' will not reject it later. -}-consumerGroupPolicy :: Concurrency -> Either PolicyError (Ordering, Concurrency)+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@@ -392,6 +444,9 @@ {- | 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@@ -401,11 +456,9 @@  Each processor's 'ProcessorId' is @\"\<subscriptionName\>-member-\<m\>\"@ so member identity is readable off the id-and two members never collide. The group-validity invariant (@groupSize >= 1@,-@0 <= member < groupSize@) is enforced downstream by-'Kiroku.Store.Subscription.subscribe' (throwing-'Kiroku.Store.Subscription.Types.InvalidConsumerGroup'); 'groupSize >= 1' is a-documented precondition of this helper.+and two members never collide. @groupSize >= 1@ is validated before any+subscription opens, throwing 'InvalidConsumerGroup' with member 0 when it is+violated. -} kirokuConsumerGroupProcessors ::     (IOE :> es) =>@@ -413,42 +466,63 @@     KirokuConsumerGroupConfig ->     Handler es RecordedEvent ->     Eff es (Either PolicyError [(ProcessorId, QueueProcessor es)])-kirokuConsumerGroupProcessors-    store+kirokuConsumerGroupProcessors store cfg@KirokuConsumerGroupConfig{subscriptionName = subName, subscriptionTarget = subTarget, groupSize = n, batchSize = bs, bufferSize = buf, queueCapacity = qCap, eventTypeFilter = etf, selector = sel} handler =+    kirokuConsumerGroupProcessorsWith mkMemberAdapter cfg handler+  where+    mkMemberAdapter m =+        kirokuAdapter+            store+            KirokuAdapterConfig+                { subscriptionName = subName+                , subscriptionTarget = subTarget+                , batchSize = bs+                , bufferSize = buf+                , queueCapacity = qCap+                , consumerGroup = Just (ConsumerGroup{member = m, size = n})+                , eventTypeFilter = etf+                , selector = sel+                }++{- | 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)])+kirokuConsumerGroupProcessorsWith+    mkMemberAdapter     KirokuConsumerGroupConfig         { subscriptionName = subName-        , subscriptionTarget = subTarget         , groupSize = n-        , batchSize = bs-        , bufferSize = buf         , memberConcurrency = mc-        , eventTypeFilter = etf-        , selector = sel         }     handler =-        case consumerGroupPolicy mc of-            Left e -> pure (Left e)-            Right (ordering, conc) -> do-                let SubscriptionName name = subName-                processors <--                    mapM-                        ( \m -> do-                            adapter <--                                kirokuAdapter-                                    store-                                    KirokuAdapterConfig-                                        { subscriptionName = subName-                                        , subscriptionTarget = subTarget-                                        , batchSize = bs-                                        , bufferSize = buf-                                        , consumerGroup = Just (ConsumerGroup{member = m, size = n})-                                        , eventTypeFilter = etf-                                        , selector = sel-                                        }-                            let pid = ProcessorId (name <> "-member-" <> T.pack (show m))-                            -- Built directly (not via 'mkProcessor', which hardcodes-                            -- Unordered/Serial) so the group policy is pinned.-                            pure (pid, QueueProcessor adapter handler ordering conc)-                        )-                        [0 .. n - 1]-                pure (Right processors)+        if n < 1+            then throwIO (InvalidConsumerGroup 0 n)+            else case consumerGroupPolicy mc of+                Left e -> pure (Left e)+                Right (ordering, conc) -> do+                    let SubscriptionName name = subName+                    adapters <- createAdapters [] 0+                    let processors =+                            [ let pid = ProcessorId (name <> "-member-" <> T.pack (show m))+                               in (pid, QueueProcessor adapter (guardKirokuHandler handler) ordering conc)+                            | (m, adapter) <- zip [0 .. n - 1] adapters+                            ]+                    pure (Right processors)+      where+        createAdapters created m+            | m >= n = pure (reverse created)+            | otherwise = do+                adapter <- mkMemberAdapter m `onException` shutdownCreated created+                createAdapters (adapter : created) (m + 1)++        shutdownCreated =+            mapM_ $ \Adapter{shutdown = shutdownAction} -> shutdownAction
src/Shibuya/Adapter/Kiroku/Convert.hs view
@@ -176,6 +176,7 @@             , partition = Nothing             , enqueuedAt = Just ts             , traceContext = metadataTraceContext meta+            , headers = Nothing             , attempt = Nothing             , attributes = eventAttributes attrs etype pos             , payload = event
test/Main.hs view
@@ -1,8 +1,10 @@ module Main where  import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM (atomically, newTVarIO, readTVar, registerDelay, writeTVar) import Control.Concurrent.STM qualified as STM+import Control.Exception qualified as E import Control.Lens ((&), (.~), (^.)) import Control.Monad (unless) import Control.Monad.IO.Class (liftIO)@@ -25,20 +27,24 @@ import Hasql.Session qualified as Session import Kiroku.Store import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Subscription.Worker (withFetchBatchHookForTest) 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,  ) import Shibuya.Adapter.Kiroku.Convert (KirokuEnvelopeAttrs, kirokuEnvelopeAttrs, toEnvelope) import Shibuya.App (     ProcessorId (..),     QueueProcessor (..),-    SupervisionStrategy (..),+    defaultAppConfig,     getAppMetrics,     mkProcessor,     runApp,@@ -49,11 +55,14 @@ import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..)) import Shibuya.Core.Ack qualified as Ack import Shibuya.Core.Error (PolicyError (..))-import Shibuya.Core.Ingested (Ingested (..))-import Shibuya.Core.Types (Envelope (..))-import Shibuya.Policy (Concurrency (..), Ordering (..))-import Shibuya.Runner.Metrics (ProcessorState (..))+import Shibuya.Core.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 ()@@ -92,6 +101,17 @@             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)@@ -156,7 +176,7 @@                                     c <- readTVar countVar                                     writeTVar countVar (c + 1)                             pure AckOk-                    res <- runApp IgnoreFailures 100 [(ProcessorId "etf", mkProcessor adapter handler)]+                    res <- runApp defaultAppConfig [(ProcessorId "etf", mkProcessor adapter handler)]                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -199,7 +219,7 @@                     case res of                         Left err -> liftIO $ expectationFailure ("kirokuConsumerGroupProcessors failed: " <> show err)                         Right processors -> do-                            appRes <- runApp IgnoreFailures 100 processors+                            appRes <- runApp defaultAppConfig processors                             case appRes of                                 Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                                 Right appHandle -> do@@ -241,7 +261,7 @@                                     c <- readTVar countVar                                     writeTVar countVar (c + 1)                             pure AckOk-                    res <- runApp IgnoreFailures 100 [(ProcessorId "sel", mkProcessor adapter handler)]+                    res <- runApp defaultAppConfig [(ProcessorId "sel", mkProcessor adapter handler)]                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -274,7 +294,7 @@                                     writeTVar countVar (c + 1)                             pure AckOk -                    res <- runApp IgnoreFailures 100 [(ProcessorId "catchup", mkProcessor adapter handler)]+                    res <- runApp defaultAppConfig [(ProcessorId "catchup", mkProcessor adapter handler)]                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -301,7 +321,7 @@                                     writeTVar countVar (c + 1)                             pure AckOk -                    res <- runApp IgnoreFailures 100 [(ProcessorId "live", mkProcessor adapter handler)]+                    res <- runApp defaultAppConfig [(ProcessorId "live", mkProcessor adapter handler)]                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -316,6 +336,78 @@                 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 [])]@@ -349,8 +441,7 @@                      res <-                         runApp-                            IgnoreFailures-                            100+                            defaultAppConfig                             [ (ProcessorId "orders", mkProcessor ordersAdapter (mkHandler ordersRef ordersCount))                             , (ProcessorId "payments", mkProcessor paymentsAdapter (mkHandler paymentsRef paymentsCount))                             , (ProcessorId "inventory", mkProcessor inventoryAdapter (mkHandler inventoryRef inventoryCount))@@ -414,8 +505,7 @@                      res <-                         runApp-                            IgnoreFailures-                            100+                            defaultAppConfig                             [ (ProcessorId "good", mkProcessor goodAdapter goodHandler)                             , (ProcessorId "bad", mkProcessor badAdapter badHandler)                             , (ProcessorId "other", mkProcessor otherAdapter otherHandler)@@ -470,8 +560,7 @@                      res <-                         runApp-                            IgnoreFailures-                            100+                            defaultAppConfig                             [ (ProcessorId "a", mkProcessor adapterA handlerA)                             , (ProcessorId "b", mkProcessor adapterB handlerB)                             ]@@ -507,7 +596,7 @@                             -- Retry the first delivery, accept the second.                             pure (if n == 1 then AckRetry (Ack.RetryDelay 0) else AckOk) -                    res <- runApp IgnoreFailures 100 [(ProcessorId "ackretry", mkProcessor adapter handler)]+                    res <- runApp defaultAppConfig [(ProcessorId "ackretry", mkProcessor adapter handler)]                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -546,7 +635,7 @@                                     liftIO $ modifyIORef' okPayloads (envelopePayload ingested :)                                     pure AckOk -                    res <- runApp IgnoreFailures 100 [(ProcessorId "ackdl", mkProcessor adapter handler)]+                    res <- runApp defaultAppConfig [(ProcessorId "ackdl", mkProcessor adapter handler)]                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -560,6 +649,79 @@                 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".@@ -604,7 +766,7 @@                             | m <- [0 .. 3 :: Int]                             ] -                    res <- runApp IgnoreFailures 100 processors+                    res <- runApp defaultAppConfig processors                     case res of                         Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                         Right appHandle -> do@@ -639,6 +801,102 @@                     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")) 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 "throws InvalidConsumerGroup for non-positive group sizes" $ \store -> do+                let handler ingested = do+                        let _ = envelopePayload ingested+                        pure AckOk+                    throwsSize n =+                        runEff+                            ( runTracingNoop $+                                kirokuConsumerGroupProcessors+                                    store+                                    (defaultConsumerGroupConfig (SubscriptionName ("cgp-invalid-" <> T.pack (show n))) AllStreams n)+                                    handler+                            )+                            `shouldThrow` ( \InvalidConsumerGroup{invalidMember = member, invalidSize = size} ->+                                                member == 0 && size == n+                                          )+                throwsSize 0+                throwsSize (-1)++            it "shuts down already-created member adapters after a later factory failure" $ \_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 $ modifyIORef' shutdowns (<> [m])+                                    }+                    handler ingested = do+                        let _ = envelopePayload ingested+                        pure AckOk+                result <-+                    E.try $+                        runEff $+                            runTracingNoop $+                                kirokuConsumerGroupProcessorsWith+                                    factory+                                    (defaultConsumerGroupConfig (SubscriptionName "cgp-partial-cleanup") AllStreams 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 "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 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]]@@ -687,7 +945,7 @@                                 pids                                     `shouldBe` ["cgp-shibuya-group-member-" <> T.pack (show m) | m <- [0 .. 3 :: Int]] -                            appRes <- runApp IgnoreFailures 100 processors+                            appRes <- runApp defaultAppConfig processors                             case appRes of                                 Left err -> liftIO $ expectationFailure ("runApp failed: " <> show err)                                 Right appHandle -> do@@ -707,9 +965,17 @@  -- Helpers -envelopePayload :: Ingested es RecordedEvent -> RecordedEvent-envelopePayload ing = let Ingested{envelope = env} = ing in env ^. #payload+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 = do@@ -764,6 +1030,13 @@     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  makeEvent :: Text -> Value -> EventData makeEvent typ p =