packages feed

keiro 0.18.0.0 → 0.19.0.0

raw patch · 9 files changed

+1358/−20 lines, 9 filesdep +shibuya-kiroku-adapterdep ~aesondep ~keiro-coredep ~keiro-test-supportPVP ok

version bump matches the API change (PVP)

Dependencies added: shibuya-kiroku-adapter

Dependency ranges changed: aeson, keiro-core, keiro-test-support, kiroku-store, shibuya-core

API changes (from Hackage documentation)

+ Keiro.Command: [seedVerifyInFlightLimit] :: RunCommandOptions -> !Int
+ Keiro.Telemetry: [snapshotSeedSkipped] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: [snapshotSeedVerificationFailed] :: KeiroMetrics -> Counter Int64
+ Keiro.Telemetry: keiroSnapshotSeedSkippedName :: Text
+ Keiro.Telemetry: keiroSnapshotSeedVerificationFailedName :: Text
+ Keiro.Telemetry: recordSnapshotSeedSkipped :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
+ Keiro.Telemetry: recordSnapshotSeedVerificationFailed :: MonadIO m => Maybe KeiroMetrics -> Int64 -> m ()
- Keiro.Command: RunCommandOptions :: !Int -> !Int32 -> ![EventId] -> !IO () -> !Int -> !Maybe KeiroMetrics -> !Bool -> !Int -> !Maybe Tracer -> !Maybe Value -> RunCommandOptions
+ Keiro.Command: RunCommandOptions :: !Int -> !Int32 -> ![EventId] -> !IO () -> !Int -> !Maybe KeiroMetrics -> !Bool -> !Int -> !Int -> !Maybe Tracer -> !Maybe Value -> RunCommandOptions
- Keiro.Telemetry: KeiroMetrics :: Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Histogram -> Histogram -> Gauge Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Histogram -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Histogram -> Gauge Int64 -> KeiroMetrics
+ Keiro.Telemetry: KeiroMetrics :: Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Histogram -> Histogram -> Gauge Int64 -> Counter Int64 -> Gauge Int64 -> Gauge Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Histogram -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Counter Int64 -> Gauge Int64 -> Histogram -> Gauge Int64 -> KeiroMetrics

Files

CHANGELOG.md view
@@ -6,6 +6,43 @@  ## [Unreleased] +## 0.19.0.0 — 2026-09-25++### Breaking Changes++- Require `shibuya-core ^>=0.10.0.0` and `kiroku-store >=0.9.0.1 && <0.10`.+  Shibuya 0.10 changes `ShutdownConfig`, `ConfigError`, `PolicyError`, and+  `ProcessorState`, and a permanent framework-owned finalization failure now+  throws `ProcessorFailure` rather than halting gracefully. Kiroku 0.9+  requires schema migration `0012` before any 0.9 process appends; see the+  root changelog for the cutover.+- `RunCommandOptions` gains `seedVerifyInFlightLimit`. Code that constructs+  the record directly must supply it; callers updating `defaultRunCommandOptions`+  receive the process-wide default of one.+- `KeiroMetrics` gains `snapshotSeedSkipped` and+  `snapshotSeedVerificationFailed` fields. Code that constructs the record+  directly rather than through `newKeiroMetrics` must supply them.++### New Features++- Limit sampled snapshot-seed full replays to one in-flight task per process by+  default. `RunCommandOptions.seedVerifyInFlightLimit` tunes the limit, with+  zero restoring unbounded verification. The new+  `keiro.snapshot.seed.skipped` and+  `keiro.snapshot.seed.verification.failed` counters expose skipped samples and+  unexpected task failures.++### Other Changes++- Add the `keiro-retention` test suite to `just verify`, with post-major heap+  measurements for isolated store, adapter, process-manager, router, and+  projection workloads (BUG-1).+- Add long and short command, hydration-only, and Kiroku tail-read retention+  legs to distinguish command heap growth from snapshot hydration and store+  reads (BUG-2).+- Count active Haskell threads in retention reports, excluding finished async+  handles that can remain reachable during a measurement loop.+ ## 0.18.0.0 — 2026-09-20  
keiro.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiro-version: 0.18.0.0+version: 0.19.0.0 synopsis: Event sourcing framework and workflow engine description:   A library that composes kiroku, keiki, and shibuya into an@@ -167,13 +167,13 @@     hs-opentelemetry-semantic-conventions >=1.40 && <2,     keiki >=0.9 && <0.10,     keiki-codec-json >=0.9 && <0.10,-    keiro-core ^>=0.18.0.0,-    kiroku-store >=0.8 && <0.9,+    keiro-core ^>=0.19.0.0,+    kiroku-store >=0.9.0.1 && <0.10,     lens >=5.2 && <5.4,     mmzk-typeid >=0.7 && <0.8,     random >=1.2.1 && <1.4,     scientific >=0.3 && <0.4,-    shibuya-core ^>=0.9.0.0,+    shibuya-core ^>=0.10.0.0,     stm >=2.5 && <2.6,     streamly >=0.11 && <0.12,     streamly-core >=0.3 && <0.4,@@ -233,10 +233,10 @@     keiki,     keiki-codec-json,     keiro,-    keiro-test-support ^>=0.18.0.0,-    kiroku-store >=0.8 && <0.9,+    keiro-test-support ^>=0.19.0.0,+    kiroku-store >=0.9.0.1 && <0.10,     process >=1.6 && <1.7,-    shibuya-core ^>=0.9.0.0,+    shibuya-core ^>=0.10.0.0,     stm >=2.5 && <2.6,     streamly-core >=0.3 && <0.4,     text >=2.1 && <2.2,@@ -245,6 +245,41 @@     uuid >=1.3 && <1.4,     vector >=0.13 && <0.14, +test-suite keiro-retention+  import: warnings, shared+  type: exitcode-stdio-1.0+  hs-source-dirs: retention+  main-is: Main.hs+  other-modules:+    Retention.Fixture+    Retention.Legs+    Retention.Measure++  ghc-options:+    -threaded+    -rtsopts+    "-with-rtsopts=-N -T"++  build-depends:+    aeson >=2.2 && <2.3,+    base >=4.21 && <5,+    effectful >=2.6 && <2.7,+    effectful-core >=2.6 && <2.7,+    hspec >=2.11,+    keiki >=0.9 && <0.10,+    keiki-codec-json >=0.9 && <0.10,+    keiro,+    keiro-core ^>=0.19.0.0,+    keiro-test-support ^>=0.19.0.0,+    kiroku-store >=0.9.0.1 && <0.10,+    shibuya-core ^>=0.10.0.0,+    shibuya-kiroku-adapter ^>=0.5.1.5,+    stm >=2.5 && <2.6,+    streamly-core >=0.3 && <0.4,+    text >=2.1 && <2.2,+    uuid >=1.3 && <1.4,+    vector >=0.13 && <0.14,+ benchmark keiro-bench   import: warnings, shared   type: exitcode-stdio-1.0@@ -275,10 +310,10 @@     hs-opentelemetry-sdk >=1.0 && <1.1,     keiki >=0.9 && <0.10,     keiro,-    keiro-core ^>=0.18.0.0,-    keiro-test-support ^>=0.18.0.0,-    kiroku-store >=0.8 && <0.9,-    shibuya-core ^>=0.9.0.0,+    keiro-core ^>=0.19.0.0,+    keiro-test-support ^>=0.19.0.0,+    kiroku-store >=0.9.0.1 && <0.10,+    shibuya-core ^>=0.10.0.0,     streamly-core >=0.3 && <0.4,     tasty-bench >=0.4,     text >=2.1 && <2.2,
+ retention/Main.hs view
@@ -0,0 +1,45 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE OverloadedRecordDot #-}++module Main (main) where++import Control.Monad (forM_, unless)+import Data.Text qualified as Text+import Keiro.Test.Postgres (withFreshResourceStore, withMigratedSuite)+import Retention.Legs (Leg (..), allLegs)+import Retention.Measure+import System.Environment (lookupEnv)+import Test.Hspec++main :: IO ()+main = do+  config <- gateConfigFromEnvironment+  selected <- selectLegs+  withMigratedSuite \fixture ->+    hspec $+      describe "Retention legs" $+        forM_ selected \leg ->+          it (Text.unpack leg.legName) $+            withFreshResourceStore fixture \handle -> do+              (verdict, samples) <- leg.run config handle+              unless (config.reportOnly || leg.legName == "command-long-history-verify-every") $ do+                verdict `shouldBe` Bounded+                whenBaseline leg.legName config samples++selectLegs :: IO [Leg]+selectLegs = do+  selected <- lookupEnv "KEIRO_RETENTION_LEGS"+  case selected of+    Nothing -> pure (filter ((/= "command-long-history-verify-every") . (.legName)) allLegs)+    Just raw -> do+      let names = fmap Text.strip (Text.splitOn "," (Text.pack raw))+          unknown = filter (`notElem` fmap (.legName) allLegs) names+      unless (not (null names) && all (not . Text.null) names && null unknown) $+        fail ("KEIRO_RETENTION_LEGS contains an unknown or empty leg: " <> show unknown)+      pure (filter (\leg -> leg.legName `elem` names) allLegs)++whenBaseline :: Text.Text -> GateConfig -> [HeapSample] -> Expectation+whenBaseline name config samples =+  if name == "baseline-no-store"+    then let (_, _, growth) = judge config samples in growth `shouldSatisfy` (< 512 * 1024)+    else pure ()
+ retention/Retention/Fixture.hs view
@@ -0,0 +1,452 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE TypeApplications #-}++module Retention.Fixture+  ( Signal (..),+    TargetCommand (..),+    TargetEvent (..),+    TargetEventStream,+    retentionTargetStream,+    LedgerCommand (..),+    LedgerEvent (..),+    LedgerRegs,+    LedgerState (..),+    RetentionLedgerStream,+    retentionLedger,+    ledgerTarget,+    seedLedger,+    seedLedgerRaw,+    retentionManager,+    retentionRouter,+    retentionTarget,+    signalStreamName,+    appendSignals,+    decodeSignal,+    ackAdapter,+    sampleOnAck,+  )+where++import Control.Concurrent.STM (atomically, tryPutTMVar)+import Control.Monad (forM_, unless, void, when)+import Data.Aeson (FromJSON, Result (..), ToJSON, Value (..), fromJSON, object, toJSON, (.=))+import Data.IORef (atomicModifyIORef', newIORef)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Data.Text qualified as Text+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Error.Static (Error)+import GHC.Generics (Generic)+import Keiki.Core+  ( Edge (..),+    HsPred,+    InCtor,+    IndexN,+    RegFile (..),+    SymTransducer (..),+    Update (..),+    WireCtor,+    inpCtor,+    matchInCtor,+    oNil,+    pack,+    proj,+    unavailableInCtor,+    unavailableWireCtor,+    (*:),+  )+import Keiki.Core qualified as Keiki+import Keiki.Shape (CanonicalStateShape)+import Keiro.Codec (Codec (..), encodeForAppend)+import Keiro.Command qualified as Command+import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))+import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow, unvalidated)+import Keiro.ProcessManager (PMCommand (..), ProcessManager (..), ProcessManagerAction (..))+import Keiro.Router (Router (..))+import Keiro.Snapshot.Codec (defaultStateCodec)+import Keiro.Snapshot.Schema (SnapshotRow (..), lookupSnapshotRow)+import Keiro.Stream (Stream, stream)+import Keiro.Stream qualified as Stream+import Keiro.Test.Postgres (StoreRunner (..))+import Kiroku.Store qualified as Store+import Kiroku.Store.Effect (Store)+import Kiroku.Store.Effect.Resource (KirokuStoreResource)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Subscription.Stream (AckItem (..), subscriptionAckStream)+import Kiroku.Store.Subscription.Types (SubscriptionConfig)+import Kiroku.Store.Subscription.Types qualified as Sub+import Kiroku.Store.Types (EventData (..), EventId (..), EventType (..), ExpectedVersion (..), GlobalPosition (..), RecordedEvent (..), StreamName (..), StreamVersion (..))+import Numeric.Natural (Natural)+import Shibuya.Adapter (Adapter (..))+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.Types (Attempt (..), Cursor (..), Envelope (..), MessageId (..))+import Streamly.Data.Stream qualified as Streamly++data Signal = Signal+  { signalId :: !Text,+    account :: !Int+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (FromJSON, ToJSON)++data TargetCommand = Credit !Int+  deriving stock (Eq, Show)++data TargetEvent = Credited !Int+  deriving stock (Eq, Show)++data TargetState = TargetReady+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)+  deriving anyclass (FromJSON, ToJSON)++instance CanonicalStateShape TargetState++type TargetEventStream = EventStream (HsPred '[] TargetCommand) '[] TargetState TargetCommand TargetEvent++data LedgerCommand = Deposit !Int+  deriving stock (Eq, Show)++data LedgerEvent = Deposited !Int+  deriving stock (Eq, Show)++data LedgerState = LedgerReady+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)+  deriving anyclass (FromJSON, ToJSON)++instance CanonicalStateShape LedgerState++type LedgerRegs = '[ '("balance", Int)]++type RetentionLedgerStream = EventStream (HsPred LedgerRegs LedgerCommand) LedgerRegs LedgerState LedgerCommand LedgerEvent++retentionLedger :: ValidatedEventStream (HsPred LedgerRegs LedgerCommand) LedgerRegs LedgerState LedgerCommand LedgerEvent+retentionLedger = mkEventStreamOrThrow "retention-ledger" ledgerStreamDef++ledgerTarget :: Stream RetentionLedgerStream+ledgerTarget = stream "retentionledger-account"++ledgerStreamDef :: RetentionLedgerStream+ledgerStreamDef =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \LedgerReady ->+              [ Edge+                  { guard = matchInCtor depositCtor,+                    update =+                      USet+                        (#balance :: IndexN "balance" LedgerRegs Int)+                        (proj (#balance :: Keiki.Index LedgerRegs Int) Keiki..+ inpCtor depositCtor #amount),+                    output = [pack depositCtor depositedCtor (inpCtor depositCtor #amount *: oNil)],+                    target = LedgerReady,+                    mode = Keiki.Live+                  }+              ],+            initial = LedgerReady,+            initialRegs = RCons (Proxy @"balance") 0 RNil,+            isFinal = \_ -> False+          },+      initialState = LedgerReady,+      initialRegisters = RCons (Proxy @"balance") 0 RNil,+      eventCodec =+        Codec+          { eventTypes = EventType "Deposited" :| [],+            eventType = \_ -> EventType "Deposited",+            schemaVersion = 1,+            encode = \(Deposited amount) -> toJSON amount,+            decode = \_ value -> case fromJSON value of+              Success amount -> Right (Deposited amount)+              Error message -> Left (Text.pack message),+            upcasters = []+          },+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Every 100,+      stateCodec = Just (defaultStateCodec @LedgerRegs @LedgerState 1)+    }++depositCtor :: InCtor LedgerCommand '[ '("amount", Int)]+depositCtor =+  unavailableInCtor+    "Deposit"+    (\(Deposit amount) -> Just (RCons Proxy amount RNil))+    (\(RCons _ amount RNil) -> Deposit amount)++depositedCtor :: WireCtor LedgerEvent (Int, ())+depositedCtor =+  unavailableWireCtor+    "Deposited"+    (\(Deposited amount) -> Just (amount, ()))+    (\(amount, ()) -> Deposited amount)++seedLedger :: StoreRunner -> Stream RetentionLedgerStream -> Int -> IO ()+seedLedger runner@(StoreRunner runStore) target count = do+  unless (count > 0 && count `mod` 100 == 0) $+    fail "seedLedger requires a positive multiple of 100 events"+  seedLedgerRaw runner target (count - 1)+  commandResult <- runStore (Command.runCommand Command.defaultRunCommandOptions {Command.seedVerifySampleRate = 0} retentionLedger target (Deposit 1))+  case commandResult of+    Right (Right result) | result.eventsAppended == 1 && result.streamVersion == StreamVersion (fromIntegral count) -> pure ()+    other -> fail ("seedLedger command: " <> show other)+  snapshot <- runStore do+    maybeId <- Store.lookupStreamId (Stream.streamName target)+    traverse lookupSnapshotRow maybeId+  case snapshot of+    Right (Just (Just row)) | row.streamVersion == StreamVersion (fromIntegral count) -> pure ()+    other -> fail ("seedLedger snapshot: " <> show other)++seedLedgerRaw :: StoreRunner -> Stream RetentionLedgerStream -> Int -> IO ()+seedLedgerRaw (StoreRunner runStore) target count = appendChunks count+  where+    eventStream = unvalidated retentionLedger+    name = Stream.streamName target+    seededEvent = either (error . show) Prelude.id (encodeForAppend eventStream.eventCodec (Deposited 1))+    appendChunks remaining+      | remaining <= 0 = pure ()+      | otherwise = do+          let chunk = min 500 remaining+          result <- runStore (Store.appendToStream name AnyVersion (replicate chunk seededEvent))+          case result of+            Left failure -> fail ("seedLedger append: " <> show failure)+            Right _ -> appendChunks (remaining - chunk)++retentionTargetStream :: ValidatedEventStream (HsPred '[] TargetCommand) '[] TargetState TargetCommand TargetEvent+retentionTargetStream = mkEventStreamOrThrow "retention-target" targetStreamDef++snapshottedTargetStream :: ValidatedEventStream (HsPred '[] TargetCommand) '[] TargetState TargetCommand TargetEvent+snapshottedTargetStream =+  mkEventStreamOrThrow+    "retention-target-snapshotted"+    targetStreamDef+      { snapshotPolicy = Every 100,+        stateCodec = Just (defaultStateCodec @'[] @TargetState 1)+      }++targetStreamDef :: TargetEventStream+targetStreamDef =+  EventStream+    { transducer = targetTransducer,+      initialState = TargetReady,+      initialRegisters = RNil,+      eventCodec =+        Codec+          { eventTypes = EventType "Credited" :| [],+            eventType = \_ -> EventType "Credited",+            schemaVersion = 1,+            encode = \(Credited amount) -> toJSON amount,+            decode = \_ value -> case fromJSON value of+              Success amount -> Right (Credited amount)+              Error message -> Left (Text.pack message),+            upcasters = []+          },+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++targetTransducer :: SymTransducer (HsPred '[] TargetCommand) '[] TargetState TargetCommand TargetEvent+targetTransducer =+  SymTransducer+    { edgesOut = \TargetReady ->+        [ Edge+            { guard = matchInCtor creditCtor,+              update = UKeep,+              output = [pack creditCtor creditedCtor (inpCtor creditCtor #amount *: oNil)],+              target = TargetReady,+              mode = Keiki.Live+            }+        ],+      initial = TargetReady,+      initialRegs = RNil,+      isFinal = \_ -> False+    }++creditCtor :: InCtor TargetCommand '[ '("amount", Int)]+creditCtor =+  unavailableInCtor+    "Credit"+    (\(Credit amount) -> Just (RCons Proxy amount RNil))+    (\(RCons _ amount RNil) -> Credit amount)++creditedCtor :: WireCtor TargetEvent (Int, ())+creditedCtor =+  unavailableWireCtor+    "Credited"+    (\(Credited amount) -> Just (amount, ()))+    (\(amount, ()) -> Credited amount)++data ManagerCommand = Seen+  deriving stock (Eq, Show)++data ManagerEvent = SeenRecorded+  deriving stock (Eq, Show)++data ManagerState = ManagerReady+  deriving stock (Bounded, Enum, Eq, Ord, Show)++type ManagerEventStream = EventStream (HsPred '[] ManagerCommand) '[] ManagerState ManagerCommand ManagerEvent++managerEventStream :: ValidatedEventStream (HsPred '[] ManagerCommand) '[] ManagerState ManagerCommand ManagerEvent+managerEventStream = mkEventStreamOrThrow "retention-manager" managerStream++managerStream :: ManagerEventStream+managerStream =+  EventStream+    { transducer =+        SymTransducer+          { edgesOut = \ManagerReady ->+              [ Edge+                  { guard = matchInCtor seenCtor,+                    update = UKeep,+                    output = [pack seenCtor seenRecordedCtor oNil],+                    target = ManagerReady,+                    mode = Keiki.Live+                  }+              ],+            initial = ManagerReady,+            initialRegs = RNil,+            isFinal = \_ -> False+          },+      initialState = ManagerReady,+      initialRegisters = RNil,+      eventCodec =+        Codec+          { eventTypes = EventType "SeenRecorded" :| [],+            eventType = \_ -> EventType "SeenRecorded",+            schemaVersion = 1,+            encode = \_ -> Null,+            decode = \_ _ -> Right SeenRecorded,+            upcasters = []+          },+      resolveStreamName = Stream.streamName,+      snapshotPolicy = Never,+      stateCodec = Nothing+    }++seenCtor :: InCtor ManagerCommand '[]+seenCtor = unavailableInCtor "Seen" (\Seen -> Just RNil) (\RNil -> Seen)++seenRecordedCtor :: WireCtor ManagerEvent ()+seenRecordedCtor = unavailableWireCtor "SeenRecorded" (\SeenRecorded -> Just ()) (\() -> SeenRecorded)++retentionTarget :: Int -> Stream TargetCommand+retentionTarget accountNumber = stream ("retentiontarget-" <> Text.pack (show (accountNumber `mod` 16)))++retentionManager :: ProcessManager Signal (HsPred '[] ManagerCommand) '[] ManagerState ManagerCommand ManagerEvent (HsPred '[] TargetCommand) '[] TargetState TargetCommand TargetEvent+retentionManager =+  ProcessManager+    { name = "retention-manager",+      correlate = signalId,+      eventStream = managerEventStream,+      streamFor = \identifier -> stream ("pm:retention-" <> identifier),+      targetEventStream = retentionTargetStream,+      targetProjections = const [],+      handle = \signal ->+        ProcessManagerAction+          { command = Seen,+            commands = [PMCommand (retentionTarget signal.account) (Credit 1)],+            timers = []+          }+    }++retentionRouter :: Bool -> Router Signal (HsPred '[] TargetCommand) '[] TargetState TargetCommand TargetEvent '[Store, Error StoreError, KirokuStoreResource, IOE]+retentionRouter snapshotTargets =+  Router+    { name = "retention-router",+      key = signalId,+      resolve = \_ -> pure [PMCommand (retentionTarget accountNumber) (Credit 1) | accountNumber <- [0 .. 3]],+      targetEventStream = if snapshotTargets then snapshottedTargetStream else retentionTargetStream,+      targetProjections = const []+    }++signalStreamName :: Int -> StreamName+signalStreamName index = StreamName ("retentionsource-" <> Text.pack (show index))++appendSignals :: StoreRunner -> Int -> IO ()+appendSignals (StoreRunner runStore) count =+  forM_ [1 .. count] \index -> do+    let signal = Signal (Text.pack (show index)) (index `mod` 16)+        event =+          EventData+            { eventId = Nothing,+              eventType = EventType "RetentionSignal",+              payload = toJSON signal,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+    result <- runStore (Store.appendToStream (signalStreamName index) NoStream [event])+    case result of+      Left failure -> fail ("appendSignals: " <> show failure)+      Right _ -> pure ()++decodeSignal :: RecordedEvent -> Maybe (RecordedEvent, Signal)+decodeSignal recorded =+  case fromJSON recorded.payload of+    Success signal -> Just (recorded, signal)+    Error _ -> Nothing++ackAdapter :: (IOE :> es) => Store.KirokuStore -> SubscriptionConfig -> Natural -> IO (Adapter es RecordedEvent, IO ())+ackAdapter store config bufferSize = do+  (source, cancel) <- subscriptionAckStream store config bufferSize+  let ingested = fmap (toIngested cancel) (Streamly.morphInner liftIO source)+  pure+    ( Adapter {adapterName = "retention-ack-bridge", source = ingested, shutdown = liftIO cancel},+      cancel+    )++toIngested :: (IOE :> es) => IO () -> AckItem -> Ingested es RecordedEvent+toIngested cancel (AckItem event attempt reply) =+  Ingested+    { envelope =+        Envelope+          { messageId = case event.eventId of EventId uuid -> MessageId (Text.pack (UUID.toString uuid)),+            cursor = case event.globalPosition of GlobalPosition position -> Just (CursorInt (fromIntegral position)),+            partition = Nothing,+            enqueuedAt = Just event.createdAt,+            traceContext = Nothing,+            headers = Nothing,+            attempt = Just (Attempt attempt),+            attributes = mempty,+            payload = event+          },+      ack = AckHandle \case+        AckHalt _ -> liftIO cancel+        decision -> liftIO $ atomically $ void $ tryPutTMVar reply (toSubscriptionResult attempt decision),+      lease = Nothing+    }++toSubscriptionResult :: Word -> AckDecision -> Sub.SubscriptionResult+toSubscriptionResult attempt = \case+  AckOk -> Sub.Continue+  AckRetry (Ack.RetryDelay delay) -> Sub.Retry (Sub.RetryDelay delay)+  AckDeadLetter reason -> Sub.DeadLetter $ case reason of+    Ack.PoisonPill detail -> Sub.DeadLetterPoison detail+    Ack.InvalidPayload detail -> Sub.DeadLetterInvalid detail+    Ack.MaxRetriesExceeded -> Sub.DeadLetterMaxAttempts (fromIntegral attempt)+    other@(Ack.ApplicationFailure code detail) ->+      Sub.DeadLetterOther+        (Ack.renderDeadLetterReason other)+        (object ["code" .= Ack.deadLetterCodeText code, "detail" .= detail])+  AckHalt _ -> Sub.Continue++sampleOnAck :: (IOE :> es) => Int -> Int -> (Int -> IO ()) -> Adapter es msg -> Eff es (Adapter es msg)+sampleOnAck expected blockSize sample adapter = do+  countRef <- liftIO (newIORef 0)+  pure adapter {source = fmap (wrap countRef) adapter.source}+  where+    wrap countRef ingested =+      ingested+        { ack = AckHandle \decision -> do+            ingested.ack.finalize decision+            unless (decision == AckOk) $ liftIO $ fail ("retention worker ack was " <> show decision)+            count <- liftIO $ atomicModifyIORef' countRef (\previous -> let next = previous + 1 in (next, next))+            when (count == expected) adapter.shutdown+            when (count `mod` blockSize == 0) $ liftIO (sample count)+        }
+ retention/Retention/Legs.hs view
@@ -0,0 +1,375 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE OverloadedRecordDot #-}++module Retention.Legs+  ( Leg (..),+    allLegs,+  )+where++import Control.Concurrent (threadDelay)+import Control.Concurrent.STM (atomically, putTMVar)+import Control.Exception (evaluate, finally)+import Control.Monad (forM_, unless, void, when)+import Data.Aeson (toJSON)+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Data.UUID qualified as UUID+import Data.Vector qualified as Vector+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Exception qualified as Effectful+import Keiro.Command (defaultRunCommandOptions)+import Keiro.Command qualified as Command+import Keiro.EventStream.Validate (unvalidated)+import Keiro.ProcessManager (defaultWorkerOptions, runProcessManagerWorkerWith)+import Keiro.Projection (AsyncApplyOutcome (..), AsyncProjection (..), applyAsyncProjection)+import Keiro.ReadModel.Schema (registerReadModel)+import Keiro.Router (runRouterWorkerWith)+import Keiro.Snapshot.Schema (SnapshotRow (..), lookupSnapshotRow)+import Keiro.Stream qualified as KeiroStream+import Keiro.Test.Postgres (StoreRunner (..))+import Kiroku.Store qualified as Store+import Kiroku.Store.Subscription qualified as Subscription+import Kiroku.Store.Subscription.Stream (AckItem (..), subscriptionAckStream)+import Kiroku.Store.Subscription.Types (SubscriptionConfig, SubscriptionName (..), SubscriptionResult (..), SubscriptionTarget (..), defaultSubscriptionConfig)+import Kiroku.Store.Transaction qualified as StoreTx+import Kiroku.Store.Types (CategoryName (..), EventData (..), EventId (..), EventType (..), ExpectedVersion (..), RecordedEvent, StreamName (..), StreamVersion (..))+import Retention.Fixture (LedgerCommand (..), TargetCommand (..), TargetEventStream, ackAdapter, appendSignals, decodeSignal, ledgerTarget, retentionLedger, retentionManager, retentionRouter, retentionTargetStream, sampleOnAck, seedLedger, seedLedgerRaw)+import Retention.Measure (GateConfig (..), HeapSample (..), Verdict (..), measureLeg, measureLegWithSampler, sampleHeap)+import Shibuya.Adapter (Adapter (..))+import Shibuya.Adapter.Kiroku (defaultKirokuAdapterConfig, kirokuAdapter)+import Shibuya.Core.Ack (AckDecision (..))+import Shibuya.Core.AckHandle (AckHandle (..))+import Shibuya.Core.Ingested (Ingested (..))+import Streamly.Data.Stream (Stream)+import Streamly.Data.Stream qualified as Streamly+import System.Environment (lookupEnv)+import System.Timeout (timeout)+import Text.Read (readMaybe)++data Leg = Leg+  { legName :: !Text,+    run :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+  }++allLegs :: [Leg]+allLegs =+  [ Leg "baseline-no-store" baselineNoStore,+    Leg "kiroku-append-probe" kirokuAppendProbe,+    Leg "kiroku-append-only" kirokuAppendOnly,+    Leg "kiroku-append-tx" kirokuAppendTx,+    Leg "kiroku-probe-only" kirokuProbeOnly,+    Leg "kiroku-subscribe-ack" kirokuSubscribeAck,+    Leg "hand-bridge-ack" handBridgeAck,+    Leg "shibuya-adapter-ack" shibuyaAdapterAck,+    Leg "pm-worker" pmWorker,+    Leg "router-worker" routerWorker,+    Leg "projection-apply" projectionApply,+    Leg "command-long-history" commandLongHistory,+    Leg "hydrate-only" hydrateOnly,+    Leg "kiroku-read-tail" kirokuReadTail,+    Leg "command-short-history" commandShortHistory,+    Leg "command-long-history-verify-every" commandLongHistoryVerifyEvery+  ]++operationCount :: GateConfig -> Int+operationCount config = config.blocks * config.blockSize++commandLongHistory :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+commandLongHistory = commandLongHistoryWithRate "command-long-history" 0++commandLongHistoryVerifyEvery :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+commandLongHistoryVerifyEvery = commandLongHistoryWithRate "command-long-history-verify-every" 1++commandLongHistoryWithRate :: Text -> Int -> GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+commandLongHistoryWithRate name rate config (_, runner@(StoreRunner runStore)) = do+  seedLedger runner ledgerTarget 10_000+  let options = defaultRunCommandOptions {Command.seedVerifySampleRate = rate}+  measured <- measureLeg config name \index -> do+    outcome <- runStore (Command.runCommand options retentionLedger ledgerTarget (Deposit 1))+    case outcome of+      Right (Right result)+        | result.eventsAppended == 1+            && result.streamVersion == StreamVersion (fromIntegral (10_000 + index)) ->+            pure ()+      other -> fail (Text.unpack name <> ": command " <> show index <> ": " <> show other)+  snapshot <- runStore do+    maybeId <- Store.lookupStreamId (KeiroStream.streamName ledgerTarget)+    traverse lookupSnapshotRow maybeId+  let expectedSnapshot = StreamVersion (fromIntegral (((10_000 + operationCount config) `div` 100) * 100))+  case snapshot of+    Right (Just (Just row)) | row.streamVersion == expectedSnapshot -> pure ()+    other -> fail (Text.unpack name <> ": final snapshot: " <> show other)+  postReturn name (operationCount config)+  pure measured++hydrateOnly :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+hydrateOnly config (_, runner@(StoreRunner runStore)) = do+  seedLedger runner ledgerTarget 10_000+  let options = defaultRunCommandOptions {Command.seedVerifySampleRate = 0}+  measured <- measureLeg config "hydrate-only" \_ -> do+    outcome <- runStore (Command.hydrate options (unvalidated retentionLedger) ledgerTarget)+    case outcome of+      Right (Right hydrated) | hydrated.streamVersion == StreamVersion 10_000 -> pure ()+      Right (Right hydrated) -> fail ("hydrate-only: unexpected version " <> show hydrated.streamVersion)+      Right (Left failure) -> fail ("hydrate-only: " <> show failure)+      Left failure -> fail ("hydrate-only: " <> show failure)+  postReturn "hydrate-only" (operationCount config)+  pure measured++kirokuReadTail :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+kirokuReadTail config (_, runner@(StoreRunner runStore)) = do+  seedLedgerRaw runner ledgerTarget 10_000+  let name = KeiroStream.streamName ledgerTarget+  measured <- measureLeg config "kiroku-read-tail" \_ -> do+    outcome <- runStore do+      maybeId <- Store.lookupStreamId name+      events <- Store.readStreamForward name (StreamVersion 9_900) 256+      pure (maybeId, events)+    case outcome of+      Right (Just _, events) | Vector.length events == 100 -> pure ()+      other -> fail ("kiroku-read-tail: " <> show (fmap (\(streamId, events) -> (streamId, Vector.length events)) other))+  postReturn "kiroku-read-tail" (operationCount config)+  pure measured++commandShortHistory :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+commandShortHistory config (_, StoreRunner runStore) = do+  let options = defaultRunCommandOptions {Command.seedVerifySampleRate = 0}+  measured <- measureLeg config "command-short-history" \index -> do+    let target = KeiroStream.stream ("retentiontarget-" <> Text.pack (show (index `mod` 16))) :: KeiroStream.Stream TargetEventStream+        expected = StreamVersion (fromIntegral ((index - 1) `div` 16 + 1))+    outcome <- runStore (Command.runCommand options retentionTargetStream target (Credit 1))+    case outcome of+      Right (Right result) | result.eventsAppended == 1 && result.streamVersion == expected -> pure ()+      other -> fail ("command-short-history: command " <> show index <> ": " <> show other)+  postReturn "command-short-history" (operationCount config)+  pure measured++baselineNoStore :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+baselineNoStore config _ =+  measureLeg config "baseline-no-store" \index ->+    void (evaluate (sum (replicate 100 (index `mod` 17))))++kirokuAppendProbe :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+kirokuAppendProbe config (_, StoreRunner runStore) = do+  measured <- measureLegWithSampler config "kiroku-append-probe" \sample -> do+    outcome <- runStore $ forM_ [1 .. operationCount config] \index -> do+      let (streamName, eventId, event) = plainEvent index+      _ <- Store.appendToStream streamName AnyVersion [event]+      exists <- Store.eventExistsInStream streamName eventId+      unless exists $ liftIO $ fail ("kiroku-append-probe: missing event " <> show index)+      when (index `mod` config.blockSize == 0) $ liftIO (sample index)+    void (expectRight outcome)+  postReturn "kiroku-append-probe" (operationCount config)+  settleSeconds <- lookupEnv "KEIRO_RETENTION_SETTLE_SECONDS"+  forM_ settleSeconds \rawSeconds -> case readMaybe rawSeconds of+    Just seconds | seconds > 0 -> do+      threadDelay (seconds * 1_000_000)+      settled <- sampleHeap (operationCount config)+      Text.IO.putStrLn ("settled leg=kiroku-append-probe after_seconds=" <> Text.pack (show seconds) <> " live_bytes=" <> Text.pack (show settled.liveBytes) <> " large_objects_bytes=" <> Text.pack (show settled.largeObjectBytes))+    _ -> fail "KEIRO_RETENTION_SETTLE_SECONDS must be a positive integer"+  pure measured++kirokuAppendOnly :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+kirokuAppendOnly config (_, StoreRunner runStore) = do+  requestedRate <- lookupEnv "KEIRO_RETENTION_APPEND_RATE"+  delayMicros <- case requestedRate of+    Nothing -> pure 0+    Just rawRate -> case readMaybe rawRate of+      Just rate | rate > 0 -> pure (1_000_000 `div` rate)+      _ -> fail "KEIRO_RETENTION_APPEND_RATE must be a positive integer"+  measured <- measureLegWithSampler config "kiroku-append-only" \sample -> do+    outcome <- runStore $ forM_ [1 .. operationCount config] \index -> do+      let (streamName, _, event) = plainEvent index+      _ <- Store.appendToStream streamName AnyVersion [event]+      when (delayMicros > 0) $ liftIO (threadDelay delayMicros)+      when (index `mod` config.blockSize == 0) $ liftIO (sample index)+    void (expectRight outcome)+  postReturn "kiroku-append-only" (operationCount config)+  pure measured++kirokuAppendTx :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+kirokuAppendTx config (_, StoreRunner runStore) = do+  measured <- measureLegWithSampler config "kiroku-append-tx" \sample -> do+    outcome <- runStore $ forM_ [1 .. operationCount config] \index -> do+      let (streamName, _, event) = plainEvent index+      result <- StoreTx.runTransactionAppendingResource streamName AnyVersion [event] (const (pure ()))+      case result of+        Left failure -> liftIO $ fail ("kiroku-append-tx: " <> show failure)+        Right () -> pure ()+      when (index `mod` config.blockSize == 0) $ liftIO (sample index)+    void (expectRight outcome)+  postReturn "kiroku-append-tx" (operationCount config)+  pure measured++kirokuProbeOnly :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+kirokuProbeOnly config (_, StoreRunner runStore) = do+  setup <- runStore $ forM_ [1 .. operationCount config] \index -> do+    let (streamName, _, event) = plainEvent index+    void (Store.appendToStream streamName AnyVersion [event])+  void (expectRight setup)+  measured <- measureLegWithSampler config "kiroku-probe-only" \sample -> do+    outcome <- runStore $ forM_ [1 .. operationCount config] \index -> do+      let (streamName, eventId, _) = plainEvent index+      exists <- Store.eventExistsInStream streamName eventId+      unless exists $ liftIO $ fail ("kiroku-probe-only: missing event " <> show index)+      when (index `mod` config.blockSize == 0) $ liftIO (sample index)+    void (expectRight outcome)+  postReturn "kiroku-probe-only" (operationCount config)+  pure measured++plainEvent :: Int -> (StreamName, EventId, EventData)+plainEvent index =+  let streamName = StreamName ("retentionplain-" <> Text.pack (show (index `mod` 16)))+      eventId = EventId (UUID.fromWords 0 0 0 (fromIntegral index))+      event =+        EventData+          { eventId = Just eventId,+            eventType = EventType "RetentionPlain",+            payload = toJSON index,+            metadata = Nothing,+            causationId = Nothing,+            correlationId = Nothing+          }+   in (streamName, eventId, event)++sourceTarget :: SubscriptionTarget+sourceTarget = Category (CategoryName "retentionsource")++sourceConfig :: Text -> SubscriptionConfig+sourceConfig name = defaultSubscriptionConfig (SubscriptionName name) sourceTarget (\_ -> pure Continue)++kirokuSubscribeAck :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+kirokuSubscribeAck config (store, runner) = do+  appendSignals runner (operationCount config)+  measured <- measureLegWithSampler config "kiroku-subscribe-ack" \sample -> do+    (source, cancel) <- subscriptionAckStream store (sourceConfig "retention-kiroku-ack") 256+    consumeAck (operationCount config) config.blockSize sample source `finally` cancel+  postReturn "kiroku-subscribe-ack" (operationCount config)+  pure measured++consumeAck :: Int -> Int -> (Int -> IO ()) -> Stream IO AckItem -> IO ()+consumeAck expected blockSize sample = go 1+  where+    go index source = do+      next <- timeout 30_000_000 (Streamly.uncons source)+      case next of+        Nothing -> fail ("kiroku-subscribe-ack: timed out after " <> show (index - 1) <> " events")+        Just Nothing -> fail ("kiroku-subscribe-ack: ended after " <> show (index - 1) <> " events")+        Just (Just (item, rest)) -> do+          atomically (putTMVar item.ackReply Continue)+          when (index `mod` blockSize == 0) (sample index)+          when (index < expected) (go (index + 1) rest)++handBridgeAck :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+handBridgeAck config (store, runner@(StoreRunner runStore)) = do+  appendSignals runner (operationCount config)+  measured <- measureLegWithSampler config "hand-bridge-ack" \sample -> do+    outcome <- runStore do+      (adapter, _) <- liftIO $ ackAdapter store (sourceConfig "retention-hand-bridge-ack") 256+      consumeAdapter (operationCount config) config.blockSize sample adapter `Effectful.finally` adapter.shutdown+    void (expectRight outcome)+  postReturn "hand-bridge-ack" (operationCount config)+  pure measured++shibuyaAdapterAck :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+shibuyaAdapterAck config (store, runner@(StoreRunner runStore)) = do+  appendSignals runner (operationCount config)+  measured <- measureLegWithSampler config "shibuya-adapter-ack" \sample -> do+    outcome <- runStore do+      adapter <- kirokuAdapter store (defaultKirokuAdapterConfig (SubscriptionName "retention-shibuya-ack") sourceTarget)+      consumeAdapter (operationCount config) config.blockSize sample adapter `Effectful.finally` adapter.shutdown+    void (expectRight outcome)+  postReturn "shibuya-adapter-ack" (operationCount config)+  pure measured++consumeAdapter :: (IOE :> es) => Int -> Int -> (Int -> IO ()) -> Adapter es RecordedEvent -> Eff es ()+consumeAdapter expected blockSize sample adapter = go 1 adapter.source+  where+    go index source = do+      next <- Streamly.uncons source+      case next of+        Nothing -> liftIO $ fail ("shibuya-adapter-ack: ended after " <> show (index - 1) <> " events")+        Just (item, rest) -> do+          item.ack.finalize AckOk+          when (index `mod` blockSize == 0) $ liftIO (sample index)+          when (index < expected) (go (index + 1) rest)++pmWorker :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+pmWorker config (store, runner@(StoreRunner runStore)) = do+  appendSignals runner (operationCount config)+  measured <- measureLegWithSampler config "pm-worker" \sample -> do+    outcome <- runStore do+      adapter <- kirokuAdapter store (defaultKirokuAdapterConfig (SubscriptionName "retention-pm") sourceTarget)+      instrumented <- sampleOnAck (operationCount config) config.blockSize sample adapter+      runProcessManagerWorkerWith defaultWorkerOptions defaultRunCommandOptions retentionManager instrumented decodeSignal+        `Effectful.finally` adapter.shutdown+    void (expectRight outcome)+  assertTargets runner (operationCount config) [0 .. 15] (\accountNumber -> length [index | index <- [1 .. operationCount config], index `mod` 16 == accountNumber])+  postReturn "pm-worker" (operationCount config)+  pure measured++routerWorker :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+routerWorker config (store, runner@(StoreRunner runStore)) = do+  let snapshotTargets = operationCount config > 1500+  Text.IO.putStrLn $ if snapshotTargets then "router-worker target snapshots: Every 100" else "router-worker target snapshots: Never"+  appendSignals runner (operationCount config)+  measured <- measureLegWithSampler config "router-worker" \sample -> do+    outcome <- runStore do+      adapter <- kirokuAdapter store (defaultKirokuAdapterConfig (SubscriptionName "retention-router") sourceTarget)+      instrumented <- sampleOnAck (operationCount config) config.blockSize sample adapter+      runRouterWorkerWith defaultWorkerOptions defaultRunCommandOptions (retentionRouter snapshotTargets) instrumented decodeSignal+        `Effectful.finally` adapter.shutdown+    void (expectRight outcome)+  assertTargets runner (operationCount config) [0 .. 3] (const (operationCount config))+  postReturn "router-worker" (operationCount config)+  pure measured++assertTargets :: StoreRunner -> Int -> [Int] -> (Int -> Int) -> IO ()+assertTargets (StoreRunner runStore) total accounts expectedFor =+  forM_ accounts \accountNumber -> do+    let name = StreamName ("retentiontarget-" <> Text.pack (show accountNumber))+    events <- expectRight =<< runStore (Store.readStreamForward name (StreamVersion 0) (fromIntegral (total + 1)))+    unless (Vector.length events == expectedFor accountNumber) $+      fail ("target " <> show name <> ": expected " <> show (expectedFor accountNumber) <> " events, got " <> show (Vector.length events))++projectionApply :: GateConfig -> (Store.KirokuStore, StoreRunner) -> IO (Verdict, [HeapSample])+projectionApply config (store, runner@(StoreRunner runStore)) = do+  void (expectRight =<< runStore (registerReadModel "retention-activity" 1 "retention-activity-v1"))+  appendSignals runner (operationCount config)+  measured <- measureLegWithSampler config "projection-apply" \sample -> do+    countRef <- newIORef 0+    let projection =+          AsyncProjection+            { name = "retention-activity",+              readModelName = "retention-activity",+              subscriptionName = "retention-projection",+              applyRecorded = \_ -> pure (),+              idempotencyKey = (.eventId)+            }+        handler event = do+          outcome <- expectRight =<< runStore (Store.runTransaction (applyAsyncProjection projection event))+          unless (outcome == AsyncApplied) $ fail ("projection-apply: " <> show outcome)+          count <- atomicModifyIORef' countRef (\previous -> let next = previous + 1 in (next, next))+          when (count `mod` config.blockSize == 0) (sample count)+          pure if count == operationCount config then Stop else Continue+        config' = defaultSubscriptionConfig (SubscriptionName "retention-projection") sourceTarget handler+    completed <- timeout 120_000_000 (Subscription.withSubscription store config' Subscription.wait)+    case completed of+      Nothing -> fail "projection-apply: subscription timed out"+      Just (Left failure) -> fail ("projection-apply: " <> show failure)+      Just (Right ()) -> pure ()+    count <- readIORef countRef+    unless (count == operationCount config) $ fail ("projection-apply: processed " <> show count <> " events")+  postReturn "projection-apply" (operationCount config)+  pure measured++postReturn :: Text -> Int -> IO ()+postReturn name count = do+  sample <- sampleHeap count+  Text.IO.putStrLn ("post-return leg=" <> name <> " live_bytes=" <> Text.pack (show sample.liveBytes) <> " large_objects_bytes=" <> Text.pack (show sample.largeObjectBytes))++expectRight :: (Show errorType) => Either errorType a -> IO a+expectRight = either (fail . show) pure
+ retention/Retention/Measure.hs view
@@ -0,0 +1,164 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE OverloadedRecordDot #-}++module Retention.Measure+  ( HeapSample (..),+    Verdict (..),+    GateConfig (..),+    gateConfigFromEnvironment,+    sampleHeap,+    judge,+    measureLeg,+    measureLegWithSampler,+    renderTable,+  )+where++import Control.Monad (forM_, unless)+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.List (intercalate)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Data.Word (Word64)+import GHC.Conc (ThreadStatus (..), listThreads, threadStatus)+import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)+import Numeric (showFFloat)+import System.Environment (lookupEnv)+import System.Mem (performMajorGC)+import Text.Read (readMaybe)++data HeapSample = HeapSample+  { operations :: !Int,+    liveBytes :: !Word64,+    largeObjectBytes :: !Word64,+    threads :: !Int+  }+  deriving stock (Eq, Show)++data Verdict = Bounded | Retained+  deriving stock (Eq, Show)++data GateConfig = GateConfig+  { blocks :: !Int,+    blockSize :: !Int,+    warmupBlocks :: !Int,+    slopeFloorBytesPerOp :: !Double,+    growthFloorBytes :: !Word64,+    reportOnly :: !Bool+  }+  deriving stock (Eq, Show)++gateConfigFromEnvironment :: IO GateConfig+gateConfigFromEnvironment = do+  total <- positiveEnv "KEIRO_RETENTION_OPERATIONS" 1500+  requestedBlocks <- lookupEnv "KEIRO_RETENTION_BLOCKS"+  blockCount <- case requestedBlocks of+    Nothing ->+      case filter (\candidate -> total `mod` candidate == 0) [6 .. 12] of+        first : _ -> pure first+        [] -> fail "KEIRO_RETENTION_OPERATIONS needs a divisor from 6 to 12; set KEIRO_RETENTION_BLOCKS explicitly"+    Just _ -> positiveEnv "KEIRO_RETENTION_BLOCKS" 6+  unless (blockCount >= 6 && total `mod` blockCount == 0) $+    fail "retention probe requires at least 6 blocks and operations divisible by blocks"+  report <- maybe False (const True) <$> lookupEnv "KEIRO_RETENTION_REPORT_ONLY"+  pure+    GateConfig+      { blocks = blockCount,+        blockSize = total `div` blockCount,+        warmupBlocks = 1,+        slopeFloorBytesPerOp = 512,+        growthFloorBytes = 2 * 1024 * 1024,+        reportOnly = report+      }++positiveEnv :: String -> Int -> IO Int+positiveEnv name defaultValue = do+  raw <- lookupEnv name+  case raw of+    Nothing -> pure defaultValue+    Just value -> case readMaybe value of+      Just parsed | parsed > 0 -> pure parsed+      _ -> fail (name <> " must be a positive integer")++sampleHeap :: Int -> IO HeapSample+sampleHeap count = do+  enabled <- getRTSStatsEnabled+  unless enabled $ fail "keiro-retention requires RTS statistics; run with +RTS -T -RTS"+  performMajorGC+  stats <- getRTSStats+  statuses <- listThreads >>= traverse threadStatus+  let threadCount = length (filter isActive statuses)+  pure+    HeapSample+      { operations = count,+        liveBytes = gcdetails_live_bytes stats.gc,+        largeObjectBytes = gcdetails_large_objects_bytes stats.gc,+        threads = threadCount+      }+  where+    -- listThreads also returns finished Async handles that can remain+    -- reachable during a measurement loop. They are not running workers.+    isActive ThreadFinished = False+    isActive ThreadDied = False+    isActive _ = True++judge :: GateConfig -> [HeapSample] -> (Verdict, Double, Word64)+judge config allSamples =+  let kept = drop config.warmupBlocks allSamples+      xs = fmap (fromIntegral . (.operations)) kept :: [Double]+      ys = fmap (fromIntegral . (.liveBytes)) kept :: [Double]+      count = fromIntegral (length kept)+      xMean = sum xs / count+      yMean = sum ys / count+      numerator = sum (zipWith (\x y -> (x - xMean) * (y - yMean)) xs ys)+      denominator = sum (fmap (\x -> (x - xMean) ^ (2 :: Int)) xs)+      slope = if denominator == 0 then 0 else numerator / denominator+      growth = case kept of+        [] -> 0+        first : _ -> max 0 (toInteger (liveBytes (last kept)) - toInteger first.liveBytes)+      verdict = if slope > config.slopeFloorBytesPerOp && growth > toInteger config.growthFloorBytes then Retained else Bounded+   in (verdict, slope, fromInteger growth)++measureLeg :: GateConfig -> Text -> (Int -> IO ()) -> IO (Verdict, [HeapSample])+measureLeg config name operation =+  measureLegWithSampler config name \sample ->+    forM_ [1 .. config.blocks * config.blockSize] \index -> do+      operation index+      whenBlockEnd index sample+  where+    whenBlockEnd index sample =+      if index `mod` config.blockSize == 0 then sample index else pure ()++measureLegWithSampler :: GateConfig -> Text -> ((Int -> IO ()) -> IO ()) -> IO (Verdict, [HeapSample])+measureLegWithSampler config name drive = do+  samplesRef <- newIORef []+  drive \count -> do+    sample <- sampleHeap count+    modifyIORef' samplesRef (sample :)+  samples <- reverse <$> readIORef samplesRef+  let expected = fmap (* config.blockSize) [1 .. config.blocks]+  unless (fmap (.operations) samples == expected) $+    fail ("retention leg " <> Text.unpack name <> " sampled at unexpected operation counts: " <> show (fmap (.operations) samples))+  let result@(verdict, _, _) = judge config samples+  Text.IO.putStrLn (renderTable name config samples result)+  pure (verdict, samples)++renderTable :: Text -> GateConfig -> [HeapSample] -> (Verdict, Double, Word64) -> Text+renderTable name config samples (verdict, slope, growth) =+  Text.unlines $+    [ "leg=" <> name <> " operations=" <> decimal (config.blocks * config.blockSize) <> " blocks=" <> decimal config.blocks <> " block=" <> decimal config.blockSize,+      "block  ops  live_bytes  large_objects_bytes  threads"+    ]+      <> zipWith renderSample [1 :: Int ..] samples+      <> [ "slope=" <> Text.pack (show (round slope :: Int)) <> " B/op  growth=" <> Text.pack (showFFloat (Just 2) (fromIntegral growth / (1024 * 1024) :: Double) "") <> " MiB  verdict=" <> case verdict of+             Bounded -> "bounded"+             Retained -> "retained"+         ]+  where+    decimal = Text.pack . show+    renderSample block sample =+      Text.intercalate "  " [decimal block, decimal sample.operations, grouped sample.liveBytes, grouped sample.largeObjectBytes, decimal sample.threads]+    grouped number = Text.pack (reverse (intercalate "," (chunksOfThree (reverse (show number)))))+    chunksOfThree [] = []+    chunksOfThree digits = let (chunk, rest) = splitAt 3 digits in chunk : chunksOfThree rest
src/Keiro/Command.hs view
@@ -89,7 +89,8 @@ where  import Control.Concurrent (threadDelay)-import Control.Exception (displayException)+import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVar, writeTVar)+import Control.Exception (SomeException, displayException) import Data.Aeson qualified as Aeson #ifdef KEIRO_REACTION_HYDRATION_PROBE import Data.ByteString.Char8 qualified as ByteString.Char8@@ -104,7 +105,7 @@ import Effectful.Concurrent (runConcurrent) import Effectful.Concurrent.Async qualified as Async import Effectful.Error.Static (Error, tryError)-import Effectful.Exception (trySync)+import Effectful.Exception (finally, mask_, onException, trySync) import GHC.Clock (getMonotonicTimeNSec) import GHC.Stack (HasCallStack) import Keiki.Core (BoolAlg, RegFile)@@ -143,6 +144,8 @@     recordSnapshotReadHits,     recordSnapshotReadMisses,     recordSnapshotSeedDivergence,+    recordSnapshotSeedSkipped,+    recordSnapshotSeedVerificationFailed,     recordSnapshotWriteFailures,     withCommandSpan,   )@@ -175,6 +178,7 @@ import Streamly.Data.Fold qualified as Fold import Streamly.Data.Stream qualified as Streamly import System.IO (stderr)+import System.IO.Unsafe (unsafePerformIO) import System.Random.Stateful (globalStdGen, uniformRM) import "hasql-transaction" Hasql.Transaction qualified as Tx import Prelude qualified@@ -350,6 +354,9 @@ --   replay through the seed version. The replay runs asynchronously and never --   blocks or fails the command. This detects hand-written fold changes that --   leave the snapshot discriminator unchanged; @0@ disables the witness.+-- * 'seedVerifyInFlightLimit' — maximum concurrent sampled verifications in+--   this process. A sampled verification is skipped when the limit is full;+--   @0@ removes the limit. data RunCommandOptions = RunCommandOptions   { retryLimit :: !Int,     pageSize :: !Int32,@@ -359,6 +366,7 @@     metrics :: !(Maybe KeiroMetrics),     verifyReplayOnAppend :: !Bool,     seedVerifySampleRate :: !Int,+    seedVerifyInFlightLimit :: !Int,     -- | Optional OpenTelemetry tracer. When 'Just', the command runner     --     opens an 'Internal'-kind span around each invocation, named after     --     the resolved stream identifier and decorated with the messaging /@@ -391,6 +399,7 @@       metrics = Nothing,       verifyReplayOnAppend = True,       seedVerifySampleRate = 1000,+      seedVerifyInFlightLimit = 1,       tracer = Nothing,       metadata = Nothing     }@@ -667,17 +676,67 @@   SnapshotSeed rs s ->   Eff es () scheduleSeedVerification options eventStream targetStream codec seed = do-  void $ trySync $ do+  scheduled <- trySync $ do     sampled <-       case options ^. #seedVerifySampleRate of         rate | rate <= 0 -> pure False         1 -> pure True         rate -> liftIO ((== (1 :: Int)) <$> uniformRM (1, rate) globalStdGen)-    when sampled-      $ void-      $ runConcurrent-      $ Async.async-      $ verifySnapshotSeed options eventStream targetStream codec seed+    when sampled $ mask_ $ do+      let limit = options ^. #seedVerifyInFlightLimit+      acquired <- liftIO (reserveSeedVerification limit)+      if acquired+        then+          ( void+              $ runConcurrent+              $ Async.async+              $ ( do+                    result <- trySync (verifySnapshotSeed options eventStream targetStream codec seed)+                    case result of+                      Left failure -> reportSeedVerificationFailure options failure+                      Right () -> pure ()+                )+              `finally` releaseSeedVerificationEff+          )+            `onException` releaseSeedVerificationEff+        else recordSnapshotSeedSkipped (options ^. #metrics) 1+  case scheduled of+    Left failure -> void $ trySync (reportSeedVerificationFailure options failure)+    Right () -> pure ()++reportSeedVerificationFailure :: (IOE :> es) => RunCommandOptions -> SomeException -> Eff es ()+reportSeedVerificationFailure options failure = do+  recordSnapshotSeedVerificationFailed (options ^. #metrics) 1+  liftIO+    $ LazyByteString.hPutStrLn stderr+    $ Aeson.encode+    $ Aeson.object+      [ "event" Aeson..= ("keiro.snapshot.seed.verification.failed" :: Text),+        "level" Aeson..= ("error" :: Text),+        "reason" Aeson..= displayException failure+      ]++-- A process-wide reservation prevents commands on different streams from+-- accumulating detached full-replay tasks. Atomic reservation includes the+-- limit check so competing command threads cannot oversubscribe it.+{-# NOINLINE seedVerificationsInFlight #-}+seedVerificationsInFlight :: TVar Int+seedVerificationsInFlight = unsafePerformIO (newTVarIO 0)++releaseSeedVerificationEff :: (IOE :> es) => Eff es ()+releaseSeedVerificationEff = liftIO releaseSeedVerification++reserveSeedVerification :: Int -> IO Bool+reserveSeedVerification limit = atomically $ do+  count <- readTVar seedVerificationsInFlight+  if limit <= 0 Prelude.|| count < limit+    then writeTVar seedVerificationsInFlight (count Prelude.+ 1) $> True+    else pure False++releaseSeedVerification :: IO ()+releaseSeedVerification = atomically $ do+  count <- readTVar seedVerificationsInFlight+  writeTVar seedVerificationsInFlight (count Prelude.- 1)  verifySnapshotSeed ::   forall phi rs s ci co es.
src/Keiro/Telemetry.hs view
@@ -108,6 +108,8 @@     keiroSnapshotWriteFailuresName,     keiroSnapshotApplyDivergenceName,     keiroSnapshotSeedDivergenceName,+    keiroSnapshotSeedSkippedName,+    keiroSnapshotSeedVerificationFailedName,     keiroDispatchFailedName,     keiroDispatchDeadletteredName,     keiroSubscriptionDeadletteredName,@@ -162,6 +164,8 @@     recordSnapshotWriteFailures,     recordSnapshotApplyDivergence,     recordSnapshotSeedDivergence,+    recordSnapshotSeedSkipped,+    recordSnapshotSeedVerificationFailed,     recordDispatchFailed,     recordDispatchDeadLettered,     recordSubscriptionDeadLettered,@@ -691,6 +695,12 @@ keiroSnapshotSeedDivergenceName :: Text keiroSnapshotSeedDivergenceName = "keiro.snapshot.seed.divergence" +keiroSnapshotSeedSkippedName :: Text+keiroSnapshotSeedSkippedName = "keiro.snapshot.seed.skipped"++keiroSnapshotSeedVerificationFailedName :: Text+keiroSnapshotSeedVerificationFailedName = "keiro.snapshot.seed.verification.failed"+ keiroDispatchFailedName :: Text keiroDispatchFailedName = "keiro.dispatch.failed" @@ -782,6 +792,8 @@     snapshotWriteFailures :: Counter Int64,     snapshotApplyDivergence :: Counter Int64,     snapshotSeedDivergence :: Counter Int64,+    snapshotSeedSkipped :: Counter Int64,+    snapshotSeedVerificationFailed :: Counter Int64,     dispatchFailed :: Counter Int64,     dispatchDeadlettered :: Counter Int64,     subscriptionDeadlettered :: Counter Int64,@@ -844,6 +856,8 @@   snapshotWriteFailures' <- counterI64 keiroSnapshotWriteFailuresName "{failure}" "Post-commit snapshot writes that failed and were swallowed."   snapshotApplyDivergence' <- counterI64 keiroSnapshotApplyDivergenceName "{failure}" "Just-appended event batches that failed to replay from the pre-command state; the stream is poisoned and its next hydration will fail."   snapshotSeedDivergence' <- counterI64 keiroSnapshotSeedDivergenceName "{failure}" "Sampled snapshot seeds whose encoded state disagreed with a full replay through the seed version."+  snapshotSeedSkipped' <- counterI64 keiroSnapshotSeedSkippedName "{verification}" "Sampled snapshot-seed verifications skipped because the in-flight limit was full."+  snapshotSeedVerificationFailed' <- counterI64 keiroSnapshotSeedVerificationFailedName "{failure}" "Sampled snapshot-seed verification tasks that raised an exception."   dispatchFailed' <- counterI64 keiroDispatchFailedName "{command}" "Process-manager/router dispatch commands that failed."   dispatchDeadlettered' <- counterI64 keiroDispatchDeadletteredName "{command}" "Rejected process-manager/router dispatch commands handled by dead-letter or skip policy."   subscriptionDeadlettered' <- counterI64 keiroSubscriptionDeadletteredName "{event}" "Kiroku source events dead-lettered by an explicit disposition or retry exhaustion."@@ -898,6 +912,8 @@         snapshotWriteFailures = snapshotWriteFailures',         snapshotApplyDivergence = snapshotApplyDivergence',         snapshotSeedDivergence = snapshotSeedDivergence',+        snapshotSeedSkipped = snapshotSeedSkipped',+        snapshotSeedVerificationFailed = snapshotSeedVerificationFailed',         dispatchFailed = dispatchFailed',         dispatchDeadlettered = dispatchDeadlettered',         subscriptionDeadlettered = subscriptionDeadlettered',@@ -1065,6 +1081,12 @@  recordSnapshotSeedDivergence :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordSnapshotSeedDivergence = recordCounter snapshotSeedDivergence++recordSnapshotSeedSkipped :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordSnapshotSeedSkipped = recordCounter snapshotSeedSkipped++recordSnapshotSeedVerificationFailed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m ()+recordSnapshotSeedVerificationFailed = recordCounter snapshotSeedVerificationFailed  recordDispatchFailed :: (MonadIO m) => Maybe KeiroMetrics -> Int64 -> m () recordDispatchFailed = recordCounter dispatchFailed
test/Main.hs view
@@ -42,7 +42,7 @@ import Effectful.Error.Static (Error, throwError) import Effectful.Exception qualified as EffException import ExternalReadSpec qualified-import GHC.Conc (ThreadStatus (..), threadStatus)+import GHC.Conc (ThreadStatus (..), listThreads, threadStatus) import GroupRebuildSpec qualified import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E@@ -2816,6 +2816,136 @@             Tx.statement targetName snapshotVersionForStreamStmt       snapshotVersion `shouldBe` Just (StreamVersion 2) +    it "bounds sampled full replays to one process-wide task and counts skipped samples" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let targetName = "snapshot-seed-verification-bounded"+          target = stream targetName :: Stream SnapshotCounterEventStream+          candidateStream =+            mkEventStreamOrThrow+              "snapshot-counter-fold-v2-bounded"+              (foldV2WithoutFingerprintBumpEventStreamDef & #snapshotPolicy .~ Never)+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #seedVerifySampleRate+              .~ 1+              & #seedVerifyInFlightLimit+              .~ 1+      seedLongSnapshotCounter storeHandle target 10000+      baselineThreads <- length <$> listThreads+      threadCounts <- forM [1 .. 20 :: Int] $ \_ -> do+        result <- Store.runStoreIO storeHandle $ runCommand options candidateStream target (Add 1)+        case result of+          Right (Right commandResult) -> commandResult ^. #eventsAppended `shouldBe` 1+          other -> expectationFailure ("sampled command failed: " <> show other)+        length <$> listThreads+      maximum threadCounts `shouldSatisfy` (<= baselineThreads + 2)+      observed <- timeout 15_000_000 $ do+        let awaitCounts = do+              _ <- forceFlushMeterProvider provider Nothing+              exported <- readIORef metricsRef+              let points = reverse (flattenScalarPoints exported)+                  count name = case lookup name points of+                    Just (IntNumber n) -> n+                    _ -> 0+                  skipped = count "keiro.snapshot.seed.skipped"+                  diverged = count "keiro.snapshot.seed.divergence"+              if skipped + diverged == 20+                then pure (skipped, diverged)+                else threadDelay 10_000 >> awaitCounts+        awaitCounts+      case observed of+        Just (skipped, diverged) -> do+          skipped `shouldSatisfy` (> 0)+          diverged `shouldSatisfy` (> 0)+        Nothing -> expectationFailure "sampled verifications did not finish or account for all twenty commands"++    it "allows unbounded sampled verification when the limit is zero" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-seed-verification-unbounded" :: Stream SnapshotCounterEventStream+          candidateStream =+            mkEventStreamOrThrow+              "snapshot-counter-fold-v2-unbounded"+              (foldV2WithoutFingerprintBumpEventStreamDef & #snapshotPolicy .~ Never)+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #seedVerifySampleRate+              .~ 1+              & #seedVerifyInFlightLimit+              .~ 0+      seedLongSnapshotCounter storeHandle target 1000+      forM_ [1 .. 4 :: Int] $ \_ -> do+        Right (Right result) <- Store.runStoreIO storeHandle $ runCommand options candidateStream target (Add 1)+        result ^. #eventsAppended `shouldBe` 1+      observed <- timeout 10_000_000 $ do+        let awaitCounts = do+              _ <- forceFlushMeterProvider provider Nothing+              exported <- readIORef metricsRef+              case lookup "keiro.snapshot.seed.divergence" (reverse (flattenScalarPoints exported)) of+                Just (IntNumber 4) -> pure ()+                _ -> threadDelay 10_000 >> awaitCounts+        awaitCounts+      observed `shouldBe` Just ()+      _ <- forceFlushMeterProvider provider Nothing+      exported <- readIORef metricsRef+      lookup "keiro.snapshot.seed.skipped" (flattenScalarPoints exported) `shouldBe` Nothing++    it "releases a verification slot after an unexpected replay exception" $ \storeHandle -> do+      (exporter, metricsRef) <- inMemoryMetricExporter+      (provider, _env) <-+        createMeterProvider emptyMaterializedResources defaultSdkMeterProviderOptions {metricExporter = Just exporter}+      meter <- getMeter provider Telemetry.keiroInstrumentationLibrary+      keiroMetrics <- Telemetry.newKeiroMetrics meter+      let target = stream "snapshot-seed-verification-failed" :: Stream SnapshotCounterEventStream+          candidateStream =+            mkEventStreamOrThrow+              "snapshot-counter-fold-v1-broken-replay"+              ( foldV1SnapshotCounterEventStreamDef+                  & #eventCodec+                  .~ ( counterCodec+                         & #decode+                         .~ ( \eventType value ->+                                case parseCounterEvent eventType value of+                                  Right (CounterAdded 2) -> error "intentional seed verification replay failure"+                                  decoded -> decoded+                            )+                     )+                  & #snapshotPolicy+                  .~ Never+              )+          options =+            defaultRunCommandOptions+              & #metrics+              ?~ keiroMetrics+              & #seedVerifySampleRate+              .~ 1+              & #seedVerifyInFlightLimit+              .~ 1+      Right (Right _) <- Store.runStoreIO storeHandle $ runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 2)+      Right (Right _) <- Store.runStoreIO storeHandle $ runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 3)+      forM_ [1 .. 2 :: Int] $ \expected -> do+        Right (Right _) <- Store.runStoreIO storeHandle $ runCommand options candidateStream target (Add 4)+        observed <- timeout 5_000_000 $ do+          let awaitFailure = do+                _ <- forceFlushMeterProvider provider Nothing+                exported <- readIORef metricsRef+                case lookup "keiro.snapshot.seed.verification.failed" (reverse (flattenScalarPoints exported)) of+                  Just (IntNumber count) | count == fromIntegral expected -> pure ()+                  _ -> threadDelay 10_000 >> awaitFailure+          awaitFailure+        observed `shouldBe` Just ()+     it "disables sampled seed verification at rate zero" $ \storeHandle -> do       (exporter, metricsRef) <- inMemoryMetricExporter       (provider, _env) <-@@ -15657,6 +15787,25 @@  snapshotCounterEventStream :: ValidatedSnapshotCounterEventStream snapshotCounterEventStream = mkEventStreamOrThrow "snapshot-counter" snapshotCounterEventStreamDef++-- Seed the same accepted fold and snapshot shape as a long-lived stream+-- without running thousands of commands in the verification-bound examples.+seedLongSnapshotCounter :: Store.KirokuStore -> Stream SnapshotCounterEventStream -> Int -> IO ()+seedLongSnapshotCounter storeHandle target historyLength = do+  encoded <- shouldBeRight (encodeForAppend counterCodec (CounterAdded 1))+  let targetName = Stream.streamName target+      rawCount = historyLength - 1+      (fullChunks, remainder) = rawCount `divMod` 500+  forM_ [1 .. fullChunks] $ \_ -> do+    Right _ <- Store.runStoreIO storeHandle $ Store.appendToStream targetName AnyVersion (replicate 500 encoded)+    pure ()+  when (remainder > 0) $ do+    Right _ <- Store.runStoreIO storeHandle $ Store.appendToStream targetName AnyVersion (replicate remainder encoded)+    pure ()+  Right (Right result) <-+    Store.runStoreIO storeHandle $+      runCommand defaultRunCommandOptions foldV1SnapshotCounterEventStream target (Add 1)+  result ^. #streamVersion `shouldBe` StreamVersion (fromIntegral historyLength)  snapshotCounterTransducer :: SymTransducer (HsPred SnapshotCounterRegs CounterCommand) SnapshotCounterRegs CounterState CounterCommand CounterEvent snapshotCounterTransducer =