packages feed

kiroku-store 0.2.0.0 → 0.3.0.0

raw patch · 35 files changed

+2779/−438 lines, 35 filesdep +mtldep ~shibuya-corePVP ok

version bump matches the API change (PVP)

Dependencies added: mtl

Dependency ranges changed: shibuya-core

API changes (from Hackage documentation)

- Kiroku.Store: NotifierStartError :: ConnectionError -> NotifierStartError
- Kiroku.Store: newtype NotifierStartError
- Kiroku.Store.Notification: NotifierStartError :: ConnectionError -> NotifierStartError
- Kiroku.Store.Notification: newtype NotifierStartError
- Kiroku.Store.SQL: deleteStreamJunctionsStmt :: Statement Int64 (Vector UUID)
+ Kiroku.Store: KirokuEventPublisherLoopError :: !SomeException -> KirokuEvent
+ Kiroku.Store: NotifierConnectError :: !ConnectionError -> NotifierStartError
+ Kiroku.Store: NotifierListenError :: !SomeException -> NotifierStartError
+ Kiroku.Store: data NotifierStartError
+ Kiroku.Store.Effect: [EventExistsInStream] :: forall (a :: Type -> Type). StreamName -> EventId -> Store a Bool
+ Kiroku.Store.Effect: [SetStreamTruncateBefore] :: forall (a :: Type -> Type). StreamName -> StreamVersion -> Store a (Maybe StreamId)
+ Kiroku.Store.Error: EmptyAppendBatch :: !StreamName -> StoreError
+ Kiroku.Store.Error: EmptyAppendBatchConflict :: !StreamName -> AppendConflict
+ Kiroku.Store.Error: EventAlreadyLinked :: !StreamName -> !Maybe EventId -> StoreError
+ Kiroku.Store.Error: LinkSourceEventMissing :: !StreamName -> StoreError
+ Kiroku.Store.Error: StreamNameTooLong :: !StreamName -> !Int -> StoreError
+ Kiroku.Store.Error: isTransientSerializationError :: UsageError -> Bool
+ Kiroku.Store.Error: mapGenericUsageError :: UsageError -> StoreError
+ Kiroku.Store.Error: mapLinkUsageError :: StreamName -> UsageError -> StoreError
+ Kiroku.Store.Error: mapTransactionUsageError :: UsageError -> StoreError
+ Kiroku.Store.Error: maxStreamNameBytes :: Int
+ Kiroku.Store.Error: validateStreamName :: StreamName -> Either StoreError ()
+ Kiroku.Store.Lifecycle: clearStreamTruncateBefore :: forall (es :: [Effect]). (HasCallStack, Store :> es) => StreamName -> Eff es (Maybe StreamId)
+ Kiroku.Store.Lifecycle: setStreamTruncateBefore :: forall (es :: [Effect]). (HasCallStack, Store :> es) => StreamName -> StreamVersion -> Eff es (Maybe StreamId)
+ Kiroku.Store.Notification: NotifierConnectError :: !ConnectionError -> NotifierStartError
+ Kiroku.Store.Notification: NotifierListenError :: !SomeException -> NotifierStartError
+ Kiroku.Store.Notification: data NotifierStartError
+ Kiroku.Store.Observability: KirokuEventPublisherLoopError :: !SomeException -> KirokuEvent
+ Kiroku.Store.Observability: emitOrDrop :: Maybe (KirokuEvent -> IO ()) -> KirokuEvent -> IO ()
+ Kiroku.Store.Read: eventExistsInStream :: forall (es :: [Effect]). (HasCallStack, Store :> es) => StreamName -> EventId -> Eff es Bool
+ Kiroku.Store.SQL: deleteAllRowsForOriginStmt :: Statement Int64 (Vector UUID)
+ Kiroku.Store.SQL: deleteDeadLettersForOrphanedEventsStmt :: Statement (Vector UUID) ()
+ Kiroku.Store.SQL: deleteJunctionsByEventIdsStmt :: Statement (Vector UUID) ()
+ Kiroku.Store.SQL: deleteStreamOwnJunctionsStmt :: Statement Int64 (Vector UUID)
+ Kiroku.Store.SQL: eventExistsInStreamStmt :: Statement (Text, UUID) Bool
+ Kiroku.Store.SQL: setStreamTruncateBeforeStmt :: Statement (Text, Int64) (Maybe StreamId)
+ Kiroku.Store.Subscription.Stream: InvalidStreamBufferSize :: Natural -> InvalidStreamBufferSize
+ Kiroku.Store.Subscription.Stream: instance GHC.Classes.Eq Kiroku.Store.Subscription.Stream.InvalidStreamBufferSize
+ Kiroku.Store.Subscription.Stream: instance GHC.Internal.Exception.Type.Exception Kiroku.Store.Subscription.Stream.InvalidStreamBufferSize
+ Kiroku.Store.Subscription.Stream: instance GHC.Internal.Show.Show Kiroku.Store.Subscription.Stream.InvalidStreamBufferSize
+ Kiroku.Store.Subscription.Stream: newtype InvalidStreamBufferSize
+ Kiroku.Store.Subscription.Worker: LiveFromCategoryNotify :: !Text -> LiveSource
+ Kiroku.Store.Subscription.Worker: LiveFromGroupPolling :: LiveSource
+ Kiroku.Store.Subscription.Worker: LiveFromPublisherQueue :: !TBQueue (Vector RecordedEvent) -> !TVar SubscriberStatus -> LiveSource
+ Kiroku.Store.Subscription.Worker: data LiveSource
+ Kiroku.Store.Subscription.Worker: withLoadCheckpointHookForTest :: LoadCheckpointHook -> IO a -> IO a
+ Kiroku.Store.Transaction: EmptyAppendBatchConflict :: !StreamName -> AppendConflict
+ Kiroku.Store.Types: [truncateBefore] :: StreamInfo -> !StreamVersion
+ Kiroku.Store.Types: categoryName :: StreamName -> CategoryName
+ Kiroku.Store.Types: streamNameInCategory :: CategoryName -> Text -> StreamName
- Kiroku.Store.Subscription.Worker: runWorker :: MonadIO m => Pool -> TBQueue (Vector RecordedEvent) -> TVar SubscriberStatus -> TVar SubscriptionState -> TVar GlobalPosition -> TVar (Map Text Word64) -> SubscriptionConfig -> Maybe (KirokuEvent -> IO ()) -> StoreSettings -> m ()
+ Kiroku.Store.Subscription.Worker: runWorker :: MonadIO m => Pool -> LiveSource -> TVar SubscriptionState -> TVar GlobalPosition -> TVar (Map Text Word64) -> SubscriptionConfig -> Maybe (KirokuEvent -> IO ()) -> StoreSettings -> m ()
- Kiroku.Store.Types: StreamInfo :: !StreamId -> !StreamName -> !StreamVersion -> !UTCTime -> !Maybe UTCTime -> StreamInfo
+ Kiroku.Store.Types: StreamInfo :: !StreamId -> !StreamName -> !StreamVersion -> !UTCTime -> !Maybe UTCTime -> !StreamVersion -> StreamInfo

Files

CHANGELOG.md view
@@ -2,6 +2,207 @@  ## Unreleased +## 0.3.0.0 — 2026-07-11++This release requires migrations `0003`–`0007` from `kiroku-store-migrations`.++### Breaking Changes++* Backward reads now paginate. `readStreamBackward` and `readAllBackward` treat a+  nonzero cursor as an *exclusive upper bound*, so a page returns events older+  than the cursor instead of newer ones. The interpreter maps the caller cursor+  `0` to `maxBound`, so `StreamVersion 0` / `GlobalPosition 0` still means "start+  from the latest event" (versions and positions start at 1, so the sentinel can+  never collide with a real value). Previously a nonzero cursor read in the wrong+  direction, which made backward pagination impossible: feeding the last returned+  version back in returned the same newest page again. Any caller passing a+  nonzero backward cursor gets different results, and any mock `Store`+  interpreter must mirror the `0 -> maxBound` mapping.+* Oversized stream names are rejected before any database work. `StoreError`+  gains `StreamNameTooLong !StreamName !Int` (the `Int` is the offending UTF-8+  byte length), and `Kiroku.Store.Error` exports `maxStreamNameBytes` (512) and+  `validateStreamName :: StreamName -> Either StoreError ()`. Every+  application-stream entry point — `appendToStream`, `appendMultiStream`,+  `linkToStream`, `softDeleteStream`, `hardDeleteStream`, `undeleteStream`, and+  the `runTransactionAppending*` wrappers — runs the same validation (reserved+  `$all` plus the length bound) up front instead of only rejecting `$all`. The+  bound exists because the append notification payload embeds the stream name and+  PostgreSQL rejects `pg_notify` payloads of 8,000 bytes or more; a name that+  used to fail late as an opaque trigger error now fails immediately with a typed+  error. Exhaustive matches on `StoreError` must handle the new constructor.+* Publisher queues are created only for non-group `AllStreams` subscriptions.+  `Category` subscriptions and all consumer-group members no longer register a+  bounded queue with the `EventPublisher`; they are DB-driven in live mode. As a+  consequence `SubscriptionConfig.queueCapacity` and+  `SubscriptionConfig.overflowPolicy` have no effect for those targets, and they+  can no longer be paused, dropped, or terminated with `SubscriptionOverflowed`.+  In the exposed `Kiroku.Store.Subscription.Worker` module, `runWorker`'s+  signature changed from taking a `TBQueue (Vector RecordedEvent)` plus a `TVar+  SubscriberStatus` to taking a single new exported sum `LiveSource+  (LiveFromPublisherQueue | LiveFromCategoryNotify | LiveFromGroupPolling)`,+  fixed at `subscribe` time from the config's `(consumerGroup, target)` shape.+* Empty append batches are rejected. `StoreError` gains `EmptyAppendBatch+  !StreamName` and `AppendConflict` gains `EmptyAppendBatchConflict !StreamName`+  (mapped by `appendConflictToStoreError`). `appendToStream` with `[]` now fails+  with `EmptyAppendBatch` before any pool work; previously it took the global+  `$all` row lock, fired NOTIFY triggers, created an empty stream under+  `NoStream`, and surfaced a misleading `WrongExpectedVersion` /+  `StreamNotFound`. `appendMultiStream []` is an explicit no-op returning `[]`+  without a round trip, while a per-stream empty event list inside a non-empty op+  list is rejected with `EmptyAppendBatch`.+* Link failures are typed. `StoreError` gains `EventAlreadyLinked !StreamName+  !(Maybe EventId)` (the `23505` unique violation on `stream_events_pkey`,+  carrying the offending event id when PostgreSQL's detail string is parseable)+  and `LinkSourceEventMissing !StreamName` (the `23502` not-null violation raised+  when `linkToStream` references an event id that does not exist; the whole batch+  rolls back). Both previously surfaced as an opaque `ConnectionError` carrying a+  stringified `UsageError`.+* `StreamInfo` gains a `truncateBefore :: !StreamVersion` field. Code+  constructing or exhaustively matching `StreamInfo` must be updated.+* The `Store` effect gains `SetStreamTruncateBefore :: StreamName ->+  StreamVersion -> Store m (Maybe StreamId)` and `EventExistsInStream ::+  StreamName -> EventId -> Store m Bool`. Mock and test interpreters of the+  `Store` effect must add arms for both.+* `Kiroku.Store.SQL`'s hard-delete statements were resplit.+  `deleteStreamJunctionsStmt` is removed, replaced by `deleteAllRowsForOriginStmt`,+  `deleteJunctionsByEventIdsStmt`, `deleteStreamOwnJunctionsStmt`, and+  `deleteDeadLettersForOrphanedEventsStmt`, which the interpreter runs in sequence+  before `deleteOrphanedEventsStmt`.+* Checkpoint-load failures now fail the subscription loudly. A `UsageError` while+  reading a subscription's saved checkpoint at startup surfaces through the+  handle's `wait` as `Left e` and the worker stops; it no longer falls back to+  `GlobalPosition 0` and silently re-processes the whole history.+* `subscriptionAckStream` and `subscriptionStream` reject a zero buffer, throwing+  the new `InvalidStreamBufferSize` exception (exported from+  `Kiroku.Store.Subscription.Stream`) when `bufferSize` is `0`; a zero-capacity+  `TBQueue` would deadlock the bridge handler on its first delivery.+* Duplicate events inside transaction bodies are typed. `runTransaction`,+  `runTransactionNoRetry`, and the `runTransactionAppending*` wrappers map a+  `23505` violation on `events_pkey` / `stream_events_pkey` to `DuplicateEvent+  (Just eid)` (new `mapTransactionUsageError` in `Kiroku.Store.Error`) instead of+  the previous opaque `ConnectionError`.++### New Features++* Logical truncate-before marker (close-the-book compaction).+  `Kiroku.Store.Lifecycle.setStreamTruncateBefore :: StreamName -> StreamVersion+  -> Eff es (Maybe StreamId)` and `clearStreamTruncateBefore :: StreamName -> Eff+  es (Maybe StreamId)` set a per-stream cursor below which ordered per-stream+  reads hide events. `readStreamForward`, `readStreamBackward`, and the paged+  `readStreamForwardStream` return only events whose `streamVersion` is at or+  above the marker; the `$all` global log, `readCategory`, subscriptions, and+  existence probes are deliberately unaffected, so global history stays complete+  and future projections can still be built from it. No events are deleted and+  the operation is fully reversible. The typical use is snapshot-and-compact:+  append a snapshot event at version `V`, then call `setStreamTruncateBefore name+  V` so rehydration starts there. The marker is surfaced on+  `StreamInfo.truncateBefore` via `getStream`, is idempotent, returns `Nothing`+  for a missing or soft-deleted stream, and rejects `$all` with+  `ReservedStreamName`. Backed by the new `streams.truncate_before` column+  shipped by `kiroku-store-migrations`.+* `Kiroku.Store.Read.eventExistsInStream` adds an indexed point lookup for+  idempotency checks that need to know whether a specific event id is present in+  a live stream without scanning that stream. Soft-deleted streams report+  `False`, matching `readStreamForward` visibility.+* `Kiroku.Store.Types` exports `categoryName :: StreamName -> CategoryName` (the+  substring before the first `-`) and its dual `streamNameInCategory ::+  CategoryName -> Text -> StreamName`. This is the canonical Haskell mirror of+  the `streams.category` generated column and of the rule used by `readCategory`+  and category-targeted subscriptions; previously every consumer hand-rolled it.+  `Notification.categoryFromPayload` now routes through `categoryName`, so the+  rule has one definition.+* New observability event `KirokuEventPublisherLoopError !SomeException`, emitted+  when the publisher's broadcast iteration throws a synchronous non-`UsageError`+  exception (typically a user-supplied `decodeHook` or observability handler).+  Exhaustive matches on `KirokuEvent` must handle it.++### Bug Fixes++* The publisher survives failing callbacks. A synchronous exception from the+  broadcast iteration is caught, reported as `KirokuEventPublisherLoopError`, and+  retried on the next notification or the 30-second safety poll without advancing+  `lastPublished`; previously it killed the publisher loop and stalled every live+  subscription. All store-internal threads (publisher, worker, notifier) now emit+  observability events through a hardened `emitOrDrop` that drops synchronous+  exceptions thrown by the handler and rethrows async ones, so a throwing+  metrics or logging callback can no longer kill a store thread.+* Streamly bridges terminate with the worker's outcome. `subscriptionStream` and+  `subscriptionAckStream` end the stream normally when the worker stops cleanly+  or is cancelled, and rethrow the worker's exception from the next stream pull+  when it dies for any other reason (overflow, handler exception, dead-letter+  database error, decode-hook exception); previously the consumer blocked+  forever. The cancel action no longer writes a sentinel into the bounded queue,+  so it cannot block behind a full bridge buffer.+* Subscription startup no longer leaks registrations. The pre-fork window in+  `subscribe` is masked and each acquired registration is paired with+  `bracketOnError` cleanup, so an exception between publisher registration and+  the worker fork can no longer leave a subscriber registered with no reader.+  Notifier startup releases its connection if `LISTEN` fails.+* Hard delete purges dead letters. `hardDeleteStream` deletes+  `kiroku.dead_letters` rows for exactly the events whose payloads it orphans,+  before deleting those payloads. Because `dead_letters.event_id` has a foreign+  key to `events(event_id)` with no cascade, hard-deleting a stream that had+  dead-lettered events previously failed on that constraint.+* Single-stream appends retry transient conflicts. `appendToStream` retries once+  when the first attempt fails with PostgreSQL `40001` (serialization failure) or+  `40P01` (deadlock detected) — the same SQLSTATE set `hasql-transaction`+  retries. Event ids are prepared before the first attempt, so the retry is+  idempotent.+* `appendToStream` and `appendMultiStream` surface duplicate events raised from+  inside transaction bodies as `DuplicateEvent` rather than a stringified+  connection error.++### Performance++* `appendMultiStream` now pipelines its deterministic pre-lock and per-stream+  append statements through Hasql pipeline mode, while preserving all-or-nothing+  rollback and transient deadlock retry behavior. The local benchmark regression+  gate measured `All.reliability-audit.appendMultiStream 3 existing streams` at+  268 us, 29% faster than the refreshed baseline.+* The publisher does no row fetches while idle. With an empty subscriber registry+  the publisher loop advances `lastPublished` from the `$all` tail with a+  single-row query instead of fetching and decoding full event rows, so a store+  whose only subscriptions are `Category` or consumer-group members pays no+  fan-out cost. It re-checks registry emptiness in the same STM transaction as+  the position write, and offers an in-flight batch to subscribers that+  registered after the snapshot, so a subscriber attaching mid-tick never skips+  events.+* No empty read round trips. `readStreamForwardStream` stops after a page shorter+  than `pageSize` rather than always probing for one more empty page (an exact+  multiple of `pageSize` still costs one final probe), flattens pages without an+  intermediate list, and `lookupStreamNames []` short-circuits to an empty map in+  the interpreter with no database round trip.+* Index hygiene and `streams` fillfactor tuning (migration `0005`).++### Other Changes++* `GlobalPosition`'s documented contract is weakened to "strictly increasing and+  totally ordered" — nothing more. Treat values as opaque cursors: construct one+  only from `0` or from a value this store returned, never by arithmetic, and+  never assume density (`pos + 1` may not exist). The current implementation+  still assigns contiguous positions, but contiguity is now explicitly an+  implementation detail, not an API guarantee.+* `linkToStream` is marked provisional. It is the only public feature that+  requires the `stream_events` junction-table layout, which a future+  global-position migration may replace; it may be removed or redesigned.+* `RetryPolicy.retryMaxAttempts` is documented as a bound on total deliveries,+  not redeliveries: the first delivery counts as attempt 1, so+  `defaultRetryPolicy` (5) means the first delivery plus four redeliveries before+  `DeadLetterMaxAttempts`. Behavior is unchanged; the previous haddock was wrong.+* `appendToStreamTx` and the `runTransactionAppending*` wrappers document the+  `$all` lock-hold hazard: the append updates Kiroku's global `$all` row and+  PostgreSQL holds that row lock until the transaction ends, so every statement+  the continuation runs afterwards blocks all other appends store-wide. Keep+  continuations minimal and run append-independent work first.+* `WrongExpectedVersion` and `WrongExpectedVersionConflict`'s third field is+  documented as a placeholder: the append statement returns zero rows on a+  mismatch and the store does not issue an extra read, so it is always+  `StreamVersion 0`. Callers needing the live version must use `getStream`.+* The append NOTIFY trigger is now split into INSERT/UPDATE triggers that exclude+  the internal `$all` row and only fire when `stream_version` actually advances,+  so soft-delete and undelete no longer emit spurious append notifications+  (migration `0003`).+ ## 0.2.0.0 — 2026-05-31  ### Breaking Changes
bench/Main.hs view
@@ -3,13 +3,18 @@  module Main where +import Control.Concurrent (threadDelay) import Control.Concurrent.Async (mapConcurrently_)+import Control.Concurrent.Async qualified as Async+import Control.Exception (finally) import Control.Lens ((^.))+import Control.Monad (replicateM_) import Data.Aeson qualified as Aeson import Data.Functor.Contravariant ((>$<)) import Data.Generics.Labels () import Data.IORef import Data.Int (Int64)+import Data.Maybe (isNothing) import Data.Text (Text) import Data.Text qualified as T import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime)@@ -20,12 +25,16 @@ import GHC.Generics (Generic) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E+import Hasql.Pipeline qualified as Pipeline import Hasql.Pool qualified as Pool import Hasql.Session qualified as Session-import Hasql.Statement (Statement, preparable)+import Hasql.Statement (Statement, preparable, unpreparable) import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store+import Kiroku.Store.Effect (buildAppendParams, prepareEvents)+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Test.Postgres (migrateTestDatabase) import Test.Tasty.Bench  data RawAppendParams = RawAppendParams@@ -98,6 +107,45 @@         rawProductionAppendParamsEncoder         rawAppendResultDecoder +beginStmt :: Statement () ()+beginStmt = unpreparable "BEGIN" E.noParams D.noResult++commitStmt :: Statement () ()+commitStmt = unpreparable "COMMIT" E.noParams D.noResult++rollbackStmt :: Statement () ()+rollbackStmt = unpreparable "ROLLBACK" E.noParams D.noResult++pipelinedMultiAppend4Streams :: [(StreamName, Text)]+pipelinedMultiAppend4Streams =+    namedBenchStreams "pipe4" 4++pipelinedMultiAppend8Streams :: [(StreamName, Text)]+pipelinedMultiAppend8Streams =+    namedBenchStreams "pipe8" 8++pipelinedContentionCurrentGroups :: [[(StreamName, Text)]]+pipelinedContentionCurrentGroups =+    namedBenchGroups "pipe-current-writer" 4 4++pipelinedContentionPipelinedGroups :: [[(StreamName, Text)]]+pipelinedContentionPipelinedGroups =+    namedBenchGroups "pipe-pipelined-writer" 4 4++namedBenchGroups :: Text -> Int -> Int -> [[(StreamName, Text)]]+namedBenchGroups prefix groups streamsPerGroup =+    [ namedBenchStreams (prefix <> "-" <> T.pack (show groupId)) streamsPerGroup+    | groupId <- [1 .. groups]+    ]++namedBenchStreams :: Text -> Int -> [(StreamName, Text)]+namedBenchStreams prefix count =+    [ ( StreamName (prefix <> "-" <> T.pack (show i))+      , prefix <> "-event-" <> T.pack (show i)+      )+    | i <- [1 .. count]+    ]+ rawScalarAppendAnyVersionSQL :: Text rawScalarAppendAnyVersionSQL =     """@@ -597,6 +645,58 @@                 ]     forceAppendList r +runCurrentMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO ()+runCurrentMultiAppend store streams = do+    r <-+        runStoreIO store $+            appendMultiStream+                [ (sn, AnyVersion, [makeEvent typ])+                | (sn, typ) <- streams+                ]+    forceAppendList r++runPipelinedMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO ()+runPipelinedMultiAppend store streams = do+    now <- getCurrentTime+    params <-+        mapM+            ( \(StreamName name, typ) -> do+                prepared <- prepareEvents [makeEvent typ]+                pure (buildAppendParams name now prepared)+            )+            streams+    let names = V.fromList [name | (StreamName name, _) <- streams]+    result <-+        Pool.use (store ^. #pool) $ do+            results <-+                Session.pipeline $+                    Pipeline.statement () beginStmt+                        *> Pipeline.statement names SQL.lockStreamsForMultiStmt+                        *> traverse (`Pipeline.statement` SQL.appendAnyVersion) params+            Session.statement () $+                if any isNothing results+                    then rollbackStmt+                    else commitStmt+            pure results+    forcePipelinedAppendList result++runSingleAppendUnderMultiWriters ::+    KirokuStore ->+    IORef Int ->+    (KirokuStore -> [(StreamName, Text)] -> IO ()) ->+    Text ->+    [[(StreamName, Text)]] ->+    IO ()+runSingleAppendUnderMultiWriters store runCounter multiAppendRunner prefix groups = do+    runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))+    workers <- mapM (Async.async . replicateM_ 20 . multiAppendRunner store) groups+    let cleanup = mapM_ Async.cancel workers+        appendMeasured i = do+            let sn = StreamName (prefix <> "-single-" <> T.pack (show runId) <> "-" <> T.pack (show i))+            r <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent (prefix <> "-single")]+            forceAppend r+    (threadDelay 20_000 >> mapM_ appendMeasured [1 .. 10 :: Int] >> mapM_ Async.wait workers) `finally` cleanup+ -- | Exercise subscription catch-up over a compact category-local backlog. runSubscriptionCatchup :: KirokuStore -> IORef Int -> IO () runSubscriptionCatchup store runCounter = do@@ -641,6 +741,16 @@ forceAppendList (Right rs) = mapM_ (\r -> (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()) rs forceAppendList (Left e) = error ("Benchmark appendMultiStream failed: " <> show e) +forcePipelinedAppendList :: Either Pool.UsageError [Maybe AppendResult] -> IO ()+forcePipelinedAppendList (Right rs) =+    mapM_+        ( \mResult -> case mResult of+            Just r -> (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()+            Nothing -> error "Pipelined appendMultiStream benchmark produced no result"+        )+        rs+forcePipelinedAppendList (Left e) = error ("Pipelined appendMultiStream benchmark failed: " <> show e)+ -- | Force evaluation of a raw append shape result or fail the benchmark. forceRawAppend :: Either Pool.UsageError (Maybe RawAppendResult) -> IO () forceRawAppend (Right (Just (!streamId, !streamVersion, !globalPosition))) =@@ -657,6 +767,7 @@ main = do     -- Start ephemeral PostgreSQL once for all benchmarks     result <- Pg.withCached $ \db -> do+        migrateTestDatabase (Pg.connectionString db)         let settings = defaultConnectionSettings (Pg.connectionString db)         withStore settings $ \store -> do             -- Counter for unique stream names across benchmarks@@ -709,7 +820,12 @@                     r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]                     forceAppend r'                 )-                [StreamName "bench-multi-a", StreamName "bench-multi-b", StreamName "bench-multi-c"]+                ( [StreamName "bench-multi-a", StreamName "bench-multi-b", StreamName "bench-multi-c"]+                    <> map fst pipelinedMultiAppend4Streams+                    <> map fst pipelinedMultiAppend8Streams+                    <> map fst (concat pipelinedContentionCurrentGroups)+                    <> map fst (concat pipelinedContentionPipelinedGroups)+                )              -- Pre-create the hot stream targeted by the two-round-trip raw             -- shape variant. rawAppendUpdateExisting requires the row to@@ -757,6 +873,7 @@             concCounter <- newIORef (0 :: Int)             rawCounter <- newIORef (0 :: Int)             subCounter <- newIORef (0 :: Int)+            pipelinedContentionCounter <- newIORef (0 :: Int)              defaultMain                 [ bgroup@@ -833,6 +950,37 @@                             whnfIO $                                 runRawTwoRoundtripAppendExistingHotStreamTx store                         ]+                    ]+                , bgroup+                    "pipelined-multi-append"+                    [ bench "current shape (4 streams)" $+                        whnfIO $+                            runCurrentMultiAppend store pipelinedMultiAppend4Streams+                    , bench "pipelined (4 streams)" $+                        whnfIO $+                            runPipelinedMultiAppend store pipelinedMultiAppend4Streams+                    , bench "current shape (8 streams)" $+                        whnfIO $+                            runCurrentMultiAppend store pipelinedMultiAppend8Streams+                    , bench "pipelined (8 streams)" $+                        whnfIO $+                            runPipelinedMultiAppend store pipelinedMultiAppend8Streams+                    , bench "single-stream under 4 current multi-stream writers" $+                        whnfIO $+                            runSingleAppendUnderMultiWriters+                                store+                                pipelinedContentionCounter+                                runCurrentMultiAppend+                                "pipe-current"+                                pipelinedContentionCurrentGroups+                    , bench "single-stream under 4 pipelined multi-stream writers" $+                        whnfIO $+                            runSingleAppendUnderMultiWriters+                                store+                                pipelinedContentionCounter+                                runPipelinedMultiAppend+                                "pipe-pipelined"+                                pipelinedContentionPipelinedGroups                     ]                 , bgroup                     "read"
bench/ShibuyaOverhead.hs view
@@ -32,10 +32,10 @@ import Kiroku.Store.Subscription.Stream (subscriptionStream) import Kiroku.Store.Subscription.Types (OverflowPolicy (..), SubscriptionConfigM (..), SubscriptionHandleM (..)) import Shibuya.Adapter (Adapter (..))-import Shibuya.App (ProcessorId (..), SupervisionStrategy (..), mkProcessor, runApp, stopApp)+import Shibuya.App (ProcessorId (..), defaultAppConfig, mkProcessor, runApp, stopApp) import Shibuya.Core.Ack (AckDecision (..)) import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..))+import Shibuya.Core.Ingested (Ingested (..), Message) import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..)) import Shibuya.Telemetry.Effect (runTracingNoop) import Streamly.Data.Fold qualified as Fold@@ -199,14 +199,14 @@                     , shutdown = liftIO cancelAction                     } -        let handler :: (IOE :> es) => Ingested es RecordedEvent -> Eff es AckDecision+        let handler :: (IOE :> es) => Message es RecordedEvent -> Eff es AckDecision             handler _ingested = do                 liftIO $ atomically $ do                     c <- readTVar countVar                     writeTVar countVar (c + 1)                 pure AckOk -        res <- runApp IgnoreFailures 100 [(ProcessorId "bench", mkProcessor adapter handler)]+        res <- runApp defaultAppConfig [(ProcessorId "bench", mkProcessor adapter handler)]         case res of             Left err -> liftIO $ error ("runApp failed: " <> show err)             Right appHandle -> do@@ -228,6 +228,7 @@                 , partition = Nothing                 , enqueuedAt = Just (event ^. #createdAt)                 , traceContext = Nothing+                , headers = Nothing                 , attempt = Nothing                 , attributes = HashMap.empty                 , payload = event
kiroku-store.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-store-version:         0.2.0.0+version:         0.3.0.0 synopsis:        High-performance PostgreSQL event store description:   Kiroku is a PostgreSQL-backed event store for Haskell applications. It@@ -70,6 +70,7 @@     , hasql-transaction     >=1.1  && <1.3     , lens                  >=5.2  && <5.4     , mmzk-typeid           >=0.6  && <0.8+    , mtl                   >=2.3  && <2.4     , stm                   >=2.5  && <2.6     , streamly-core         >=0.3  && <0.4     , text                  >=2.0  && <2.2@@ -87,6 +88,7 @@   hs-source-dirs: test   other-modules:     Test.CatchupDbErrorNoPrematureSwitch+    Test.Category     Test.CategoryIdleNoSpin     Test.Causation     Test.Concurrency@@ -97,9 +99,14 @@     Test.FailureInjection     Test.Helpers     Test.InterpreterHooks+    Test.NotifyGuard     Test.Properties+    Test.PublisherCallbackResilience+    Test.PublisherIdleAdvance     Test.PublisherRestartNoRebroadcast     Test.ReadStream+    Test.StartupFailureSurfacing+    Test.StreamBridgeTermination     Test.StreamNameLookup     Test.SubscriptionPauseResume     Test.SubscriptionReconnect@@ -107,12 +114,14 @@     Test.SubscriptionRetryDeadLetter     Test.SubscriptionState     Test.Transaction+    Test.TruncateBefore    ghc-options:    -threaded -rtsopts -with-rtsopts=-N   build-depends:     , aeson                 >=2.1  && <2.3     , async                 >=2.2  && <2.3     , base                  >=4.18 && <5+    , bytestring            >=0.11 && <0.13     , containers            >=0.6  && <0.8     , contravariant-extras  >=0.3     , directory@@ -120,6 +129,7 @@     , ephemeral-pg          >=0.2  && <0.3     , generic-lens          >=2.2  && <2.4     , hasql                 >=1.10 && <1.11+    , hasql-notifications   >=0.2  && <0.3     , hasql-pool            >=1.2  && <1.5     , hasql-transaction     >=1.1  && <1.3     , hedgehog              >=1.4  && <1.8@@ -128,6 +138,7 @@     , kiroku-store     , kiroku-test-support     , lens                  >=5.2  && <5.4+    , mmzk-typeid           >=0.6  && <0.8     , stm                   >=2.5  && <2.6     , streamly-core         >=0.3  && <0.4     , text                  >=2.0  && <2.2@@ -142,23 +153,24 @@   hs-source-dirs: bench   ghc-options:    -threaded -rtsopts "-with-rtsopts=-N -A32m"   build-depends:-    , aeson              >=2.1  && <2.3-    , async              >=2.2  && <2.3-    , base               >=4.18 && <5-    , deepseq            >=1.4-    , ephemeral-pg       >=0.2  && <0.3-    , generic-lens       >=2.2  && <2.4-    , hasql              >=1.10 && <1.11-    , hasql-pool         >=1.2  && <1.5-    , hasql-transaction  >=1.1  && <1.3+    , aeson                >=2.1  && <2.3+    , async                >=2.2  && <2.3+    , base                 >=4.18 && <5+    , deepseq              >=1.4+    , ephemeral-pg         >=0.2  && <0.3+    , generic-lens         >=2.2  && <2.4+    , hasql                >=1.10 && <1.11+    , hasql-pool           >=1.2  && <1.5+    , hasql-transaction    >=1.1  && <1.3     , kiroku-store-    , lens               >=5.2  && <5.4-    , mmzk-typeid        >=0.6-    , tasty-bench        >=0.4-    , text               >=2.0  && <2.2-    , time               >=1.12 && <1.15-    , uuid               >=1.3  && <1.4-    , vector             >=0.13 && <0.14+    , kiroku-test-support+    , lens                 >=5.2  && <5.4+    , mmzk-typeid          >=0.6+    , tasty-bench          >=0.4+    , text                 >=2.0  && <2.2+    , time                 >=1.12 && <1.15+    , uuid                 >=1.3  && <1.4+    , vector               >=0.13 && <0.14  benchmark kiroku-shibuya-overhead   import:         common@@ -176,7 +188,7 @@     , generic-lens          >=2.2  && <2.4     , kiroku-store     , lens                  >=5.2  && <5.4-    , shibuya-core          >=0.6  && <0.7+    , shibuya-core          >=0.8  && <0.9     , stm                   >=2.5  && <2.6     , streamly              >=0.11     , streamly-core         >=0.3  && <0.4
src/Kiroku/Store/Append.hs view
@@ -58,14 +58,13 @@   existing stream (including soft-deleted). * 'Kiroku.Store.Error.DuplicateEvent' — caller-supplied 'eventId'   collides with an existing event.+* 'Kiroku.Store.Error.EmptyAppendBatch' — the supplied event list is+  empty.  Empty event lists: passing @[]@ is a programming mistake. The-underlying CTE returns 0 rows, which the interpreter maps to a-constructor that depends on the supplied 'ExpectedVersion'-('Kiroku.Store.Error.WrongExpectedVersion' for 'ExactVersion',-'Kiroku.Store.Error.StreamNotFound' for 'StreamExists', and so on).-None of these is the caller's intent; reject the call yourself before-invoking the API.+interpreter rejects the call with 'Kiroku.Store.Error.EmptyAppendBatch'+before any database work. This avoids taking the global @$all@ row lock,+firing notifications, or creating an empty stream under 'NoStream'. -} appendToStream ::     (HasCallStack, Store :> es) =>@@ -101,7 +100,9 @@ The returned list mirrors the input order. Each 'AppendResult' carries the corresponding stream's final state. -Empty input @[]@ is a no-op programming mistake; do not call.+Empty operation input @[]@ returns @[]@ without a database round trip.+Any per-stream empty event list is rejected with+'Kiroku.Store.Error.EmptyAppendBatch' before the transaction opens. -} appendMultiStream ::     (HasCallStack, Store :> es) =>
src/Kiroku/Store/Effect.hs view
@@ -20,6 +20,7 @@ ) where  import Control.Lens ((^.))+import Control.Monad.Except qualified as Except import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Aeson (Value) import Data.Foldable (for_)@@ -40,14 +41,18 @@ import Effectful.Dispatch.Dynamic (interpret_) import Effectful.Error.Static (Error, runErrorNoCallStack, throwError) import GHC.Generics (Generic)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pipeline qualified as Pipeline import Hasql.Pool (Pool) import Hasql.Pool qualified as Pool import Hasql.Session qualified as Session+import Hasql.Statement (Statement, unpreparable) import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store.Connection (KirokuStore (..)) import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)-import Kiroku.Store.Error (StoreError (..), attributeMultiStreamError, emptyResultError, mapUsageError)+import Kiroku.Store.Error (StoreError (..), attributeMultiStreamError, emptyResultError, isTransientSerializationError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName) import Kiroku.Store.Observability (KirokuEvent (..)) import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Settings (decodeEvents, enrichEvents)@@ -74,6 +79,13 @@     Surfaced as 'Kiroku.Store.Read.lookupStreamId'.     -}     LookupStreamId :: StreamName -> Store m (Maybe StreamId)+    {- | Check whether an event id exists in a live stream without materializing+    stream events. Soft-deleted streams behave as nonexistent, mirroring+    'Kiroku.Store.Read.readStreamForward'.++    Surfaced as 'Kiroku.Store.Read.eventExistsInStream'.+    -}+    EventExistsInStream :: StreamName -> EventId -> Store m Bool     {- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a     single round trip. The result 'Map' contains an entry only for ids that     name an existing stream (live or soft-deleted); hard-deleted or unknown ids@@ -103,6 +115,14 @@     SoftDeleteStream :: StreamName -> Store m (Maybe StreamId)     HardDeleteStream :: StreamName -> Store m (Maybe StreamId)     UndeleteStream :: StreamName -> Store m (Maybe StreamId)+    {- | Set a stream's logical truncate-before marker. Per-stream ordered+    reads then return only events whose version is >= the marker; the @$all@+    global log, category reads, and subscriptions are unaffected. Reversible.++    Surfaced as 'Kiroku.Store.Lifecycle.setStreamTruncateBefore' /+    'Kiroku.Store.Lifecycle.clearStreamTruncateBefore'.+    -}+    SetStreamTruncateBefore :: StreamName -> StreamVersion -> Store m (Maybe StreamId)     {- | Run an arbitrary @hasql-transaction@ value in a 'BEGIN'/'COMMIT'     block on a single pool connection. Escape hatch from the abstract     'Store' effect into the underlying SQL world; mock interpreters are@@ -130,20 +150,34 @@     Eff es a runStorePool store = interpret_ $ \case     AppendToStream (StreamName name) expected events -> do-        rejectReservedApplicationStream name+        rejectInvalidApplicationStream name+        case events of+            [] -> throwError (EmptyAppendBatch (StreamName name))+            _ -> pure ()         events' <- liftIO $ enrichEvents (store ^. #storeSettings) events         now <- liftIO getCurrentTime         prepared <- prepareEvents events'         let params = buildAppendParams name now prepared-        result <- liftIO $ Pool.use (store ^. #pool) $ case expected of-            ExactVersion (StreamVersion v) ->-                Session.statement (params, v) SQL.appendExpectedVersion-            StreamExists ->-                Session.statement params SQL.appendStreamExists-            NoStream ->-                Session.statement params SQL.appendNoStream-            AnyVersion ->-                Session.statement params SQL.appendAnyVersion+        let runOnce =+                Pool.use (store ^. #pool) $ case expected of+                    ExactVersion (StreamVersion v) ->+                        Session.statement (params, v) SQL.appendExpectedVersion+                    StreamExists ->+                        Session.statement params SQL.appendStreamExists+                    NoStream ->+                        Session.statement params SQL.appendNoStream+                    AnyVersion ->+                        Session.statement params SQL.appendAnyVersion+        firstAttempt <- liftIO runOnce+        result <- case firstAttempt of+            -- Match hasql-transaction's retryable SQLSTATE set, but only once:+            -- PostgreSQL rolls back the victim transaction and event ids were+            -- prepared before the first attempt, so this retry is idempotent.+            Left usageErr+                | isTransientSerializationError usageErr ->+                    liftIO runOnce+            _ ->+                pure firstAttempt         case result of             Left usageErr ->                 throwError (mapUsageError name expected usageErr)@@ -157,9 +191,10 @@                 Session.statement (name, startVer, limit) SQL.readStreamForwardStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs     ReadStreamBackward (StreamName name) (StreamVersion startVer) limit -> do+        let cursor = if startVer == 0 then maxBound else startVer         evs <-             usePool (store ^. #pool) $-                Session.statement (name, startVer, limit) SQL.readStreamBackwardStmt+                Session.statement (name, cursor, limit) SQL.readStreamBackwardStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs     ReadAllForward (GlobalPosition startPos) limit -> do         evs <-@@ -167,9 +202,10 @@                 Session.statement (startPos, limit) SQL.readAllForwardStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs     ReadAllBackward (GlobalPosition startPos) limit -> do+        let cursor = if startPos == 0 then maxBound else startPos         evs <-             usePool (store ^. #pool) $-                Session.statement (startPos, limit) SQL.readAllBackwardStmt+                Session.statement (cursor, limit) SQL.readAllBackwardStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs     GetStream (StreamName name) ->         usePool (store ^. #pool) $@@ -178,6 +214,11 @@         fmap (fmap StreamId) $             usePool (store ^. #pool) $                 Session.statement name SQL.findStreamIdStmt+    EventExistsInStream (StreamName name) (EventId eid) ->+        usePool (store ^. #pool) $+            Session.statement (name, eid) SQL.eventExistsInStreamStmt+    LookupStreamNames [] ->+        pure Map.empty     LookupStreamNames sids ->         fmap             (Map.fromList . map (\(s, nm) -> (StreamId s, StreamName nm)) . V.toList)@@ -185,23 +226,30 @@                 Session.statement [s | StreamId s <- sids] SQL.lookupStreamNamesStmt             )     LinkToStream (StreamName name) eventIds -> do-        rejectReservedApplicationStream name+        rejectInvalidApplicationStream name         let uuids = V.fromList [uid | EventId uid <- eventIds]         result <--            usePool (store ^. #pool) $-                Session.statement (uuids, name) SQL.linkToStreamStmt+            liftIO $+                Pool.use (store ^. #pool) $+                    Session.statement (uuids, name) SQL.linkToStreamStmt         case result of-            Nothing -> throwError (StreamNotFound (StreamName name))-            Just r -> pure r+            Left usageErr -> throwError (mapLinkUsageError (StreamName name) usageErr)+            Right Nothing -> throwError (StreamNotFound (StreamName name))+            Right (Just r) -> pure r     ReadCategoryForward (CategoryName cat) (GlobalPosition startPos) limit -> do         evs <-             usePool (store ^. #pool) $                 Session.statement (startPos, cat, limit) SQL.readCategoryForwardStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs+    AppendMultiStream [] ->+        pure []     AppendMultiStream ops -> do-        case find (\(StreamName name, _, _) -> isReservedApplicationStream name) ops of-            Just (sn, _, _) -> throwError (ReservedStreamName sn)+        case firstStreamNameError ops of+            Just err -> throwError err             Nothing -> pure ()+        case find (\(_, _, evts) -> null evts) ops of+            Just (sn, _, _) -> throwError (EmptyAppendBatch sn)+            Nothing -> pure ()         now <- liftIO getCurrentTime         -- Prepare all events for all streams         preparedOps <-@@ -213,27 +261,20 @@                 )                 ops         let names = V.fromList [n | (StreamName n, _, _) <- ops]-        let txn = do-                -- Pre-lock the user-named streams in deterministic (stream_id)-                -- order to avoid row-lock deadlocks between concurrent-                -- multi-stream txns touching overlapping streams in different-                -- user orders. See EP-1 F4.-                Tx.statement names SQL.lockStreamsForMultiStmt-                results <--                    mapM-                        ( \(StreamName name, expected, prepared) -> do-                            let params = buildAppendParams name now prepared-                            appendDispatchTx expected params-                        )-                        preparedOps-                -- If any result is Nothing (version conflict), condemn the transaction-                case any isNothing results of-                    True -> Tx.condemn >> pure results-                    False -> pure results-        result <--            liftIO $+        let runOnce =                 Pool.use (store ^. #pool) $-                    TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+                    runAppendMultiStreamPipeline names now preparedOps+        firstAttempt <- liftIO runOnce+        result <- case firstAttempt of+            -- Match the single-stream append retry and hasql-transaction's+            -- retryable SQLSTATE set. The failed transaction has rolled back,+            -- and event ids were prepared before the first attempt, so retrying+            -- once is idempotent.+            Left usageErr+                | isTransientSerializationError usageErr ->+                    liftIO runOnce+            _ ->+                pure firstAttempt         case result of             Left usageErr ->                 throwError (attributeMultiStreamError [(sn, ev) | (sn, ev, _) <- ops] usageErr)@@ -260,18 +301,22 @@                     Session.statement eid SQL.findCausationAncestorsStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs     SoftDeleteStream (StreamName name) -> do-        rejectReservedApplicationStream name+        rejectInvalidApplicationStream name         usePool (store ^. #pool) $             Session.statement name SQL.softDeleteStreamStmt     HardDeleteStream (StreamName name) -> do-        rejectReservedApplicationStream name+        rejectInvalidApplicationStream name         let txn = do                 Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"                 mSid <- Tx.statement name SQL.findStreamIdStmt                 case mSid of                     Nothing -> pure Nothing                     Just sid -> do-                        affected <- Tx.statement sid SQL.deleteStreamJunctionsStmt+                        originated <- Tx.statement sid SQL.deleteAllRowsForOriginStmt+                        Tx.statement originated SQL.deleteJunctionsByEventIdsStmt+                        linkedIn <- Tx.statement sid SQL.deleteStreamOwnJunctionsStmt+                        let affected = originated <> linkedIn+                        Tx.statement affected SQL.deleteDeadLettersForOrphanedEventsStmt                         Tx.statement affected SQL.deleteOrphanedEventsStmt                         Tx.statement sid SQL.deleteStreamRowStmt                         pure (Just (StreamId sid))@@ -287,9 +332,13 @@             Nothing -> pure ()         pure result     UndeleteStream (StreamName name) -> do-        rejectReservedApplicationStream name+        rejectInvalidApplicationStream name         usePool (store ^. #pool) $             Session.statement name SQL.undeleteStreamStmt+    SetStreamTruncateBefore (StreamName name) (StreamVersion v) -> do+        rejectInvalidApplicationStream name+        usePool (store ^. #pool) $+            Session.statement (name, v) SQL.setStreamTruncateBeforeStmt     RunTransaction tx ->         runTxOnPool (store ^. #pool) TxSessions.transaction tx     RunTransactionNoRetry tx ->@@ -344,21 +393,25 @@             Pool.use pool $                 entry TxSessions.ReadCommitted TxSessions.Write tx     case result of-        Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))+        Left usageErr -> throwError (mapTransactionUsageError usageErr)         Right a -> pure a --- | The seeded $all row is the global read stream, not an application stream.-isReservedApplicationStream :: Text -> Bool-isReservedApplicationStream = (== "$all")--rejectReservedApplicationStream ::+rejectInvalidApplicationStream ::     (Error StoreError :> es) =>     Text ->     Eff es ()-rejectReservedApplicationStream name-    | isReservedApplicationStream name = throwError (ReservedStreamName (StreamName name))-    | otherwise = pure ()+rejectInvalidApplicationStream name =+    either throwError pure (validateStreamName (StreamName name)) +firstStreamNameError :: [(StreamName, ExpectedVersion, [EventData])] -> Maybe StoreError+firstStreamNameError ops =+    case find (isLeft . validateStreamName . streamNameOf) ops of+        Just (sn, _, _) -> either Just (const Nothing) (validateStreamName sn)+        Nothing -> Nothing+  where+    streamNameOf (sn, _, _) = sn+    isLeft = either (const True) (const False)+ -- --------------------------------------------------------------------------- -- Internal helpers (moved from Append) -- ---------------------------------------------------------------------------@@ -412,6 +465,55 @@         , createdAts = V.fromList (replicate (length prepared) now)         , streamName = name         }++multiAppendBeginStmt :: Statement () ()+multiAppendBeginStmt = unpreparable "BEGIN" E.noParams D.noResult++multiAppendCommitStmt :: Statement () ()+multiAppendCommitStmt = unpreparable "COMMIT" E.noParams D.noResult++multiAppendRollbackStmt :: Statement () ()+multiAppendRollbackStmt = unpreparable "ROLLBACK" E.noParams D.noResult++runAppendMultiStreamPipeline ::+    Vector Text ->+    UTCTime ->+    [(StreamName, ExpectedVersion, [PreparedEvent])] ->+    Session.Session [Maybe AppendResult]+runAppendMultiStreamPipeline names now preparedOps =+    Except.catchError body $ \err -> do+        Session.statement () multiAppendRollbackStmt+        Except.throwError err+  where+    body = do+        results <-+            Session.pipeline $+                Pipeline.statement () multiAppendBeginStmt+                    -- Pre-lock the user-named streams in deterministic (stream_id)+                    -- order to avoid row-lock deadlocks between concurrent+                    -- multi-stream txns touching overlapping streams in different+                    -- user orders. See EP-1 F4.+                    *> Pipeline.statement names SQL.lockStreamsForMultiStmt+                    *> traverse appendPrepared preparedOps+        Session.statement () $+            if any isNothing results+                then multiAppendRollbackStmt+                else multiAppendCommitStmt+        pure results++    appendPrepared (StreamName name, expected, prepared) =+        appendDispatchPipeline expected (buildAppendParams name now prepared)++appendDispatchPipeline :: ExpectedVersion -> SQL.AppendParams -> Pipeline.Pipeline (Maybe AppendResult)+appendDispatchPipeline expected params = case expected of+    ExactVersion (StreamVersion v) ->+        Pipeline.statement (params, v) SQL.appendExpectedVersion+    StreamExists ->+        Pipeline.statement params SQL.appendStreamExists+    NoStream ->+        Pipeline.statement params SQL.appendNoStream+    AnyVersion ->+        Pipeline.statement params SQL.appendAnyVersion  {- | Dispatch the four 'SQL.append*' statements through 'Tx.statement', selecting the right one based on the supplied 'ExpectedVersion'.
src/Kiroku/Store/Error.hs view
@@ -1,21 +1,29 @@ module Kiroku.Store.Error (     StoreError (..),+    maxStreamNameBytes,+    validateStreamName,      -- * Append-precondition conflicts (Tx-flavored)     AppendConflict (..),     appendConflictToStoreError,     emptyResultConflict,     -- Internal helpers used by Effect module+    mapGenericUsageError,+    mapLinkUsageError,+    mapTransactionUsageError,     mapUsageError,     emptyResultError,     attributeMultiStreamError,+    isTransientSerializationError,     -- Internal pure helper exposed for unit testing     extractStreamNameFromDetail, ) where  import Control.Exception (Exception)+import Data.ByteString qualified as BS import Data.Text (Text) import Data.Text qualified as T+import Data.Text.Encoding qualified as TE import Data.UUID (UUID) import Data.UUID qualified as UUID import GHC.Generics (Generic)@@ -44,10 +52,21 @@ data StoreError     = {- | The actual stream version did not match the caller's       'ExactVersion' expectation. The third field is the actual-      version (or 'StreamVersion' 0 when the version could not be-      recovered, e.g., the row was concurrently soft-deleted).+      version placeholder. The append statement returns zero rows on a+      version mismatch and the store does not issue an extra read to+      recover the live version, so this field is 'StreamVersion' 0 on+      every empty-CTE rejection. Callers needing the live version must+      read the stream, for example with 'Kiroku.Store.Read.getStream'.       -}       WrongExpectedVersion !StreamName !ExpectedVersion !StreamVersion+    | {- | The caller supplied an empty event batch to an append surface.++      Appending zero events is always a programming mistake: before this+      guard existed, an empty batch silently took the global @$all@ row+      lock, fired NOTIFY triggers, and under 'NoStream' even created an+      empty stream. The interpreter rejects it before any pool work.+      -}+      EmptyAppendBatch !StreamName     | -- | The named stream does not exist (or has been soft-deleted).       StreamNotFound !StreamName     | {- | The named stream is reserved for store internals and cannot be@@ -56,6 +75,16 @@       @streams.stream_id = 0@ row.       -}       ReservedStreamName !StreamName+    | {- | The stream name exceeds 'maxStreamNameBytes' bytes of UTF-8.++      The append notification payload embeds the stream name, and+      PostgreSQL rejects @pg_notify@ payloads of 8,000 bytes or more.+      Enforcing a much smaller byte bound before any database work+      prevents an oversized stream name from failing late as an opaque+      trigger/server error. The second field is the offending UTF-8+      byte length.+      -}+      StreamNameTooLong !StreamName !Int     | {- | The named stream already exists. Returned for 'NoStream'       expectations against an existing stream and for 'linkToStream'       targets that already exist with conflicting state.@@ -70,6 +99,20 @@       offending id to the user should match on 'Just'.       -}       DuplicateEvent !(Maybe EventId)+    | {- | A 'Kiroku.Store.Link.linkToStream' call tried to link an+      event into a target stream that already contains it.++      Maps the @23505@ unique violation on @stream_events_pkey@, whose+      key is @(event_id, stream_id)@. Carries 'Just' the offending event+      id when PostgreSQL's detail string is parseable.+      -}+      EventAlreadyLinked !StreamName !(Maybe EventId)+    | {- | A 'Kiroku.Store.Link.linkToStream' call referenced an event id+      that does not exist. The link CTE surfaces this as a @23502@+      not-null violation on @stream_events.original_stream_id@ and the+      whole batch rolls back.+      -}+      LinkSourceEventMissing !StreamName     | {- | The connection pool timed out acquiring a connection. Almost       always retryable after a small backoff; sustained timeouts       indicate that the pool is undersized for the offered load or@@ -97,6 +140,19 @@     deriving stock (Eq, Show, Generic)     deriving anyclass (Exception) +-- | Maximum stream-name length in UTF-8 bytes.+maxStreamNameBytes :: Int+maxStreamNameBytes = 512++-- | Validate application stream names before any database work.+validateStreamName :: StreamName -> Either StoreError ()+validateStreamName sn@(StreamName name)+    | name == "$all" = Left (ReservedStreamName sn)+    | byteLength > maxStreamNameBytes = Left (StreamNameTooLong sn byteLength)+    | otherwise = Right ()+  where+    byteLength = BS.length (TE.encodeUtf8 name)+ {- | Map a hasql 'UsageError' to a 'StoreError'.  Pattern matches on the error hierarchy:@@ -113,7 +169,7 @@ and @ix_streams_stream_name@. If a future schema migration renames a constraint, the @23505@ branch falls through to the generic 'WrongExpectedVersion' mapping; keep the names stable in-@kiroku-store-migrations/sql-migrations@.+@kiroku-store-migrations/migrations@. -} mapUsageError :: Text -> ExpectedVersion -> UsageError -> StoreError mapUsageError streamName expected = \case@@ -124,6 +180,58 @@     AcquisitionTimeoutUsageError ->         PoolAcquisitionTimeout +{- | Map a hasql 'UsageError' raised inside an opaque transaction body.++The body has no stream context, so append duplicate violations are mapped+directly to 'DuplicateEvent' and all other failures fall back to the standard+append-shaped mapper with sentinel stream context.+-}+mapTransactionUsageError :: UsageError -> StoreError+mapTransactionUsageError usageErr =+    case extractServerError usageErr of+        Just (Errors.ServerError "23505" message detail _ _)+            | containsConstraint "events_pkey" message detail ->+                DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))+            | containsConstraint "stream_events_pkey" message detail ->+                DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))+        _ ->+            mapUsageError "<transaction>" AnyVersion usageErr+  where+    containsConstraint name message detail =+        name `T.isInfixOf` message || maybe False (T.isInfixOf name) detail++-- | Generic, non-append-shaped mapping for hasql pool usage errors.+mapGenericUsageError :: UsageError -> StoreError+mapGenericUsageError = \case+    ConnectionUsageError connErr ->+        ConnectionLost (T.pack (show connErr))+    AcquisitionTimeoutUsageError ->+        PoolAcquisitionTimeout+    SessionUsageError sessionErr ->+        case extractServerError (SessionUsageError sessionErr) of+            Just (Errors.ServerError code message _ _ _) ->+                UnexpectedServerError code message+            Nothing ->+                ConnectionError ("Session error: " <> T.pack (show sessionErr))++-- | Map link-specific hasql failures to typed link errors.+mapLinkUsageError :: StreamName -> UsageError -> StoreError+mapLinkUsageError target usageErr =+    case extractServerError usageErr of+        Just (Errors.ServerError "23505" message detail _ _)+            | containsConstraint "stream_events_pkey" message detail ->+                EventAlreadyLinked target (extractCompositeEventId detail)+        Just (Errors.ServerError "23502" _ _ _ _) ->+            LinkSourceEventMissing target+        _ ->+            mapGenericUsageError usageErr+  where+    containsConstraint name message detail =+        name `T.isInfixOf` message || maybe False (T.isInfixOf name) detail++    extractCompositeEventId (Just d) = EventId <$> extractFirstUuidFromCompositeDetail d+    extractCompositeEventId Nothing = Nothing+ mapSessionError :: Text -> ExpectedVersion -> Errors.SessionError -> StoreError mapSessionError streamName expected = \case     Errors.StatementSessionError _ _ _ _ _ stmtErr ->@@ -192,11 +300,14 @@ -} data AppendConflict     = {- | Mirror of 'WrongExpectedVersion'. The third field is the-      actual stream version, or @StreamVersion 0@ when it could not-      be recovered (e.g., the CTE returned no rows because the-      target was concurrently soft-deleted).+      stream-version placeholder. The append statement returns zero+      rows on a version mismatch and the store does not issue an extra+      read to recover the live version, so this field is+      @'StreamVersion' 0@ on every empty-CTE rejection.       -}       WrongExpectedVersionConflict !StreamName !ExpectedVersion !StreamVersion+    | -- | Mirror of 'EmptyAppendBatch'.+      EmptyAppendBatchConflict !StreamName     | -- | Mirror of 'StreamNotFound'.       StreamNotFoundConflict !StreamName     | -- | Mirror of 'StreamAlreadyExists'.@@ -210,6 +321,7 @@ appendConflictToStoreError :: AppendConflict -> StoreError appendConflictToStoreError = \case     WrongExpectedVersionConflict sn ev sv -> WrongExpectedVersion sn ev sv+    EmptyAppendBatchConflict sn -> EmptyAppendBatch sn     StreamNotFoundConflict sn -> StreamNotFound sn     StreamAlreadyExistsConflict sn -> StreamAlreadyExists sn @@ -268,6 +380,19 @@                  in UUID.fromText uuidText         _ -> Nothing +{- | Extract the first UUID from a PostgreSQL composite-key detail string like:+"Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists."+-}+extractFirstUuidFromCompositeDetail :: Text -> Maybe UUID+extractFirstUuidFromCompositeDetail detail =+    case T.breakOn "=(" detail of+        (_, rest)+            | not (T.null rest) ->+                let afterParen = T.drop 2 rest -- skip "=("+                    uuidText = T.strip (T.takeWhile (\c -> c /= ',' && c /= ')') afterParen)+                 in UUID.fromText uuidText+        _ -> Nothing+ {- | Extract a stream name from a PostgreSQL unique-violation detail string like @"Key (stream_name)=(orders-1) already exists."@. @@ -345,3 +470,12 @@             Errors.ServerStatementError serverErr -> Just serverErr             _ -> Nothing     _ -> Nothing++-- | True for PostgreSQL transient transaction aborts retried by hasql-transaction.+isTransientSerializationError :: UsageError -> Bool+isTransientSerializationError usageErr =+    case extractServerError usageErr of+        Just (Errors.ServerError code _ _ _ _) ->+            code == "40001" || code == "40P01"+        Nothing ->+            False
src/Kiroku/Store/Lifecycle.hs view
@@ -2,6 +2,8 @@     softDeleteStream,     hardDeleteStream,     undeleteStream,+    setStreamTruncateBefore,+    clearStreamTruncateBefore, ) where  import Effectful (Eff, (:>))@@ -46,7 +48,7 @@ transaction. Direct @DELETE@ or @TRUNCATE@ against the underlying tables without setting that GUC raises an exception via the @protect_deletion@ and @protect_truncation@ triggers in-@kiroku-store-migrations/sql-migrations@.+@kiroku-store-migrations/migrations@.  /The GUC is an advisory protection, not a security boundary./ Any PostgreSQL session with @DELETE@ privilege on @events@,@@ -108,3 +110,42 @@     StreamName ->     Eff es (Maybe StreamId) undeleteStream name = send (UndeleteStream name)++{- | Set the logical truncate-before marker for a stream. After this call,+'Kiroku.Store.Read.readStreamForward' and 'readStreamBackward' return only the+events whose per-stream version is >= @before@; earlier events are hidden from+ordered stream reads but are NOT deleted.++This is the close-the-book / snapshot-and-compact primitive: append a snapshot+event (it lands at version @V@), then call @setStreamTruncateBefore name V@ so+rehydration starts from the snapshot.++The marker does NOT affect the @$all@ global log, 'Kiroku.Store.Read.readCategory',+subscriptions, or existence probes — the global history stays complete, so+projections (including ones not yet written) can still be built from it. The+operation is fully reversible: lower the marker or call+'clearStreamTruncateBefore' to re-expose hidden events.++Returns @Just streamId@ on success, @Nothing@ if the stream does not exist or is+soft-deleted. The reserved stream @$all@ is rejected with+'Kiroku.Store.Error.ReservedStreamName'. Per-stream versions are 1-based, so a+@before@ of 0 or 1 keeps the whole stream. Idempotent: setting the same value+again returns the same result and changes nothing.+-}+setStreamTruncateBefore ::+    (HasCallStack, Store :> es) =>+    StreamName ->+    StreamVersion ->+    Eff es (Maybe StreamId)+setStreamTruncateBefore name before = send (SetStreamTruncateBefore name before)++{- | Clear a stream's truncate-before marker, re-exposing the full history to+ordered stream reads. Equivalent to @setStreamTruncateBefore name 0@. Returns+@Just streamId@ on success, @Nothing@ for a missing or soft-deleted stream.+Rejects @$all@ with 'Kiroku.Store.Error.ReservedStreamName'.+-}+clearStreamTruncateBefore ::+    (HasCallStack, Store :> es) =>+    StreamName ->+    Eff es (Maybe StreamId)+clearStreamTruncateBefore name = setStreamTruncateBefore name (StreamVersion 0)
src/Kiroku/Store/Link.hs view
@@ -10,6 +10,15 @@  {- | Link existing events into a target stream. +__Provisional API.__ No known consumer uses this function (audited+2026-06-11: zero usage in keiro, the only downstream framework). It is the+only public feature that requires the @stream_events@ junction-table layout,+which a future global-position migration may replace with a single-table event+layout (global position as a column on @events@); in that case this function+will be removed or redesigned (e.g. rehomed to a dedicated @stream_links@ side+table). If you have a real use case, surface it before depending on this. See+docs/architecture/global-position-migration-path.md.+ Creates the target stream if it does not exist; otherwise appends links to the existing target. Linking does not duplicate the event payload — the @events@ row is shared, and only a new junction row in@@ -23,7 +32,8 @@ * Every supplied 'Kiroku.Store.Types.EventId' must reference an event   that currently exists. Linking an unknown id (a typo, or an id that   was hard-deleted) rejects the entire batch atomically — the target-  stream is left in its pre-call state. (See EP-1 F3.)+  stream is left in its pre-call state — with+  'Kiroku.Store.Error.LinkSourceEventMissing'. (See EP-1 F3.) * The target must not be soft-deleted. Linking to a soft-deleted target   fails with 'Kiroku.Store.Error.StreamNotFound'. (See EP-1 F5.) * The target must not be @$all@. @$all@ is the global read stream, and@@ -31,8 +41,7 @@   the interpreter rejects this with   'Kiroku.Store.Error.ReservedStreamName'. * Linking the same event into the same target stream twice fails with-  a primary-key violation on the junction's @(stream_id, event_id)@-  uniqueness.+  'Kiroku.Store.Error.EventAlreadyLinked'.  Returns the target's 'Kiroku.Store.Types.LinkResult' — its id and the position of the /last/ linked event in the target.
src/Kiroku/Store/Notification.hs view
@@ -9,12 +9,11 @@ import Control.Concurrent.Async (Async) import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM (TChan, TVar, atomically, modifyTVar', newBroadcastTChanIO, newTVarIO, readTVarIO, writeTChan, writeTVar)-import Control.Exception (Exception, SomeException, asyncExceptionFromException, bracketOnError, catch, throwIO, toException)+import Control.Exception (Exception, SomeAsyncException, SomeException, asyncExceptionFromException, bracketOnError, catch, throwIO, toException) import Control.Monad (void) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.ByteString (ByteString) import Data.ByteString.Char8 qualified as BC-import Data.Foldable (for_) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text)@@ -30,7 +29,8 @@ import Hasql.Notifications qualified as Notifications import Hasql.Session qualified as Session import Hasql.Statement (Statement, unpreparable)-import Kiroku.Store.Observability (KirokuEvent (..))+import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop)+import Kiroku.Store.Types (CategoryName (..), StreamName (..), categoryName)  {- | A Notifier manages a dedicated PostgreSQL connection for LISTEN/NOTIFY. It writes a @()@ tick to a broadcast 'TChan' on every notification,@@ -61,15 +61,15 @@     -}     } -{- | Raised by 'startNotifier' when the initial dedicated @LISTEN@-connection cannot be acquired. Carries @hasql@'s underlying-'Connection.ConnectionError' (often a TCP-level failure or an-authentication error) for diagnostics.+{- | Raised by 'startNotifier' when the initial dedicated @LISTEN@ setup fails. -Replaces the prior @IOException@-via-@fail@ shape so callers can pattern-match on a typed startup exception.+Connection-acquire failures carry @hasql@'s underlying+'Connection.ConnectionError'. LISTEN failures carry the exception thrown after+the connection was acquired; that connection has already been released. -}-newtype NotifierStartError = NotifierStartError ConnectionError+data NotifierStartError+    = NotifierConnectError !ConnectionError+    | NotifierListenError !SomeException     deriving stock (Show)     deriving anyclass (Exception) @@ -108,7 +108,7 @@  On 'Async.AsyncCancelled' (from cancellation), exits cleanly. -Initial-acquire failure raises 'NotifierStartError'.+Initial acquire or LISTEN failure raises 'NotifierStartError'. -} startNotifier ::     (MonadIO m) =>@@ -122,18 +122,22 @@ startNotifier connString schema mHandler = liftIO $ do     chan <- newBroadcastTChanIO     catGenVar <- newTVarIO Map.empty-    conn <- acquireOrThrow connString     let channel = toPgIdentifier (schema <> ".events")-    Notifications.listen conn channel-    connRef <- newTVarIO conn-    thread <- Async.async (listenerLoop chan catGenVar connRef channel connString mHandler)-    pure-        Notifier-            { tickChan = chan-            , listenerThread = thread-            , listenerConnRef = connRef-            , categoryGenerations = catGenVar-            }+    bracketOnError (acquireOrThrow connString) Connection.release $ \conn -> do+        Notifications.listen conn channel+            `catch` \(e :: SomeException) ->+                case asyncExceptionFromException e of+                    Just (ae :: SomeAsyncException) -> throwIO ae+                    Nothing -> throwIO (NotifierListenError e)+        connRef <- newTVarIO conn+        thread <- Async.async (listenerLoop chan catGenVar connRef channel connString mHandler)+        pure+            Notifier+                { tickChan = chan+                , listenerThread = thread+                , listenerConnRef = connRef+                , categoryGenerations = catGenVar+                }  {- | Stop the Notifier. Cancels the listener thread, waits for it to finish, and releases whichever connection the loop is currently holding.@@ -210,7 +214,7 @@                 emit KirokuEventNotifierReconnected                 go -    emit evt = for_ mHandler ($ evt)+    emit = emitOrDrop mHandler  -- The 'waitForNotifications' callback. Wakes the publisher (the bare @()@ tick, -- preserving the existing AllStreams broadcast path) AND bumps the originating@@ -228,14 +232,15 @@ -- payload is @stream_name,stream_id,stream_version@; @stream_id@ and -- @stream_version@ are comma-free integers, so the @stream_name@ is everything -- except the last two comma-separated fields (rejoined, since a stream name may--- itself contain commas). The category is then the prefix before the first @-@,--- matching the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@--- column. A category never contains @-@, so @takeWhile (/= '-')@ is exact.+-- itself contain commas). The category is then derived by 'categoryName' — the+-- single Haskell definition of the "prefix before the first @-@" rule, matching+-- the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column. categoryFromPayload :: ByteString -> Text categoryFromPayload payload =     let fields = BC.split ',' payload-        streamName = BC.intercalate "," (take (max 0 (length fields - 2)) fields)-     in decodeUtf8Lenient (BC.takeWhile (/= '-') streamName)+        streamNameBytes = BC.intercalate "," (take (max 0 (length fields - 2)) fields)+        CategoryName category = categoryName (StreamName (decodeUtf8Lenient streamNameBytes))+     in category  -- A synthetic exception used when 'waitForNotifications' returns without -- raising (hasql-notifications turned a connection error into a Left@@ -246,14 +251,13 @@     deriving anyclass (Exception)  -- Internal: acquire a connection, set its application_name, or throw on failure.--- Throws 'NotifierStartError' (which derives 'Exception') instead of the--- prior @fail@-based 'IOException', so callers can match on a typed startup--- exception.+-- Throws 'NotifierStartError' (which derives 'Exception') for connection+-- acquisition failures, so callers can match on a typed startup exception. acquireOrThrow :: Text -> IO Connection acquireOrThrow connStr = do     result <- Connection.acquire (Conn.connectionString connStr)     case result of-        Left err -> throwIO (NotifierStartError err)+        Left err -> throwIO (NotifierConnectError err)         Right conn -> do             -- Tag the connection so operators can identify the listener in             -- pg_stat_activity. Failures here are non-fatal — fall back to the
src/Kiroku/Store/Observability.hs view
@@ -41,9 +41,11 @@     SubscriptionStopReason (..),     SubscriptionGroupContext (..),     DeadLetterReason (..),+    emitOrDrop, ) where -import Control.Exception (SomeException)+import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO)+import Data.Foldable (for_) import Data.Int (Int32) import Hasql.Pool (UsageError) import Kiroku.Store.Subscription.Fsm (DeadLetterReason (..), SubscriptionStopReason (..))@@ -77,20 +79,27 @@       the application pool) or a persistent server error.       -}       KirokuEventPublisherPoolError !UsageError+    | {- | The publisher loop's broadcast iteration threw a synchronous+      exception that was not a 'UsageError' — typically a user-supplied+      'Kiroku.Store.Settings.decodeHook' or observability handler throwing.+      The publisher skipped this tick and will retry on the next notification+      or the 30-second safety poll. Sustained emissions mean a deterministically+      failing callback is stalling live broadcast until it is fixed.+      -}+      KirokuEventPublisherLoopError !SomeException     | {- | A subscription's worker thread encountered a 'UsageError' in-      the database phase identified by 'SubscriptionDbPhase'. The-      worker may continue with a documented fallback for checkpoint-      load/save phases, while fetch-batch errors are retried at the same-      cursor. The event is the operator's structured signal that this-      happened. The trailing 'SubscriptionGroupContext' identifies which-      consumer-group member (if any) emitted it.+      the database phase identified by 'SubscriptionDbPhase'. Checkpoint-load+      errors fail startup loudly, fetch-batch errors are retried at the same+      cursor, and checkpoint-save errors are reported while the worker continues.+      The event is the operator's structured signal that this happened. The+      trailing 'SubscriptionGroupContext' identifies which consumer-group member+      (if any) emitted it.       -}       KirokuEventSubscriptionDbError !SubscriptionName !SubscriptionDbPhase !UsageError !SubscriptionGroupContext     | {- | A subscription's worker thread has just started; the worker-      will begin from the recorded 'GlobalPosition' (zero if no-      checkpoint exists or 'KirokuEventSubscriptionDbError' fired in-      the @LoadCheckpoint@ phase). The trailing 'SubscriptionGroupContext'-      identifies which consumer-group member (if any) started.+      will begin from the recorded 'GlobalPosition' (zero only when no+      checkpoint exists). The trailing 'SubscriptionGroupContext' identifies+      which consumer-group member (if any) started.       -}       KirokuEventSubscriptionStarted !SubscriptionName !GlobalPosition !SubscriptionGroupContext     | {- | The subscription has reached the EventPublisher's@@ -189,13 +198,26 @@       KirokuEventHardDeleteIssued !StreamName !StreamId     deriving stock (Show) +{- | Invoke the optional observability handler, dropping any synchronous exception+it throws. Asynchronous exceptions, including thread cancellation, are rethrown.++Store-internal threads must not die because a metrics or logging callback threw;+the callback contract is that it should be fast and non-throwing.+-}+emitOrDrop :: Maybe (KirokuEvent -> IO ()) -> KirokuEvent -> IO ()+emitOrDrop mHandler evt =+    for_ mHandler $ \handler ->+        handler evt `catch` \(e :: SomeException) ->+            case asyncExceptionFromException e of+                Just (ae :: SomeAsyncException) -> throwIO ae+                Nothing -> pure ()+ -- | Which database phase a 'KirokuEventSubscriptionDbError' fired in. data SubscriptionDbPhase     = {- | 'Kiroku.Store.Subscription.Worker' failed to read the saved-      checkpoint at subscription startup. The worker continues with-      'Kiroku.Store.Types.GlobalPosition' @0@; on a fresh subscription-      this is correct, on an existing subscription it silently-      re-processes events.+      checkpoint at subscription startup. The worker fails startup loudly+      instead of silently re-processing from 'Kiroku.Store.Types.GlobalPosition'+      @0@.       -}       LoadCheckpoint     | {- | The worker's catch-up or category-live database fetch
src/Kiroku/Store/Read.hs view
@@ -7,6 +7,7 @@     readCategory,     getStream,     lookupStreamId,+    eventExistsInStream,     lookupStreamName,     lookupStreamNames, ) where@@ -48,8 +49,10 @@ The streaming sibling of 'readStreamForward'. Identical SQL path and identical error semantics: this function dispatches 'readStreamForward' repeatedly with the supplied @pageSize@ as the per-call limit, advancing the exclusive-'StreamVersion' cursor across pages until the next call returns an empty-batch.+'StreamVersion' cursor across pages until a page comes back shorter than+@pageSize@. If the stream length is an exact multiple of @pageSize@, one+final empty-page probe is still required because the wrapper does not know+the total stream length up front.  The exclusive-cursor convention is preserved end-to-end: passing @'StreamVersion' 0@ reads from the first event in the stream. Empty and@@ -67,24 +70,31 @@     Int32 ->     Stream (Eff es) RecordedEvent readStreamForwardStream name startVer pageSize =-    Stream.concatMap (Stream.fromList . V.toList) pages+    Stream.concatMap fromVector pages   where-    pages = Stream.unfoldrM nextPage startVer-    nextPage cursor = do+    pages = Stream.unfoldrM nextPage (Just startVer)+    nextPage Nothing = pure Nothing+    nextPage (Just cursor) = do         events <- readStreamForward name cursor pageSize         if V.null events             then pure Nothing-            else-                let lastV = V.last events ^. #streamVersion-                 in pure (Just (events, lastV))+            else do+                let nextState+                        | V.length events < fromIntegral pageSize = Nothing+                        | otherwise = Just (V.last events ^. #streamVersion)+                pure (Just (events, nextState)) +    fromVector :: Vector a -> Stream (Eff es) a+    fromVector v = Stream.unfoldr (\i -> fmap (\x -> (x, i + 1)) (v V.!? i)) 0+ {- | Read events from a named stream in backward (descending version) order.  The cursor is exclusive: events with @streamVersion < startVer@ are returned (events older than the cursor). To read the entire stream from-the latest event backward, pass @'StreamVersion' 0@ (the SQL treats it-as \"newer than any\"). Returns an empty vector for nonexistent or+the latest event backward, pass @'StreamVersion' 0@. The interpreter maps+0 to the maximum cursor value, so it never collides with a real stream+version; versions start at 1. Returns an empty vector for nonexistent or soft-deleted streams. -} readStreamBackward ::@@ -116,7 +126,8 @@ ('GlobalPosition'-descending) order.  Cursor exclusive. To start from the most recent event, pass-@'GlobalPosition' 0@ (treated as \"after everything\" by the SQL).+@'GlobalPosition' 0@. The interpreter maps 0 to the maximum cursor value,+so it never collides with a real global position; positions start at 1. -} readAllBackward ::     (HasCallStack, Store :> es) =>@@ -170,6 +181,20 @@     Eff es (Maybe StreamId) lookupStreamId name = send (LookupStreamId name) +{- | Return 'True' when the supplied event id is linked into the supplied live+stream, and 'False' when the event id is absent, belongs only to another stream,+or the stream is nonexistent or soft-deleted.++This is a point lookup for idempotency checks. Prefer it over scanning a stream+when the caller already has the event id it is looking for.+-}+eventExistsInStream ::+    (HasCallStack, Store :> es) =>+    StreamName ->+    EventId ->+    Eff es Bool+eventExistsInStream name eid = send (EventExistsInStream name eid)+ {- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a single round trip, returning a 'Map' that omits any id which does not name an existing stream (hard-deleted or never created). Live and soft-deleted streams are@@ -188,8 +213,8 @@ -- names '!?' (event '^.' #originalStreamId) :: Maybe StreamName @ -Passing @[]@ returns an empty map without a database round trip's worth of work-(an empty @ANY(ARRAY[])@ matches nothing).+Passing @[]@ returns an empty map without any database round trip because+the interpreter short-circuits. -} lookupStreamNames ::     (HasCallStack, Store :> es) =>
src/Kiroku/Store/SQL.hs view
@@ -18,6 +18,7 @@     readAllBackwardStmt,     readCategoryForwardStmt,     getStreamStmt,+    eventExistsInStreamStmt,     lookupStreamNamesStmt,     currentGlobalPositionStmt, @@ -33,10 +34,14 @@     -- * Lifecycle statements     softDeleteStreamStmt,     undeleteStreamStmt,+    setStreamTruncateBeforeStmt,      -- * Hard-delete statements (used in sequence inside one transaction)     findStreamIdStmt,-    deleteStreamJunctionsStmt,+    deleteAllRowsForOriginStmt,+    deleteJunctionsByEventIdsStmt,+    deleteStreamOwnJunctionsStmt,+    deleteDeadLettersForOrphanedEventsStmt,     deleteOrphanedEventsStmt,     deleteStreamRowStmt, @@ -386,7 +391,7 @@         <*> D.column (D.nullable D.uuid)         <*> D.column (D.nonNullable D.timestamptz) --- | Shared decoder for a StreamInfo row (5 columns).+-- | Shared decoder for a StreamInfo row (6 columns). streamInfoRow :: D.Row StreamInfo streamInfoRow =     StreamInfo@@ -395,6 +400,7 @@         <*> (StreamVersion <$> D.column (D.nonNullable D.int8))         <*> D.column (D.nonNullable D.timestamptz)         <*> D.column (D.nullable D.timestamptz)+        <*> (StreamVersion <$> D.column (D.nonNullable D.int8)) -- truncate_before  -- | Encoder for stream read params: (stream_name, start_version, limit). readStreamEncoder :: E.Params (Text, Int64, Int32)@@ -459,13 +465,34 @@         (E.param (E.nonNullable E.text))         (D.rowMaybe streamInfoRow) +-- | Point probe for whether an event exists in a live stream.+eventExistsInStreamStmt :: Statement (Text, UUID) Bool+eventExistsInStreamStmt =+    preparable+        """+        SELECT EXISTS (+          SELECT 1+          FROM stream_events se+          WHERE se.event_id = $2+            AND se.stream_id = (+              SELECT stream_id+              FROM streams+              WHERE stream_name = $1+                AND deleted_at IS NULL+            )+        )+        """+        (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.uuid)))+        (D.singleRow (D.column (D.nonNullable D.bool)))+ -- --------------------------------------------------------------------------- -- Read SQL Templates -- ---------------------------------------------------------------------------  {- | Read from a named stream in forward order.-Resolves stream name to ID via subquery, joins stream_events with events,-filters on stream_version > start_version, orders ascending, limits.+Joins stream_events with events and streams, filters on stream_version >+start_version, hides the logically-truncated prefix (stream_version >=+streams.truncate_before), orders ascending, limits. For stream reads, global_position is set to 0 (not available without $all join). -} readStreamForwardSQL :: Text@@ -477,14 +504,21 @@            e.data, e.metadata, e.causation_id, e.correlation_id,            e.created_at     FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = (SELECT stream_id FROM streams WHERE stream_name = $1 AND deleted_at IS NULL)+    JOIN events e  ON e.event_id  = se.event_id+    JOIN streams s ON s.stream_id = se.stream_id+    WHERE s.stream_name = $1+      AND s.deleted_at IS NULL       AND se.stream_version > $2+      AND se.stream_version >= s.truncate_before     ORDER BY se.stream_version ASC     LIMIT $3     """ --- | Read from a named stream in backward order.+{- | Read from a named stream in backward order.+The start version is an exclusive upper bound. The interpreter maps+caller cursor 0 to 'maxBound' so "from latest" stays a Haskell API+sentinel and the SQL keeps a plain range predicate.+-} readStreamBackwardSQL :: Text readStreamBackwardSQL =     """@@ -494,15 +528,21 @@            e.data, e.metadata, e.causation_id, e.correlation_id,            e.created_at     FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = (SELECT stream_id FROM streams WHERE stream_name = $1 AND deleted_at IS NULL)-      AND se.stream_version > $2+    JOIN events e  ON e.event_id  = se.event_id+    JOIN streams s ON s.stream_id = se.stream_id+    WHERE s.stream_name = $1+      AND s.deleted_at IS NULL+      AND se.stream_version < $2+      AND se.stream_version >= s.truncate_before     ORDER BY se.stream_version DESC     LIMIT $3     """  {- | Read from the global $all stream in forward order. stream_id = 0 is the $all stream. stream_version on $all is the global position.+The global log deliberately ignores the per-stream @truncate_before@ marker:+logical truncation only hides events from ordered per-stream reads, never from+the @$all@ backbone that subscriptions and projections consume. -} readAllForwardSQL :: Text readAllForwardSQL =@@ -520,7 +560,11 @@     LIMIT $2     """ --- | Read from the global $all stream in backward order.+{- | Read from the global $all stream in backward order.+The start position is an exclusive upper bound. The interpreter maps+caller cursor 0 to 'maxBound' so "from latest" stays a Haskell API+sentinel and the SQL keeps a plain range predicate.+-} readAllBackwardSQL :: Text readAllBackwardSQL =     """@@ -532,7 +576,7 @@     FROM stream_events se     JOIN events e ON e.event_id = se.event_id     WHERE se.stream_id = 0-      AND se.stream_version > $1+      AND se.stream_version < $1     ORDER BY se.stream_version DESC     LIMIT $2     """@@ -541,7 +585,7 @@ getStreamSQL :: Text getStreamSQL =     """-    SELECT stream_id, stream_name, stream_version, created_at, deleted_at+    SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before     FROM streams     WHERE stream_name = $1     """@@ -892,6 +936,16 @@         (E.param (E.nonNullable E.text))         (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8))) +{- | Set a stream's logical truncate-before marker. Returns Nothing if the+stream does not exist or is soft-deleted.+-}+setStreamTruncateBeforeStmt :: Statement (Text, Int64) (Maybe StreamId)+setStreamTruncateBeforeStmt =+    preparable+        setStreamTruncateBeforeSQL+        (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int8)))+        (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))+ {- | Look up a stream's id by name. Returns Nothing if the stream does not exist. Used as the first step in hard-delete; subsequent steps key off the id rather than the name so a single resolution is reused.@@ -920,43 +974,107 @@             )         ) -{- | Delete every junction row that references the given stream — both rows whose-@stream_id@ is the target (the stream's own and any links from elsewhere) and-rows whose @original_stream_id@ is the target ($all entries plus link rows in-other streams that referenced events originating from the deleted stream).+{- | Delete the @$all@ junction rows for events that originated in the target+stream, returning exactly those originated event ids. -Returns the distinct set of @event_id@s that lost at least one junction row.-The caller passes this set to @deleteOrphanedEventsStmt@ to remove event payloads-that no longer have any surviving junctions.+Served by @ix_stream_events_all_by_origin@, whose partial predicate is+@stream_id = 0@. Every event that originated in a stream has one @$all@ row, so+this indexed delete yields the complete originated-event set without scanning all+of @stream_events@. -The previous single-CTE implementation tried to inline the orphan-event delete,-but PostgreSQL §7.8.2 specifies that data-modifying CTEs run against the same-snapshot, so the @NOT EXISTS@ subquery on stream_events saw the pre-delete state-and never deleted any events. Splitting into two statements within the same-hasql-transaction lets each statement see the previous statement's effects.+The caller then passes the returned ids to 'deleteJunctionsByEventIdsStmt' to+remove the remaining home/link junction rows for those originated events. -}-deleteStreamJunctionsStmt :: Statement Int64 (Vector UUID)-deleteStreamJunctionsStmt =+deleteAllRowsForOriginStmt :: Statement Int64 (Vector UUID)+deleteAllRowsForOriginStmt =     preparable         """-        WITH deleted AS (-          DELETE FROM stream_events-          WHERE stream_id = $1-             OR original_stream_id = $1-          RETURNING event_id-        )-        SELECT DISTINCT event_id FROM deleted+        DELETE FROM stream_events+        WHERE original_stream_id = $1+          AND stream_id = 0+        RETURNING event_id         """         (E.param (E.nonNullable E.int8))         (D.rowVector (D.column (D.nonNullable D.uuid))) +{- | Delete all remaining junction rows for the given event ids.++Served by the @stream_events@ primary key @(event_id, stream_id)@. This removes+the originated events' home rows and any link rows in other streams after+'deleteAllRowsForOriginStmt' has removed their @$all@ rows.+-}+deleteJunctionsByEventIdsStmt :: Statement (Vector UUID) ()+deleteJunctionsByEventIdsStmt =+    preparable+        """+        DELETE FROM stream_events+        WHERE event_id = ANY($1::uuid[])+        """+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+        D.noResult++{- | Delete junction rows whose visible stream is the target stream, returning+the affected event ids.++Served by @ix_stream_events_stream_version@ (or the unique replacement+@ux_stream_events_stream_version@ after EP-5 M3) on @(stream_id,+stream_version)@. After 'deleteJunctionsByEventIdsStmt' this matches only+events that originated elsewhere and were linked into the deleted stream. Those+events normally survive; returning them lets the same orphan check make the+decision instead of relying on that assumption.+-}+deleteStreamOwnJunctionsStmt :: Statement Int64 (Vector UUID)+deleteStreamOwnJunctionsStmt =+    preparable+        """+        DELETE FROM stream_events+        WHERE stream_id = $1+        RETURNING event_id+        """+        (E.param (E.nonNullable E.int8))+        (D.rowVector (D.column (D.nonNullable D.uuid)))++{- | Delete dead-letter rows for candidate events that have become orphaned.++Served by @ix_dead_letters_event_id@. The @NOT EXISTS@ predicate mirrors+'deleteOrphanedEventsStmt', so dead letters are purged exactly for events whose+payload rows are about to be removed while dead letters for surviving linked-in+events remain.++Must be called after the junction deletes and before 'deleteOrphanedEventsStmt'+within the same transaction so the @NOT EXISTS@ subquery sees the post-delete+state of @stream_events@ and the @dead_letters.event_id@ foreign key remains+satisfied when orphan event payloads are deleted.+-}+deleteDeadLettersForOrphanedEventsStmt :: Statement (Vector UUID) ()+deleteDeadLettersForOrphanedEventsStmt =+    preparable+        """+        DELETE FROM dead_letters dl+        WHERE dl.event_id = ANY($1::uuid[])+          AND NOT EXISTS (+            SELECT 1 FROM stream_events se+            WHERE se.event_id = dl.event_id+          )+        """+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+        D.noResult+ {- | Delete event payloads from the @events@ table for the given event ids, but only those whose junction rows have all been removed. Events that still have any surviving @stream_events@ row are preserved (they remain visible from their other homes). -Must be called after @deleteStreamJunctionsStmt@ within the same transaction-so the @NOT EXISTS@ subquery sees the post-delete state of @stream_events@.+The first implementation tried to inline orphan-event deletion with a+data-modifying CTE, but PostgreSQL §7.8.2 specifies that data-modifying CTEs run+against the same snapshot, so the @NOT EXISTS@ subquery on stream_events saw the+pre-delete state and never deleted any events. Splitting into multiple+statements within the same hasql-transaction lets each statement see previous+statements' effects.++Must be called after 'deleteDeadLettersForOrphanedEventsStmt' within the same+transaction so the @NOT EXISTS@ subquery sees the post-delete state of+@stream_events@ and the @dead_letters.event_id@ foreign key is already clear. -} deleteOrphanedEventsStmt :: Statement (Vector UUID) () deleteOrphanedEventsStmt =@@ -1029,6 +1147,20 @@     SET deleted_at = NULL     WHERE stream_name = $1       AND deleted_at IS NOT NULL+    RETURNING stream_id+    """++{- | Set the logical truncate-before marker on a live stream. Soft-deleted+streams (deleted_at IS NOT NULL) match no row and return Nothing, matching+the other lifecycle statements.+-}+setStreamTruncateBeforeSQL :: Text+setStreamTruncateBeforeSQL =+    """+    UPDATE streams+    SET truncate_before = $2+    WHERE stream_name = $1+      AND deleted_at IS NULL     RETURNING stream_id     """ 
src/Kiroku/Store/Subscription.hs view
@@ -13,7 +13,7 @@  import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO)-import Control.Exception (bracket, finally, throwIO)+import Control.Exception (bracket, bracketOnError, finally, mask, throwIO) import Control.Lens ((^.)) import Control.Monad (when) import Control.Monad.IO.Class (MonadIO, liftIO)@@ -31,23 +31,24 @@ import Kiroku.Store.Subscription.EventPublisher qualified as Pub import Kiroku.Store.Subscription.Fsm (SubscriptionState (..), stateCursor, stateName) import Kiroku.Store.Subscription.Types-import Kiroku.Store.Subscription.Worker (configMember, runWorker)-import Kiroku.Store.Types (GlobalPosition (..))+import Kiroku.Store.Subscription.Worker (LiveSource (..), configMember, runWorker)+import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..))  {- | Start a subscription. Returns a handle for cancellation and waiting.  The subscription spawns a worker thread that:  1. Reads the checkpoint from the database (or starts from global position 0-   for a fresh subscription name).+   for a fresh subscription name). A database error while loading the checkpoint+   fails the worker loudly through 'wait'; it does not fall back to 0. 2. Catches up by querying the database directly until it reaches the    'Kiroku.Store.Subscription.EventPublisher.lastPublished' cursor. 3. Switches to live mode. For 'Kiroku.Store.Subscription.Types.AllStreams'-   subscriptions, the worker reads pre-broadcast events from the-   publisher's bounded per-subscriber queue. For-   'Kiroku.Store.Subscription.Types.Category' subscriptions, the worker-   bypasses the broadcast entirely and re-queries the database with the-   SQL category filter whenever 'lastPublished' advances.+   subscriptions outside a consumer group, the worker reads pre-broadcast+   events from the publisher's bounded per-subscriber queue. For+   'Kiroku.Store.Subscription.Types.Category' subscriptions and all+   consumer-group subscriptions, no publisher queue is created; the worker+   re-queries the database in live mode.  The returned 'SubscriptionHandle' carries an implicit lifecycle contract: the worker thread runs until 'cancel' is called or the handler returns 'Stop'.@@ -92,11 +93,17 @@   in flight at cancellation time will replay. * @Left e@ where @e@ is   'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' — the publisher-  marked this subscription overflowed under-  'Kiroku.Store.Subscription.Types.DropSubscription'. Investigate the-  slow handler and either fix the slowness, raise 'queueCapacity', or-  switch to 'Kiroku.Store.Subscription.Types.DropOldest' if the consumer-  can tolerate event loss.+  marked this non-group 'Kiroku.Store.Subscription.Types.AllStreams'+  subscription overflowed under+  'Kiroku.Store.Subscription.Types.DropSubscription'. Category and+  consumer-group subscriptions do not own publisher queues, so+  'queueCapacity' and 'overflowPolicy' have no effect for them.+  Investigate the slow handler and either fix the slowness, raise+  'queueCapacity', or switch to 'Kiroku.Store.Subscription.Types.DropOldest'+  if the consumer can tolerate event loss.+* @Left e@ where @e@ is a 'Hasql.Pool.UsageError' from checkpoint load —+  startup could not read the saved checkpoint. The worker stops rather than+  silently replaying from global position 0. * @Left e@ for any exception thrown by the handler — handler exceptions   are not caught; the worker thread dies and the original exception   propagates to the consumer. This is intentional: a handler that@@ -110,65 +117,83 @@     for_ (consumerGroup config) $ \(ConsumerGroup m n) ->         when (n < 1 || m < 0 || m >= n) $             throwIO (InvalidConsumerGroup m n)-    (queue, statusVar, unsubscribe) <--        atomically $-            Pub.subscribePublisher-                (store ^. #publisher)-                (queueCapacity config)-                (overflowPolicy config)-    -- The worker writes its current FSM state here on every transition; the-    -- handle's 'currentState' reads it. Seeded with the catch-up entry state so-    -- a read before the worker's first transition is sensible.-    stateVar <- newTVarIO (CatchingUp (GlobalPosition 0) 0)-    let pubPosVar = Pub.lastPublished (store ^. #publisher)-        catGenVar = Notifier.categoryGenerations (store ^. #notifier)-    -- Register this worker's state cell into the store's central registry under-    -- (name, member). A fresh token identifies *this* worker so that an older-    -- worker's cleanup cannot delete a newer worker's replacement entry under-    -- the same key, and a held handle reads only the cell it registered.-    token <- newUnique-    let reg = store ^. #subscriptionRegistry-        key = (name config, configMember config)-        -- `finally unsubscribe` removes this subscription from the publisher's-        -- registry whenever the worker exits — gracefully on Stop, by-        -- cancellation, or on any exception (including SubscriptionOverflowed).-        -- Forgetting to unsubscribe would leave a registry entry with no-        -- reader, and the publisher would needlessly trigger this subscriber's-        -- overflow policy on the next batch. We extend the same `finally` to-        -- also deregister from the subscription-state registry on ANY exit, so-        -- the registry never leaks a stale entry. The delete is-        -- token-conditional so stale cleanup from an older duplicate-key worker-        -- cannot remove a newer worker's live entry.-        cleanup =-            unsubscribe-                >> atomically-                    ( modifyTVar' reg $-                        Map.update-                            ( \(tok', cell) ->-                                if tok' == token-                                    then Nothing-                                    else Just (tok', cell)-                            )-                            key-                    )-    -- Insert before forking so a caller that reads a snapshot immediately after-    -- `subscribe` returns already sees this subscription.-    atomically $ modifyTVar' reg (Map.insert key (token, stateVar))-    thread <--        Async.async-            ( runWorker (store ^. #pool) queue statusVar stateVar pubPosVar catGenVar config (store ^. #eventHandler) (store ^. #storeSettings)-                `finally` cleanup+    mask $ \_restore ->+        bracketOnError+            ( case (consumerGroup config, target config) of+                (Nothing, AllStreams) -> do+                    (queue, statusVar, unsubscribe) <-+                        atomically $+                            Pub.subscribePublisher+                                (store ^. #publisher)+                                (queueCapacity config)+                                (overflowPolicy config)+                    pure (LiveFromPublisherQueue queue statusVar, unsubscribe)+                (Nothing, Category (CategoryName cat)) ->+                    pure (LiveFromCategoryNotify cat, pure ())+                (Just _, _) ->+                    pure (LiveFromGroupPolling, pure ())             )-    pure-        SubscriptionHandle-            { cancel = Async.cancel thread-            , wait = Async.waitCatch thread-            , currentState = do-                m <- readTVarIO reg-                case Map.lookup key m of-                    Just (tok, cell) | tok == token -> Just <$> readTVarIO cell-                    _ -> pure Nothing-            }+            (\(_, unsubscribe) -> unsubscribe)+            $ \(liveSource, unsubscribe) -> do+                -- The worker writes its current FSM state here on every transition; the+                -- handle's 'currentState' reads it. Seeded with the catch-up entry state so+                -- a read before the worker's first transition is sensible.+                stateVar <- newTVarIO (CatchingUp (GlobalPosition 0) 0)+                let pubPosVar = Pub.lastPublished (store ^. #publisher)+                    catGenVar = Notifier.categoryGenerations (store ^. #notifier)+                -- Register this worker's state cell into the store's central registry under+                -- (name, member). A fresh token identifies *this* worker so that an older+                -- worker's cleanup cannot delete a newer worker's replacement entry under+                -- the same key, and a held handle reads only the cell it registered.+                token <- newUnique+                let reg = store ^. #subscriptionRegistry+                    key = (name config, configMember config)+                    deregister =+                        atomically+                            ( modifyTVar' reg $+                                Map.update+                                    ( \(tok', cell) ->+                                        if tok' == token+                                            then Nothing+                                            else Just (tok', cell)+                                    )+                                    key+                            )+                    -- `finally cleanup` removes this subscription from the publisher's+                    -- registry whenever the worker exits — gracefully on Stop, by+                    -- cancellation, or on any exception (including SubscriptionOverflowed).+                    -- Forgetting to unsubscribe would leave a registry entry with no+                    -- reader, and the publisher would needlessly trigger this subscriber's+                    -- overflow policy on the next batch. We extend the same `finally` to+                    -- also deregister from the subscription-state registry on ANY exit, so+                    -- the registry never leaks a stale entry. The delete is+                    -- token-conditional so stale cleanup from an older duplicate-key worker+                    -- cannot remove a newer worker's live entry.+                    cleanup = unsubscribe >> deregister+                -- Insert before forking so a caller that reads a snapshot immediately after+                -- `subscribe` returns already sees this subscription.+                bracketOnError+                    (atomically $ modifyTVar' reg (Map.insert key (token, stateVar)))+                    (const deregister)+                    $ \() -> do+                        -- Every acquisition above is paired with a release in the+                        -- pre-fork window; once asyncWithUnmask returns, ownership of+                        -- both releases has transferred to the worker thread's cleanup.+                        thread <-+                            Async.asyncWithUnmask $ \unmask ->+                                unmask+                                    (runWorker (store ^. #pool) liveSource stateVar pubPosVar catGenVar config (store ^. #eventHandler) (store ^. #storeSettings))+                                    `finally` cleanup+                        pure+                            SubscriptionHandle+                                { cancel = Async.cancel thread+                                , wait = Async.waitCatch thread+                                , currentState = do+                                    m <- readTVarIO reg+                                    case Map.lookup key m of+                                        Just (tok, cell) | tok == token -> Just <$> readTVarIO cell+                                        _ -> pure Nothing+                                }  {- | Bracket-style subscription lifecycle. 
src/Kiroku/Store/Subscription/EventPublisher.hs view
@@ -1,12 +1,14 @@-{- | The centralized @$all@ broadcaster shared by every all-stream subscription.+{- | The centralized @$all@ broadcaster shared by queue-consuming live clients. -A single 'EventPublisher' reads new events from the global stream once and-fans them out to each registered 'Subscriber' through that subscriber's own-bounded 'Control.Concurrent.STM.TBQueue', rather than having every subscription-poll the database independently. 'startPublisher' launches the broadcast loop,+A single 'EventPublisher' reads new events from the global stream once when at+least one 'Subscriber' is registered and fans them out through each+subscriber's bounded 'Control.Concurrent.STM.TBQueue'. When the registry is+empty, it does not fetch full event rows; it advances 'lastPublished' from the+@$all@ tail with a single-row query so DB-driven subscriptions can still use the+position as their live gate. 'startPublisher' launches the loop, 'subscribePublisher' registers a subscriber (returning its queue, a status 'TVar', and an unsubscribe action), 'publisherPosition' reports the last-broadcast cursor, and 'stopPublisher' tears the loop down.+published cursor, and 'stopPublisher' tears the loop down.  When a subscriber's queue fills, the publisher applies that subscriber's 'Kiroku.Store.Subscription.Types.OverflowPolicy' by setting its@@ -49,7 +51,8 @@     writeTVar,  ) import Control.Concurrent.STM qualified as STM-import Control.Exception (throwIO)+import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO)+import Control.Monad (when) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Foldable (for_) import Data.Int (Int32, Int64)@@ -60,18 +63,17 @@ import Hasql.Pool (Pool) import Hasql.Pool qualified as Pool import Hasql.Session qualified as Session-import Kiroku.Store.Observability (KirokuEvent (..))+import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop) import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Settings (StoreSettings, decodeEvents) import Kiroku.Store.Subscription.Types (OverflowPolicy (..)) import Kiroku.Store.Types (GlobalPosition (..), RecordedEvent (..)) import Numeric.Natural (Natural) -{- | The centralized EventPublisher. Reads events from the database once per-notification and fans them out to a registry of bounded per-subscriber-queues. The registry replaces the prior unbounded broadcast 'TChan' so-that one slow subscriber cannot grow the publisher's fan-out memory-without limit (EP-3 F6).+{- | The centralized EventPublisher. With registered subscribers, reads events+from the database once per notification and fans them out to a registry of+bounded per-subscriber queues. With an empty registry, fetches only the current+@$all@ tail to keep 'lastPublished' moving without decoding event rows. -} data EventPublisher = EventPublisher     { subscribers :: !(TVar (IntMap Subscriber))@@ -121,10 +123,11 @@ safetyPollMicros = 30_000_000  {- | Start the EventPublisher. Spawns a thread that waits for ticks from the-Notifier (or a 30-second safety poll timeout), queries the database for-new events, and delivers them to every active subscriber. Per-subscriber-overflow handling is determined by each subscription's-'OverflowPolicy'.+Notifier (or a 30-second safety poll timeout). With at least one registered+subscriber, the thread queries full event rows and delivers them to every active+subscriber; with an empty registry, it advances 'lastPublished' from the @$all@+tail using a single-row query and fetches no payload rows. Per-subscriber+overflow handling is determined by each subscription's 'OverflowPolicy'.  If an 'eventHandler' callback is supplied, the publisher emits 'Kiroku.Store.Observability.KirokuEventPublisherPoolError' when its read@@ -147,11 +150,12 @@ startPublisher pool notifierChan mHandler stSettings = liftIO $ do     subsVar <- newTVarIO IntMap.empty     nextIdVar <- newTVarIO 0+    -- Duplicate the notifier channel before reading the tail so ticks arriving+    -- during startup are redundant rather than lost until the safety poll.+    tickChan <- atomically (dupTChan notifierChan)     tailResult <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)     tailPos <- either throwIO pure tailResult     pos <- newTVarIO (GlobalPosition tailPos)-    -- Get a personal copy of the notifier's broadcast channel-    tickChan <- atomically (dupTChan notifierChan)     thread <- Async.async (publisherLoop pool tickChan subsVar pos mHandler stSettings)     pure         EventPublisher@@ -212,18 +216,51 @@         waitForWakeup tickChan         -- Drain all pending ticks (debouncing)         drainTicks tickChan-        -- Fetch and broadcast-        fetchAndBroadcast+        -- Fetch and broadcast. If a synchronous callback failure occurs after+        -- partial delivery, lastPublished is not advanced, so the next tick+        -- re-fetches under Kiroku's at-least-once delivery contract.+        fetchAndBroadcast `catch` \(e :: SomeException) ->+            case asyncExceptionFromException e of+                Just (ae :: SomeAsyncException) -> throwIO ae+                Nothing -> emitOrDrop mHandler (KirokuEventPublisherLoopError e)         loop      fetchAndBroadcast = do+        subs <- readTVarIO subsVar+        if IntMap.null subs+            then cheapAdvance+            else fullFetch++    cheapAdvance = do+        result <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)+        case result of+            Left err -> do+                -- Surface the pool error so operators see why the publisher+                -- position has stalled; the 30-second safety poll will retry.+                emitOrDrop mHandler (KirokuEventPublisherPoolError err)+            Right tailPos -> do+                -- Re-check registry emptiness in the same STM transaction as+                -- the position write. If a queue subscriber registered after+                -- our snapshot, fall through to a full fetch so no event skips+                -- a queue that now exists.+                raced <- atomically $ do+                    subs' <- readTVar subsVar+                    if IntMap.null subs'+                        then do+                            GlobalPosition cur <- readTVar posVar+                            writeTVar posVar (GlobalPosition (max cur tailPos))+                            pure False+                        else pure True+                when raced fullFetch++    fullFetch = do         GlobalPosition pos <- readTVarIO posVar         result <- Pool.use pool (Session.statement (pos, publisherBatchSize) SQL.readAllForwardStmt)         case result of             Left err -> do                 -- Surface the pool error so operators see why broadcast                 -- has stalled; the 30-second safety poll will retry.-                for_ mHandler ($ KirokuEventPublisherPoolError err)+                emitOrDrop mHandler (KirokuEventPublisherPoolError err)             Right rawEvents                 | V.null rawEvents -> pure ()                 | otherwise -> do@@ -239,18 +276,26 @@                     -- cannot rollback another's enqueue.                     subs <- readTVarIO subsVar                     for_ (IntMap.elems subs) (deliverBatch events)-                    -- Advance posVar after attempting delivery to all subscribers.-                    -- Subscribers under DropSubscription have already been marked-                    -- Overflowed; their workers will observe and exit. Subscribers-                    -- under DropOldest have lost the oldest batch but their queues-                    -- are not full anymore.-                    atomically (writeTVar posVar newPos)+                    -- Advance posVar only after attempting delivery to the+                    -- snapshot cohort. In the same transaction, re-read the+                    -- registry and offer the in-flight batch to subscribers that+                    -- registered after the snapshot. This closes the attach race:+                    -- every subscriber either received the batch before the+                    -- advance or registered after the advanced position was+                    -- visible and will cover the range through SQL catch-up.+                    atomically $ do+                        subs' <- readTVar subsVar+                        for_ (IntMap.elems (subs' `IntMap.difference` subs)) (deliverBatchSTM events)+                        writeTVar posVar newPos                     -- If we got a full batch, there may be more — loop immediately                     if V.length events >= fromIntegral publisherBatchSize-                        then fetchAndBroadcast+                        then fullFetch                         else pure ()      deliverBatch events sub = atomically $ do+        deliverBatchSTM events sub++    deliverBatchSTM events sub = do         full <- isFullTBQueue (subQueue sub)         if not full             then do
src/Kiroku/Store/Subscription/Fsm.hs view
@@ -122,7 +122,7 @@       DeadLetterPoison !Text     | -- | The event payload failed validation/parsing; the 'Text' is the detail.       DeadLetterInvalid !Text-    | -- | The bounded retry budget was exhausted after the given number of attempts.+    | -- | The bounded retry budget was exhausted after the given number of total deliveries.       DeadLetterMaxAttempts !Int     | -- | A custom reason: a summary plus structured JSON detail.       DeadLetterOther !Text !Value
src/Kiroku/Store/Subscription/Stream.hs view
@@ -20,6 +20,10 @@ ('Kiroku.Store.Subscription.Types.eventTypeFilter' and 'Kiroku.Store.Subscription.Types.selector'): a filtered-out event never reaches either bridge.++Both streams end normally when the subscription worker stops cleanly or is+cancelled. If the worker dies for any other reason, the next stream pull+rethrows the worker's exception to the stream consumer. -} module Kiroku.Store.Subscription.Stream (     -- * Plain pull stream@@ -27,21 +31,32 @@      -- * Ack-coupled pull stream     AckItem (..),+    InvalidStreamBufferSize (..),     subscriptionAckStream, ) where +import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM (+    STM,     TBQueue,     TMVar,+    TVar,     atomically,     newEmptyTMVarIO,     newTBQueueIO,+    newTVarIO,+    orElse,     putTMVar,     readTBQueue,+    readTVar,+    retry,     takeTMVar,     writeTBQueue,+    writeTVar,  )+import Control.Exception (Exception, SomeException, fromException, throwIO)+import Control.Monad (when) import Data.IORef (atomicModifyIORef', newIORef) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Subscription (subscribe)@@ -77,11 +92,33 @@     -- ^ one-shot reply the consumer must fill exactly once     } +{- | Thrown by 'subscriptionAckStream' when the requested bridge queue capacity+is zero.++A zero-capacity 'TBQueue' would make the bridge handler block forever on its+first delivery, before a stream consumer can ever see the event or reply to it.+-}+newtype InvalidStreamBufferSize = InvalidStreamBufferSize Natural+    deriving stock (Eq, Show)+    deriving anyclass (Exception)++data BridgeTermination+    = BridgeClosedCleanly+    | BridgeCrashed !SomeException++closeBridge :: TVar (Maybe BridgeTermination) -> BridgeTermination -> STM ()+closeBridge closedVar termination =+    readTVar closedVar >>= \case+        Nothing -> writeTVar closedVar (Just termination)+        Just _ -> pure ()+ {- | Create a pull-based 'Stream' from a kiroku subscription.  A bounded 'TBQueue' sits between kiroku's push-based handler and the-returned Streamly stream. The stream terminates when the underlying-subscription ends or the returned cancel action runs.+returned Streamly stream. The stream terminates normally when the underlying+subscription stops cleanly or the returned cancel action runs. If the+underlying worker crashes, the next stream pull rethrows the worker exception+to the consumer.  The @handler@ field in the provided 'SubscriptionConfig' is ignored — the bridge provides its own handler.@@ -98,13 +135,13 @@ element type is unchanged ('RecordedEvent') — filtering only removes elements, it does not reshape them. -The returned cancel action cancels the underlying subscription and-writes the sentinel so any blocked reader wakes up and terminates.+The returned cancel action cancels the underlying subscription and wakes any+blocked reader without writing to the bounded queue. -} subscriptionStream ::     KirokuStore ->     SubscriptionConfig ->-    -- | TBQueue capacity (buffer size for backpressure)+    -- | TBQueue capacity for the bridge; must be at least 1.     Natural ->     IO (Stream IO RecordedEvent, IO ()) subscriptionStream store config bufferSize = do@@ -128,17 +165,23 @@ installs its own.  The returned cancel action cancels the underlying subscription (interrupting any-worker blocked waiting for a reply) and writes the @Nothing@ sentinel so a-blocked reader wakes up and the stream terminates.+worker blocked waiting for a reply) and wakes any blocked reader without+writing to the bounded queue. A clean worker stop or cancellation ends the+stream normally; any other worker exception is rethrown from the next stream+pull. This includes overflow, handler exceptions, dead-letter database errors,+and decode-hook exceptions. -} subscriptionAckStream ::     KirokuStore ->     SubscriptionConfig ->-    -- | TBQueue capacity (buffer size for backpressure)+    -- | TBQueue capacity for the bridge; must be at least 1.     Natural ->     IO (Stream IO AckItem, IO ()) subscriptionAckStream store config bufferSize = do+    when (bufferSize < 1) $+        throwIO (InvalidStreamBufferSize bufferSize)     queue <- newTBQueueIO bufferSize+    closedVar <- newTVarIO Nothing     -- Tracks the previous (eventId, attempt) so a consecutive redelivery of the     -- same event (the worker's bounded retry) is reported with an incremented     -- attempt. The worker delivers events one at a time and blocks on the reply,@@ -153,22 +196,33 @@                         | eid == eventId event -> (Just (eid, n + 1), n + 1)                     _ -> (Just (eventId event, 0), 0)             reply <- newEmptyTMVarIO-            atomically (writeTBQueue queue (Just (AckItem event attempt reply)))+            atomically (writeTBQueue queue (AckItem event attempt reply))             atomically (takeTMVar reply)      let bridgeConfig = config{handler = bridgeHandler}      subHandle <- subscribe store bridgeConfig+    _monitor <- Async.async $ do+        outcome <- wait subHandle+        atomically . closeBridge closedVar $ case outcome of+            Right () -> BridgeClosedCleanly+            Left e+                | Just Async.AsyncCancelled <- fromException e -> BridgeClosedCleanly+                | otherwise -> BridgeCrashed e      let cancelAction = do             cancel subHandle-            atomically (writeTBQueue queue Nothing)+            atomically (closeBridge closedVar BridgeClosedCleanly)      let step :: () -> IO (Maybe (AckItem, ()))         step () = do-            mItem <- atomically (readTBQueue queue)-            case mItem of-                Just item -> pure (Just (item, ()))-                Nothing -> pure Nothing+            next <-+                atomically $+                    (Right <$> readTBQueue queue)+                        `orElse` (readTVar closedVar >>= maybe retry (pure . Left))+            case next of+                Right item -> pure (Just (item, ()))+                Left BridgeClosedCleanly -> pure Nothing+                Left (BridgeCrashed e) -> throwIO e      pure (Stream.unfoldrM step (), cancelAction)
src/Kiroku/Store/Subscription/Types.hs view
@@ -152,9 +152,10 @@       Stop     | {- | Redeliver /this same event/ after the given delay before the checkpoint       advances past it. Redelivery is bounded by the subscription's-      'RetryPolicy' ('retryMaxAttempts'); once the bound is reached the worker-      dead-letters the event with 'DeadLetterMaxAttempts' and advances to the-      next event. The adapter maps Shibuya's @AckRetry@ to this.+      'RetryPolicy' ('retryMaxAttempts'), which counts total deliveries, not+      redeliveries; once the bound is reached the worker dead-letters the event+      with 'DeadLetterMaxAttempts' and advances to the next event. The adapter+      maps Shibuya's @AckRetry@ to this.       -}       Retry !RetryDelay     | {- | Record this event in @kiroku.dead_letters@ with the given reason and@@ -164,22 +165,24 @@       DeadLetter !DeadLetterReason     deriving stock (Eq, Show) -{- | Bounds on how many times a single event is redelivered before it is+{- | Bounds how many total deliveries a single event gets before it is dead-lettered. The per-redelivery delay is carried by each 'Retry' result (mirroring Shibuya's per-decision @AckRetry RetryDelay@), so the policy only carries the attempt bound. -} newtype RetryPolicy = RetryPolicy     { retryMaxAttempts :: Int-    {- ^ Maximum redeliveries of one event before dead-lettering it. A value-    @<= 1@ means the first 'Retry' immediately dead-letters (no redelivery).+    {- ^ Maximum total deliveries of one event before dead-lettering it. The+    first delivery counts as attempt 1. A value @<= 1@ means the first 'Retry'+    immediately dead-letters (no redelivery).     -}     }     deriving stock (Eq, Show) -{- | The default 'RetryPolicy': up to five redeliveries of a single event before-it is dead-lettered with 'DeadLetterMaxAttempts'. Handlers that never return-'Retry' are unaffected by this default.+{- | The default 'RetryPolicy': up to five total deliveries of a single event —+the first delivery plus four redeliveries — before it is dead-lettered with+'DeadLetterMaxAttempts'. Handlers that never return 'Retry' are unaffected by+this default. -} defaultRetryPolicy :: RetryPolicy defaultRetryPolicy = RetryPolicy{retryMaxAttempts = 5}@@ -244,17 +247,21 @@     -- ^ Number of events to fetch per batch during catch-up (default: 100)     , queueCapacity :: !Natural     {- ^ Maximum number of /batches/ the publisher may enqueue for this-    subscriber before applying 'overflowPolicy'. Each batch is up to+    subscriber before applying 'overflowPolicy'. Applies only to non-group+    'AllStreams' subscriptions, because Category and consumer-group+    subscriptions do not create publisher queues. Each batch is up to     'EventPublisher.publisherBatchSize' events, so the effective event-    capacity is @queueCapacity * publisherBatchSize@. Default: 16-    batches (~16,000 events at the default publisher batch size).+    capacity is @queueCapacity * publisherBatchSize@. Default: 16 batches+    (~16,000 events at the default publisher batch size).     -}     , overflowPolicy :: !OverflowPolicy     {- ^ What the publisher does when this subscriber's queue is full.-    Default: 'PauseAndResume' — a slow subscriber is paused and then-    recovers losslessly (re-reading missed events from its checkpoint)-    rather than being terminated or silently growing the publisher's-    fan-out memory.+    Applies only to non-group 'AllStreams' subscriptions, because Category+    and consumer-group subscriptions are DB-driven and have no publisher+    queue. Default: 'PauseAndResume' — a slow subscriber is paused and+    then recovers losslessly (re-reading missed events from its checkpoint)+    rather than being terminated or silently growing the publisher's fan-out+    memory.     -}     , consumerGroup :: !(Maybe ConsumerGroup)     {- ^ 'Nothing' (the default) = ordinary single-consumer subscription.
src/Kiroku/Store/Subscription/Worker.hs view
@@ -18,12 +18,15 @@ the single delivery primitive shared by every live path, so behaviour is identical for @AllStreams@, @Category@, and consumer-group subscriptions. -'withFetchBatchHookForTest' is a test-only seam for injecting fetch failures.+'withFetchBatchHookForTest' and 'withLoadCheckpointHookForTest' are test-only+seams for injecting fetch and checkpoint-load failures. -} module Kiroku.Store.Subscription.Worker (+    LiveSource (..),     runWorker,     configMember,     withFetchBatchHookForTest,+    withLoadCheckpointHookForTest, ) where  import Contravariant.Extras (contrazip2)@@ -32,7 +35,6 @@ import Control.Concurrent.STM (TBQueue, TVar, atomically, check, orElse, readTBQueue, readTVar, registerDelay, tryReadTBQueue, writeTVar) import Control.Exception (SomeException, bracket, fromException, throwIO, try) import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.Foldable (for_) import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Int (Int32, Int64) import Data.Map.Strict (Map)@@ -53,6 +55,7 @@     SubscriptionDeliveryPhase (..),     SubscriptionGroupContext (..),     SubscriptionStopReason (..),+    emitOrDrop,  ) import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Settings (StoreSettings, decodeEvents)@@ -81,10 +84,18 @@     GlobalPosition ->     IO (Maybe (Either Pool.UsageError (Vector RecordedEvent))) +type LoadCheckpointHook =+    SubscriptionConfig ->+    IO (Maybe (Either Pool.UsageError (Maybe Int64)))+ {-# NOINLINE fetchBatchHookRef #-} fetchBatchHookRef :: IORef (Maybe FetchBatchHook) fetchBatchHookRef = unsafePerformIO (newIORef Nothing) +{-# NOINLINE loadCheckpointHookRef #-}+loadCheckpointHookRef :: IORef (Maybe LoadCheckpointHook)+loadCheckpointHookRef = unsafePerformIO (newIORef Nothing)+ {- | Install a process-local fetch hook for tests that need deterministic subscription-worker fault injection. Production code leaves the hook unset. -}@@ -99,16 +110,51 @@         (writeIORef fetchBatchHookRef)         (const action) +{- | Install a process-local checkpoint-load hook for tests that need+deterministic subscription-startup fault injection. Production code leaves the+hook unset.+-}+withLoadCheckpointHookForTest :: LoadCheckpointHook -> IO a -> IO a+withLoadCheckpointHookForTest hook action =+    bracket+        ( do+            previous <- readIORef loadCheckpointHookRef+            writeIORef loadCheckpointHookRef (Just hook)+            pure previous+        )+        (writeIORef loadCheckpointHookRef)+        (const action)+ fetchRetryDelayMicros :: Int -> Int fetchRetryDelayMicros attempt =     min categorySafetyPollMicros (100_000 * (2 ^ min attempt 9 :: Int)) +{- | How a worker obtains live-mode batches, fixed at 'subscribe' time from the+config's (consumerGroup, target) shape.++Only 'LiveFromPublisherQueue' owns a registration with the EventPublisher. The+other shapes are DB-driven and the publisher must do no fan-out work for them.+-}+data LiveSource+    = {- | Non-group AllStreams: read the publisher's bounded queue; the status+      TVar carries Paused/Overflowed backpressure signals.+      -}+      LiveFromPublisherQueue !(TBQueue (Vector RecordedEvent)) !(TVar SubscriberStatus)+    | {- | Non-group Category: wake on the named category's NOTIFY generation+      counter and re-query the database.+      -}+      LiveFromCategoryNotify !Text+    | {- | Consumer-group member, for either target: wake when the global+      position advances and re-query with the partition predicate.+      -}+      LiveFromGroupPolling+ {- | Run the subscription worker loop. Two phases:  Phase 1 (catch-up): queries database directly until reaching publisherPosition.-Phase 2 (live): for AllStreams, reads from the bounded TBQueue the publisher-delivers to; for Category, re-queries the database whenever the publisher-advances (the broadcast TBQueue is unused for category subscriptions).+Phase 2 (live): for 'LiveFromPublisherQueue', reads from the bounded TBQueue+the publisher delivers to; for 'LiveFromCategoryNotify' and+'LiveFromGroupPolling', re-queries the database, and no publisher queue exists.  Runs until the handler returns 'Stop', the thread is cancelled, or the publisher signals overflow on the subscriber's status TVar (in which@@ -132,8 +178,7 @@ runWorker ::     (MonadIO m) =>     Pool ->-    TBQueue (Vector RecordedEvent) ->-    TVar SubscriberStatus ->+    LiveSource ->     {- | the worker's current FSM state, written on every transition so callers     can read it through 'Kiroku.Store.Subscription.Types.currentState'.     -}@@ -152,8 +197,8 @@     -}     StoreSettings ->     m ()-runWorker pool liveQueue statusVar stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $ do-    let emit evt = for_ mHandler ($ evt)+runWorker pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $ do+    let emit = emitOrDrop mHandler         subName = name config         groupCtx = groupCtxOf config     posRef <- newIORef (GlobalPosition 0)@@ -225,8 +270,8 @@                             Right events                                 | V.null events -> pure CaughtUp                                 | otherwise -> pure (BatchFetched events)-            Live c -> case (consumerGroup config, target config) of-                (Nothing, AllStreams) -> do+            Live c -> case liveSource of+                LiveFromPublisherQueue liveQueue statusVar -> do                     writeIORef posRef c                     atomically $ do                         status <- readTVar statusVar@@ -242,9 +287,9 @@                                 events <- readTBQueue liveQueue                                 let fresh = V.filter ((> c) . globalPosition) events                                 pure (if V.null fresh then FetchEmpty else BatchFetched fresh)-                (Nothing, Category (CategoryName cat)) ->+                LiveFromCategoryNotify cat ->                     liveExitToInput =<< liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef c stSettings-                (Just _, _) ->+                LiveFromGroupPolling ->                     liveExitToInput =<< liveLoopDbDriven pool config stateVar pubPosVar emit posRef c stSettings             -- Recoverable backpressure: the publisher set 'Paused' because this             -- subscriber's bounded queue filled. Drain the stale queue (those@@ -254,10 +299,17 @@             -- AllStreams live path's @> cursor@ filter drops any superseded queued             -- entries once the worker is live again.             Paused{} -> do-                atomically $ do-                    drainQueue liveQueue-                    writeTVar statusVar Pub.Active-                pure QueueDrained+                case liveSource of+                    LiveFromPublisherQueue liveQueue statusVar -> do+                        atomically $ do+                            drainQueue liveQueue+                            writeTVar statusVar Pub.Active+                        pure QueueDrained+                    -- Defensive totality: only the queue branch can produce+                    -- QueueBackpressured, so DB-driven sources should never+                    -- enter Paused.+                    LiveFromCategoryNotify{} -> pure QueueDrained+                    LiveFromGroupPolling -> pure QueueDrained             -- Reconnecting: re-probe the database from the checkpoint. A success             -- re-enters catch-up (delivering everything after the cursor); a             -- failure stays in 'Reconnecting' for another backed-off attempt; an@@ -383,10 +435,9 @@ configMember :: SubscriptionConfig -> Int32 configMember config = maybe 0 member (consumerGroup config) --- Load the checkpoint from the database, defaulting to 0.--- Surfaces a database error through the event handler before falling--- back to the safe default; without the event the operator has no signal--- that a transient pool error caused a silent re-process from position 0.+-- Load the checkpoint from the database, defaulting to 0 only when no checkpoint+-- row exists. A database error is emitted and rethrown so startup fails loudly+-- instead of silently re-processing from position 0. -- Keyed by (subscription_name, member) so each group member resumes from its -- own saved position. loadCheckpoint ::@@ -397,11 +448,15 @@ loadCheckpoint pool config emit = do     let subName@(SubscriptionName name') = name config         mem = configMember config-    result <- Pool.use pool (Session.statement (name', mem) SQL.getCheckpointMemberStmt)+    mHook <- readIORef loadCheckpointHookRef+    injected <- maybe (pure Nothing) (\hook -> hook config) mHook+    result <- case injected of+        Just hooked -> pure hooked+        Nothing -> Pool.use pool (Session.statement (name', mem) SQL.getCheckpointMemberStmt)     case result of         Left err -> do             emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))-            pure (GlobalPosition 0)+            throwIO err         Right Nothing -> pure (GlobalPosition 0)         Right (Just pos) -> pure (GlobalPosition pos) 
src/Kiroku/Store/Transaction.hs view
@@ -70,7 +70,7 @@ import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Effect (PreparedEvent, Store (..), appendDispatchTx, buildAppendParams, prepareEvents) import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)-import Kiroku.Store.Error (AppendConflict (..), StoreError (..), appendConflictToStoreError, emptyResultConflict)+import Kiroku.Store.Error (AppendConflict (..), StoreError (..), appendConflictToStoreError, emptyResultConflict, validateStreamName) import Kiroku.Store.Settings qualified as Settings import Kiroku.Store.Types @@ -137,6 +137,15 @@ entering the transaction body, or use 'runTransactionAppending', which performs the rejection up front. +Lock-hold note: this append statement updates Kiroku's global @$all@+stream row and PostgreSQL holds that row lock until the surrounding+transaction commits or rolls back. Any statements the caller runs+after a successful 'appendToStreamTx' keep that global append lock+held, blocking other appends store-wide. Prefer the higher-level+'runTransactionAppending' wrappers unless you need custom transaction+shaping; when using this function directly, run any append-independent+work before calling it.+ The combinator returns 'Either' instead of throwing because 'Tx.Transaction' has no exception channel. On 'Left' the caller decides whether to call 'Tx.condemn' to roll back the transaction, branch@@ -147,8 +156,10 @@ generates UUIDv7s) and the @createdAt@ timestamp must be captured by the caller prior to entering the transaction body. -Empty event lists are a programming mistake — see-'Kiroku.Store.Append.appendToStream' for the rationale.+Empty event lists are a programming mistake. This function returns+@'Left' ('EmptyAppendBatchConflict' …)@ without dispatching a statement;+the high-level @runTransactionAppending*@ wrappers return+@'Left' ('EmptyAppendBatch' …)@ before opening a transaction. -} appendToStreamTx ::     StreamName ->@@ -157,11 +168,15 @@     UTCTime ->     Tx.Transaction (Either AppendConflict AppendResult) appendToStreamTx sn@(StreamName name) expected prepared now = do-    let params = buildAppendParams name now prepared-    mResult <- appendDispatchTx expected params-    pure $ case mResult of-        Just r -> Right r-        Nothing -> Left (emptyResultConflict sn expected)+    case prepared of+        [] ->+            pure (Left (EmptyAppendBatchConflict sn))+        _ -> do+            let params = buildAppendParams name now prepared+            mResult <- appendDispatchTx expected params+            pure $ case mResult of+                Just r -> Right r+                Nothing -> Left (emptyResultConflict sn expected)  -- --------------------------------------------------------------------------- -- Convenience wrapper@@ -176,9 +191,13 @@  Behavior: -* If the target stream is the reserved @$all@, the call rejects with-  @'Left' ('ReservedStreamName' …)@ /before/ opening any transaction;-  the continuation is not invoked.+* If the target stream name is invalid (reserved @$all@ or longer than+  'Kiroku.Store.Error.maxStreamNameBytes'), the call rejects with+  @'Left'@ /before/ opening any transaction; the continuation is not+  invoked.+* If the event list is empty, the call rejects with+  @'Left' ('EmptyAppendBatch' …)@ /before/ opening any transaction; the+  continuation is not invoked. * UUIDv7 ids are generated for events with a 'Nothing' 'eventId', and   the current time is captured, before the transaction begins   ('Tx.Transaction' has no 'MonadIO' so this prep cannot happen@@ -195,6 +214,22 @@   a serialization conflict; use 'runTransactionAppendingNoRetry' to   disable retry. +Lock-hold guidance:++* The append updates Kiroku's global @$all@ stream row. PostgreSQL+  holds that row lock until this transaction commits or rolls back.+* The continuation runs /after/ the append, so every SQL statement it+  executes extends the interval during which all other appends in the+  store are blocked on the @$all@ row.+* Keep the continuation minimal: pre-compute values before entering+  the transaction, avoid unbounded loops or per-row work that could+  have been batched, and never wait on external systems. A+  'Tx.Transaction' cannot perform arbitrary 'IO', but it can still+  contain many database round trips.+* Work that does not need the new 'AppendResult' should run before the+  append, either outside this wrapper or in a custom 'runTransaction'+  body that calls 'appendToStreamTx' last.+ Connection-level failures from the @hasql-pool@ layer surface through the surrounding @'Effectful.Error.Static.Error' 'StoreError'@ effect ('Kiroku.Store.Error.ConnectionError', 'ConnectionLost', etc.), not@@ -227,7 +262,8 @@  Like 'runTransactionAppending', this wrapper bypasses the 'Kiroku.Store.Settings.enrichEvent' hook; see that function's-Haddock for guidance on hooking under different stacks.+Haddock for guidance on hooking under different stacks and for the+@$all@ lock-hold warning that applies equally here. -} runTransactionAppendingNoRetry ::     (HasCallStack, IOE :> es, Store :> es) =>@@ -250,7 +286,9 @@ This is the recommended path for callers — most prominently the @keiro@ projection layer — that want trace-context or PII-injection hooks to fire uniformly across both direct @'appendToStream'@ calls-and transactional append+projection sites.+and transactional append+projection sites. The continuation still runs+after the append; see 'runTransactionAppending' for the @$all@+lock-hold warning and keep resource-aware continuations just as small. -} runTransactionAppendingResource ::     (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>@@ -265,7 +303,9 @@     runTransactionAppendingWith RunTransaction sn expected events' k  {- | Like 'runTransactionAppendingResource' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood.+'Hasql.Transaction.Sessions.transactionNoRetry' under the hood. The+@$all@ lock-hold guidance from 'runTransactionAppending' applies+unchanged. -} runTransactionAppendingResourceNoRetry ::     (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>@@ -306,7 +346,8 @@     (AppendResult -> Tx.Transaction a) ->     Eff es (Either StoreError a) runTransactionAppendingWith ctor sn@(StreamName name) expected events k-    | name == "$all" = pure (Left (ReservedStreamName sn))+    | Left err <- validateStreamName sn = pure (Left err)+    | null events = pure (Left (EmptyAppendBatch sn))     | otherwise = do         prepared <- prepareEventsIO events         now <- liftIO getCurrentTime
src/Kiroku/Store/Types.hs view
@@ -12,12 +12,15 @@     AppendResult (..),     LinkResult (..),     CategoryName (..),+    categoryName,+    streamNameInCategory,     EventFilter (..), ) where  import Data.Aeson (Value) import Data.Int (Int64) import Data.Text (Text)+import Data.Text qualified as Text import Data.Time (UTCTime) import Data.UUID (UUID) import GHC.Generics (Generic)@@ -79,9 +82,16 @@ newtype StreamVersion = StreamVersion Int64     deriving stock (Eq, Ord, Show, Generic) -{- | Monotonic global position counter, shared across all streams.-Strictly increasing per successful append; gap-free under all four-'ExpectedVersion' paths (see EP-1's audit). Used by the @$all@ read+{- | Global position of an event in the @$all@ ordering, shared across all+streams. __Contract:__ strictly increasing per successful append, and totally+ordered — nothing more. Treat values as opaque cursors: construct a+'GlobalPosition' only from @0@ (the beginning of the store) or from a value+previously returned by this store; never derive one by arithmetic, and never+assume positions are dense (@pos + 1@ may not exist). The current+implementation happens to assign contiguous positions (see EP-1's audit), but+contiguity is an implementation detail, not an API guarantee, and is the part+that would change under a sequence-based allocation scheme — see+docs/architecture/global-position-migration-path.md. Used by the @$all@ read path and by subscriptions as the cursor key. -} newtype GlobalPosition = GlobalPosition Int64@@ -182,6 +192,13 @@     soft-deleted, 'Nothing' otherwise. Hard-deleted streams do not     appear in 'getStream' at all.     -}+    , truncateBefore :: !StreamVersion+    {- ^ The logical truncate-before marker. Ordered per-stream reads+    ('Kiroku.Store.Read.readStreamForward' / 'readStreamBackward') return only+    events whose per-stream version is >= this value. 0 (the default) keeps the+    whole stream. Does not affect the @$all@ global log, category reads, or+    subscriptions. Set with 'Kiroku.Store.Lifecycle.setStreamTruncateBefore'.+    -}     }     deriving stock (Eq, Show, Generic) @@ -266,6 +283,38 @@ -} newtype CategoryName = CategoryName Text     deriving stock (Eq, Ord, Show, Generic)++{- | The category of a stream name: the substring before the first @-@. This+is the canonical Haskell mirror of the+@streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column and+of the category rule used by 'Kiroku.Store.Read.readCategory' and+category-targeted subscriptions. A category never contains @-@, so this is+exact.++>>> categoryName (StreamName "orders-1")+CategoryName "orders"++A name without a dash is its own category:++>>> categoryName (StreamName "singleton")+CategoryName "singleton"+-}+categoryName :: StreamName -> CategoryName+categoryName (StreamName t) = CategoryName (Text.takeWhile (/= '-') t)++{- | Build the stream name for a category and an id segment, i.e.+@\<category\>-\<id\>@. The dual of 'categoryName': for any dash-free category,+@'categoryName' ('streamNameInCategory' cat seg) == cat@.++This is permissive — it does not reject a hyphenated 'CategoryName' or an empty+segment (the store accepts such names); opinionated validation is the caller's+concern.++>>> streamNameInCategory (CategoryName "orders") "1"+StreamName "orders-1"+-}+streamNameInCategory :: CategoryName -> Text -> StreamName+streamNameInCategory (CategoryName cat) seg = StreamName (cat <> "-" <> seg)  {- | Filter passed to the 'Kiroku.Store.Effect.FindEvents' constructor and the public smart constructors in "Kiroku.Store.Causation". Each constructor maps
test/Main.hs view
@@ -19,6 +19,8 @@ import Data.Vector qualified as V import Effectful (Eff, IOE, runEff, (:>)) import Effectful.Error.Static (runErrorNoCallStack)+import Hasql.Errors qualified as Errors+import Hasql.Pool (UsageError (..)) import Kiroku.Store import Kiroku.Store.Error (extractStreamNameFromDetail) import Kiroku.Store.Subscription.Effect qualified as SubEff@@ -26,6 +28,7 @@ import Kiroku.Store.Subscription.Types (OverflowPolicy (..), SubscriptionConfigM (..), SubscriptionOverflowed (..)) import Kiroku.Test.Postgres (withMigratedTestDatabase) import Test.CatchupDbErrorNoPrematureSwitch qualified as CatchupDbErrorNoPrematureSwitch+import Test.Category qualified as Category import Test.CategoryIdleNoSpin qualified as CategoryIdleNoSpin import Test.Causation qualified as Causation import Test.Concurrency qualified as Concurrency@@ -37,9 +40,14 @@ import Test.Helpers import Test.Hspec import Test.InterpreterHooks qualified as InterpreterHooks+import Test.NotifyGuard qualified as NotifyGuard import Test.Properties qualified as Properties+import Test.PublisherCallbackResilience qualified as PublisherCallbackResilience+import Test.PublisherIdleAdvance qualified as PublisherIdleAdvance import Test.PublisherRestartNoRebroadcast qualified as PublisherRestartNoRebroadcast import Test.ReadStream qualified as ReadStream+import Test.StartupFailureSurfacing qualified as StartupFailureSurfacing+import Test.StreamBridgeTermination qualified as StreamBridgeTermination import Test.StreamNameLookup qualified as StreamNameLookup import Test.SubscriptionPauseResume qualified as SubscriptionPauseResume import Test.SubscriptionReconnect qualified as SubscriptionReconnect@@ -47,25 +55,33 @@ import Test.SubscriptionRetryDeadLetter qualified as SubscriptionRetryDeadLetter import Test.SubscriptionState qualified as SubscriptionState import Test.Transaction qualified as Transaction+import Test.TruncateBefore qualified as TruncateBefore  main :: IO () main = withSharedMigratedPostgres $ hspec $ do+    Category.spec     Properties.spec     Concurrency.spec     FailureInjection.spec     Transaction.spec+    TruncateBefore.spec     ReadStream.spec     StreamNameLookup.spec+    StreamBridgeTermination.spec     InterpreterHooks.spec     Causation.spec     ConsumerGroupSql.spec     ConsumerGroup.spec     ConsumerGroupEffect.spec+    NotifyGuard.spec     CategoryIdleNoSpin.spec+    PublisherCallbackResilience.spec+    PublisherIdleAdvance.spec     PublisherRestartNoRebroadcast.spec     CatchupDbErrorNoPrematureSwitch.spec     SubscriptionPauseResume.spec     SubscriptionReconnect.spec+    StartupFailureSurfacing.spec     SubscriptionState.spec     SubscriptionRegistry.spec     SubscriptionRetryDeadLetter.spec@@ -112,6 +128,43 @@                         Left (StreamAlreadyExists _) -> pure ()                         other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other) +            describe "empty event batches" $ do+                it "rejects NoStream without creating a phantom stream or advancing $all" $ \store -> do+                    Right beforeAll <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+                    result <- runStoreIO store $ appendToStream (StreamName "empty-nostream") NoStream []+                    result `shouldBe` Left (EmptyAppendBatch (StreamName "empty-nostream"))+                    runStoreIO store (getStream (StreamName "empty-nostream")) `shouldReturn` Right Nothing+                    Right afterAll <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+                    fmap (^. #globalPosition) (V.toList afterAll) `shouldBe` fmap (^. #globalPosition) (V.toList beforeAll)++                it "rejects empty batches for existing-stream expectations without changing version" $ \store -> do+                    Right _ <- runStoreIO store $ appendToStream (StreamName "empty-existing") NoStream [makeEvent "Created" (Aeson.object [])]+                    let assertEmptyRejected expected = do+                            result <- runStoreIO store $ appendToStream (StreamName "empty-existing") expected []+                            result `shouldBe` Left (EmptyAppendBatch (StreamName "empty-existing"))+                            Right (Just info) <- runStoreIO store $ getStream (StreamName "empty-existing")+                            (info ^. #version) `shouldBe` StreamVersion 1+                    assertEmptyRejected (ExactVersion (StreamVersion 1))+                    assertEmptyRejected StreamExists+                    assertEmptyRejected AnyVersion++                it "rejects an empty per-stream batch in appendMultiStream without committing any stream" $ \store -> do+                    let ok = StreamName "empty-multi-ok"+                        bad = StreamName "empty-multi-bad"+                    result <-+                        runStoreIO store $+                            appendMultiStream+                                [ (ok, NoStream, [makeEvent "Created" (Aeson.object [])])+                                , (bad, NoStream, [])+                                ]+                    result `shouldBe` Left (EmptyAppendBatch bad)+                    runStoreIO store (getStream ok) `shouldReturn` Right Nothing+                    runStoreIO store (getStream bad) `shouldReturn` Right Nothing++                it "treats an empty appendMultiStream operation list as a no-op" $ \store -> do+                    result <- runStoreIO store $ appendMultiStream []+                    result `shouldBe` Right []+             describe "ExactVersion" $ do                 it "appends when version matches" $ \store -> do                     Right r1 <- runStoreIO store $ appendToStream (StreamName "order-789") NoStream [makeEvent "OrderCreated" (Aeson.object [])]@@ -277,6 +330,45 @@                         Just si -> (si ^. #deletedAt) `shouldBe` Nothing                         Nothing -> expectationFailure "Expected reserved $all stream row to remain present" +                it "rejects stream names longer than 512 UTF-8 bytes before creating streams" $ \store -> do+                    let overAscii = StreamName (T.replicate 513 "a")+                        overUtf8 = StreamName (T.replicate 171 (T.singleton '\12354'))+                        assertTooLong sn = do+                            result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "TooLong" (Aeson.object [])]+                            result `shouldBe` Left (StreamNameTooLong sn 513)+                            runStoreIO store (getStream sn) `shouldReturn` Right Nothing+                    assertTooLong overAscii+                    assertTooLong overUtf8++                it "rejects oversized link targets without creating the target stream" $ \store -> do+                    let over = StreamName (T.replicate 513 "l")+                    Right _ <- runStoreIO store $ appendToStream (StreamName "length-link-source") NoStream [makeEvent "Source" (Aeson.object [])]+                    Right events <- runStoreIO store $ readStreamForward (StreamName "length-link-source") (StreamVersion 0) 10+                    let eid = V.head events ^. #eventId+                    result <- runStoreIO store $ linkToStream over [eid]+                    result `shouldBe` Left (StreamNameTooLong over 513)+                    runStoreIO store (getStream over) `shouldReturn` Right Nothing++                it "rejects oversized multi-stream targets without partial commit" $ \store -> do+                    let ok = StreamName "length-multi-ok"+                        over = StreamName (T.replicate 513 "m")+                    result <-+                        runStoreIO store $+                            appendMultiStream+                                [ (ok, NoStream, [makeEvent "ShouldRollback" (Aeson.object [])])+                                , (over, NoStream, [makeEvent "TooLong" (Aeson.object [])])+                                ]+                    result `shouldBe` Left (StreamNameTooLong over 513)+                    runStoreIO store (getStream ok) `shouldReturn` Right Nothing+                    runStoreIO store (getStream over) `shouldReturn` Right Nothing++                it "accepts a 512-byte stream name" $ \store -> do+                    let exact = StreamName (T.replicate 512 "e")+                    result <- runStoreIO store $ appendToStream exact NoStream [makeEvent "ExactLength" (Aeson.object [])]+                    case result of+                        Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1+                        Left err -> expectationFailure ("Expected 512-byte stream name to append, got: " <> show err)+         describe "readStreamForward" $ do             it "reads events in forward order (read-your-own-writes)" $ \store -> do                 let events =@@ -311,6 +403,24 @@                 (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2                 (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 1 +            it "paginates backward using stream version as an exclusive cursor" $ \store -> do+                let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+                Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd-page") NoStream events++                Right page1 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") (StreamVersion 0) 2+                fmap (^. #streamVersion) (V.toList page1) `shouldBe` [StreamVersion 5, StreamVersion 4]++                let cursor1 = V.last page1 ^. #streamVersion+                Right page2 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") cursor1 2+                fmap (^. #streamVersion) (V.toList page2) `shouldBe` [StreamVersion 3, StreamVersion 2]++                let cursor2 = V.last page2 ^. #streamVersion+                Right page3 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") cursor2 2+                fmap (^. #streamVersion) (V.toList page3) `shouldBe` [StreamVersion 1]++                Right page4 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") (V.last page3 ^. #streamVersion) 2+                V.length page4 `shouldBe` 0+         describe "cursor-based pagination" $ do             it "paginates using stream version as cursor" $ \store -> do                 let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]@@ -357,6 +467,20 @@                 (V.head result ^. #eventType) `shouldBe` EventType "Y"                 (result V.! 1 ^. #eventType) `shouldBe` EventType "X" +            it "paginates backward using global position as an exclusive cursor" $ \store -> do+                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s1") NoStream [makeEvent "X" (Aeson.object [])]+                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s2") NoStream [makeEvent "Y" (Aeson.object [])]+                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s3") NoStream [makeEvent "Z" (Aeson.object [])]++                Right page1 <- runStoreIO store $ readAllBackward (GlobalPosition 0) 2+                fmap (^. #globalPosition) (V.toList page1) `shouldBe` [GlobalPosition 3, GlobalPosition 2]++                Right page2 <- runStoreIO store $ readAllBackward (GlobalPosition 2) 2+                fmap (^. #globalPosition) (V.toList page2) `shouldBe` [GlobalPosition 1]++                Right page3 <- runStoreIO store $ readAllBackward (GlobalPosition 1) 2+                V.length page3 `shouldBe` 0+         describe "read empty/nonexistent stream" $ do             it "returns empty Vector for nonexistent stream" $ \store -> do                 Right result <- runStoreIO store $ readStreamForward (StreamName "no-such-stream") (StreamVersion 0) 100@@ -532,7 +656,9 @@                 Right _ <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]                 result <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]                 case result of-                    Left _ -> pure () -- Expected: some error (PK violation)+                    Left (EventAlreadyLinked (StreamName "linked-dup") mEid) ->+                        mEid `shouldBe` Just eid+                    Left err -> expectationFailure ("Expected EventAlreadyLinked, got: " <> show err)                     Right _ -> expectationFailure "Expected error for duplicate link"              -- F3 regression — silent version gap when source events do not exist.@@ -544,7 +670,8 @@                 let bogus = EventId (case UUID.fromString "11111111-1111-7111-8111-111111111111" of Just u -> u; Nothing -> error "bad uuid")                 result <- runStoreIO store $ linkToStream (StreamName "f3-bogus") [bogus]                 case result of-                    Left _ -> pure ()+                    Left (LinkSourceEventMissing (StreamName "f3-bogus")) -> pure ()+                    Left err -> expectationFailure ("Expected LinkSourceEventMissing, got: " <> show err)                     Right r -> expectationFailure ("Expected error for missing event, got: " <> show r)                 -- Target stream must not have been left in a half-created state.                 Right info <- runStoreIO store $ getStream (StreamName "f3-bogus")@@ -557,7 +684,8 @@                 let bogus = EventId (case UUID.fromString "22222222-2222-7222-8222-222222222222" of Just u -> u; Nothing -> error "bad uuid")                 result <- runStoreIO store $ linkToStream (StreamName "f3-mixed-tgt") [realId, bogus]                 case result of-                    Left _ -> pure ()+                    Left (LinkSourceEventMissing (StreamName "f3-mixed-tgt")) -> pure ()+                    Left err -> expectationFailure ("Expected LinkSourceEventMissing, got: " <> show err)                     Right r -> expectationFailure ("Expected error for partial-missing batch, got: " <> show r)                 Right info <- runStoreIO store $ getStream (StreamName "f3-mixed-tgt")                 info `shouldBe` Nothing@@ -850,6 +978,39 @@                 after <- countEvents store                 (before - after) `shouldBe` 3 +            it "removes dead letters for orphaned events before deleting their payloads" $ \store -> do+                Right _ <- runStoreIO store $ appendToStream (StreamName "dl-hard-source") NoStream [makeEvent "DeadLettered" (Aeson.object [])]+                Right events <- runStoreIO store $ readStreamForward (StreamName "dl-hard-source") (StreamVersion 0) 10+                let event = V.head events+                    eid = event ^. #eventId+                insertDeadLetterForEvent store "dl-hard-subscription" event+                countDeadLettersForEvents store [eid] `shouldReturn` 1++                Right mId <- runStoreIO store $ hardDeleteStream (StreamName "dl-hard-source")+                mId `shouldSatisfy` (/= Nothing)+                countDeadLettersForEvents store [eid] `shouldReturn` 0+                Right remaining <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+                V.filter (\recorded -> (recorded ^. #eventId) == eid) remaining `shouldBe` V.empty++            it "keeps dead letters for linked-in events that survive a hard delete" $ \store -> do+                Right _ <- runStoreIO store $ appendToStream (StreamName "dl-origin") NoStream [makeEvent "Survivor" (Aeson.object [])]+                Right originEvents <- runStoreIO store $ readStreamForward (StreamName "dl-origin") (StreamVersion 0) 10+                let event = V.head originEvents+                    eid = event ^. #eventId+                Right _ <- runStoreIO store $ linkToStream (StreamName "dl-link-target") [eid]+                insertDeadLetterForEvent store "dl-survivor-subscription" event+                countDeadLettersForEvents store [eid] `shouldReturn` 1++                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dl-link-target")+                countDeadLettersForEvents store [eid] `shouldReturn` 1+                Right afterTargetDelete <- runStoreIO store $ readStreamForward (StreamName "dl-origin") (StreamVersion 0) 10+                fmap (^. #eventId) (V.toList afterTargetDelete) `shouldBe` [eid]++                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dl-origin")+                countDeadLettersForEvents store [eid] `shouldReturn` 0+                Right afterOriginDelete <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+                V.filter (\recorded -> (recorded ^. #eventId) == eid) afterOriginDelete `shouldBe` V.empty+             -- F1 regression — events that are linked to other streams must survive             -- hard-delete of their source stream's owner if any non-target junctions remain.             it "preserves events still linked to non-target streams" $ \store -> do@@ -1725,6 +1886,24 @@         it "returns Nothing for an empty parenthesised value" $ do             extractStreamNameFromDetail "Key (stream_name)=() already exists."                 `shouldBe` Nothing++    describe "isTransientSerializationError" $ do+        let serverUsage code =+                SessionUsageError $+                    Errors.StatementSessionError+                        1+                        0+                        ""+                        []+                        True+                        ( Errors.ServerStatementError $+                            Errors.ServerError code "server error" Nothing Nothing Nothing+                        )+        it "recognizes only serialization and deadlock SQLSTATEs" $ do+            isTransientSerializationError (serverUsage "40P01") `shouldBe` True+            isTransientSerializationError (serverUsage "40001") `shouldBe` True+            isTransientSerializationError (serverUsage "23505") `shouldBe` False+            isTransientSerializationError AcquisitionTimeoutUsageError `shouldBe` False      -- =================================================================     -- Notifier reconnection tests (EP-3 F1)
+ test/Test/Category.hs view
@@ -0,0 +1,70 @@+{- | Pure laws for the category accessor and safe stream-name constructor+('Kiroku.Store.Types.categoryName' and 'streamNameInCategory').++These pin the "category = substring before the first @-@" rule — enforced by+the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column+and re-derived in 'Kiroku.Store.Notification' — so the public accessor cannot+drift from it. The round-trip law is the contract keiro's typed @Category@ API+(ExecPlan #66) builds on.++Pure; no database. Runs inside hspec via 'hspec-hedgehog'.+-}+module Test.Category (spec) where++import Data.Text (Text)+import Data.Text qualified as T+import Hedgehog+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range+import Kiroku.Store.Types (CategoryName (..), StreamName (..), categoryName, streamNameInCategory)+import Test.Hspec+import Test.Hspec.Hedgehog (hedgehog)++{- | A non-empty, dash-free category. Includes @:@ and @$@ to exercise the+sub-namespacing convention (e.g. @"wf:orders"@) without the @-@ boundary.+-}+genCategoryText :: Gen Text+genCategoryText = Gen.text (Range.linear 1 16) (Gen.element categoryChars)+  where+    categoryChars = ['a' .. 'z'] <> ['0' .. '9'] <> [':', '$', '_']++{- | An arbitrary id segment. May contain @-@: the id lives after the first+@-@, so embedded dashes must not change the leading category.+-}+genSegment :: Gen Text+genSegment = Gen.text (Range.linear 0 16) (Gen.element segmentChars)+  where+    segmentChars = ['a' .. 'z'] <> ['0' .. '9'] <> ['-', ':', '_']++-- | An arbitrary stream name (may contain dashes anywhere, or none).+genStreamNameText :: Gen Text+genStreamNameText = Gen.text (Range.linear 0 24) (Gen.element nameChars)+  where+    nameChars = ['a' .. 'z'] <> ['0' .. '9'] <> ['-', ':', '_']++spec :: Spec+spec = describe "Kiroku.Store.Types category rule" $ do+    describe "categoryName" $ do+        it "takes the substring before the first dash" $+            categoryName (StreamName "orders-1") `shouldBe` CategoryName "orders"++        it "treats a dashless name as its own category" $+            categoryName (StreamName "singleton") `shouldBe` CategoryName "singleton"++        it "stops at the first dash even with later dashes" $+            categoryName (StreamName "orders-a-b-c") `shouldBe` CategoryName "orders"++        it "never yields a category containing a dash" $ hedgehog $ do+            name <- forAll genStreamNameText+            let CategoryName cat = categoryName (StreamName name)+            assert (not (T.isInfixOf "-" cat))++    describe "streamNameInCategory" $ do+        it "joins category and segment with a dash" $+            streamNameInCategory (CategoryName "orders") "1" `shouldBe` StreamName "orders-1"++    describe "round-trip" $ do+        it "categoryName . streamNameInCategory == id, for dash-free categories" $ hedgehog $ do+            cat <- forAll genCategoryText+            seg <- forAll genSegment+            categoryName (streamNameInCategory (CategoryName cat) seg) === CategoryName cat
test/Test/Concurrency.hs view
@@ -249,6 +249,29 @@                         (V.toList allEvts)             sort positions `shouldBe` [1, 2, 3, 4, 5, 6] +    -- EP-4 M3 — Best-effort regression for the single-stream append retry.+    -- The race can pass vacuously on fast machines, but it must never surface+    -- PostgreSQL's transient transaction SQLSTATEs (40001/40P01) to callers as+    -- UnexpectedServerError.+    it "single-stream append retry does not leak transient SQLSTATEs" $+        withTestStore $ \store -> do+            forM_ [1 .. 25 :: Int] $ \i -> do+                let a = StreamName ("retry-a-" <> T.pack (show i))+                    b = StreamName ("retry-b-" <> T.pack (show i))+                    multi =+                        appendMultiStream+                            [ (a, NoStream, [makeEvent "A" (Aeson.object [])])+                            , (b, AnyVersion, [makeEvent "B-multi" (Aeson.object [])])+                            ]+                    single =+                        appendToStream b NoStream [makeEvent "B-single" (Aeson.object [])]+                (multiResult, singleResult) <-+                    Async.concurrently+                        (runStoreIO store multi)+                        (runStoreIO store single)+                assertNoTransientLeak ("multi round " <> show i) multiResult+                assertNoTransientLeak ("single round " <> show i) singleResult+ assertRightAppend :: Either StoreError AppendResult -> IO () assertRightAppend = \case     Right _ -> pure ()@@ -258,6 +281,14 @@ assertRightMulti = \case     Right _ -> pure ()     Left err -> expectationFailure ("appendMultiStream should succeed, got: " <> show err)++assertNoTransientLeak :: String -> Either StoreError a -> IO ()+assertNoTransientLeak label = \case+    Left (UnexpectedServerError code _)+        | code == "40P01" || code == "40001" ->+            expectationFailure (label <> ": transient SQLSTATE leaked as UnexpectedServerError " <> show code)+    _ ->+        pure ()  streamVersions :: V.Vector RecordedEvent -> [Int64] streamVersions =
test/Test/Helpers.hs view
@@ -28,6 +28,8 @@      -- * Raw SQL helpers     countEvents,+    countDeadLettersForEvents,+    insertDeadLetterForEvent,     insertEventUsingDefaultId,     serverVersionNum,     truncateRejected,@@ -53,9 +55,11 @@ import Control.Exception (SomeException) import Control.Lens ((^.)) import Data.Aeson (Value)+import Data.Aeson qualified as Aeson import Data.Generics.Labels () import Data.Int (Int32, Int64) import Data.Text (Text)+import Data.UUID (UUID) import Hasql.Connection qualified as Connection import Hasql.Connection.Settings qualified as Conn import Hasql.Decoders qualified as D@@ -64,6 +68,7 @@ import Hasql.Session qualified as Session import Hasql.Statement (Statement, preparable, unpreparable) import Kiroku.Store+import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Subscription.EventPublisher (publisherPosition) import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres) @@ -121,6 +126,41 @@     result <- Pool.use (store ^. #pool) (Session.statement () stmt)     case result of         Left err -> error ("countEvents failed: " <> show err)+        Right n -> pure n++-- | Insert a dead-letter row for a recorded event without running a subscription.+insertDeadLetterForEvent :: KirokuStore -> Text -> RecordedEvent -> IO ()+insertDeadLetterForEvent store subscriptionName event = do+    let EventId eid = event ^. #eventId+        GlobalPosition globalPosition = event ^. #globalPosition+        params =+            SQL.DeadLetterParams+                { SQL.dlSubscriptionName = subscriptionName+                , SQL.dlMember = 0+                , SQL.dlGlobalPosition = globalPosition+                , SQL.dlEventId = eid+                , SQL.dlReason = Aeson.object [("source", Aeson.String "test")]+                , SQL.dlReasonSummary = "test dead letter"+                , SQL.dlAttemptCount = 1+                }+    result <- Pool.use (store ^. #pool) (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)+    case result of+        Left err -> error ("insertDeadLetterForEvent failed: " <> show err)+        Right () -> pure ()++-- | Count dead-letter rows referencing any of the supplied events.+countDeadLettersForEvents :: KirokuStore -> [EventId] -> IO Int64+countDeadLettersForEvents store eventIds = do+    let uuids = fmap (\(EventId eid) -> eid) eventIds+        stmt :: Statement [UUID] Int64+        stmt =+            preparable+                "SELECT COUNT(*) FROM dead_letters WHERE event_id = ANY($1::uuid[])"+                (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+                (D.singleRow (D.column (D.nonNullable D.int8)))+    result <- Pool.use (store ^. #pool) (Session.statement uuids stmt)+    case result of+        Left err -> error ("countDeadLettersForEvents failed: " <> show err)         Right n -> pure n  {- | Insert directly into @events@ without an @event_id@ so the database
+ test/Test/NotifyGuard.hs view
@@ -0,0 +1,122 @@+module Test.NotifyGuard (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.STM (TVar, atomically, check, modifyTVar', newTVarIO, readTVar)+import Control.Exception (bracket, throwIO)+import Control.Lens ((^.))+import Data.Aeson qualified as Aeson+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.Generics.Labels ()+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Conn+import Hasql.Notifications qualified as Notifications+import Kiroku.Store+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import System.IO.Error (userError)+import Test.Helpers (makeEvent)+import Test.Hspec++spec :: Spec+spec =+    describe "NOTIFY trigger guard" $+        around withNotifyStore $ do+            it "emits one unchanged append payload and no lifecycle or $all payloads" $ \(store, listenerConn) -> do+                payloads <- newTVarIO []++                Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")+                Async.withAsync+                    (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)+                    $ \_listener -> do+                        let firstStream = StreamName "notify-guard-1"+                            secondStream = StreamName "notify-guard-2"++                        Right first <-+                            runStoreIO store $+                                appendToStream firstStream NoStream [makeEvent "NotifyGuardFirst" (Aeson.object [])]+                        waitForPayload payloads (expectedPayload firstStream first)++                        Right _ <- runStoreIO store $ softDeleteStream firstStream+                        Right _ <- runStoreIO store $ undeleteStream firstStream++                        Right second <-+                            runStoreIO store $+                                appendToStream secondStream NoStream [makeEvent "NotifyGuardSecond" (Aeson.object [])]+                        waitForPayload payloads (expectedPayload secondStream second)++                        actual <- atomically (readTVar payloads)+                        actual+                            `shouldBe` [ expectedPayload firstStream first+                                       , expectedPayload secondStream second+                                       ]+                        map payloadFieldCount actual `shouldBe` [3, 3]+                        actual `shouldSatisfy` all (not . BS.isPrefixOf "$all,")++            it "emits the unchanged payload for a 512-byte stream name" $ \(store, listenerConn) -> do+                payloads <- newTVarIO []++                Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")+                Async.withAsync+                    (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)+                    $ \_listener -> do+                        let stream = StreamName (T.replicate 512 "n")++                        Right result <-+                            runStoreIO store $+                                appendToStream stream NoStream [makeEvent "NotifyGuardMaxName" (Aeson.object [])]+                        let expected = expectedPayload stream result+                        waitForPayload payloads expected++                        actual <- atomically (readTVar payloads)+                        actual `shouldBe` [expected]+                        payloadFieldCount expected `shouldBe` 3++withNotifyStore :: ((KirokuStore, Connection.Connection) -> IO ()) -> IO ()+withNotifyStore action =+    withMigratedTestDatabase $ \connStr ->+        withStore (defaultConnectionSettings connStr) $ \store ->+            withConnection connStr $ \listenerConn ->+                action (store, listenerConn)++withConnection :: T.Text -> (Connection.Connection -> IO a) -> IO a+withConnection connStr =+    bracket acquire Connection.release+  where+    acquire = do+        result <- Connection.acquire (Conn.connectionString connStr)+        case result of+            Left err -> throwIO (userError ("failed to acquire LISTEN connection: " <> show err))+            Right conn -> pure conn++expectedPayload :: StreamName -> AppendResult -> ByteString+expectedPayload (StreamName name) result =+    let StreamId sid = result ^. #streamId+        StreamVersion version = result ^. #streamVersion+     in T.encodeUtf8 $+            T.intercalate+                ","+                [ name+                , T.pack (show sid)+                , T.pack (show version)+                ]++payloadFieldCount :: ByteString -> Int+payloadFieldCount = length . BS.split 44++waitForPayload :: TVar [ByteString] -> ByteString -> IO ()+waitForPayload payloads expected = do+    result <-+        Async.race+            (threadDelay 5_000_000)+            ( atomically $ do+                seen <- readTVar payloads+                check (expected `elem` seen)+            )+    case result of+        Left () -> do+            seen <- atomically (readTVar payloads)+            expectationFailure ("timed out waiting for notification " <> show expected <> "; saw " <> show seen)+        Right () -> pure ()
+ test/Test/PublisherCallbackResilience.hs view
@@ -0,0 +1,106 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE OverloadedStrings #-}++module Test.PublisherCallbackResilience (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.MVar (newEmptyMVar, tryPutMVar)+import Control.Concurrent.STM (atomically, check, newTVarIO, readTVar, writeTVar)+import Control.Exception (Exception, throwIO)+import Control.Lens ((&), (.~), (^.))+import Data.Aeson qualified as Aeson+import Data.Data (Typeable)+import Data.Generics.Labels ()+import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef)+import Kiroku.Store+import Test.Helpers (caughtUpEventHandler, makeEvent, waitForSubscriptionLive, waitWithTimeout, withTestStoreSettings)+import Test.Hspec++data CallbackBoom = CallbackBoom+    deriving stock (Show, Typeable)+    deriving anyclass (Exception)++timeoutMicros :: Int+timeoutMicros = 10_000_000++within :: String -> IO a -> IO a+within label action = do+    result <- Async.race (threadDelay timeoutMicros) action+    case result of+        Left () -> fail ("timed out waiting for " <> label)+        Right a -> pure a++spec :: Spec+spec = describe "publisher callback resilience" $ do+    it "keeps the publisher alive when decodeHook throws once" $ do+        failedOnce <- newIORef False+        loopErrorSeen <- newEmptyMVar+        caughtUp <- newEmptyMVar+        delivered <- newIORef ([] :: [EventType])+        deliveredCount <- newTVarIO (0 :: Int)++        let subName = SubscriptionName "publisher-decode-hook-resilience"+            decodeOnce event+                | event ^. #eventType == EventType "Boom" = do+                    alreadyFailed <- atomicModifyIORef' failedOnce (\old -> (True, old))+                    if alreadyFailed+                        then pure event+                        else throwIO CallbackBoom+                | otherwise = pure event+            observe evt = do+                case evt of+                    KirokuEventPublisherLoopError{} -> () <$ tryPutMVar loopErrorSeen ()+                    _ -> pure ()+                caughtUpEventHandler subName caughtUp Nothing evt+            tweak settings =+                settings+                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just decodeOnce}+                    & #eventHandler .~ Just observe+            handler event = do+                modifyIORef' delivered ((event ^. #eventType) :)+                atomically $ do+                    n <- readTVar deliveredCount+                    let n' = n + 1+                    writeTVar deliveredCount n'+                    pure $ if n' >= 2 then Stop else Continue++        withTestStoreSettings tweak $ \store -> do+            handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)+            waitForSubscriptionLive caughtUp+            Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-boom") NoStream [makeEvent "Boom" (Aeson.object [])]+            within "publisher loop error event" (waitForSubscriptionLive loopErrorSeen)+            Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-ok") NoStream [makeEvent "Ok" (Aeson.object [])]+            result <- waitWithTimeout timeoutMicros handle+            case result of+                Right (Right ()) -> pure ()+                Left timeout -> expectationFailure timeout+                Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)++        seen <- reverse <$> readIORef delivered+        seen `shouldBe` [EventType "Boom", EventType "Ok"]++    it "drops throwing eventHandler exceptions without killing publisher or worker" $ do+        deliveredCount <- newTVarIO (0 :: Int)+        let tweak settings = settings & #eventHandler .~ Just (\_ -> throwIO CallbackBoom)+            handler _ = do+                atomically $ do+                    n <- readTVar deliveredCount+                    let n' = n + 1+                    writeTVar deliveredCount n'+                    pure $ if n' >= 3 then Stop else Continue++        withTestStoreSettings tweak $ \store -> do+            handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "throwing-event-handler-resilience") AllStreams handler)+            Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-1") NoStream [makeEvent "One" (Aeson.object [])]+            Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-2") NoStream [makeEvent "Two" (Aeson.object [])]+            Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-3") NoStream [makeEvent "Three" (Aeson.object [])]+            result <- waitWithTimeout timeoutMicros handle+            case result of+                Right (Right ()) -> pure ()+                Left timeout -> expectationFailure timeout+                Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)++        finalCount <- atomically (readTVar deliveredCount)+        finalCount `shouldBe` 3
+ test/Test/PublisherIdleAdvance.hs view
@@ -0,0 +1,140 @@+{-# LANGUAGE OverloadedStrings #-}++module Test.PublisherIdleAdvance (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.MVar (newEmptyMVar, tryPutMVar)+import Control.Concurrent.STM (readTVarIO)+import Control.Exception (bracket)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (when)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.IntMap.Strict qualified as IntMap+import Data.List (sort)+import Data.String (fromString)+import Kiroku.Store+import Kiroku.Store.Subscription.EventPublisher qualified as Pub+import Test.Helpers (caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, withTestStoreSettings)+import Test.Hspec++timeoutMicros :: Int+timeoutMicros = 10_000_000++within :: String -> IO a -> IO a+within label action = do+    result <- Async.race (threadDelay timeoutMicros) action+    case result of+        Left () -> fail ("timed out waiting for " <> label)+        Right a -> pure a++countingSettings :: IORef Int -> ConnectionSettings -> ConnectionSettings+countingSettings counter settings =+    settings+        & #storeSettings+            .~ defaultStoreSettings+                { decodeHook =+                    Just $ \event -> do+                        atomicModifyIORef' counter (\n -> (n + 1, ()))+                        pure event+                }++appendEvents :: KirokuStore -> Int -> String -> IO GlobalPosition+appendEvents store n prefix = do+    results <-+        traverse+            ( \i ->+                runStoreIO store $+                    appendToStream+                        (StreamName (fromString (prefix <> "-" <> show i)))+                        NoStream+                        [makeEvent (fromString ("E" <> show i)) (Aeson.object [])]+            )+            [1 .. n]+    case sequence results of+        Left err -> fail ("append failed: " <> show err)+        Right [] -> fail "appendEvents called with zero events"+        Right xs -> pure (last xs ^. #globalPosition)++publisherSubscriberCount :: KirokuStore -> IO Int+publisherSubscriberCount store =+    IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))++spec :: Spec+spec = describe "publisher idle advance" $ do+    it "advances lastPublished without decoding any rows when no subscriber is registered" $ do+        counter <- newIORef 0+        withTestStoreSettings (countingSettings counter) $ \store -> do+            tailPos <- appendEvents store 25 "pubidle-empty"+            waitForPublisher store tailPos+            readIORef counter `shouldReturn` 0++    it "does not fetch full rows while only a category subscriber exists" $ do+        counter <- newIORef 0+        caughtUp <- newEmptyMVar+        delivered <- newEmptyMVar+        let subName = SubscriptionName "pubidle-category"+            observe = caughtUpEventHandler subName caughtUp Nothing+            handler event = do+                case event ^. #eventType of+                    EventType "Wake" -> () <$ tryPutMVar delivered ()+                    _ -> pure ()+                pure Continue+            tweak settings =+                countingSettings counter settings+                    & #eventHandler .~ Just observe+        withTestStoreSettings tweak $ \store ->+            bracket+                (subscribe store (defaultSubscriptionConfig subName (Category (CategoryName "pubidlea")) handler))+                cancel+                $ \_handle -> do+                    within "category subscription live" (waitForSubscriptionLive caughtUp)+                    before <- readIORef counter+                    tailPos <- appendEvents store 30 "pubidleb"+                    waitForPublisher store tailPos+                    after <- readIORef counter+                    (after - before) `shouldBe` 0+                    publisherSubscriberCount store `shouldReturn` 0++                    Right wakePos <-+                        runStoreIO store $+                            appendToStream+                                (StreamName "pubidlea-1")+                                NoStream+                                [makeEvent "Wake" (Aeson.object [])]+                    waitForPublisher store (wakePos ^. #globalPosition)+                    within "category wake event" (waitForSubscriptionLive delivered)++    it "switches from cheap advance to all-stream delivery without gaps" $ do+        counter <- newIORef 0+        caughtUp <- newEmptyMVar+        seenRef <- newIORef ([] :: [GlobalPosition])+        seenEnough <- newEmptyMVar+        let subName = SubscriptionName "pubidle-transition"+            observe = caughtUpEventHandler subName caughtUp Nothing+            handler event = do+                let pos = event ^. #globalPosition+                count <-+                    atomicModifyIORef' seenRef $ \old ->+                        let new = pos : old+                         in (new, length new)+                when (count >= 35) (() <$ tryPutMVar seenEnough ())+                pure Continue+            tweak settings =+                countingSettings counter settings+                    & #eventHandler .~ Just observe+        withTestStoreSettings tweak $ \store -> do+            tailBefore <- appendEvents store 20 "pubidle-before"+            waitForPublisher store tailBefore+            bracket+                (subscribe store (defaultSubscriptionConfig subName AllStreams handler))+                cancel+                $ \_handle -> do+                    within "all-stream subscription live" (waitForSubscriptionLive caughtUp)+                    tailAfter <- appendEvents store 15 "pubidle-after"+                    waitForPublisher store tailAfter+                    within "all-stream events" (waitForSubscriptionLive seenEnough)+                    seen <- sort <$> readIORef seenRef+                    seen `shouldBe` fmap GlobalPosition [1 .. 35]
test/Test/ReadStream.hs view
@@ -9,86 +9,145 @@ import Control.Lens ((^.)) import Data.Aeson qualified as Aeson import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef) import Data.List.NonEmpty qualified as NE import Data.Text qualified as T+import Data.UUID.V7 qualified as V7 import Data.Vector qualified as V import Kiroku.Store import Streamly.Data.Fold qualified as Fold import Streamly.Data.Stream qualified as Stream-import Test.Helpers (makeEvent, withTestStore)+import Test.Helpers (makeEvent, waitForPublisher, withTestStore, withTestStoreSettings) import Test.Hspec  spec :: Spec-spec = around withTestStore $+spec = do     describe "readStreamForwardStream" $ do-        it "matches readStreamForward on a single-page stream" $ \store -> do-            let name = StreamName "rsfs-single-page"-                events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-            Right _ <- runStoreIO store $ appendToStream name NoStream events-            streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)-            vectored <- runStoreIO store $ readStreamForward name (StreamVersion 0) 256-            case (streamed, vectored) of-                (Right xs, Right v) -> xs `shouldBe` V.toList v-                _ -> expectationFailure ("Unexpected error: " <> show streamed <> " / " <> show vectored)+        around withTestStore $ do+            it "matches readStreamForward on a single-page stream" $ \store -> do+                let name = StreamName "rsfs-single-page"+                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+                Right _ <- runStoreIO store $ appendToStream name NoStream events+                streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)+                vectored <- runStoreIO store $ readStreamForward name (StreamVersion 0) 256+                case (streamed, vectored) of+                    (Right xs, Right v) -> xs `shouldBe` V.toList v+                    _ -> expectationFailure ("Unexpected error: " <> show streamed <> " / " <> show vectored) -        it "folds 1000 events end-to-end with Fold.length across multiple pages" $ \store -> do-            let name = StreamName "rsfs-multi-page"-                total = 1000 :: Int-                events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. total]]-            Right _ <- runStoreIO store $ appendToStream name NoStream events-            countResult <--                runStoreIO store $-                    Stream.fold Fold.length (readStreamForwardStream name (StreamVersion 0) 256)-            countResult `shouldBe` Right total-            allEvents <--                runStoreIO store $-                    Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)-            case allEvents of-                Right xs -> case NE.nonEmpty xs of-                    Just ne -> do-                        let h = NE.head ne-                            l = NE.last ne-                        (h ^. #streamVersion) `shouldBe` StreamVersion 1-                        (h ^. #eventType) `shouldBe` EventType (T.pack "E1")-                        (l ^. #streamVersion) `shouldBe` StreamVersion (fromIntegral total)-                        (l ^. #eventType) `shouldBe` EventType (T.pack ("E" <> show total))-                    Nothing -> expectationFailure "Expected non-empty events list"-                Left err -> expectationFailure ("Unexpected error: " <> show err)+            it "folds 1000 events end-to-end with Fold.length across multiple pages" $ \store -> do+                let name = StreamName "rsfs-multi-page"+                    total = 1000 :: Int+                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. total]]+                Right _ <- runStoreIO store $ appendToStream name NoStream events+                countResult <-+                    runStoreIO store $+                        Stream.fold Fold.length (readStreamForwardStream name (StreamVersion 0) 256)+                countResult `shouldBe` Right total+                allEvents <-+                    runStoreIO store $+                        Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)+                case allEvents of+                    Right xs -> case NE.nonEmpty xs of+                        Just ne -> do+                            let h = NE.head ne+                                l = NE.last ne+                            (h ^. #streamVersion) `shouldBe` StreamVersion 1+                            (h ^. #eventType) `shouldBe` EventType (T.pack "E1")+                            (l ^. #streamVersion) `shouldBe` StreamVersion (fromIntegral total)+                            (l ^. #eventType) `shouldBe` EventType (T.pack ("E" <> show total))+                        Nothing -> expectationFailure "Expected non-empty events list"+                    Left err -> expectationFailure ("Unexpected error: " <> show err) -        it "preserves order and avoids duplicates / gaps when paging at pageSize 2" $ \store -> do-            let name = StreamName "rsfs-page-boundary"-                events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-            Right _ <- runStoreIO store $ appendToStream name NoStream events-            versions <--                runStoreIO store $-                    Stream.toList $-                        fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)-            versions-                `shouldBe` Right-                    [ StreamVersion 1-                    , StreamVersion 2-                    , StreamVersion 3-                    , StreamVersion 4-                    , StreamVersion 5-                    ]+            it "preserves order and avoids duplicates / gaps when paging at pageSize 2" $ \store -> do+                let name = StreamName "rsfs-page-boundary"+                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+                Right _ <- runStoreIO store $ appendToStream name NoStream events+                versions <-+                    runStoreIO store $+                        Stream.toList $+                            fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)+                versions+                    `shouldBe` Right+                        [ StreamVersion 1+                        , StreamVersion 2+                        , StreamVersion 3+                        , StreamVersion 4+                        , StreamVersion 5+                        ] -        it "terminates immediately on a nonexistent stream" $ \store -> do-            let name = StreamName "rsfs-never-created"-            result <--                runStoreIO store $-                    Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)-            result `shouldBe` Right []+            it "terminates immediately on a nonexistent stream" $ \store -> do+                let name = StreamName "rsfs-never-created"+                result <-+                    runStoreIO store $+                        Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)+                result `shouldBe` Right [] -        it "honors a non-zero starting cursor with exclusivity" $ \store -> do-            let name = StreamName "rsfs-non-zero-cursor"-                events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-            Right _ <- runStoreIO store $ appendToStream name NoStream events-            tailEvents <--                runStoreIO store $-                    Stream.toList (readStreamForwardStream name (StreamVersion 2) 256)-            case tailEvents of-                Right xs -> do-                    length xs `shouldBe` 3-                    map (^. #streamVersion) xs-                        `shouldBe` [StreamVersion 3, StreamVersion 4, StreamVersion 5]-                Left err -> expectationFailure ("Unexpected error: " <> show err)+            it "honors a non-zero starting cursor with exclusivity" $ \store -> do+                let name = StreamName "rsfs-non-zero-cursor"+                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+                Right _ <- runStoreIO store $ appendToStream name NoStream events+                tailEvents <-+                    runStoreIO store $+                        Stream.toList (readStreamForwardStream name (StreamVersion 2) 256)+                case tailEvents of+                    Right xs -> do+                        length xs `shouldBe` 3+                        map (^. #streamVersion) xs+                            `shouldBe` [StreamVersion 3, StreamVersion 4, StreamVersion 5]+                    Left err -> expectationFailure ("Unexpected error: " <> show err)++        it "uses one pool checkout per non-empty page when the final page is short" $ do+            ref <- newIORef (0 :: Int)+            let handler (ConnectionObservation _ InUseConnectionStatus) =+                    modifyIORef' ref (+ 1)+                handler _ =+                    pure ()+            withTestStoreSettings (\settings -> settings{observationHandler = Just handler}) $ \store -> do+                let name = StreamName "rsfs-short-page-count"+                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+                Right appendResult <- runStoreIO store $ appendToStream name NoStream events+                waitForPublisher store (appendResult ^. #globalPosition)+                before <- readIORef ref+                streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 2)+                after <- readIORef ref+                case streamed of+                    Right xs -> do+                        map (^. #streamVersion) xs+                            `shouldBe` [StreamVersion 1, StreamVersion 2, StreamVersion 3, StreamVersion 4, StreamVersion 5]+                        after - before `shouldBe` 3+                    Left err -> expectationFailure ("Unexpected error: " <> show err)++    describe "eventExistsInStream" $+        around withTestStore $ do+            it "finds an existing event id in its live stream" $ \store -> do+                eid <- EventId <$> V7.genUUID+                let name = StreamName "event-exists-present"+                Right _ <- runStoreIO store $ appendToStream name NoStream [eventWithId eid]+                runStoreIO store (eventExistsInStream name eid) `shouldReturn` Right True++            it "returns False for absent ids and ids linked only to another stream" $ \store -> do+                eid <- EventId <$> V7.genUUID+                otherEid <- EventId <$> V7.genUUID+                let source = StreamName "event-exists-source"+                    target = StreamName "event-exists-target"+                Right _ <- runStoreIO store $ appendToStream source NoStream [eventWithId eid]+                runStoreIO store (eventExistsInStream source otherEid) `shouldReturn` Right False+                runStoreIO store (eventExistsInStream target eid) `shouldReturn` Right False++            it "treats soft-deleted streams as absent" $ \store -> do+                eid <- EventId <$> V7.genUUID+                let name = StreamName "event-exists-soft-deleted"+                Right _ <- runStoreIO store $ appendToStream name NoStream [eventWithId eid]+                Right (Just _) <- runStoreIO store $ softDeleteStream name+                runStoreIO store (eventExistsInStream name eid) `shouldReturn` Right False++eventWithId :: EventId -> EventData+eventWithId eid =+    EventData+        { eventId = Just eid+        , eventType = EventType "Created"+        , payload = Aeson.object []+        , metadata = Nothing+        , causationId = Nothing+        , correlationId = Nothing+        }
+ test/Test/StartupFailureSurfacing.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE OverloadedStrings #-}++module Test.StartupFailureSurfacing (spec) where++import Control.Concurrent.Async qualified as Async+import Control.Concurrent.STM (atomically, newTVarIO, readTVar, readTVarIO, writeTVar)+import Control.Exception (SomeException, fromException)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (replicateM_)+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.IntMap.Strict qualified as IntMap+import Data.Map.Strict qualified as Map+import Hasql.Pool qualified as Pool+import Kiroku.Store+import Kiroku.Store.Subscription.EventPublisher qualified as Pub+import Kiroku.Store.Subscription.Worker (withLoadCheckpointHookForTest)+import Test.Helpers (waitWithTimeout, withTestStore, withTestStoreSettings)+import Test.Hspec++spec :: Spec+spec = describe "startup failure surfacing" $ do+    it "fails subscription startup loudly when checkpoint load fails" $ do+        handlerCalled <- newTVarIO False+        observedRef <- newIORef ([] :: [KirokuEvent])+        let subName = SubscriptionName "load-checkpoint-fails-loudly"+            cfg =+                defaultSubscriptionConfig subName AllStreams $ \_ -> do+                    atomically (writeTVar handlerCalled True)+                    pure Continue+            observe evt = modifyIORef' observedRef (evt :)+            injectLoadFailure _ = pure (Just (Left Pool.AcquisitionTimeoutUsageError))+            tweak settings = settings & #eventHandler .~ Just observe++        withTestStoreSettings tweak $ \store ->+            withLoadCheckpointHookForTest injectLoadFailure $ do+                handle <- subscribe store cfg+                result <- waitWithTimeout 5_000_000 handle+                case result of+                    Right (Left e)+                        | Just Pool.AcquisitionTimeoutUsageError <- fromException e -> pure ()+                    Left timeout -> expectationFailure timeout+                    Right other -> expectationFailure ("expected checkpoint load failure, got: " <> show other)++        wasCalled <- readTVarIO handlerCalled+        wasCalled `shouldBe` False+        observed <- reverse <$> readIORef observedRef+        any (isLoadCheckpointError subName) observed `shouldBe` True+        any (isCrashedStop subName) observed `shouldBe` True++    it "leaves no publisher or subscription registry entries after a subscribe/cancel storm" $+        withTestStore $ \store -> do+            let cfg = defaultSubscriptionConfig (SubscriptionName "subscribe-cancel-storm") AllStreams (\_ -> pure Continue)+            -- 200 iterations gives async exceptions many chances to land in the+            -- pre-fork window without making the test expensive on normal runs.+            replicateM_ 200 $ do+                pending <- Async.async (subscribe store cfg)+                Async.cancel pending+                outcome <- Async.waitCatch pending+                case outcome of+                    Right handle -> cancel handle >> (() <$ wait handle)+                    Left (_ :: SomeException) -> pure ()++            subs <- readTVarIO (Pub.subscribers (store ^. #publisher))+            reg <- readTVarIO (store ^. #subscriptionRegistry)+            IntMap.null subs `shouldBe` True+            Map.null reg `shouldBe` True++isLoadCheckpointError :: SubscriptionName -> KirokuEvent -> Bool+isLoadCheckpointError expected = \case+    KirokuEventSubscriptionDbError actual LoadCheckpoint Pool.AcquisitionTimeoutUsageError _+        | actual == expected -> True+    _ -> False++isCrashedStop :: SubscriptionName -> KirokuEvent -> Bool+isCrashedStop expected = \case+    KirokuEventSubscriptionStopped actual _ (StopWorkerCrashed e) _+        | actual == expected+        , Just Pool.AcquisitionTimeoutUsageError <- fromException e ->+            True+    _ -> False
+ test/Test/StreamBridgeTermination.hs view
@@ -0,0 +1,108 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE OverloadedStrings #-}++module Test.StreamBridgeTermination (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.STM (atomically, putTMVar)+import Control.Exception (Exception, SomeException, finally, fromException, throwIO, try)+import Control.Lens ((^.))+import Data.Aeson qualified as Aeson+import Data.Data (Typeable)+import Data.Generics.Labels ()+import Hasql.Pool qualified as Pool+import Kiroku.Store+import Kiroku.Store.Subscription.Stream (AckItem (..), InvalidStreamBufferSize (..), subscriptionAckStream)+import Kiroku.Store.Subscription.Worker (withFetchBatchHookForTest)+import Streamly.Data.Stream qualified as Stream+import Test.Helpers (makeEvent, waitForPublisher, withTestStore)+import Test.Hspec++data TestBoom = TestBoom+    deriving stock (Show, Typeable)+    deriving anyclass (Exception)++timeoutMicros :: Int+timeoutMicros = 10_000_000++within :: String -> IO a -> IO a+within label action = do+    result <- Async.race (threadDelay timeoutMicros) action+    case result of+        Left () -> fail ("timed out waiting for " <> label)+        Right a -> pure a++appendOne :: KirokuStore -> StreamName -> EventType -> IO GlobalPosition+appendOne store streamName (EventType eventType') = do+    Right r <- runStoreIO store $ appendToStream streamName NoStream [makeEvent eventType' (Aeson.object [])]+    pure (r ^. #globalPosition)++spec :: Spec+spec = describe "stream bridge termination" $ do+    it "rejects a zero-sized bridge buffer" $+        withTestStore $ \store -> do+            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-zero-buffer-sub") AllStreams (\_ -> pure Continue)+            subscriptionAckStream store cfg 0+                `shouldThrow` ( \e ->+                                    case e of+                                        InvalidStreamBufferSize 0 -> True+                                        _ -> False+                              )++    it "rethrows the worker exception to the consumer when the worker dies" $+        withTestStore $ \store -> do+            pos <- appendOne store (StreamName "bridge-crash") (EventType "BridgeCrash")+            waitForPublisher store pos+            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-crash-sub") AllStreams (\_ -> pure Continue)+                injectBoom _ _ = throwIO TestBoom+            withFetchBatchHookForTest injectBoom $ do+                (stream, cancelStream) <- subscriptionAckStream store cfg 16+                pulled <- within "stream pull to throw TestBoom" (try (Stream.uncons stream))+                cancelStream+                case pulled of+                    Left e+                        | Just TestBoom <- fromException (e :: SomeException) -> pure ()+                    Left e -> expectationFailure ("expected TestBoom, got: " <> show e)+                    Right _ -> expectationFailure "expected stream pull to throw TestBoom"++    it "ends the stream after a clean worker stop" $+        withTestStore $ \store -> do+            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-clean-stop-sub") AllStreams (\_ -> pure Continue)+            (stream0, cancelStream) <- subscriptionAckStream store cfg 16+            finally+                ( do+                    pos <- appendOne store (StreamName "bridge-clean-stop") (EventType "BridgeCleanStop")+                    waitForPublisher store pos+                    mFirst <- within "first bridge item" (Stream.uncons stream0)+                    (stream1, item) <- case mFirst of+                        Nothing -> fail "expected one bridge item before clean stop"+                        Just (firstItem, rest) -> pure (rest, firstItem)+                    atomically (putTMVar (ackReply item) Stop)+                    mNext <- within "stream end after clean stop" (Stream.uncons stream1)+                    case mNext of+                        Nothing -> pure ()+                        Just _ -> expectationFailure "expected stream to end after clean stop"+                )+                cancelStream++    it "cancelAction returns promptly even when the bridge queue is full" $+        withTestStore $ \store -> do+            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-full-cancel-sub") AllStreams (\_ -> pure Continue)+            (stream0, cancelStream) <- subscriptionAckStream store cfg 1+            pos1 <- appendOne store (StreamName "bridge-full-cancel-1") (EventType "BridgeFullCancel1")+            pos2 <- appendOne store (StreamName "bridge-full-cancel-2") (EventType "BridgeFullCancel2")+            waitForPublisher store pos2++            within "non-blocking cancelAction with a full queue" cancelStream++            mFirst <- within "drained item after cancel" (Stream.uncons stream0)+            case mFirst of+                Nothing -> pure ()+                Just (item, stream1) -> do+                    atomically (putTMVar (ackReply item) Continue)+                    mNext <- within "stream end after cancel" (Stream.uncons stream1)+                    case mNext of+                        Nothing -> pure ()+                        Just _ -> expectationFailure "expected stream to end after cancel"
test/Test/StreamNameLookup.hs view
@@ -8,15 +8,16 @@ import Control.Lens ((^.)) import Data.Aeson qualified as Aeson import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef) import Data.Map.Strict qualified as Map import Data.Vector qualified as V import Kiroku.Store-import Test.Helpers (makeEvent, withTestStore)+import Test.Helpers (makeEvent, waitForPublisher, withTestStore, withTestStoreSettings) import Test.Hspec  spec :: Spec-spec = around withTestStore $-    describe "lookupStreamNames / lookupStreamName" $ do+spec = describe "lookupStreamNames / lookupStreamName" $ do+    around withTestStore $ do         it "resolves originalStreamId back to the source stream for $all reads" $ \store -> do             Right _ <-                 runStoreIO store $@@ -50,3 +51,28 @@             found `shouldBe` Just (StreamName "widgets-7")             Right missing <- runStoreIO store $ lookupStreamName (StreamId 888888)             missing `shouldBe` Nothing++    it "short-circuits empty input without a pool checkout" $ do+        ref <- newIORef (0 :: Int)+        let handler (ConnectionObservation _ InUseConnectionStatus) =+                modifyIORef' ref (+ 1)+            handler _ =+                pure ()+        withTestStoreSettings (\settings -> settings{observationHandler = Just handler}) $ \store -> do+            Right appendResult <-+                runStoreIO store $+                    appendToStream (StreamName "lookup-count-1") NoStream [makeEvent "LookupCounted" (Aeson.object [])]+            waitForPublisher store (appendResult ^. #globalPosition)+            Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "lookup-count-1")++            beforeEmpty <- readIORef ref+            Right emptyNames <- runStoreIO store $ lookupStreamNames []+            afterEmpty <- readIORef ref+            emptyNames `shouldBe` Map.empty+            afterEmpty - beforeEmpty `shouldBe` 0++            beforeReal <- readIORef ref+            Right names <- runStoreIO store $ lookupStreamNames [sid]+            afterReal <- readIORef ref+            names `shouldBe` Map.singleton sid (StreamName "lookup-count-1")+            afterReal - beforeReal `shouldBe` 1
test/Test/SubscriptionRegistry.hs view
@@ -21,16 +21,19 @@ module Test.SubscriptionRegistry (spec) where  import Control.Concurrent (threadDelay)+import Control.Concurrent.STM (readTVarIO) import Control.Exception (throwIO) import Control.Lens ((^.)) import Data.Aeson qualified as Aeson import Data.Foldable (for_) import Data.Generics.Labels () import Data.Int (Int32)+import Data.IntMap.Strict qualified as IntMap import Data.Map.Strict qualified as Map import Data.Maybe (isJust, isNothing) import Data.Text (Text) import Kiroku.Store+import Kiroku.Store.Subscription.EventPublisher qualified as Pub import Kiroku.Store.Subscription.Types (SubscriptionConfigM (..)) import Test.Helpers (makeEvent, waitForPublisher, withTestStore) import Test.Hspec@@ -46,6 +49,17 @@         { consumerGroup = Just (ConsumerGroup{member = m, size = n})         } +-- | A size-@n@ @$all@ group config for member @m@ whose handler never stops.+groupAllCont :: Text -> Int32 -> Int32 -> SubscriptionConfig+groupAllCont nm m n =+    (defaultSubscriptionConfig (SubscriptionName nm) AllStreams (\_ -> pure Continue))+        { consumerGroup = Just (ConsumerGroup{member = m, size = n})+        }++publisherSubscriberCount :: KirokuStore -> IO Int+publisherSubscriberCount store =+    IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))+ -- | Poll the snapshot until the keyed entry reaches the given 'statePhase' label. waitUntilPhase :: Int -> KirokuStore -> (SubscriptionName, Int32) -> Text -> IO Bool waitUntilPhase budget store key phase@@ -168,3 +182,37 @@             cs2 <- currentState h2             cs2 `shouldSatisfy` isJust             cancel h2++    describe "publisher queue registration" $ do+        it "registers publisher queues only for non-group AllStreams subscriptions" $+            withTestStore $ \store -> do+                initial <- publisherSubscriberCount store+                initial `shouldBe` 0++                hCategory <- subscribe store (defaultSubscriptionConfig (SubscriptionName "reg-cat-no-queue") (Category (CategoryName "regcat")) (\_ -> pure Continue))+                publisherSubscriberCount store `shouldReturn` 0++                hGroupCategory <- subscribe store (groupCont "reg-group-cat-no-queue" "regcat" 0 2)+                publisherSubscriberCount store `shouldReturn` 0++                hGroupAll <- subscribe store (groupAllCont "reg-group-all-no-queue" 0 2)+                publisherSubscriberCount store `shouldReturn` 0++                hAll <- subscribe store (plainCont "reg-all-has-queue")+                publisherSubscriberCount store `shouldReturn` 1++                cancel hAll+                _ <- wait hAll+                publisherSubscriberCount store `shouldReturn` 0++                cancel hCategory+                _ <- wait hCategory+                publisherSubscriberCount store `shouldReturn` 0++                cancel hGroupCategory+                _ <- wait hGroupCategory+                publisherSubscriberCount store `shouldReturn` 0++                cancel hGroupAll+                _ <- wait hGroupAll+                publisherSubscriberCount store `shouldReturn` 0
test/Test/Transaction.hs view
@@ -12,7 +12,9 @@ import Data.IORef (modifyIORef', newIORef, readIORef) import Data.Int (Int64) import Data.Text (Text)+import Data.Text qualified as T import Data.Time.Clock (getCurrentTime)+import Data.UUID.V7 qualified as V7 import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Pool (Pool)@@ -109,6 +111,23 @@             rows <- countSideTable (store ^. #pool)             rows `shouldBe` 0 +        it "surfaces duplicate caller-supplied event ids as DuplicateEvent" $ \store -> do+            eid <- EventId <$> V7.genUUID+            let evt = eventWithId eid+            prepared <- prepareEventsIO [evt]+            now <- getCurrentTime+            first <-+                runStoreIO store $+                    runTransaction $+                        appendToStreamTx (StreamName "txn-duplicate-source-1") NoStream prepared now+            first `shouldSatisfy` either (const False) isRight++            duplicate <-+                runStoreIO store $+                    runTransaction $+                        appendToStreamTx (StreamName "txn-duplicate-target-1") NoStream prepared now+            duplicate `shouldSatisfy` isDuplicateEvent+     describe "runTransactionAppending" $ do         it "commits 3 events and a side-table row in one ACID transaction" $ \store -> do             createSideTable (store ^. #pool)@@ -226,6 +245,64 @@             rows <- countSideTable (store ^. #pool)             rows `shouldBe` 0 +        it "rejects oversized stream names before opening any transaction" $ \store -> do+            createSideTable (store ^. #pool)+            let over = StreamName (T.replicate 513 "t")+            result <-+                runStoreIO store $+                    runTransactionAppending+                        over+                        AnyVersion+                        [makeEvent "Should" (Aeson.object [])]+                        ( \ar -> do+                            let StreamId sid = ar ^. #streamId+                            Tx.statement (sid, "should-never-run") insertSideRowStmt+                            pure ar+                        )+            result `shouldBe` Right (Left (StreamNameTooLong over 513))+            runStoreIO store (getStream over) `shouldReturn` Right Nothing+            rows <- countSideTable (store ^. #pool)+            rows `shouldBe` 0++        it "rejects empty event batches before opening a transaction" $ \store -> do+            createSideTable (store ^. #pool)+            result <-+                runStoreIO store $+                    runTransactionAppending+                        (StreamName "txn-wrapper-empty-1")+                        NoStream+                        []+                        ( \ar -> do+                            let StreamId sid = ar ^. #streamId+                            Tx.statement (sid, "should-never-run") insertSideRowStmt+                            pure ar+                        )+            result `shouldBe` Right (Left (EmptyAppendBatch (StreamName "txn-wrapper-empty-1")))+            runStoreIO store (getStream (StreamName "txn-wrapper-empty-1")) `shouldReturn` Right Nothing+            rows <- countSideTable (store ^. #pool)+            rows `shouldBe` 0++        it "surfaces duplicate caller-supplied event ids as DuplicateEvent" $ \store -> do+            eid <- EventId <$> V7.genUUID+            let evt = eventWithId eid+            first <-+                runStoreIO store $+                    runTransactionAppending+                        (StreamName "txn-wrapper-duplicate-source-1")+                        NoStream+                        [evt]+                        pure+            first `shouldSatisfy` either (const False) isRight++            duplicate <-+                runStoreIO store $+                    runTransactionAppending+                        (StreamName "txn-wrapper-duplicate-target-1")+                        NoStream+                        [evt]+                        pure+            duplicate `shouldSatisfy` isDuplicateEvent+ -- --------------------------------------------------------------------------- -- Local statements and pool-side helpers -- ---------------------------------------------------------------------------@@ -283,3 +360,24 @@     case r of         Left err -> error ("countSideTable failed: " <> show err)         Right n -> pure n++isRight :: Either a b -> Bool+isRight = \case+    Right _ -> True+    Left _ -> False++isDuplicateEvent :: Either StoreError a -> Bool+isDuplicateEvent = \case+    Left (DuplicateEvent _) -> True+    _ -> False++eventWithId :: EventId -> EventData+eventWithId eid =+    EventData+        { eventId = Just eid+        , eventType = EventType "Created"+        , payload = Aeson.object []+        , metadata = Nothing+        , causationId = Nothing+        , correlationId = Nothing+        }
+ test/Test/TruncateBefore.hs view
@@ -0,0 +1,125 @@+{- | Tests for the logical truncate-before marker (close-the-book+compaction, ExecPlan docs/plans/65).++The marker is a per-stream cursor that hides a prefix from the /ordered+per-stream/ reads ('readStreamForward' / 'readStreamBackward' and the paged+'readStreamForwardStream') while leaving the global @$all@ log, category+reads, and the physical row count untouched. The distinguishing property —+and the single most important assertion here — is that after a truncate the+@$all@ log and 'readCategory' still return the full history and+'countEvents' is unchanged: nothing is deleted, only hidden, and the+operation is fully reversible.+-}+module Test.TruncateBefore (spec) where++import Control.Lens ((^.))+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.Text qualified as T+import Data.Vector qualified as V+import Kiroku.Store+import Streamly.Data.Stream qualified as Stream+import Test.Helpers (countEvents, makeEvent, withTestStore)+import Test.Hspec++-- | Append @n@ events @E1 .. En@ to a fresh stream and return its name.+seedStream :: KirokuStore -> StreamName -> Int -> IO ()+seedStream store name n = do+    let events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. n]]+    Right _ <- runStoreIO store $ appendToStream name NoStream events+    pure ()++-- | The per-stream versions returned by a forward read, in order.+forwardVersions :: KirokuStore -> StreamName -> IO [StreamVersion]+forwardVersions store name = do+    Right v <- runStoreIO store $ readStreamForward name (StreamVersion 0) 100+    pure (map (^. #streamVersion) (V.toList v))++spec :: Spec+spec = describe "TruncateBefore" $+    around withTestStore $ do+        it "bounds per-stream reads to the kept suffix" $ \store -> do+            let name = StreamName "preference-abc"+            seedStream store name 6 -- versions 1..6; treat v6 as the snapshot+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)++            forwardVersions store name `shouldReturn` [StreamVersion 6]++            Right back <- runStoreIO store $ readStreamBackward name (StreamVersion 0) 100+            map (^. #streamVersion) (V.toList back) `shouldBe` [StreamVersion 6]++        it "leaves the $all global log and category reads intact" $ \store -> do+            let name = StreamName "preference-abc"+            seedStream store name 6+            before <- countEvents store+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)++            -- \$all global log still returns the full history.+            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+            length (V.toList allEvents) `shouldBe` 6++            -- Category read still returns the full history.+            Right catEvents <- runStoreIO store $ readCategory (CategoryName "preference") (GlobalPosition 0) 1000+            length (V.toList catEvents) `shouldBe` 6++            -- Nothing was physically deleted.+            after <- countEvents store+            after `shouldBe` before++        it "is reversible via clearStreamTruncateBefore" $ \store -> do+            let name = StreamName "preference-abc"+            seedStream store name 6+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)+            forwardVersions store name `shouldReturn` [StreamVersion 6]++            Right (Just _) <- runStoreIO store $ clearStreamTruncateBefore name+            forwardVersions store name+                `shouldReturn` map StreamVersion [1 .. 6]++        it "is idempotent" $ \store -> do+            let name = StreamName "preference-abc"+            seedStream store name 6+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)+            first <- forwardVersions store name+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)+            second <- forwardVersions store name+            first `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]+            second `shouldBe` first++        it "applies across paged reads" $ \store -> do+            let name = StreamName "preference-abc"+            seedStream store name 6+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)+            -- Page size 2 forces multiple pages across the kept suffix.+            Right streamed <-+                runStoreIO store $+                    Stream.toList $+                        fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)+            streamed `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]++        it "reflects the marker in getStream" $ \store -> do+            let name = StreamName "preference-abc"+            seedStream store name 6+            Right (Just info0) <- runStoreIO store $ getStream name+            (info0 ^. #truncateBefore) `shouldBe` StreamVersion 0+            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 3)+            Right (Just info1) <- runStoreIO store $ getStream name+            (info1 ^. #truncateBefore) `shouldBe` StreamVersion 3++        it "returns Nothing for missing or soft-deleted streams" $ \store -> do+            Right missing <- runStoreIO store $ setStreamTruncateBefore (StreamName "does-not-exist") (StreamVersion 1)+            missing `shouldBe` Nothing++            let name = StreamName "preference-abc"+            seedStream store name 3+            Right (Just _) <- runStoreIO store $ softDeleteStream name+            Right deleted <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 1)+            deleted `shouldBe` Nothing++        it "rejects $all with ReservedStreamName" $ \store -> do+            result <- runStoreIO store $ setStreamTruncateBefore (StreamName "$all") (StreamVersion 1)+            case result of+                Left (ReservedStreamName (StreamName "$all")) -> pure ()+                other ->+                    expectationFailure+                        ("Expected Left (ReservedStreamName \"$all\"), got: " <> show other)