kiroku-store 0.11.0.1 → 0.12.0.0
raw patch · 98 files changed
+20869/−19736 lines, 98 filesdep ~shibuya-corePVP ok
version bump matches the API change (PVP)
Dependency ranges changed: shibuya-core
API changes (from Hackage documentation)
+ Kiroku.Store: KirokuEventSubscriptionTargetMismatch :: !SubscriptionTargetMismatch -> !SubscriptionGroupContext -> KirokuEvent
+ Kiroku.Store.Notification: [namespaceRegistry] :: Notifier -> !NamespaceRegistry
+ Kiroku.Store.Notification: notifyNamespace :: NamespaceRegistry -> Text -> STM ()
+ Kiroku.Store.Notification: registerNamespace :: NamespaceRegistry -> Text -> STM (TVar Word64, STM ())
+ Kiroku.Store.Notification: type NamespaceRegistry = TVar Map Text (Int, TVar Word64)
+ Kiroku.Store.Observability: KirokuEventSubscriptionTargetMismatch :: !SubscriptionTargetMismatch -> !SubscriptionGroupContext -> KirokuEvent
+ Kiroku.Store.SQL: NamespaceWindow :: !Vector RecordedEvent -> !GlobalPosition -> !Int32 -> !Bool -> NamespaceWindow
+ Kiroku.Store.SQL: [exhausted] :: NamespaceWindow -> !Bool
+ Kiroku.Store.SQL: [namespaceEvents] :: NamespaceWindow -> !Vector RecordedEvent
+ Kiroku.Store.SQL: [scannedCount] :: NamespaceWindow -> !Int32
+ Kiroku.Store.SQL: [scannedThrough] :: NamespaceWindow -> !GlobalPosition
+ Kiroku.Store.SQL: data NamespaceWindow
+ Kiroku.Store.SQL: instance GHC.Internal.Show.Show Kiroku.Store.SQL.NamespaceWindow
+ Kiroku.Store.SQL: readNamespaceGroupWindowStmt :: Statement (Int64, Int64, Text, Int32, Int32, Int32) NamespaceWindow
+ Kiroku.Store.SQL: readNamespaceWindowStmt :: Statement (Int64, Int64, Text, Int32) NamespaceWindow
+ Kiroku.Store.SQL: saveNamespaceCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()
+ Kiroku.Store.Subscription.Fsm: DeliverNamespaceWindow :: !DecodedBatch -> !GlobalPosition -> !Bool -> Effect
+ Kiroku.Store.Subscription.Fsm: NamespaceWindowCompleted :: !GlobalPosition -> !Bool -> Input
+ Kiroku.Store.Subscription.Fsm: NamespaceWindowFetched :: !DecodedBatch -> !GlobalPosition -> !Bool -> Input
+ Kiroku.Store.Subscription.Types: CategoryNamespace :: !Text -> SubscriptionTarget
+ Kiroku.Store.Subscription.Worker: LiveFromNamespaceNotify :: !TVar Word64 -> LiveSource
+ Kiroku.Store.Subscription.Worker: withNamespaceSafetyPollForTest :: Int -> IO a -> IO a
+ Kiroku.Store.Subscription.Worker: withNamespaceWindowHookForTest :: NamespaceWindowHook -> IO a -> IO a
- Kiroku.Store.Notification: Notifier :: !TChan () -> !Async () -> !TVar Connection -> !TVar (Map Text Word64) -> Notifier
+ Kiroku.Store.Notification: Notifier :: !TChan () -> !Async () -> !TVar Connection -> !NamespaceRegistry -> !TVar (Map Text Word64) -> Notifier
Files
- CHANGELOG.md +91/−0
- bench/CheckpointTargetCost.hs +63/−64
- bench/Explain.hs +295/−304
- bench/Main.hs +982/−989
- bench/RegressionGate.hs +622/−316
- bench/ShibuyaOverhead.hs +227/−227
- bench/StreamHeadCost.hs +71/−71
- bench/StreamHeadPaired.hs +91/−91
- kiroku-store.cabal +4/−2
- src/Kiroku/Store.hs +12/−12
- src/Kiroku/Store/Append.hs +97/−98
- src/Kiroku/Store/Causation.hs +44/−47
- src/Kiroku/Store/Connection.hs +209/−219
- src/Kiroku/Store/Effect.hs +694/−707
- src/Kiroku/Store/Effect/Resource.hs +38/−39
- src/Kiroku/Store/Error.hs +407/−436
- src/Kiroku/Store/HistoryRetention.hs +74/−80
- src/Kiroku/Store/HistoryRetention/Internal.hs +54/−53
- src/Kiroku/Store/HistoryRetention/SQL.hs +210/−209
- src/Kiroku/Store/HistoryRetention/Types.hs +77/−76
- src/Kiroku/Store/Lifecycle.hs +135/−139
- src/Kiroku/Store/Link.hs +47/−47
- src/Kiroku/Store/Notification.hs +210/−167
- src/Kiroku/Store/Observability.hs +248/−272
- src/Kiroku/Store/Read.hs +203/−216
- src/Kiroku/Store/SQL.hs +1653/−1572
- src/Kiroku/Store/Settings.hs +104/−110
- src/Kiroku/Store/Subscription.hs +295/−293
- src/Kiroku/Store/Subscription/Checkpoint.hs +97/−102
- src/Kiroku/Store/Subscription/Checkpoint/SQL.hs +233/−221
- src/Kiroku/Store/Subscription/CheckpointInventory/SQL.hs +81/−80
- src/Kiroku/Store/Subscription/DeadLetter/SQL.hs +97/−94
- src/Kiroku/Store/Subscription/Effect.hs +90/−93
- src/Kiroku/Store/Subscription/EventPublisher.hs +248/−256
- src/Kiroku/Store/Subscription/Fsm.hs +285/−291
- src/Kiroku/Store/Subscription/Stream.hs +168/−174
- src/Kiroku/Store/Subscription/Types.hs +448/−474
- src/Kiroku/Store/Subscription/Worker.hs +1083/−944
- src/Kiroku/Store/Transaction.hs +289/−299
- src/Kiroku/Store/Types.hs +247/−282
- test/Main.hs +2190/−2186
- test/Test/BrowseQueryPlans.hs +53/−51
- test/Test/BrowseReads.hs +66/−66
- test/Test/BrowseReadsMock.hs +21/−21
- test/Test/CatchupDbErrorNoPrematureSwitch.hs +74/−74
- test/Test/Category.hs +33/−36
- test/Test/CategoryIdleNoSpin.hs +176/−177
- test/Test/Causation.hs +118/−121
- test/Test/Concurrency.hs +277/−278
- test/Test/ConsumerGroup.hs +246/−249
- test/Test/ConsumerGroupEffect.hs +150/−153
- test/Test/ConsumerGroupResize.hs +176/−176
- test/Test/ConsumerGroupSql.hs +183/−186
- test/Test/DeadLetterQueryPlans.hs +46/−40
- test/Test/EventTypeFilter.hs +230/−231
- test/Test/FailureInjection.hs +98/−99
- test/Test/HandlerStall.hs +227/−215
- test/Test/Helpers.hs +216/−228
- test/Test/HistoryRetention.hs +282/−282
- test/Test/HistoryRetentionMock.hs +52/−52
- test/Test/InterpreterHooks.hs +146/−147
- test/Test/NamespaceQueryPlans.hs +98/−0
- test/Test/NotifyGuard.hs +72/−72
- test/Test/PerformanceStructure.hs +613/−615
- test/Test/Properties.hs +158/−163
- test/Test/PublisherCallbackResilience.hs +491/−425
- test/Test/PublisherDropCounter.hs +32/−32
- test/Test/PublisherIdleAdvance.hs +127/−127
- test/Test/PublisherRestartNoRebroadcast.hs +180/−180
- test/Test/ReadStream.hs +123/−124
- test/Test/ScriptSessionErrors.hs +83/−83
- test/Test/StartupFailureSurfacing.hs +82/−82
- test/Test/StreamBridgeTermination.hs +73/−73
- test/Test/StreamHead.hs +77/−77
- test/Test/StreamHeadIsolation.hs +21/−21
- test/Test/StreamHeadMock.hs +13/−13
- test/Test/StreamHistoryGuard.hs +147/−147
- test/Test/StreamNameLookup.hs +58/−59
- test/Test/SubscriptionCheckpointInitialization.hs +228/−97
- test/Test/SubscriptionCheckpointInitializationMock.hs +27/−27
- test/Test/SubscriptionCheckpointInventory.hs +165/−165
- test/Test/SubscriptionCheckpointInventoryMock.hs +25/−25
- test/Test/SubscriptionCheckpointReset.hs +144/−144
- test/Test/SubscriptionCheckpointWorker.hs +191/−191
- test/Test/SubscriptionDeadLetters.hs +96/−93
- test/Test/SubscriptionDeadLettersMock.hs +11/−11
- test/Test/SubscriptionNamespace.hs +375/−0
- test/Test/SubscriptionPauseResume.hs +163/−164
- test/Test/SubscriptionReconnect.hs +161/−162
- test/Test/SubscriptionRegistry.hs +152/−153
- test/Test/SubscriptionRetryDeadLetter.hs +128/−129
- test/Test/SubscriptionState.hs +123/−124
- test/Test/SubscriptionTarget.hs +162/−133
- test/Test/Transaction.hs +340/−343
- test/Test/TruncateBefore.hs +88/−89
- test/Test/UniqueViolationMapping.hs +72/−72
- test/Test/VisibleGlobalHeadPosition.hs +72/−72
- test/Test/VisibleGlobalHeadPositionMock.hs +15/−15
CHANGELOG.md view
@@ -1,5 +1,53 @@ # Changelog +## 0.12.0.0 — 2026-10-26++### Breaking Changes++- `SubscriptionTarget` gains `CategoryNamespace Text`; update exhaustive matches.+ Apply migration 0016 from kiroku-store-migrations 0.7.2.0 with affected workers+ stopped before using namespace subscriptions. Older binaries must not manage+ namespace bindings.+- `KirokuEvent` gains `KirokuEventSubscriptionTargetMismatch`; update exhaustive+ observers.+- In the exposed low-level modules, `Notifier` gains `namespaceRegistry`;+ complete record constructors must supply it. FSM `Input` gains+ `NamespaceWindowFetched` and `NamespaceWindowCompleted`, and FSM `Effect` gains+ `DeliverNamespaceWindow`; update exhaustive custom drivers.++### New Features++- Add exact-category/colon-boundary namespace fan-in, including future categories+ and consumer groups. Workers scan bounded global windows, decode matching+ payloads only, checkpoint completed scans, and share selective namespace wakes.+- Expose namespace registration and notification helpers and bounded namespace+ window/checkpoint SQL statements for low-level integrations.++### Bug Fixes++- Invalid stall intervals emit one crashed `Stopped` before `wait` returns.+ Target refusals emit their typed detail event followed by one crashed stop.+- Preserve the initial event filter and selector decision across decode retries,+ including recovery to an excluded rewritten type.+- Low-level checkpoint initialization inherits uniform sibling size and binding+ under the topology lock, preserving existing rows and missing-position policies.+- Avoid payload probes for empty namespace windows.++### Other Changes++- Sparse namespace replay traverses global history independently per worker;+ namespace checkpoints can advance beyond the last delivered matching event.+- Retain the namespace cohort's explicit practical performance acceptance:+ 20 verified comparison trials, five pairs per case. The statistical+ zero-slowdown verdicts remain inconclusive; historical replay throughput was+ not measured. See `bench/results/ep54-kenshou/README.md` and `acceptance.json`.+- Tighten append workload measurement validation and retain the inspection+ cohort's accepted write-cost evidence.+- Correct omitted source-compatibility changes in the 0.10.0.0 and 0.11.0.0 notes.+- Require [shibuya-core](mori://shinzui/shibuya/packages/shibuya-core)+ `>=0.12 && <0.13` in the Shibuya overhead benchmark and initialize its deliveries+ with `DeliveryCurrent`.+ ## 0.11.0.1 — 2026-10-20 ### Bug Fixes@@ -41,7 +89,19 @@ - Byte-ordered stream browsing with exact category, literal prefix and exclusive name cursors; paged category enumeration and canonical event lookup by id. +### Erratum — 2026-10-25 +The published 0.11.0.0 notes omitted this source-compatibility break; the Hackage+copy cannot be updated:++- `SubscriptionDeadLetter` and `SubscriptionDeadLetterQuery` add exported record+ selectors `eventId`, `globalPosition`, `after` and `limit` through `Kiroku.Store`+ under `DuplicateRecordFields`. Bare uses of those names on `RecordedEvent` or+ `HistoryRetentionInventoryQuery` can now fail with ambiguous-occurrence errors.+ Disambiguate field access or narrow imports; the repository's `Worker.hs` needed+ `hiding (eventId, globalPosition)` on its conflicting import.++ ## 0.10.0.0 — 2026-10-10 ### Breaking Changes@@ -84,6 +144,37 @@ `DuplicateEvent` with a parseable caller ID. `ux_stream_events_stream_version` returns `UnexpectedServerError "23505"` with the original message for an invariant failure. Unknown append constraints retain the expected-version fallback.++### Erratum — 2026-10-25++The published 0.10.0.0 notes omitted or only implied the following breaking+changes; the Hackage copy cannot be updated:++- `SubscriptionConfigM` also gains `targetBindingPolicy` (default `AdoptUnbound`),+ and `batchSize` changes from `Int32` to validated `BatchSize`. Complete record+ constructors must supply the new field; construct batch sizes with `mkBatchSize`.+- `subscriptionStream` and `subscriptionAckStream` take validated+ `StreamBufferSize` in place of their previous numeric buffer-capacity parameter;+ use `mkStreamBufferSize`.+- `StoreError.EventDecodeFailed` and+ `DeadLetterReason.DeadLetterDecodeFailure`, mentioned above as features, also+ require updates to exhaustive matches. Observer matches must additionally+ handle the new `KirokuEvent.KirokuEventSubscriptionTargetBound` constructor.+- `InvalidConsumerGroup` and `InvalidStreamBufferSize` lose their `Exception`+ instances. Handle construction failures as `Left` values from the validated+ constructors; `subscribe`, `subscriptionAckStream` and+ `kirokuConsumerGroupProcessors` no longer throw those errors.+- Exposed `Kiroku.Store.Subscription.Fsm` constructors change payloads:+ `ConnectionLost` carries a `GlobalPosition`, and `BatchFetched` and+ `DeliverBatch` carry `DecodedBatch`.+- In exposed `Kiroku.Store.Subscription.EventPublisher`, `subscribePublisher`+ returns a `TBQueue DecodedBatch` in its result. Exposed+ `Kiroku.Store.Subscription.Worker.LiveFromPublisherQueue` likewise takes a+ `TBQueue DecodedBatch`.+- In exposed `Kiroku.Store.SQL`, `saveCheckpointMemberStmt` takes the six-tuple+ `(Text, Int32, Int64, Int32, Text, Maybe Text)`. `DeadLetterParams` gains+ `dlTargetKind`, `dlTargetCategory` and `dlGroupSize`; update direct callers and+ complete record constructors. ## 0.9.0.1 — 2026-09-25
bench/CheckpointTargetCost.hs view
@@ -1,10 +1,9 @@ {-# LANGUAGE MultilineStrings #-} {-# LANGUAGE OverloadedRecordDot #-} -{- | Small supplementary EP-2 comparison of the changed checkpoint-save path.-No acceptance threshold: local ratios retain their uncertainty. The control-statement and row layout are from 23a03a1b8b56773a140d0683c4c8d4d34b9d7e36.--}+-- | Small supplementary EP-2 comparison of the changed checkpoint-save path.+-- No acceptance threshold: local ratios retain their uncertainty. The control+-- statement and row layout are from 23a03a1b8b56773a140d0683c4c8d4d34b9d7e36. module Main where import Contravariant.Extras (contrazip4)@@ -27,60 +26,60 @@ main :: IO () main = do- original <- ephemeralConfig- let keys = ["fsync", "synchronous_commit", "full_page_writes", "shared_buffers", "wal_level"]- durable = original{Pg.postgresSettings = filter (\(key, _) -> key `notElem` keys) original.postgresSettings <> [("fsync", "on"), ("synchronous_commit", "on"), ("full_page_writes", "on"), ("shared_buffers", "128MB"), ("wal_level", "replica")]}- result <- Pg.withCachedConfig durable Pg.defaultCacheConfig $ \database -> do- let connection = Pg.connectionString database- migrateTestDatabase connection- withStore (defaultConnectionSettings connection) measure- either (fail . show) pure result+ original <- ephemeralConfig+ let keys = ["fsync", "synchronous_commit", "full_page_writes", "shared_buffers", "wal_level"]+ durable = original {Pg.postgresSettings = filter (\(key, _) -> key `notElem` keys) original.postgresSettings <> [("fsync", "on"), ("synchronous_commit", "on"), ("full_page_writes", "on"), ("shared_buffers", "128MB"), ("wal_level", "replica")]}+ result <- Pg.withCachedConfig durable Pg.defaultCacheConfig $ \database -> do+ let connection = Pg.connectionString database+ migrateTestDatabase connection+ withStore (defaultConnectionSettings connection) measure+ either (fail . show) pure result measure :: KirokuStore -> IO () measure store = do- settings <- use store $ Session.statement () (preparable "SELECT current_setting('server_version_num')::int4 >= 180000 AND current_setting('server_version_num')::int4 < 190000 AND current_setting('fsync') = 'on' AND current_setting('synchronous_commit') = 'on' AND current_setting('full_page_writes') = 'on'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))- if settings then pure () else fail "requires durable PostgreSQL 18"- use store $- Session.script- """- CREATE SCHEMA ep2_control;- CREATE TABLE ep2_control.subscriptions (- subscription_id BIGSERIAL PRIMARY KEY,- subscription_name TEXT NOT NULL,- stream_name TEXT NOT NULL DEFAULT '$all',- last_seen BIGINT NOT NULL DEFAULT 0,- consumer_group_member INT NOT NULL DEFAULT 0,- consumer_group_size INT NOT NULL DEFAULT 1,- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()- );- CREATE UNIQUE INDEX ON ep2_control.subscriptions (subscription_name, consumer_group_member);- """- controlCounter <- newIORef 0- candidateCounter <- newIORef 0- let save candidate = do- p <- atomicModifyIORef' (if candidate then candidateCounter else controlCounter) (\n -> (n + 1, n + 1))- let m = fromIntegral (p `mod` 4)- if candidate- then use store $ Session.statement ("target-cost", m, p, 4, "performance") SQL.saveCategoryCheckpointMemberStmt- else use store $ Session.statement ("target-cost", m, p, 4) controlSave- trial pair candidate = do- walBefore <- use store (Session.statement () walPosition)- start <- getMonotonicTimeNSec- replicateM_ 2000 (save candidate)- end <- getMonotonicTimeNSec- walAfter <- use store (Session.statement () walPosition)- putStrLn $ show pair <> "," <> (if candidate then "candidate" else "control") <> ",2000," <> show (fromIntegral (end - start) / 2000 :: Double) <> "," <> show (fromIntegral (walAfter - walBefore) / 2000 :: Double)- replicateM_ 200 (save False >> save True)- putStrLn "pair,arm,saves,ns_per_save,wal_bytes_per_save"- forM_ [1 .. 3 :: Int] $ \pair ->- if odd pair- then trial pair False >> trial pair True- else trial pair True >> trial pair False- -- Every completed save advances one durable member; both arms do equal work.- expected <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 FROM ep2_control.subscriptions" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))- actual <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 AND bool_and(target_kind = 'category' AND target_category = 'performance') FROM kiroku.subscriptions WHERE subscription_name = 'target-cost'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))- if expected && actual then putStrLn "durable equal-work checks: passed" else fail "durable equal-work checks failed"+ settings <- use store $ Session.statement () (preparable "SELECT current_setting('server_version_num')::int4 >= 180000 AND current_setting('server_version_num')::int4 < 190000 AND current_setting('fsync') = 'on' AND current_setting('synchronous_commit') = 'on' AND current_setting('full_page_writes') = 'on'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))+ if settings then pure () else fail "requires durable PostgreSQL 18"+ use store $+ Session.script+ """+ CREATE SCHEMA ep2_control;+ CREATE TABLE ep2_control.subscriptions (+ subscription_id BIGSERIAL PRIMARY KEY,+ subscription_name TEXT NOT NULL,+ stream_name TEXT NOT NULL DEFAULT '$all',+ last_seen BIGINT NOT NULL DEFAULT 0,+ consumer_group_member INT NOT NULL DEFAULT 0,+ consumer_group_size INT NOT NULL DEFAULT 1,+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()+ );+ CREATE UNIQUE INDEX ON ep2_control.subscriptions (subscription_name, consumer_group_member);+ """+ controlCounter <- newIORef 0+ candidateCounter <- newIORef 0+ let save candidate = do+ p <- atomicModifyIORef' (if candidate then candidateCounter else controlCounter) (\n -> (n + 1, n + 1))+ let m = fromIntegral (p `mod` 4)+ if candidate+ then use store $ Session.statement ("target-cost", m, p, 4, "performance") SQL.saveCategoryCheckpointMemberStmt+ else use store $ Session.statement ("target-cost", m, p, 4) controlSave+ trial pair candidate = do+ walBefore <- use store (Session.statement () walPosition)+ start <- getMonotonicTimeNSec+ replicateM_ 2000 (save candidate)+ end <- getMonotonicTimeNSec+ walAfter <- use store (Session.statement () walPosition)+ putStrLn $ show pair <> "," <> (if candidate then "candidate" else "control") <> ",2000," <> show (fromIntegral (end - start) / 2000 :: Double) <> "," <> show (fromIntegral (walAfter - walBefore) / 2000 :: Double)+ replicateM_ 200 (save False >> save True)+ putStrLn "pair,arm,saves,ns_per_save,wal_bytes_per_save"+ forM_ [1 .. 3 :: Int] $ \pair ->+ if odd pair+ then trial pair False >> trial pair True+ else trial pair True >> trial pair False+ -- Every completed save advances one durable member; both arms do equal work.+ expected <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 FROM ep2_control.subscriptions" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))+ actual <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 AND bool_and(target_kind = 'category' AND target_category = 'performance') FROM kiroku.subscriptions WHERE subscription_name = 'target-cost'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))+ if expected && actual then putStrLn "durable equal-work checks: passed" else fail "durable equal-work checks failed" use :: KirokuStore -> Session.Session a -> IO a use store session = Pool.use (store ^. #pool) session >>= either (fail . show) pure@@ -90,12 +89,12 @@ controlSave :: Statement (Text, Int32, Int64, Int32) () controlSave =- preparable- """- INSERT INTO ep2_control.subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size)- VALUES ($1, $2, $3, now(), $4)- ON CONFLICT (subscription_name, consumer_group_member)- DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size- """- (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))- D.noResult+ preparable+ """+ INSERT INTO ep2_control.subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size)+ VALUES ($1, $2, $3, now(), $4)+ ON CONFLICT (subscription_name, consumer_group_member)+ DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size+ """+ (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))+ D.noResult
bench/Explain.hs view
@@ -1,27 +1,26 @@ {-# LANGUAGE MultilineStrings #-} -{- | PostgreSQL-side append profiling harness.--Two modes:-- * default — boots a cached ephemeral PostgreSQL via 'Pg.withCachedConfig', runs- the production @AnyVersion@ CTE under- @EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)@ wrapped in- @BEGIN ... ROLLBACK@, prints the result to stdout, then runs the same- query under @FORMAT JSON@ and writes the result to- @kiroku-store/bench/explain-results/anyversion-singleton.json@.-- * @--auto-explain@ — boots an ephemeral PostgreSQL with the @auto_explain@- contrib loaded via 'Pg.autoExplainConfig', redirects the server's stderr- to @kiroku-store/bench/explain-results/auto-explain.log@, opens a- 'KirokuStore' (which migrates the schema), runs a small workload (one- @AnyVersion@ append, one @ExactVersion@ append against the same stream,- one @ReadStreamForward@, one @ReadAllForward@), and exits. PostgreSQL- flushes the @auto_explain@ output to the captured log on shutdown.--Belongs to ExecPlan-@docs/plans/26-postgresql-side-append-profiling-with-explain-analyze-and-auto-explain.md@.--}+-- | PostgreSQL-side append profiling harness.+--+-- Two modes:+--+-- * default — boots a cached ephemeral PostgreSQL via 'Pg.withCachedConfig', runs+-- the production @AnyVersion@ CTE under+-- @EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)@ wrapped in+-- @BEGIN ... ROLLBACK@, prints the result to stdout, then runs the same+-- query under @FORMAT JSON@ and writes the result to+-- @kiroku-store/bench/explain-results/anyversion-singleton.json@.+--+-- * @--auto-explain@ — boots an ephemeral PostgreSQL with the @auto_explain@+-- contrib loaded via 'Pg.autoExplainConfig', redirects the server's stderr+-- to @kiroku-store/bench/explain-results/auto-explain.log@, opens a+-- 'KirokuStore' (which migrates the schema), runs a small workload (one+-- @AnyVersion@ append, one @ExactVersion@ append against the same stream,+-- one @ReadStreamForward@, one @ReadAllForward@), and exits. PostgreSQL+-- flushes the @auto_explain@ output to the captured log on shutdown.+--+-- Belongs to ExecPlan+-- @docs/plans/26-postgresql-side-append-profiling-with-explain-analyze-and-auto-explain.md@. module Main where import Control.Concurrent (threadDelay)@@ -61,179 +60,173 @@ -- --------------------------------------------------------------------------- data AppendParams = AppendParams- { eventIds :: !(V.Vector UUID)- , eventTypes :: !(V.Vector Text)- , causationIds :: !(V.Vector (Maybe UUID))- , correlationIds :: !(V.Vector (Maybe UUID))- , payloads :: !(V.Vector Aeson.Value)- , metadatas :: !(V.Vector (Maybe Aeson.Value))- , createdAts :: !(V.Vector UTCTime)- , streamName :: !Text- }- deriving stock (Generic)+ { eventIds :: !(V.Vector UUID),+ eventTypes :: !(V.Vector Text),+ causationIds :: !(V.Vector (Maybe UUID)),+ correlationIds :: !(V.Vector (Maybe UUID)),+ payloads :: !(V.Vector Aeson.Value),+ metadatas :: !(V.Vector (Maybe Aeson.Value)),+ createdAts :: !(V.Vector UTCTime),+ streamName :: !Text+ }+ deriving stock (Generic) appendParamsEncoder :: E.Params AppendParams appendParamsEncoder =- ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))- <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))- <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))- <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))- <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))- <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))- <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))- <> ((^. #streamName) >$< E.param (E.nonNullable E.text))+ ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+ <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+ <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+ <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+ <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))+ <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))+ <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))+ <> ((^. #streamName) >$< E.param (E.nonNullable E.text)) appendAnyVersionSQL :: Text appendAnyVersionSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_upsert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($8, (SELECT count(*) FROM new_events))- ON CONFLICT (stream_name)- DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)- WHERE streams.deleted_at IS NULL- RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_upsert)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_upsert su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_upsert)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_upsert su- )- SELECT su.stream_id,- su.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_upsert su- CROSS JOIN all_update au- """+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, (SELECT count(*) FROM new_events))+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_upsert)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_upsert su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_upsert)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_upsert su+ CROSS JOIN all_update au+ """ -- --------------------------------------------------------------------------- -- EXPLAIN wrappers + sample params -- --------------------------------------------------------------------------- -{- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT TEXT@.-Each row of the result is one indented plan line. Uses 'unpreparable'-because the EXPLAIN runs once and the statement-cache cost would be wasted.--}+-- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT TEXT@.+-- Each row of the result is one indented plan line. Uses 'unpreparable'+-- because the EXPLAIN runs once and the statement-cache cost would be wasted. explainStatementText :: Statement AppendParams [Text] explainStatementText =- unpreparable- sql- appendParamsEncoder- (D.rowList (D.column (D.nonNullable D.text)))+ unpreparable+ sql+ appendParamsEncoder+ (D.rowList (D.column (D.nonNullable D.text))) where sql =- "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)\n"- <> appendAnyVersionSQL+ "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)\n"+ <> appendAnyVersionSQL -{- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT JSON@.-PostgreSQL returns one row whose single column is of type @json@; we use-'D.jsonBytes' with an identity parser to preserve the original-byte-for-byte output.--}+-- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT JSON@.+-- PostgreSQL returns one row whose single column is of type @json@; we use+-- 'D.jsonBytes' with an identity parser to preserve the original+-- byte-for-byte output. explainStatementJson :: Statement AppendParams BS.ByteString explainStatementJson =- unpreparable- sql- appendParamsEncoder- (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))+ unpreparable+ sql+ appendParamsEncoder+ (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))) where sql =- "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT JSON)\n"- <> appendAnyVersionSQL+ "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT JSON)\n"+ <> appendAnyVersionSQL -{- | One-event 'AppendParams' for an @AnyVersion@ append against a fresh-stream name.--}+-- | One-event 'AppendParams' for an @AnyVersion@ append against a fresh+-- stream name. mkSampleParams :: IO AppendParams mkSampleParams = do- eid <- V4.nextRandom- now <- getCurrentTime- let nowMicros = show now- pure- AppendParams- { eventIds = V.singleton eid- , eventTypes = V.singleton "ExplainEvent"- , causationIds = V.singleton Nothing- , correlationIds = V.singleton Nothing- , payloads = V.singleton (Aeson.object ["explain" Aeson..= Aeson.String "anyversion"])- , metadatas = V.singleton Nothing- , createdAts = V.singleton now- , streamName = T.pack ("explain-" <> nowMicros)- }+ eid <- V4.nextRandom+ now <- getCurrentTime+ let nowMicros = show now+ pure+ AppendParams+ { eventIds = V.singleton eid,+ eventTypes = V.singleton "ExplainEvent",+ causationIds = V.singleton Nothing,+ correlationIds = V.singleton Nothing,+ payloads = V.singleton (Aeson.object ["explain" Aeson..= Aeson.String "anyversion"]),+ metadatas = V.singleton Nothing,+ createdAts = V.singleton now,+ streamName = T.pack ("explain-" <> nowMicros)+ } -{- | Run a single TEXT-format EXPLAIN session inside @BEGIN ... ROLLBACK@-so the inserted rows do not persist.--}+-- | Run a single TEXT-format EXPLAIN session inside @BEGIN ... ROLLBACK@+-- so the inserted rows do not persist. explainSessionText :: AppendParams -> Session.Session [Text] explainSessionText params = do- Session.script "BEGIN"- rows <- Session.statement params explainStatementText- Session.script "ROLLBACK"- pure rows+ Session.script "BEGIN"+ rows <- Session.statement params explainStatementText+ Session.script "ROLLBACK"+ pure rows -{- | JSON-format counterpart of 'explainSessionText'. The result is the-@json@ column's raw bytes.--}+-- | JSON-format counterpart of 'explainSessionText'. The result is the+-- @json@ column's raw bytes. explainSessionJson :: AppendParams -> Session.Session BS.ByteString explainSessionJson params = do- Session.script "BEGIN"- bs <- Session.statement params explainStatementJson- Session.script "ROLLBACK"- pure bs+ Session.script "BEGIN"+ bs <- Session.statement params explainStatementJson+ Session.script "ROLLBACK"+ pure bs -- --------------------------------------------------------------------------- -- Output paths -- --------------------------------------------------------------------------- -{- | Walk up from the current working directory until a @cabal.project@ file-is found, then return that directory. Falls back to the CWD if no marker-is found. This makes the harness work whether invoked from the repo root-('cabal run', direct binary invocation) or from the package directory-('cabal bench', which sets CWD to @kiroku-store\/@).--}+-- | Walk up from the current working directory until a @cabal.project@ file+-- is found, then return that directory. Falls back to the CWD if no marker+-- is found. This makes the harness work whether invoked from the repo root+-- ('cabal run', direct binary invocation) or from the package directory+-- ('cabal bench', which sets CWD to @kiroku-store\/@). locateRepoRoot :: IO FilePath locateRepoRoot = do- cwd <- getCurrentDirectory- go cwd+ cwd <- getCurrentDirectory+ go cwd where go dir = do- let marker = dir </> "cabal.project"- present <- doesFileExist marker- if present- then pure dir- else- let parent = takeDirectory dir- in if parent == dir- then getCurrentDirectory- else go parent+ let marker = dir </> "cabal.project"+ present <- doesFileExist marker+ if present+ then pure dir+ else+ let parent = takeDirectory dir+ in if parent == dir+ then getCurrentDirectory+ else go parent explainResultsDir :: FilePath -> FilePath explainResultsDir repoRoot = repoRoot </> "kiroku-store" </> "bench" </> "explain-results"@@ -256,174 +249,172 @@ runExplainAnalyze :: IO () runExplainAnalyze = do- repoRoot <- locateRepoRoot- let dir = explainResultsDir repoRoot- jsonPath = jsonOutputPath repoRoot- textPath = textOutputPath repoRoot- createDirectoryIfMissing True dir- putStrLn ("=== Output directory: " <> dir <> " ===")- putStrLn "=== Booting cached ephemeral PostgreSQL for EXPLAIN ANALYZE ==="- config <- ephemeralConfig- result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do- let settings = defaultConnectionSettings (Pg.connectionString db)- withStore settings $ \store -> do- paramsText <- mkSampleParams- paramsJson <- mkSampleParams+ repoRoot <- locateRepoRoot+ let dir = explainResultsDir repoRoot+ jsonPath = jsonOutputPath repoRoot+ textPath = textOutputPath repoRoot+ createDirectoryIfMissing True dir+ putStrLn ("=== Output directory: " <> dir <> " ===")+ putStrLn "=== Booting cached ephemeral PostgreSQL for EXPLAIN ANALYZE ==="+ config <- ephemeralConfig+ result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do+ let settings = defaultConnectionSettings (Pg.connectionString db)+ withStore settings $ \store -> do+ paramsText <- mkSampleParams+ paramsJson <- mkSampleParams - putStrLn ""- putStrLn "=== EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT) of appendAnyVersionSQL ==="- putStrLn ""- textRows <- runSession store (explainSessionText paramsText)- let textOut = T.intercalate "\n" textRows- TIO.putStrLn textOut- TIO.writeFile textPath textOut+ putStrLn ""+ putStrLn "=== EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT) of appendAnyVersionSQL ==="+ putStrLn ""+ textRows <- runSession store (explainSessionText paramsText)+ let textOut = T.intercalate "\n" textRows+ TIO.putStrLn textOut+ TIO.writeFile textPath textOut - putStrLn ""- putStrLn ("=== Writing FORMAT JSON output to " <> jsonPath <> " ===")- jsonBytes <- runSession store (explainSessionJson paramsJson)- BS.writeFile jsonPath jsonBytes+ putStrLn ""+ putStrLn ("=== Writing FORMAT JSON output to " <> jsonPath <> " ===")+ jsonBytes <- runSession store (explainSessionJson paramsJson)+ BS.writeFile jsonPath jsonBytes - putStrLn ""- putStrLn ("=== TEXT output also archived at " <> textPath <> " ===")- case result of- Left err ->- error ("EphemeralPg failed to start: " <> show err)- Right () ->- pure ()+ putStrLn ""+ putStrLn ("=== TEXT output also archived at " <> textPath <> " ===")+ case result of+ Left err ->+ error ("EphemeralPg failed to start: " <> show err)+ Right () ->+ pure () -- --------------------------------------------------------------------------- -- Milestone 2: auto_explain capture of a small workload -- --------------------------------------------------------------------------- -{- | A representative slice of the production read+write paths so the-@auto_explain@ log captures one of each kind. Kept tiny so the resulting-log is human-readable.--}+-- | A representative slice of the production read+write paths so the+-- @auto_explain@ log captures one of each kind. Kept tiny so the resulting+-- log is human-readable. runSmallWorkload :: KirokuStore -> IO () runSmallWorkload store = do- let sn = StreamName "explain-auto"- -- AnyVersion append: hits the appendAnyVersionSQL CTE.- r1 <- runStoreIO store $ appendToStream sn AnyVersion [mkEvent "Created"]- forceOk "AnyVersion" r1- let res1 = case r1 of- Right ok -> ok- Left e -> error ("AnyVersion append failed: " <> show e)- -- ExactVersion append against the same stream: hits the- -- appendExpectedVersionSQL CTE with a non-trivial conflict check.- r2 <-- runStoreIO store $- appendToStream sn (ExactVersion (res1 ^. #streamVersion)) [mkEvent "Updated"]- forceOk "ExactVersion" r2- -- One read forward from the same stream (limit 100 events).- r3 <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100- forceOk "readStreamForward" r3- -- One read forward from the $all stream (limit 100 events).- r4 <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- forceOk "readAllForward" r4+ let sn = StreamName "explain-auto"+ -- AnyVersion append: hits the appendAnyVersionSQL CTE.+ r1 <- runStoreIO store $ appendToStream sn AnyVersion [mkEvent "Created"]+ forceOk "AnyVersion" r1+ let res1 = case r1 of+ Right ok -> ok+ Left e -> error ("AnyVersion append failed: " <> show e)+ -- ExactVersion append against the same stream: hits the+ -- appendExpectedVersionSQL CTE with a non-trivial conflict check.+ r2 <-+ runStoreIO store $+ appendToStream sn (ExactVersion (res1 ^. #streamVersion)) [mkEvent "Updated"]+ forceOk "ExactVersion" r2+ -- One read forward from the same stream (limit 100 events).+ r3 <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100+ forceOk "readStreamForward" r3+ -- One read forward from the $all stream (limit 100 events).+ r4 <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ forceOk "readAllForward" r4 where mkEvent :: Text -> EventData mkEvent ty =- EventData- Nothing- (EventType ty)- (Aeson.object ["t" Aeson..= ty])- Nothing- Nothing- Nothing+ EventData+ Nothing+ (EventType ty)+ (Aeson.object ["t" Aeson..= ty])+ Nothing+ Nothing+ Nothing forceOk :: (Show a) => Text -> Either StoreError a -> IO () forceOk label = \case- Left e -> error (T.unpack label <> " failed: " <> show e)- Right _ -> pure ()+ Left e -> error (T.unpack label <> " failed: " <> show e)+ Right _ -> pure () runAutoExplain :: IO () runAutoExplain = do- repoRoot <- locateRepoRoot- let dir = explainResultsDir repoRoot- logPath = autoExplainLogPath repoRoot- csvPath = autoExplainCsvPath repoRoot- createDirectoryIfMissing True dir- putStrLn ("=== Output directory: " <> dir <> " ===")- putStrLn "=== Booting ephemeral PostgreSQL with auto_explain enabled ==="- -- ephemeral-pg unconditionally discards the postgres process's stderr- -- (`setStderr nullStream` at EphemeralPg/Process/Postgres.hs:78), so- -- plan 26's documented "override Config.stderr to a file handle"- -- approach cannot work. Instead, configure PostgreSQL's own logging- -- collector to write a CSV log directly to disk; auto_explain's output- -- is captured as the message column of those CSV rows. See plan 26's- -- Surprises & Discoveries for the full debrief.- let logDir = dir </> "pglog"- logFilename = "auto-explain.log"- createDirectoryIfMissing True logDir- let collectorSettings =- [ ("logging_collector", "'on'")- , ("log_destination", "'csvlog'")- , ("log_directory", "'" <> T.pack logDir <> "'")- , ("log_filename", "'" <> T.pack logFilename <> "'")- , ("log_rotation_age", "'0'")- , ("log_rotation_size", "'0'")- , ("log_truncate_on_rotation", "'off'")- , ("log_line_prefix", "'%t [%p] '")- , -- Required to actually capture auto_explain output: empirical- -- testing showed that with default log_min_messages (warning)- -- the csvlog file stayed at 0 bytes, even though- -- auto_explain.log_min_duration was 0. Forcing the threshold- -- down to log_min_messages = 'log' lets the auto_explain- -- ereport(LOG, ...) calls through. See plan 26's Surprises- -- & Discoveries for the empirical evidence.- ("log_min_messages", "'log'")- ]- base <- ephemeralConfig- let cfg =- base- <> PgC.autoExplainConfig 0- <> (mempty :: PgC.Config){PgC.postgresSettings = collectorSettings}- result <- Pg.withConfig cfg $ \db -> do- let settings = defaultConnectionSettings (Pg.connectionString db)- withStore settings $ \store -> do- putStrLn "=== Running small workload (1 AnyVersion + 1 ExactVersion + 2 reads) ==="- runSmallWorkload store- putStrLn "=== Waiting so the logging collector flushes ==="- threadDelay 3_000_000 -- 3 seconds- case result of- Left err ->- error ("EphemeralPg failed to start: " <> show err)- Right () -> do- files <- listDirectory logDir- let collectorCsv = logDir </> "auto-explain.csv"- let collectorStderrLog = logDir </> "auto-explain.log"- hasCsv <- doesFileExist collectorCsv- hasLog <- doesFileExist collectorStderrLog- if hasCsv- then do- contents <- BS.readFile collectorCsv- BS.writeFile csvPath contents- putStrLn ("=== auto_explain csvlog: " <> csvPath <> " (" <> show (BS.length contents) <> " bytes) ===")- else putStrLn ("=== WARNING: missing " <> collectorCsv <> "; emitted: " <> show files <> " ===")- if hasLog- then do- contents <- BS.readFile collectorStderrLog- BS.writeFile logPath contents- putStrLn ("=== auto_explain stderr (small): " <> logPath <> " (" <> show (BS.length contents) <> " bytes) ===")- else putStrLn ("=== NOTE: collector did not produce a stderr-format log (csvlog only) ===")- -- Drop the intermediate pglog/ directory; we have copies in the- -- documented output paths above.- removePathForcibly logDir+ repoRoot <- locateRepoRoot+ let dir = explainResultsDir repoRoot+ logPath = autoExplainLogPath repoRoot+ csvPath = autoExplainCsvPath repoRoot+ createDirectoryIfMissing True dir+ putStrLn ("=== Output directory: " <> dir <> " ===")+ putStrLn "=== Booting ephemeral PostgreSQL with auto_explain enabled ==="+ -- ephemeral-pg unconditionally discards the postgres process's stderr+ -- (`setStderr nullStream` at EphemeralPg/Process/Postgres.hs:78), so+ -- plan 26's documented "override Config.stderr to a file handle"+ -- approach cannot work. Instead, configure PostgreSQL's own logging+ -- collector to write a CSV log directly to disk; auto_explain's output+ -- is captured as the message column of those CSV rows. See plan 26's+ -- Surprises & Discoveries for the full debrief.+ let logDir = dir </> "pglog"+ logFilename = "auto-explain.log"+ createDirectoryIfMissing True logDir+ let collectorSettings =+ [ ("logging_collector", "'on'"),+ ("log_destination", "'csvlog'"),+ ("log_directory", "'" <> T.pack logDir <> "'"),+ ("log_filename", "'" <> T.pack logFilename <> "'"),+ ("log_rotation_age", "'0'"),+ ("log_rotation_size", "'0'"),+ ("log_truncate_on_rotation", "'off'"),+ ("log_line_prefix", "'%t [%p] '"),+ -- Required to actually capture auto_explain output: empirical+ -- testing showed that with default log_min_messages (warning)+ -- the csvlog file stayed at 0 bytes, even though+ -- auto_explain.log_min_duration was 0. Forcing the threshold+ -- down to log_min_messages = 'log' lets the auto_explain+ -- ereport(LOG, ...) calls through. See plan 26's Surprises+ -- & Discoveries for the empirical evidence.+ ("log_min_messages", "'log'")+ ]+ base <- ephemeralConfig+ let cfg =+ base+ <> PgC.autoExplainConfig 0+ <> (mempty :: PgC.Config) {PgC.postgresSettings = collectorSettings}+ result <- Pg.withConfig cfg $ \db -> do+ let settings = defaultConnectionSettings (Pg.connectionString db)+ withStore settings $ \store -> do+ putStrLn "=== Running small workload (1 AnyVersion + 1 ExactVersion + 2 reads) ==="+ runSmallWorkload store+ putStrLn "=== Waiting so the logging collector flushes ==="+ threadDelay 3_000_000 -- 3 seconds+ case result of+ Left err ->+ error ("EphemeralPg failed to start: " <> show err)+ Right () -> do+ files <- listDirectory logDir+ let collectorCsv = logDir </> "auto-explain.csv"+ let collectorStderrLog = logDir </> "auto-explain.log"+ hasCsv <- doesFileExist collectorCsv+ hasLog <- doesFileExist collectorStderrLog+ if hasCsv+ then do+ contents <- BS.readFile collectorCsv+ BS.writeFile csvPath contents+ putStrLn ("=== auto_explain csvlog: " <> csvPath <> " (" <> show (BS.length contents) <> " bytes) ===")+ else putStrLn ("=== WARNING: missing " <> collectorCsv <> "; emitted: " <> show files <> " ===")+ if hasLog+ then do+ contents <- BS.readFile collectorStderrLog+ BS.writeFile logPath contents+ putStrLn ("=== auto_explain stderr (small): " <> logPath <> " (" <> show (BS.length contents) <> " bytes) ===")+ else putStrLn ("=== NOTE: collector did not produce a stderr-format log (csvlog only) ===")+ -- Drop the intermediate pglog/ directory; we have copies in the+ -- documented output paths above.+ removePathForcibly logDir -- --------------------------------------------------------------------------- -- Shared helpers -- --------------------------------------------------------------------------- -{- | Run a Hasql 'Session' directly against the store's pool, surfacing-pool errors via 'error'. Used by the EXPLAIN harness which bypasses the-Store effect layer.--}+-- | Run a Hasql 'Session' directly against the store's pool, surfacing+-- pool errors via 'error'. Used by the EXPLAIN harness which bypasses the+-- Store effect layer. runSession :: KirokuStore -> Session.Session a -> IO a runSession store session = do- r <- Pool.use (store ^. #pool) session- case r of- Left e -> error ("Hasql.Pool.use failed: " <> show e)- Right a -> pure a+ r <- Pool.use (store ^. #pool) session+ case r of+ Left e -> error ("Hasql.Pool.use failed: " <> show e)+ Right a -> pure a -- --------------------------------------------------------------------------- -- main@@ -431,8 +422,8 @@ main :: IO () main = do- hSetBuffering stdout LineBuffering- args <- getArgs- if "--auto-explain" `elem` args- then runAutoExplain- else runExplainAnalyze+ hSetBuffering stdout LineBuffering+ args <- getArgs+ if "--auto-explain" `elem` args+ then runAutoExplain+ else runExplainAnalyze
bench/Main.hs view
@@ -32,992 +32,985 @@ import Test.Tasty.Bench data RawAppendParams = RawAppendParams- { eventId :: !UUID- , eventType :: !Text- , causationId :: !(Maybe UUID)- , correlationId :: !(Maybe UUID)- , payload :: !Aeson.Value- , metadata :: !(Maybe Aeson.Value)- , createdAt :: !UTCTime- , streamName :: !Text- }- deriving stock (Generic)--data RawProductionAppendParams = RawProductionAppendParams- { productionEventIds :: !(V.Vector UUID)- , productionEventTypes :: !(V.Vector Text)- , productionCausationIds :: !(V.Vector (Maybe UUID))- , productionCorrelationIds :: !(V.Vector (Maybe UUID))- , productionPayloads :: !(V.Vector Aeson.Value)- , productionMetadatas :: !(V.Vector (Maybe Aeson.Value))- , productionCreatedAts :: !(V.Vector UTCTime)- , productionStreamName :: !Text- }- deriving stock (Generic)--type RawAppendResult = (Int64, Int64, Int64)--rawAppendParamsEncoder :: E.Params RawAppendParams-rawAppendParamsEncoder =- ((^. #eventId) >$< E.param (E.nonNullable E.uuid))- <> ((^. #eventType) >$< E.param (E.nonNullable E.text))- <> ((^. #causationId) >$< E.param (E.nullable E.uuid))- <> ((^. #correlationId) >$< E.param (E.nullable E.uuid))- <> ((^. #payload) >$< E.param (E.nonNullable E.jsonb))- <> ((^. #metadata) >$< E.param (E.nullable E.jsonb))- <> ((^. #createdAt) >$< E.param (E.nonNullable E.timestamptz))- <> ((^. #streamName) >$< E.param (E.nonNullable E.text))--rawProductionAppendParamsEncoder :: E.Params RawProductionAppendParams-rawProductionAppendParamsEncoder =- ((^. #productionEventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))- <> ((^. #productionEventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))- <> ((^. #productionCausationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))- <> ((^. #productionCorrelationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))- <> ((^. #productionPayloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))- <> ((^. #productionMetadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))- <> ((^. #productionCreatedAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))- <> ((^. #productionStreamName) >$< E.param (E.nonNullable E.text))--rawAppendResultDecoder :: D.Result (Maybe RawAppendResult)-rawAppendResultDecoder =- D.rowMaybe $- (,,)- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.int8)--rawScalarAppendAnyVersion :: Statement RawAppendParams (Maybe RawAppendResult)-rawScalarAppendAnyVersion =- preparable- rawScalarAppendAnyVersionSQL- rawAppendParamsEncoder- rawAppendResultDecoder--rawProductionAppendAnyVersion :: Statement RawProductionAppendParams (Maybe RawAppendResult)-rawProductionAppendAnyVersion =- preparable- rawProductionAppendAnyVersionSQL- rawProductionAppendParamsEncoder- rawAppendResultDecoder--rawScalarAppendAnyVersionSQL :: Text-rawScalarAppendAnyVersionSQL =- """- WITH- new_event AS (- SELECT $1::uuid AS event_id,- $2::text AS event_type,- $3::uuid AS causation_id,- $4::uuid AS correlation_id,- $5::jsonb AS data,- $6::jsonb AS metadata,- $7::timestamptz AS created_at- ),- stream_upsert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($8, 1)- ON CONFLICT (stream_name)- DO UPDATE SET stream_version = streams.stream_version + 1- WHERE streams.deleted_at IS NULL- RETURNING stream_id, category, stream_version - 1 AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_event- WHERE EXISTS (SELECT 1 FROM stream_upsert)- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1- FROM new_event ne- CROSS JOIN stream_upsert su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + 1- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_upsert)- RETURNING stream_version - 1 AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category- FROM new_event ne- CROSS JOIN all_update au- CROSS JOIN stream_upsert su- )- SELECT su.stream_id,- su.initial_version + 1,- au.initial_global_version + 1- FROM stream_upsert su- CROSS JOIN all_update au- """--rawProductionAppendAnyVersionSQL :: Text-rawProductionAppendAnyVersionSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_upsert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($8, (SELECT count(*) FROM new_events))- ON CONFLICT (stream_name)- DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)- WHERE streams.deleted_at IS NULL- RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_upsert)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_upsert su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_upsert)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_upsert su- )- SELECT su.stream_id,- su.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_upsert su- CROSS JOIN all_update au- """---- Two-round-trip variants: the proof-of-concept that the plan-23 restructure--- is structurally faster than the production arrays/unnest CTE. The resolve--- query reads (stream_id, stream_version, deleted_at) by stream_name; one of--- two append CTEs then runs keyed on the resolved stream_id (existing) or on--- the stream_name with no ON CONFLICT (new).--data RawResolution = RawResolution- { resStreamId :: !Int64- , resStreamVersion :: !Int64- , resDeletedAt :: !(Maybe UTCTime)- }- deriving stock (Generic)--rawResolveStreamStmt :: Statement Text (Maybe RawResolution)-rawResolveStreamStmt =- preparable- rawResolveStreamSQL- (E.param (E.nonNullable E.text))- ( D.rowMaybe $- RawResolution- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.int8)- <*> D.column (D.nullable D.timestamptz)- )--rawResolveStreamSQL :: Text-rawResolveStreamSQL =- """- SELECT stream_id, stream_version, deleted_at- FROM streams- WHERE stream_name = $1- """---- | Append params for the two-round-trip "existing" path: stream_id is known.-data RawAppendExistingParams = RawAppendExistingParams- { existingStreamId :: !Int64- , existingEventId :: !UUID- , existingEventType :: !Text- , existingCausationId :: !(Maybe UUID)- , existingCorrelationId :: !(Maybe UUID)- , existingPayload :: !Aeson.Value- , existingMetadata :: !(Maybe Aeson.Value)- , existingCreatedAt :: !UTCTime- }- deriving stock (Generic)--rawAppendExistingEncoder :: E.Params RawAppendExistingParams-rawAppendExistingEncoder =- ((^. #existingStreamId) >$< E.param (E.nonNullable E.int8))- <> ((^. #existingEventId) >$< E.param (E.nonNullable E.uuid))- <> ((^. #existingEventType) >$< E.param (E.nonNullable E.text))- <> ((^. #existingCausationId) >$< E.param (E.nullable E.uuid))- <> ((^. #existingCorrelationId) >$< E.param (E.nullable E.uuid))- <> ((^. #existingPayload) >$< E.param (E.nonNullable E.jsonb))- <> ((^. #existingMetadata) >$< E.param (E.nullable E.jsonb))- <> ((^. #existingCreatedAt) >$< E.param (E.nonNullable E.timestamptz))--rawAppendUpdateExisting :: Statement RawAppendExistingParams (Maybe RawAppendResult)-rawAppendUpdateExisting =- preparable- rawAppendUpdateExistingSQL- rawAppendExistingEncoder- rawAppendResultDecoder--{- | Update an existing stream keyed on integer stream_id. No version check,-no soft-delete check, no EXISTS gating, no count(*) — Haskell-side-validation has already done all of that.--}-rawAppendUpdateExistingSQL :: Text-rawAppendUpdateExistingSQL =- """- WITH- stream_update AS (- UPDATE streams- SET stream_version = stream_version + 1- WHERE stream_id = $1::bigint- RETURNING stream_id, category, stream_version - 1 AS initial_version- ),- inserted_event AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)- ),- source_link AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT $2::uuid, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1- FROM stream_update su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + 1- WHERE stream_id = 0- RETURNING stream_version - 1 AS initial_global_version- ),- all_link AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT $2::uuid, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category- FROM all_update au- CROSS JOIN stream_update su- )- SELECT su.stream_id,- su.initial_version + 1,- au.initial_global_version + 1- FROM stream_update su- CROSS JOIN all_update au- """---- | Append params for the two-round-trip "new stream" path: stream_name only.-data RawAppendNewParams = RawAppendNewParams- { newStreamName :: !Text- , newEventId :: !UUID- , newEventType :: !Text- , newCausationId :: !(Maybe UUID)- , newCorrelationId :: !(Maybe UUID)- , newPayload :: !Aeson.Value- , newMetadata :: !(Maybe Aeson.Value)- , newCreatedAt :: !UTCTime- }- deriving stock (Generic)--rawAppendNewEncoder :: E.Params RawAppendNewParams-rawAppendNewEncoder =- ((^. #newStreamName) >$< E.param (E.nonNullable E.text))- <> ((^. #newEventId) >$< E.param (E.nonNullable E.uuid))- <> ((^. #newEventType) >$< E.param (E.nonNullable E.text))- <> ((^. #newCausationId) >$< E.param (E.nullable E.uuid))- <> ((^. #newCorrelationId) >$< E.param (E.nullable E.uuid))- <> ((^. #newPayload) >$< E.param (E.nonNullable E.jsonb))- <> ((^. #newMetadata) >$< E.param (E.nullable E.jsonb))- <> ((^. #newCreatedAt) >$< E.param (E.nonNullable E.timestamptz))--rawAppendCreateNew :: Statement RawAppendNewParams (Maybe RawAppendResult)-rawAppendCreateNew =- preparable- rawAppendCreateNewSQL- rawAppendNewEncoder- rawAppendResultDecoder--{- | Create a new stream and append one event. No ON CONFLICT — Haskell-side-validation already proved the stream is absent; a race-loser hits the-ix_streams_stream_name unique constraint and surfaces as a usage error.--}-rawAppendCreateNewSQL :: Text-rawAppendCreateNewSQL =- """- WITH- stream_insert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($1::text, 1)- RETURNING stream_id, category, 0::bigint AS initial_version- ),- inserted_event AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)- ),- source_link AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT $2::uuid, si.stream_id, si.initial_version + 1, si.stream_id, si.initial_version + 1- FROM stream_insert si- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + 1- WHERE stream_id = 0- RETURNING stream_version - 1 AS initial_global_version- ),- all_link AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT $2::uuid, 0, au.initial_global_version + 1, si.stream_id, si.initial_version + 1, si.category- FROM all_update au- CROSS JOIN stream_insert si- )- SELECT si.stream_id,- si.initial_version + 1,- au.initial_global_version + 1- FROM stream_insert si- CROSS JOIN all_update au- """--mkRawAppendParams :: Text -> IO RawAppendParams-mkRawAppendParams name = do- eventUuid <- oneUuid- now <- getCurrentTime- pure- RawAppendParams- { eventId = eventUuid- , eventType = "RawBenchEvent"- , causationId = Nothing- , correlationId = Nothing- , payload = Aeson.object [("benchmark", Aeson.Bool True)]- , metadata = Nothing- , createdAt = now- , streamName = name- }--mkRawProductionAppendParams :: RawAppendParams -> RawProductionAppendParams-mkRawProductionAppendParams params =- RawProductionAppendParams- { productionEventIds = V.singleton (params ^. #eventId)- , productionEventTypes = V.singleton (params ^. #eventType)- , productionCausationIds = V.singleton (params ^. #causationId)- , productionCorrelationIds = V.singleton (params ^. #correlationId)- , productionPayloads = V.singleton (params ^. #payload)- , productionMetadatas = V.singleton (params ^. #metadata)- , productionCreatedAts = V.singleton (params ^. #createdAt)- , productionStreamName = params ^. #streamName- }--oneUuid :: IO UUID-oneUuid = do- generated <- V7.genUUIDs 1- case generated of- uuid : _ -> pure uuid- [] -> error "oneUuid: UUID generator returned no IDs"--runRawScalarAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()-runRawScalarAppendAnyVersionNewStream store counter = do- streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))- params <- mkRawAppendParams ("raw-scalar-new-" <> T.pack (show streamId))- result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion- forceRawAppend result--runRawProductionAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()-runRawProductionAppendAnyVersionNewStream store counter = do- streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))- params <- mkRawProductionAppendParams <$> mkRawAppendParams ("raw-production-new-" <> T.pack (show streamId))- result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion- forceRawAppend result--runRawScalarAppendAnyVersionHotStream :: KirokuStore -> IO ()-runRawScalarAppendAnyVersionHotStream store = do- params <- mkRawAppendParams "raw-scalar-hot"- result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion- forceRawAppend result--runRawProductionAppendAnyVersionHotStream :: KirokuStore -> IO ()-runRawProductionAppendAnyVersionHotStream store = do- params <- mkRawProductionAppendParams <$> mkRawAppendParams "raw-production-hot"- result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion- forceRawAppend result--{- | Two-round-trip hot-stream variant: resolve stream by name, then UPDATE-the existing row keyed on integer stream_id. Both statements run on the-same pooled connection in a Session, each in its own implicit transaction-(no explicit BEGIN/COMMIT). This mirrors eventstore's small-batch path-(`EventStore.Streams.Stream.append_to_stream/5` at < 1000 events), which-runs two Postgrex.query calls without a transaction wrapper and relies on-the stream_events (stream_id, stream_version) unique constraint to detect-races. Wrapping in an explicit BEGIN/COMMIT adds two extra round-trips-and pessimises this path; that variant is measured separately below.--}-runRawTwoRoundtripAppendExistingHotStream :: KirokuStore -> IO ()-runRawTwoRoundtripAppendExistingHotStream store = do- base <- mkRawAppendParams "raw-two-roundtrip-hot"- result <- Pool.use (store ^. #pool) $ do- mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt- case mRes of- Nothing -> pure (Left "two-roundtrip hot bench: stream not pre-created")- Just res -> do- let params = mkRawAppendExistingParams (res ^. #resStreamId) base- fmap Right (Session.statement params rawAppendUpdateExisting)- forceTwoRoundtripResult result--{- | Two-round-trip new-stream variant. Same shape as the existing-stream-variant: two implicit-transaction round-trips on a pooled connection,-no explicit transaction wrapper.--}-runRawTwoRoundtripAppendNewStream :: KirokuStore -> IORef Int -> IO ()-runRawTwoRoundtripAppendNewStream store counter = do- streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))- base <- mkRawAppendParams ("raw-two-roundtrip-new-" <> T.pack (show streamId))- result <- Pool.use (store ^. #pool) $ do- mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt- case mRes of- Just _ -> pure (Left "two-roundtrip new bench: stream already exists")- Nothing -> do- let params = mkRawAppendNewParams base- fmap Right (Session.statement params rawAppendCreateNew)- forceTwoRoundtripResult result--{- | Two-round-trip hot-stream variant wrapped in an explicit-read-committed transaction. Provided as a separate benchmark to quantify-the BEGIN/COMMIT round-trip overhead so we can decide whether the live-append path can safely drop the transaction wrapper.--}-runRawTwoRoundtripAppendExistingHotStreamTx :: KirokuStore -> IO ()-runRawTwoRoundtripAppendExistingHotStreamTx store = do- base <- mkRawAppendParams "raw-two-roundtrip-hot"- let txn = do- mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt- case mRes of- Nothing -> pure (Left "two-roundtrip hot tx bench: stream not pre-created")- Just res -> do- let params = mkRawAppendExistingParams (res ^. #resStreamId) base- fmap Right (Tx.statement params rawAppendUpdateExisting)- result <-- Pool.use (store ^. #pool) $- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn- forceTwoRoundtripResult result---- | Two-round-trip new-stream variant wrapped in an explicit transaction.-runRawTwoRoundtripAppendNewStreamTx :: KirokuStore -> IORef Int -> IO ()-runRawTwoRoundtripAppendNewStreamTx store counter = do- streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))- base <- mkRawAppendParams ("raw-two-roundtrip-new-tx-" <> T.pack (show streamId))- let txn = do- mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt- case mRes of- Just _ -> pure (Left "two-roundtrip new tx bench: stream already exists")- Nothing -> do- let params = mkRawAppendNewParams base- fmap Right (Tx.statement params rawAppendCreateNew)- result <-- Pool.use (store ^. #pool) $- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn- forceTwoRoundtripResult result--mkRawAppendExistingParams :: Int64 -> RawAppendParams -> RawAppendExistingParams-mkRawAppendExistingParams sid base =- RawAppendExistingParams- { existingStreamId = sid- , existingEventId = base ^. #eventId- , existingEventType = base ^. #eventType- , existingCausationId = base ^. #causationId- , existingCorrelationId = base ^. #correlationId- , existingPayload = base ^. #payload- , existingMetadata = base ^. #metadata- , existingCreatedAt = base ^. #createdAt- }--mkRawAppendNewParams :: RawAppendParams -> RawAppendNewParams-mkRawAppendNewParams base =- RawAppendNewParams- { newStreamName = base ^. #streamName- , newEventId = base ^. #eventId- , newEventType = base ^. #eventType- , newCausationId = base ^. #causationId- , newCorrelationId = base ^. #correlationId- , newPayload = base ^. #payload- , newMetadata = base ^. #metadata- , newCreatedAt = base ^. #createdAt- }--forceTwoRoundtripResult ::- Either Pool.UsageError (Either String (Maybe RawAppendResult)) -> IO ()-forceTwoRoundtripResult (Left e) =- error ("Two-round-trip bench pool error: " <> show e)-forceTwoRoundtripResult (Right (Left msg)) =- error ("Two-round-trip bench precondition failed: " <> msg)-forceTwoRoundtripResult (Right (Right inner)) =- forceRawAppend (Right inner)--{- | Run @writers@ concurrent appenders, each performing @ops@ appends to-its own unique stream. Used by the structured concurrent-writer-benchmarks (EP-6 F19); replaces the wall-clock @mapConcurrently_@-measurement that previously lived inline in @main@.--The @runCounter@ ref is bumped once per call so stream names are unique-across the many iterations tasty-bench runs.--}-runConcurrentWriters :: KirokuStore -> IORef Int -> Int -> Int -> IO ()-runConcurrentWriters store runCounter writers ops = do- runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))- mapConcurrently_- (\tid -> mapM_ (appendOne tid runId) [1 .. ops])- [1 .. writers]- where- appendOne :: Int -> Int -> Int -> IO ()- appendOne tid runId i = do- let sn = StreamName ("conc-" <> T.pack (show runId) <> "-" <> T.pack (show tid) <> "-" <> T.pack (show i))- r <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "ConcEvent"]- forceAppend r---- | Exercise the hot stream named in the focused reliability audit.-runHotInvoicePayment :: KirokuStore -> Int -> IO ()-runHotInvoicePayment store ops =- mapM_- ( \i -> do- r <- runStoreIO store $ appendToStream (StreamName "invoice-payment") AnyVersion [makeEvent ("InvoicePayment" <> T.pack (show i))]- forceAppend r- )- [1 .. ops]---- | Exercise appendMultiStream against existing streams.-runAppendMultiStream :: KirokuStore -> IO ()-runAppendMultiStream store = do- r <-- runStoreIO store $- appendMultiStream- [ (StreamName "bench-multi-a", AnyVersion, [makeEvent "MultiA"])- , (StreamName "bench-multi-b", AnyVersion, [makeEvent "MultiB"])- , (StreamName "bench-multi-c", AnyVersion, [makeEvent "MultiC"])- ]- forceAppendList r---- | Exercise subscription catch-up over a compact category-local backlog.-runSubscriptionCatchup :: KirokuStore -> IORef Int -> IO ()-runSubscriptionCatchup store runCounter = do- runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))- let cat = "benchsub" <> T.pack (show runId)- sn = StreamName (cat <> "-stream")- subName = SubscriptionName ("bench-sub-" <> T.pack (show runId))- events = map (\i -> makeEvent ("SubCatchup" <> T.pack (show i))) [1 .. 100 :: Int]- r <- runStoreIO store $ appendToStream sn NoStream events- forceAppend r- seenRef <- newIORef (0 :: Int)- let handler _ = do- n <- atomicModifyIORef' seenRef (\m -> let m' = m + 1 in (m', m'))- pure $ if n >= 100 then Stop else Continue- cfg =- SubscriptionConfig- { name = subName- , target = Category (CategoryName cat)- , handler = handler- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- result <- wait handle- case result of- Right () -> pure ()- Left e -> error ("Subscription catch-up benchmark failed: " <> show e)---- | Force evaluation of an append result or fail the benchmark.-forceAppend :: Either StoreError AppendResult -> IO ()-forceAppend (Right r) = (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()-forceAppend (Left e) = error ("Benchmark append failed: " <> show e)---- | Force evaluation of multi-stream append results or fail the benchmark.-forceAppendList :: Either StoreError [AppendResult] -> IO ()-forceAppendList (Right rs) = mapM_ (\r -> (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()) rs-forceAppendList (Left e) = error ("Benchmark appendMultiStream 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))) =- streamId `seq` streamVersion `seq` globalPosition `seq` pure ()-forceRawAppend (Right Nothing) = error "Raw append benchmark produced no result"-forceRawAppend (Left e) = error ("Raw append benchmark failed: " <> show e)---- | Force evaluation of a read result or fail the benchmark.-forceRead :: Either StoreError (V.Vector RecordedEvent) -> IO ()-forceRead (Right v) = V.length v `seq` pure ()-forceRead (Left e) = error ("Benchmark read failed: " <> show e)--seedCheckpointInventoryStmt :: Statement Int32 ()-seedCheckpointInventoryStmt =- preparable- """- INSERT INTO subscriptions (- subscription_name,- consumer_group_member,- last_seen,- updated_at- )- SELECT 'inventory-' || lpad(n::text, 8, '0'),- 0,- 0,- now()- FROM generate_series(1, $1::int4) AS n- """- (E.param (E.nonNullable E.int4))- D.noResult--seedCheckpointInventory :: KirokuStore -> Int32 -> IO ()-seedCheckpointInventory store rowCount = do- result <- Pool.use (store ^. #pool) $ Session.statement rowCount seedCheckpointInventoryStmt- case result of- Left err -> error ("Checkpoint inventory benchmark setup failed: " <> show err)- Right () -> pure ()--forceCheckpointInventory :: Either StoreError SubscriptionCheckpointInventory -> IO ()-forceCheckpointInventory (Left err) = error ("Checkpoint inventory benchmark failed: " <> show err)-forceCheckpointInventory (Right (SubscriptionCheckpointInventory (GlobalPosition storePos) rows)) = do- let !checksum = V.foldl' forceCheckpoint (fromIntegral storePos) rows- checksum `seq` pure ()- where- forceCheckpoint !acc (SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) updatedAt) =- updatedAt `seq`- acc- + T.length name- + fromIntegral member- + fromIntegral position--runCheckpointInventoryBenchmark :: KirokuStore -> IO ()-runCheckpointInventoryBenchmark store =- runStoreIO store subscriptionCheckpointInventory >>= forceCheckpointInventory--withInventoryBenchmarkStores :: (KirokuStore -> KirokuStore -> IO a) -> IO a-withInventoryBenchmarkStores action =- withSharedMigratedPostgres $- withMigratedTestDatabase $ \inventory100Connection ->- withMigratedTestDatabase $ \inventory10000Connection ->- withStore (defaultConnectionSettings inventory100Connection) $ \inventory100Store ->- withStore (defaultConnectionSettings inventory10000Connection) $ \inventory10000Store -> do- seedCheckpointInventory inventory100Store 100- seedCheckpointInventory inventory10000Store 10_000- action inventory100Store inventory10000Store--{- | A store seeded with 'categoryScalingFixtureSql': categories @performance@-(200 streams), @idle@ (20,000 single-event streams), and @noise@, head at-'categoryScalingHead'. Must run inside 'withSharedMigratedPostgres'.--}-withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a-withCategoryScalingStore action =- withMigratedTestDatabase $ \connection ->- withStore (defaultConnectionSettings connection) $ \store -> do- seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)- case seeded of- Left err -> error ("Category-scaling benchmark setup failed: " <> show err)- Right () -> action store---- | Run the plain category read 10 times at one cursor and limit.-runCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> IO ()-runCategoryPolls store category cursor limit =- mapM_- ( \_ ->- Pool.use (store ^. #pool) (Session.statement (cursor, category, limit) SQL.readCategoryForwardStmt)- >>= forceCategoryPoll- )- [1 .. 10 :: Int]---- | Run the consumer-group category read 10 times for one member.-runGroupCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> Int32 -> Int32 -> IO ()-runGroupCategoryPolls store category cursor member size limit =- mapM_- ( \_ ->- Pool.use (store ^. #pool) (Session.statement (cursor, category, member, size, limit) SQL.readCategoryForwardConsumerGroupStmt)- >>= forceCategoryPoll- )- [1 .. 10 :: Int]--forceCategoryPoll :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO ()-forceCategoryPoll (Right events) = V.length events `seq` pure ()-forceCategoryPoll (Left err) = error ("Category-scaling benchmark read failed: " <> show err)--main :: IO ()-main = do- -- Start ephemeral PostgreSQL once for all benchmarks- config <- ephemeralConfig- result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do- migrateTestDatabase (Pg.connectionString db)- let settings = defaultConnectionSettings (Pg.connectionString db)- withStore settings $ \store -> do- -- Counter for unique stream names across benchmarks- counter <- newIORef (0 :: Int)- let nextStream :: Text -> IO StreamName- nextStream prefix = do- n <- atomicModifyIORef' counter (\n -> (n + 1, n))- pure (StreamName (prefix <> "-" <> T.pack (show n)))-- -- Pre-populate streams for category benchmarks (B10)- -- 100 categories × 10 streams × 100 events = 100K events- putStrLn "\n--- Pre-populating category data (100 cats × 10 streams × 100 events) ---"- catT0 <- getCurrentTime- mapM_- ( \cat -> do- mapM_- ( \s -> do- let sn = StreamName ("cat" <> T.pack (show cat) <> "-" <> T.pack (show s))- let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]- r' <- runStoreIO store $ appendToStream sn NoStream evts- forceAppend r'- )- [1 .. 10 :: Int]- )- [1 .. 100 :: Int]- catT1 <- getCurrentTime- let catElapsed = realToFrac (diffUTCTime catT1 catT0) :: Double- putStrLn $ " Setup time: " <> show catElapsed <> "s (100K events)"-- -- Pre-populate streams for read benchmarks- -- B4: Single stream with 1000 events- let readStreamName = StreamName "bench-read-stream"- let readEvents = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 1000 :: Int]- r <- runStoreIO store $ appendToStream readStreamName NoStream readEvents- forceAppend r-- -- B5: 10 streams with 100 events each for $all reads (1000 total)- mapM_- ( \s -> do- let sn = StreamName ("bench-all-" <> T.pack (show s))- let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]- r' <- runStoreIO store $ appendToStream sn NoStream evts- forceAppend r'- )- [1 .. 10 :: Int]-- -- Pre-create fixed streams for appendMultiStream benchmark iterations.- mapM_- ( \sn -> do- r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]- forceAppend r'- )- [StreamName "bench-multi-a", StreamName "bench-multi-b", StreamName "bench-multi-c"]-- -- Pre-create the hot stream targeted by the two-round-trip raw- -- shape variant. rawAppendUpdateExisting requires the row to- -- exist (no ON CONFLICT), so without this warmup the first- -- iteration would fail. The scalar-singleton and production- -- variants already create their hot stream on first iteration- -- via ON CONFLICT — pre-creating them here would change the- -- existing benchmark contract, so we only seed the two-round-trip- -- target.- do- r' <- runStoreIO store $ appendToStream (StreamName "raw-two-roundtrip-hot") AnyVersion [makeEvent "Init"]- forceAppend r'-- -- B9: Pool saturation benchmark (64 concurrent writers, 100 appends each)- putStrLn "\n--- B9: Pool saturation (64 writers × 100 appends, pool size 10) ---"- satCounter <- newIORef (0 :: Int)- let nextSatStream :: Int -> Int -> StreamName- nextSatStream tid i = StreamName ("sat-" <> T.pack (show tid) <> "-" <> T.pack (show i))- t0 <- getCurrentTime- mapConcurrently_- ( \tid -> do- mapM_- ( \i -> do- let sn = nextSatStream tid i- r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "SatEvent"]- forceAppend r'- atomicModifyIORef' satCounter (\n -> (n + 1, ()))- )- [1 .. 100 :: Int]- )- [1 .. 64 :: Int]- t1 <- getCurrentTime- totalOps <- readIORef satCounter- let elapsed = realToFrac (diffUTCTime t1 t0) :: Double- let throughput = fromIntegral totalOps / elapsed- let avgLatency = elapsed / fromIntegral totalOps * 1000 -- ms- putStrLn $ " Total appends: " <> show totalOps- putStrLn $ " Elapsed: " <> show elapsed <> "s"- putStrLn $ " Throughput: " <> show (round throughput :: Int) <> " ops/s"- putStrLn $ " Avg latency: " <> show avgLatency <> " ms"- putStrLn "---"-- -- Counter shared by the concurrent-writer benchmarks so each- -- iteration uses a fresh stream-name run-id.- concCounter <- newIORef (0 :: Int)- rawCounter <- newIORef (0 :: Int)- subCounter <- newIORef (0 :: Int)-- withInventoryBenchmarkStores $ \inventory100Store inventory10000Store ->- withCategoryScalingStore $ \scalingStore ->- defaultMain- [ bgroup- "append"- [ bgroup- "single-event"- [ bench "NoStream (new stream)" $ whnfIO $ do- sn <- nextStream "bench-single"- r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "BenchEvent"]- forceAppend r'- , bench "AnyVersion (new stream)" $ whnfIO $ do- sn <- nextStream "bench-any"- r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "BenchEvent"]- forceAppend r'- ]- , bgroup- "batch-10"- [ bench "NoStream" $ whnfIO $ do- sn <- nextStream "bench-b10"- let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 10 :: Int]- r' <- runStoreIO store $ appendToStream sn NoStream events- forceAppend r'- ]- , bgroup- "batch-100"- [ bench "NoStream" $ whnfIO $ do- sn <- nextStream "bench-b100"- let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]- r' <- runStoreIO store $ appendToStream sn NoStream events- forceAppend r'- ]- , bgroup- "sequential"- [ bench "10 appends to same stream" $ whnfIO $ do- sn <- nextStream "bench-seq"- r0 <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]- forceAppend r0- let res0 = case r0 of- Right ok -> ok- Left e -> error ("Sequential append failed: " <> show e)- let go _ 0 = pure ()- go v n = do- r' <- runStoreIO store $ appendToStream sn (ExactVersion v) [makeEvent "Seq"]- case r' of- Right res -> go (res ^. #streamVersion) (n - 1 :: Int)- Left e -> error ("Sequential append failed: " <> show e)- go (res0 ^. #streamVersion) 9- ]- ]- , bgroup- "raw-append-shape"- [ bgroup- "AnyVersion"- [ bench "scalar singleton (new stream)" $- whnfIO $- runRawScalarAppendAnyVersionNewStream store rawCounter- , bench "production arrays/unnest (new stream)" $- whnfIO $- runRawProductionAppendAnyVersionNewStream store rawCounter- , bench "two-roundtrip (new stream)" $- whnfIO $- runRawTwoRoundtripAppendNewStream store rawCounter- , bench "two-roundtrip + BEGIN/COMMIT (new stream)" $- whnfIO $- runRawTwoRoundtripAppendNewStreamTx store rawCounter- , bench "scalar singleton (hot stream)" $- whnfIO $- runRawScalarAppendAnyVersionHotStream store- , bench "production arrays/unnest (hot stream)" $- whnfIO $- runRawProductionAppendAnyVersionHotStream store- , bench "two-roundtrip (hot stream)" $- whnfIO $- runRawTwoRoundtripAppendExistingHotStream store- , bench "two-roundtrip + BEGIN/COMMIT (hot stream)" $- whnfIO $- runRawTwoRoundtripAppendExistingHotStreamTx store- ]- ]- , bgroup- "read"- [ bench "stream forward (100-event page)" $ whnfIO $ do- r' <- runStoreIO store $ readStreamForward readStreamName (StreamVersion 0) 100- forceRead r'- , bench "$all forward (100-event page)" $ whnfIO $ do- r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- forceRead r'- ]- , bgroup- "category"- [ bench "category forward (100-event page)" $ whnfIO $ do- -- Read from cat1 category (has 10 streams × 100 events = 1000 events)- r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 0) 100- forceRead r'- , bench "exhausted-category" $ whnfIO $ do- -- cat1 events are inserted early in setup; a high cursor proves- -- category reads do not scan the rest of $all looking for matches.- r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 90_000) 100- forceRead r'- , bench "$all forward (100-event page baseline)" $ whnfIO $ do- r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- forceRead r'- ]- , -- F19 — Concurrent-writer stress as structured benchmarks.- -- The legacy ad-hoc B9 measurement (still present above- -- for historical comparability) prints throughput and- -- latency once; these bgroup entries surface the same- -- workload through tasty-bench so it participates in the- -- baseline-regression workflow (Justfile bench-regression).- bgroup- "concurrent"- [ bench "8 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 8 10- , bench "32 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 32 10- ]- , bgroup- "reliability-audit"- [ bench "hot invoice-payment 10 AnyVersion appends" $ whnfIO $ runHotInvoicePayment store 10- , bench "appendMultiStream 3 existing streams" $ whnfIO $ runAppendMultiStream store- , bench "subscription category catch-up 100 events" $ whnfIO $ runSubscriptionCatchup store subCounter- ]- , -- BUG-2: a caught-up category poll must not cost work- -- proportional to the number of streams in the category.- -- Each cell runs the statement 10 times.- bgroup- "category-scaling"- [ bench "plain caught-up poll (200 streams)" $- whnfIO $- runCategoryPolls scalingStore "performance" categoryScalingHead 100- , bench "plain caught-up poll (20000 streams)" $- whnfIO $- runCategoryPolls scalingStore "idle" categoryScalingHead 100- , bench "group caught-up poll (20000 streams)" $- whnfIO $- runGroupCategoryPolls scalingStore "idle" categoryScalingHead 1 2 100- , bench "plain page from 0 (20000 streams)" $- whnfIO $- runCategoryPolls scalingStore "idle" 0 100- , bench "exhausted category (200 streams)" $- -- performance ends at 20,000; 60,000 other rows follow.- whnfIO $- runCategoryPolls scalingStore "performance" 20_000 100- ]- , bgroup- "subscription-checkpoint-inventory"- [ bench "100 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory100Store- , bench "10000 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory10000Store- ]- ]- case result of- Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)- Right () -> pure ()--makeEvent :: Text -> EventData-makeEvent typ =- EventData- { eventId = Nothing- , eventType = EventType typ- , payload = Aeson.object [("benchmark", Aeson.Bool True)]- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }+ { eventId :: !UUID,+ eventType :: !Text,+ causationId :: !(Maybe UUID),+ correlationId :: !(Maybe UUID),+ payload :: !Aeson.Value,+ metadata :: !(Maybe Aeson.Value),+ createdAt :: !UTCTime,+ streamName :: !Text+ }+ deriving stock (Generic)++data RawProductionAppendParams = RawProductionAppendParams+ { productionEventIds :: !(V.Vector UUID),+ productionEventTypes :: !(V.Vector Text),+ productionCausationIds :: !(V.Vector (Maybe UUID)),+ productionCorrelationIds :: !(V.Vector (Maybe UUID)),+ productionPayloads :: !(V.Vector Aeson.Value),+ productionMetadatas :: !(V.Vector (Maybe Aeson.Value)),+ productionCreatedAts :: !(V.Vector UTCTime),+ productionStreamName :: !Text+ }+ deriving stock (Generic)++type RawAppendResult = (Int64, Int64, Int64)++rawAppendParamsEncoder :: E.Params RawAppendParams+rawAppendParamsEncoder =+ ((^. #eventId) >$< E.param (E.nonNullable E.uuid))+ <> ((^. #eventType) >$< E.param (E.nonNullable E.text))+ <> ((^. #causationId) >$< E.param (E.nullable E.uuid))+ <> ((^. #correlationId) >$< E.param (E.nullable E.uuid))+ <> ((^. #payload) >$< E.param (E.nonNullable E.jsonb))+ <> ((^. #metadata) >$< E.param (E.nullable E.jsonb))+ <> ((^. #createdAt) >$< E.param (E.nonNullable E.timestamptz))+ <> ((^. #streamName) >$< E.param (E.nonNullable E.text))++rawProductionAppendParamsEncoder :: E.Params RawProductionAppendParams+rawProductionAppendParamsEncoder =+ ((^. #productionEventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+ <> ((^. #productionEventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+ <> ((^. #productionCausationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+ <> ((^. #productionCorrelationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+ <> ((^. #productionPayloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))+ <> ((^. #productionMetadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))+ <> ((^. #productionCreatedAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))+ <> ((^. #productionStreamName) >$< E.param (E.nonNullable E.text))++rawAppendResultDecoder :: D.Result (Maybe RawAppendResult)+rawAppendResultDecoder =+ D.rowMaybe $+ (,,)+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.int8)++rawScalarAppendAnyVersion :: Statement RawAppendParams (Maybe RawAppendResult)+rawScalarAppendAnyVersion =+ preparable+ rawScalarAppendAnyVersionSQL+ rawAppendParamsEncoder+ rawAppendResultDecoder++rawProductionAppendAnyVersion :: Statement RawProductionAppendParams (Maybe RawAppendResult)+rawProductionAppendAnyVersion =+ preparable+ rawProductionAppendAnyVersionSQL+ rawProductionAppendParamsEncoder+ rawAppendResultDecoder++rawScalarAppendAnyVersionSQL :: Text+rawScalarAppendAnyVersionSQL =+ """+ WITH+ new_event AS (+ SELECT $1::uuid AS event_id,+ $2::text AS event_type,+ $3::uuid AS causation_id,+ $4::uuid AS correlation_id,+ $5::jsonb AS data,+ $6::jsonb AS metadata,+ $7::timestamptz AS created_at+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, 1)+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + 1+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, category, stream_version - 1 AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_event+ WHERE EXISTS (SELECT 1 FROM stream_upsert)+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1+ FROM new_event ne+ CROSS JOIN stream_upsert su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + 1+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_upsert)+ RETURNING stream_version - 1 AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category+ FROM new_event ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ SELECT su.stream_id,+ su.initial_version + 1,+ au.initial_global_version + 1+ FROM stream_upsert su+ CROSS JOIN all_update au+ """++rawProductionAppendAnyVersionSQL :: Text+rawProductionAppendAnyVersionSQL =+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, (SELECT count(*) FROM new_events))+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_upsert)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_upsert su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_upsert)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_upsert su+ CROSS JOIN all_update au+ """++-- Two-round-trip variants: the proof-of-concept that the plan-23 restructure+-- is structurally faster than the production arrays/unnest CTE. The resolve+-- query reads (stream_id, stream_version, deleted_at) by stream_name; one of+-- two append CTEs then runs keyed on the resolved stream_id (existing) or on+-- the stream_name with no ON CONFLICT (new).++data RawResolution = RawResolution+ { resStreamId :: !Int64,+ resStreamVersion :: !Int64,+ resDeletedAt :: !(Maybe UTCTime)+ }+ deriving stock (Generic)++rawResolveStreamStmt :: Statement Text (Maybe RawResolution)+rawResolveStreamStmt =+ preparable+ rawResolveStreamSQL+ (E.param (E.nonNullable E.text))+ ( D.rowMaybe $+ RawResolution+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.int8)+ <*> D.column (D.nullable D.timestamptz)+ )++rawResolveStreamSQL :: Text+rawResolveStreamSQL =+ """+ SELECT stream_id, stream_version, deleted_at+ FROM streams+ WHERE stream_name = $1+ """++-- | Append params for the two-round-trip "existing" path: stream_id is known.+data RawAppendExistingParams = RawAppendExistingParams+ { existingStreamId :: !Int64,+ existingEventId :: !UUID,+ existingEventType :: !Text,+ existingCausationId :: !(Maybe UUID),+ existingCorrelationId :: !(Maybe UUID),+ existingPayload :: !Aeson.Value,+ existingMetadata :: !(Maybe Aeson.Value),+ existingCreatedAt :: !UTCTime+ }+ deriving stock (Generic)++rawAppendExistingEncoder :: E.Params RawAppendExistingParams+rawAppendExistingEncoder =+ ((^. #existingStreamId) >$< E.param (E.nonNullable E.int8))+ <> ((^. #existingEventId) >$< E.param (E.nonNullable E.uuid))+ <> ((^. #existingEventType) >$< E.param (E.nonNullable E.text))+ <> ((^. #existingCausationId) >$< E.param (E.nullable E.uuid))+ <> ((^. #existingCorrelationId) >$< E.param (E.nullable E.uuid))+ <> ((^. #existingPayload) >$< E.param (E.nonNullable E.jsonb))+ <> ((^. #existingMetadata) >$< E.param (E.nullable E.jsonb))+ <> ((^. #existingCreatedAt) >$< E.param (E.nonNullable E.timestamptz))++rawAppendUpdateExisting :: Statement RawAppendExistingParams (Maybe RawAppendResult)+rawAppendUpdateExisting =+ preparable+ rawAppendUpdateExistingSQL+ rawAppendExistingEncoder+ rawAppendResultDecoder++-- | Update an existing stream keyed on integer stream_id. No version check,+-- no soft-delete check, no EXISTS gating, no count(*) — Haskell-side+-- validation has already done all of that.+rawAppendUpdateExistingSQL :: Text+rawAppendUpdateExistingSQL =+ """+ WITH+ stream_update AS (+ UPDATE streams+ SET stream_version = stream_version + 1+ WHERE stream_id = $1::bigint+ RETURNING stream_id, category, stream_version - 1 AS initial_version+ ),+ inserted_event AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)+ ),+ source_link AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT $2::uuid, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1+ FROM stream_update su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + 1+ WHERE stream_id = 0+ RETURNING stream_version - 1 AS initial_global_version+ ),+ all_link AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT $2::uuid, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category+ FROM all_update au+ CROSS JOIN stream_update su+ )+ SELECT su.stream_id,+ su.initial_version + 1,+ au.initial_global_version + 1+ FROM stream_update su+ CROSS JOIN all_update au+ """++-- | Append params for the two-round-trip "new stream" path: stream_name only.+data RawAppendNewParams = RawAppendNewParams+ { newStreamName :: !Text,+ newEventId :: !UUID,+ newEventType :: !Text,+ newCausationId :: !(Maybe UUID),+ newCorrelationId :: !(Maybe UUID),+ newPayload :: !Aeson.Value,+ newMetadata :: !(Maybe Aeson.Value),+ newCreatedAt :: !UTCTime+ }+ deriving stock (Generic)++rawAppendNewEncoder :: E.Params RawAppendNewParams+rawAppendNewEncoder =+ ((^. #newStreamName) >$< E.param (E.nonNullable E.text))+ <> ((^. #newEventId) >$< E.param (E.nonNullable E.uuid))+ <> ((^. #newEventType) >$< E.param (E.nonNullable E.text))+ <> ((^. #newCausationId) >$< E.param (E.nullable E.uuid))+ <> ((^. #newCorrelationId) >$< E.param (E.nullable E.uuid))+ <> ((^. #newPayload) >$< E.param (E.nonNullable E.jsonb))+ <> ((^. #newMetadata) >$< E.param (E.nullable E.jsonb))+ <> ((^. #newCreatedAt) >$< E.param (E.nonNullable E.timestamptz))++rawAppendCreateNew :: Statement RawAppendNewParams (Maybe RawAppendResult)+rawAppendCreateNew =+ preparable+ rawAppendCreateNewSQL+ rawAppendNewEncoder+ rawAppendResultDecoder++-- | Create a new stream and append one event. No ON CONFLICT — Haskell-side+-- validation already proved the stream is absent; a race-loser hits the+-- ix_streams_stream_name unique constraint and surfaces as a usage error.+rawAppendCreateNewSQL :: Text+rawAppendCreateNewSQL =+ """+ WITH+ stream_insert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($1::text, 1)+ RETURNING stream_id, category, 0::bigint AS initial_version+ ),+ inserted_event AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)+ ),+ source_link AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT $2::uuid, si.stream_id, si.initial_version + 1, si.stream_id, si.initial_version + 1+ FROM stream_insert si+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + 1+ WHERE stream_id = 0+ RETURNING stream_version - 1 AS initial_global_version+ ),+ all_link AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT $2::uuid, 0, au.initial_global_version + 1, si.stream_id, si.initial_version + 1, si.category+ FROM all_update au+ CROSS JOIN stream_insert si+ )+ SELECT si.stream_id,+ si.initial_version + 1,+ au.initial_global_version + 1+ FROM stream_insert si+ CROSS JOIN all_update au+ """++mkRawAppendParams :: Text -> IO RawAppendParams+mkRawAppendParams name = do+ eventUuid <- oneUuid+ now <- getCurrentTime+ pure+ RawAppendParams+ { eventId = eventUuid,+ eventType = "RawBenchEvent",+ causationId = Nothing,+ correlationId = Nothing,+ payload = Aeson.object [("benchmark", Aeson.Bool True)],+ metadata = Nothing,+ createdAt = now,+ streamName = name+ }++mkRawProductionAppendParams :: RawAppendParams -> RawProductionAppendParams+mkRawProductionAppendParams params =+ RawProductionAppendParams+ { productionEventIds = V.singleton (params ^. #eventId),+ productionEventTypes = V.singleton (params ^. #eventType),+ productionCausationIds = V.singleton (params ^. #causationId),+ productionCorrelationIds = V.singleton (params ^. #correlationId),+ productionPayloads = V.singleton (params ^. #payload),+ productionMetadatas = V.singleton (params ^. #metadata),+ productionCreatedAts = V.singleton (params ^. #createdAt),+ productionStreamName = params ^. #streamName+ }++oneUuid :: IO UUID+oneUuid = do+ generated <- V7.genUUIDs 1+ case generated of+ uuid : _ -> pure uuid+ [] -> error "oneUuid: UUID generator returned no IDs"++runRawScalarAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()+runRawScalarAppendAnyVersionNewStream store counter = do+ streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+ params <- mkRawAppendParams ("raw-scalar-new-" <> T.pack (show streamId))+ result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion+ forceRawAppend result++runRawProductionAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()+runRawProductionAppendAnyVersionNewStream store counter = do+ streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+ params <- mkRawProductionAppendParams <$> mkRawAppendParams ("raw-production-new-" <> T.pack (show streamId))+ result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion+ forceRawAppend result++runRawScalarAppendAnyVersionHotStream :: KirokuStore -> IO ()+runRawScalarAppendAnyVersionHotStream store = do+ params <- mkRawAppendParams "raw-scalar-hot"+ result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion+ forceRawAppend result++runRawProductionAppendAnyVersionHotStream :: KirokuStore -> IO ()+runRawProductionAppendAnyVersionHotStream store = do+ params <- mkRawProductionAppendParams <$> mkRawAppendParams "raw-production-hot"+ result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion+ forceRawAppend result++-- | Two-round-trip hot-stream variant: resolve stream by name, then UPDATE+-- the existing row keyed on integer stream_id. Both statements run on the+-- same pooled connection in a Session, each in its own implicit transaction+-- (no explicit BEGIN/COMMIT). This mirrors eventstore's small-batch path+-- (`EventStore.Streams.Stream.append_to_stream/5` at < 1000 events), which+-- runs two Postgrex.query calls without a transaction wrapper and relies on+-- the stream_events (stream_id, stream_version) unique constraint to detect+-- races. Wrapping in an explicit BEGIN/COMMIT adds two extra round-trips+-- and pessimises this path; that variant is measured separately below.+runRawTwoRoundtripAppendExistingHotStream :: KirokuStore -> IO ()+runRawTwoRoundtripAppendExistingHotStream store = do+ base <- mkRawAppendParams "raw-two-roundtrip-hot"+ result <- Pool.use (store ^. #pool) $ do+ mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt+ case mRes of+ Nothing -> pure (Left "two-roundtrip hot bench: stream not pre-created")+ Just res -> do+ let params = mkRawAppendExistingParams (res ^. #resStreamId) base+ fmap Right (Session.statement params rawAppendUpdateExisting)+ forceTwoRoundtripResult result++-- | Two-round-trip new-stream variant. Same shape as the existing-stream+-- variant: two implicit-transaction round-trips on a pooled connection,+-- no explicit transaction wrapper.+runRawTwoRoundtripAppendNewStream :: KirokuStore -> IORef Int -> IO ()+runRawTwoRoundtripAppendNewStream store counter = do+ streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+ base <- mkRawAppendParams ("raw-two-roundtrip-new-" <> T.pack (show streamId))+ result <- Pool.use (store ^. #pool) $ do+ mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt+ case mRes of+ Just _ -> pure (Left "two-roundtrip new bench: stream already exists")+ Nothing -> do+ let params = mkRawAppendNewParams base+ fmap Right (Session.statement params rawAppendCreateNew)+ forceTwoRoundtripResult result++-- | Two-round-trip hot-stream variant wrapped in an explicit+-- read-committed transaction. Provided as a separate benchmark to quantify+-- the BEGIN/COMMIT round-trip overhead so we can decide whether the live+-- append path can safely drop the transaction wrapper.+runRawTwoRoundtripAppendExistingHotStreamTx :: KirokuStore -> IO ()+runRawTwoRoundtripAppendExistingHotStreamTx store = do+ base <- mkRawAppendParams "raw-two-roundtrip-hot"+ let txn = do+ mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt+ case mRes of+ Nothing -> pure (Left "two-roundtrip hot tx bench: stream not pre-created")+ Just res -> do+ let params = mkRawAppendExistingParams (res ^. #resStreamId) base+ fmap Right (Tx.statement params rawAppendUpdateExisting)+ result <-+ Pool.use (store ^. #pool) $+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+ forceTwoRoundtripResult result++-- | Two-round-trip new-stream variant wrapped in an explicit transaction.+runRawTwoRoundtripAppendNewStreamTx :: KirokuStore -> IORef Int -> IO ()+runRawTwoRoundtripAppendNewStreamTx store counter = do+ streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+ base <- mkRawAppendParams ("raw-two-roundtrip-new-tx-" <> T.pack (show streamId))+ let txn = do+ mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt+ case mRes of+ Just _ -> pure (Left "two-roundtrip new tx bench: stream already exists")+ Nothing -> do+ let params = mkRawAppendNewParams base+ fmap Right (Tx.statement params rawAppendCreateNew)+ result <-+ Pool.use (store ^. #pool) $+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+ forceTwoRoundtripResult result++mkRawAppendExistingParams :: Int64 -> RawAppendParams -> RawAppendExistingParams+mkRawAppendExistingParams sid base =+ RawAppendExistingParams+ { existingStreamId = sid,+ existingEventId = base ^. #eventId,+ existingEventType = base ^. #eventType,+ existingCausationId = base ^. #causationId,+ existingCorrelationId = base ^. #correlationId,+ existingPayload = base ^. #payload,+ existingMetadata = base ^. #metadata,+ existingCreatedAt = base ^. #createdAt+ }++mkRawAppendNewParams :: RawAppendParams -> RawAppendNewParams+mkRawAppendNewParams base =+ RawAppendNewParams+ { newStreamName = base ^. #streamName,+ newEventId = base ^. #eventId,+ newEventType = base ^. #eventType,+ newCausationId = base ^. #causationId,+ newCorrelationId = base ^. #correlationId,+ newPayload = base ^. #payload,+ newMetadata = base ^. #metadata,+ newCreatedAt = base ^. #createdAt+ }++forceTwoRoundtripResult ::+ Either Pool.UsageError (Either String (Maybe RawAppendResult)) -> IO ()+forceTwoRoundtripResult (Left e) =+ error ("Two-round-trip bench pool error: " <> show e)+forceTwoRoundtripResult (Right (Left msg)) =+ error ("Two-round-trip bench precondition failed: " <> msg)+forceTwoRoundtripResult (Right (Right inner)) =+ forceRawAppend (Right inner)++-- | Run @writers@ concurrent appenders, each performing @ops@ appends to+-- its own unique stream. Used by the structured concurrent-writer+-- benchmarks (EP-6 F19); replaces the wall-clock @mapConcurrently_@+-- measurement that previously lived inline in @main@.+--+-- The @runCounter@ ref is bumped once per call so stream names are unique+-- across the many iterations tasty-bench runs.+runConcurrentWriters :: KirokuStore -> IORef Int -> Int -> Int -> IO ()+runConcurrentWriters store runCounter writers ops = do+ runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))+ mapConcurrently_+ (\tid -> mapM_ (appendOne tid runId) [1 .. ops])+ [1 .. writers]+ where+ appendOne :: Int -> Int -> Int -> IO ()+ appendOne tid runId i = do+ let sn = StreamName ("conc-" <> T.pack (show runId) <> "-" <> T.pack (show tid) <> "-" <> T.pack (show i))+ r <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "ConcEvent"]+ forceAppend r++-- | Exercise the hot stream named in the focused reliability audit.+runHotInvoicePayment :: KirokuStore -> Int -> IO ()+runHotInvoicePayment store ops =+ mapM_+ ( \i -> do+ r <- runStoreIO store $ appendToStream (StreamName "invoice-payment") AnyVersion [makeEvent ("InvoicePayment" <> T.pack (show i))]+ forceAppend r+ )+ [1 .. ops]++-- | Exercise appendMultiStream against existing streams.+runAppendMultiStream :: KirokuStore -> IO ()+runAppendMultiStream store = do+ r <-+ runStoreIO store $+ appendMultiStream+ [ (StreamName "bench-multi-a", AnyVersion, [makeEvent "MultiA"]),+ (StreamName "bench-multi-b", AnyVersion, [makeEvent "MultiB"]),+ (StreamName "bench-multi-c", AnyVersion, [makeEvent "MultiC"])+ ]+ forceAppendList r++-- | Exercise subscription catch-up over a compact category-local backlog.+runSubscriptionCatchup :: KirokuStore -> IORef Int -> IO ()+runSubscriptionCatchup store runCounter = do+ runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))+ let cat = "benchsub" <> T.pack (show runId)+ sn = StreamName (cat <> "-stream")+ subName = SubscriptionName ("bench-sub-" <> T.pack (show runId))+ events = map (\i -> makeEvent ("SubCatchup" <> T.pack (show i))) [1 .. 100 :: Int]+ r <- runStoreIO store $ appendToStream sn NoStream events+ forceAppend r+ seenRef <- newIORef (0 :: Int)+ let handler _ = do+ n <- atomicModifyIORef' seenRef (\m -> let m' = m + 1 in (m', m'))+ pure $ if n >= 100 then Stop else Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = Category (CategoryName cat),+ handler = handler,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ result <- wait handle+ case result of+ Right () -> pure ()+ Left e -> error ("Subscription catch-up benchmark failed: " <> show e)++-- | Force evaluation of an append result or fail the benchmark.+forceAppend :: Either StoreError AppendResult -> IO ()+forceAppend (Right r) = (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()+forceAppend (Left e) = error ("Benchmark append failed: " <> show e)++-- | Force evaluation of multi-stream append results or fail the benchmark.+forceAppendList :: Either StoreError [AppendResult] -> IO ()+forceAppendList (Right rs) = mapM_ (\r -> (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()) rs+forceAppendList (Left e) = error ("Benchmark appendMultiStream 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))) =+ streamId `seq` streamVersion `seq` globalPosition `seq` pure ()+forceRawAppend (Right Nothing) = error "Raw append benchmark produced no result"+forceRawAppend (Left e) = error ("Raw append benchmark failed: " <> show e)++-- | Force evaluation of a read result or fail the benchmark.+forceRead :: Either StoreError (V.Vector RecordedEvent) -> IO ()+forceRead (Right v) = V.length v `seq` pure ()+forceRead (Left e) = error ("Benchmark read failed: " <> show e)++seedCheckpointInventoryStmt :: Statement Int32 ()+seedCheckpointInventoryStmt =+ preparable+ """+ INSERT INTO subscriptions (+ subscription_name,+ consumer_group_member,+ last_seen,+ updated_at+ )+ SELECT 'inventory-' || lpad(n::text, 8, '0'),+ 0,+ 0,+ now()+ FROM generate_series(1, $1::int4) AS n+ """+ (E.param (E.nonNullable E.int4))+ D.noResult++seedCheckpointInventory :: KirokuStore -> Int32 -> IO ()+seedCheckpointInventory store rowCount = do+ result <- Pool.use (store ^. #pool) $ Session.statement rowCount seedCheckpointInventoryStmt+ case result of+ Left err -> error ("Checkpoint inventory benchmark setup failed: " <> show err)+ Right () -> pure ()++forceCheckpointInventory :: Either StoreError SubscriptionCheckpointInventory -> IO ()+forceCheckpointInventory (Left err) = error ("Checkpoint inventory benchmark failed: " <> show err)+forceCheckpointInventory (Right (SubscriptionCheckpointInventory (GlobalPosition storePos) rows)) = do+ let !checksum = V.foldl' forceCheckpoint (fromIntegral storePos) rows+ checksum `seq` pure ()+ where+ forceCheckpoint !acc (SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) updatedAt) =+ updatedAt `seq`+ acc+ + T.length name+ + fromIntegral member+ + fromIntegral position++runCheckpointInventoryBenchmark :: KirokuStore -> IO ()+runCheckpointInventoryBenchmark store =+ runStoreIO store subscriptionCheckpointInventory >>= forceCheckpointInventory++withInventoryBenchmarkStores :: (KirokuStore -> KirokuStore -> IO a) -> IO a+withInventoryBenchmarkStores action =+ withSharedMigratedPostgres $+ withMigratedTestDatabase $ \inventory100Connection ->+ withMigratedTestDatabase $ \inventory10000Connection ->+ withStore (defaultConnectionSettings inventory100Connection) $ \inventory100Store ->+ withStore (defaultConnectionSettings inventory10000Connection) $ \inventory10000Store -> do+ seedCheckpointInventory inventory100Store 100+ seedCheckpointInventory inventory10000Store 10_000+ action inventory100Store inventory10000Store++-- | A store seeded with 'categoryScalingFixtureSql': categories @performance@+-- (200 streams), @idle@ (20,000 single-event streams), and @noise@, head at+-- 'categoryScalingHead'. Must run inside 'withSharedMigratedPostgres'.+withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a+withCategoryScalingStore action =+ withMigratedTestDatabase $ \connection ->+ withStore (defaultConnectionSettings connection) $ \store -> do+ seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)+ case seeded of+ Left err -> error ("Category-scaling benchmark setup failed: " <> show err)+ Right () -> action store++-- | Run the plain category read 10 times at one cursor and limit.+runCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> IO ()+runCategoryPolls store category cursor limit =+ mapM_+ ( \_ ->+ Pool.use (store ^. #pool) (Session.statement (cursor, category, limit) SQL.readCategoryForwardStmt)+ >>= forceCategoryPoll+ )+ [1 .. 10 :: Int]++-- | Run the consumer-group category read 10 times for one member.+runGroupCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> Int32 -> Int32 -> IO ()+runGroupCategoryPolls store category cursor member size limit =+ mapM_+ ( \_ ->+ Pool.use (store ^. #pool) (Session.statement (cursor, category, member, size, limit) SQL.readCategoryForwardConsumerGroupStmt)+ >>= forceCategoryPoll+ )+ [1 .. 10 :: Int]++forceCategoryPoll :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO ()+forceCategoryPoll (Right events) = V.length events `seq` pure ()+forceCategoryPoll (Left err) = error ("Category-scaling benchmark read failed: " <> show err)++main :: IO ()+main = do+ -- Start ephemeral PostgreSQL once for all benchmarks+ config <- ephemeralConfig+ result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do+ migrateTestDatabase (Pg.connectionString db)+ let settings = defaultConnectionSettings (Pg.connectionString db)+ withStore settings $ \store -> do+ -- Counter for unique stream names across benchmarks+ counter <- newIORef (0 :: Int)+ let nextStream :: Text -> IO StreamName+ nextStream prefix = do+ n <- atomicModifyIORef' counter (\n -> (n + 1, n))+ pure (StreamName (prefix <> "-" <> T.pack (show n)))++ -- Pre-populate streams for category benchmarks (B10)+ -- 100 categories × 10 streams × 100 events = 100K events+ putStrLn "\n--- Pre-populating category data (100 cats × 10 streams × 100 events) ---"+ catT0 <- getCurrentTime+ mapM_+ ( \cat -> do+ mapM_+ ( \s -> do+ let sn = StreamName ("cat" <> T.pack (show cat) <> "-" <> T.pack (show s))+ let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]+ r' <- runStoreIO store $ appendToStream sn NoStream evts+ forceAppend r'+ )+ [1 .. 10 :: Int]+ )+ [1 .. 100 :: Int]+ catT1 <- getCurrentTime+ let catElapsed = realToFrac (diffUTCTime catT1 catT0) :: Double+ putStrLn $ " Setup time: " <> show catElapsed <> "s (100K events)"++ -- Pre-populate streams for read benchmarks+ -- B4: Single stream with 1000 events+ let readStreamName = StreamName "bench-read-stream"+ let readEvents = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 1000 :: Int]+ r <- runStoreIO store $ appendToStream readStreamName NoStream readEvents+ forceAppend r++ -- B5: 10 streams with 100 events each for $all reads (1000 total)+ mapM_+ ( \s -> do+ let sn = StreamName ("bench-all-" <> T.pack (show s))+ let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]+ r' <- runStoreIO store $ appendToStream sn NoStream evts+ forceAppend r'+ )+ [1 .. 10 :: Int]++ -- Pre-create fixed streams for appendMultiStream benchmark iterations.+ mapM_+ ( \sn -> do+ r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]+ forceAppend r'+ )+ [StreamName "bench-multi-a", StreamName "bench-multi-b", StreamName "bench-multi-c"]++ -- Pre-create the hot stream targeted by the two-round-trip raw+ -- shape variant. rawAppendUpdateExisting requires the row to+ -- exist (no ON CONFLICT), so without this warmup the first+ -- iteration would fail. The scalar-singleton and production+ -- variants already create their hot stream on first iteration+ -- via ON CONFLICT — pre-creating them here would change the+ -- existing benchmark contract, so we only seed the two-round-trip+ -- target.+ do+ r' <- runStoreIO store $ appendToStream (StreamName "raw-two-roundtrip-hot") AnyVersion [makeEvent "Init"]+ forceAppend r'++ -- B9: Pool saturation benchmark (64 concurrent writers, 100 appends each)+ putStrLn "\n--- B9: Pool saturation (64 writers × 100 appends, pool size 10) ---"+ satCounter <- newIORef (0 :: Int)+ let nextSatStream :: Int -> Int -> StreamName+ nextSatStream tid i = StreamName ("sat-" <> T.pack (show tid) <> "-" <> T.pack (show i))+ t0 <- getCurrentTime+ mapConcurrently_+ ( \tid -> do+ mapM_+ ( \i -> do+ let sn = nextSatStream tid i+ r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "SatEvent"]+ forceAppend r'+ atomicModifyIORef' satCounter (\n -> (n + 1, ()))+ )+ [1 .. 100 :: Int]+ )+ [1 .. 64 :: Int]+ t1 <- getCurrentTime+ totalOps <- readIORef satCounter+ let elapsed = realToFrac (diffUTCTime t1 t0) :: Double+ let throughput = fromIntegral totalOps / elapsed+ let avgLatency = elapsed / fromIntegral totalOps * 1000 -- ms+ putStrLn $ " Total appends: " <> show totalOps+ putStrLn $ " Elapsed: " <> show elapsed <> "s"+ putStrLn $ " Throughput: " <> show (round throughput :: Int) <> " ops/s"+ putStrLn $ " Avg latency: " <> show avgLatency <> " ms"+ putStrLn "---"++ -- Counter shared by the concurrent-writer benchmarks so each+ -- iteration uses a fresh stream-name run-id.+ concCounter <- newIORef (0 :: Int)+ rawCounter <- newIORef (0 :: Int)+ subCounter <- newIORef (0 :: Int)++ withInventoryBenchmarkStores $ \inventory100Store inventory10000Store ->+ withCategoryScalingStore $ \scalingStore ->+ defaultMain+ [ bgroup+ "append"+ [ bgroup+ "single-event"+ [ bench "NoStream (new stream)" $ whnfIO $ do+ sn <- nextStream "bench-single"+ r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "BenchEvent"]+ forceAppend r',+ bench "AnyVersion (new stream)" $ whnfIO $ do+ sn <- nextStream "bench-any"+ r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "BenchEvent"]+ forceAppend r'+ ],+ bgroup+ "batch-10"+ [ bench "NoStream" $ whnfIO $ do+ sn <- nextStream "bench-b10"+ let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 10 :: Int]+ r' <- runStoreIO store $ appendToStream sn NoStream events+ forceAppend r'+ ],+ bgroup+ "batch-100"+ [ bench "NoStream" $ whnfIO $ do+ sn <- nextStream "bench-b100"+ let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]+ r' <- runStoreIO store $ appendToStream sn NoStream events+ forceAppend r'+ ],+ bgroup+ "sequential"+ [ bench "10 appends to same stream" $ whnfIO $ do+ sn <- nextStream "bench-seq"+ r0 <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]+ forceAppend r0+ let res0 = case r0 of+ Right ok -> ok+ Left e -> error ("Sequential append failed: " <> show e)+ let go _ 0 = pure ()+ go v n = do+ r' <- runStoreIO store $ appendToStream sn (ExactVersion v) [makeEvent "Seq"]+ case r' of+ Right res -> go (res ^. #streamVersion) (n - 1 :: Int)+ Left e -> error ("Sequential append failed: " <> show e)+ go (res0 ^. #streamVersion) 9+ ]+ ],+ bgroup+ "raw-append-shape"+ [ bgroup+ "AnyVersion"+ [ bench "scalar singleton (new stream)" $+ whnfIO $+ runRawScalarAppendAnyVersionNewStream store rawCounter,+ bench "production arrays/unnest (new stream)" $+ whnfIO $+ runRawProductionAppendAnyVersionNewStream store rawCounter,+ bench "two-roundtrip (new stream)" $+ whnfIO $+ runRawTwoRoundtripAppendNewStream store rawCounter,+ bench "two-roundtrip + BEGIN/COMMIT (new stream)" $+ whnfIO $+ runRawTwoRoundtripAppendNewStreamTx store rawCounter,+ bench "scalar singleton (hot stream)" $+ whnfIO $+ runRawScalarAppendAnyVersionHotStream store,+ bench "production arrays/unnest (hot stream)" $+ whnfIO $+ runRawProductionAppendAnyVersionHotStream store,+ bench "two-roundtrip (hot stream)" $+ whnfIO $+ runRawTwoRoundtripAppendExistingHotStream store,+ bench "two-roundtrip + BEGIN/COMMIT (hot stream)" $+ whnfIO $+ runRawTwoRoundtripAppendExistingHotStreamTx store+ ]+ ],+ bgroup+ "read"+ [ bench "stream forward (100-event page)" $ whnfIO $ do+ r' <- runStoreIO store $ readStreamForward readStreamName (StreamVersion 0) 100+ forceRead r',+ bench "$all forward (100-event page)" $ whnfIO $ do+ r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ forceRead r'+ ],+ bgroup+ "category"+ [ bench "category forward (100-event page)" $ whnfIO $ do+ -- Read from cat1 category (has 10 streams × 100 events = 1000 events)+ r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 0) 100+ forceRead r',+ bench "exhausted-category" $ whnfIO $ do+ -- cat1 events are inserted early in setup; a high cursor proves+ -- category reads do not scan the rest of $all looking for matches.+ r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 90_000) 100+ forceRead r',+ bench "$all forward (100-event page baseline)" $ whnfIO $ do+ r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ forceRead r'+ ],+ -- F19 — Concurrent-writer stress as structured benchmarks.+ -- The legacy ad-hoc B9 measurement (still present above+ -- for historical comparability) prints throughput and+ -- latency once; these bgroup entries surface the same+ -- workload through tasty-bench so it participates in the+ -- baseline-regression workflow (Justfile bench-regression).+ bgroup+ "concurrent"+ [ bench "8 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 8 10,+ bench "32 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 32 10+ ],+ bgroup+ "reliability-audit"+ [ bench "hot invoice-payment 10 AnyVersion appends" $ whnfIO $ runHotInvoicePayment store 10,+ bench "appendMultiStream 3 existing streams" $ whnfIO $ runAppendMultiStream store,+ bench "subscription category catch-up 100 events" $ whnfIO $ runSubscriptionCatchup store subCounter+ ],+ -- BUG-2: a caught-up category poll must not cost work+ -- proportional to the number of streams in the category.+ -- Each cell runs the statement 10 times.+ bgroup+ "category-scaling"+ [ bench "plain caught-up poll (200 streams)" $+ whnfIO $+ runCategoryPolls scalingStore "performance" categoryScalingHead 100,+ bench "plain caught-up poll (20000 streams)" $+ whnfIO $+ runCategoryPolls scalingStore "idle" categoryScalingHead 100,+ bench "group caught-up poll (20000 streams)" $+ whnfIO $+ runGroupCategoryPolls scalingStore "idle" categoryScalingHead 1 2 100,+ bench "plain page from 0 (20000 streams)" $+ whnfIO $+ runCategoryPolls scalingStore "idle" 0 100,+ bench "exhausted category (200 streams)" $+ -- performance ends at 20,000; 60,000 other rows follow.+ whnfIO $+ runCategoryPolls scalingStore "performance" 20_000 100+ ],+ bgroup+ "subscription-checkpoint-inventory"+ [ bench "100 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory100Store,+ bench "10000 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory10000Store+ ]+ ]+ case result of+ Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)+ Right () -> pure ()++makeEvent :: Text -> EventData+makeEvent typ =+ EventData+ { eventId = Nothing,+ eventType = EventType typ,+ payload = Aeson.object [("benchmark", Aeson.Bool True)],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }
bench/RegressionGate.hs view
@@ -2,17 +2,21 @@ module Main where +import Control.Exception (finally) import Control.Lens ((^.))-import Control.Monad (forM, unless)+import Control.Monad (forM, replicateM_, unless) import Data.Aeson qualified as Aeson import Data.Generics.Labels ()-import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.Int (Int32, Int64)+import Data.List (intercalate) import Data.Maybe (isNothing) import Data.Text (Text) import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8Lenient) import Data.Time.Clock (getCurrentTime) import Data.Vector qualified as V+import GHC.Clock (getMonotonicTime) import Hasql.Decoders qualified as D import Hasql.Pool qualified as Pool import Hasql.Session qualified as Session@@ -22,135 +26,391 @@ import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Subscription.EventPublisher qualified as Publisher import Kiroku.Test.Fixtures.CategoryScaling (categoryScalingFixtureSql, categoryScalingHead) import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres)-import Test.Tasty (localOption)+import System.Environment (lookupEnv)+import System.Mem (performGC)+import Test.Tasty (localOption, withResource) import Test.Tasty.Bench main :: IO ()-main =- withSharedMigratedPostgres $- withMigratedTestDatabase $ \controlConnectionString ->- withMigratedTestDatabase $ \candidateConnectionString ->- withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->- withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->- withAppendCategoryStores $ \appendControlStore appendCandidateStore ->- withCategoryScalingStore $ \scalingStore -> do- let fourStreams = namedStreams "workload-gate-4" 4- eightStreams = namedStreams "workload-gate-8" 8- seedStreams controlStore (fourStreams <> eightStreams)- seedStreams candidateStore (fourStreams <> eightStreams)+main = do+ mode <- lookupEnv "KIROKU_GATE_DIAGNOSE"+ withSharedMigratedPostgres $+ withMigratedTestDatabase $ \controlConnectionString ->+ withMigratedTestDatabase $ \candidateConnectionString ->+ withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->+ withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->+ withAppendCategoryStores $ \appendControlStore appendCandidateStore appendWarmStore ->+ withCategoryScalingStore $ \scalingStore -> do+ let fourStreams = namedStreams "workload-gate-4" 4+ eightStreams = namedStreams "workload-gate-8" 8+ seedStreams controlStore (fourStreams <> eightStreams)+ seedStreams candidateStore (fourStreams <> eightStreams) - runSequentialMultiAppend controlStore fourStreams- runProductionMultiAppend candidateStore fourStreams- runSequentialMultiAppend controlStore eightStreams- runProductionMultiAppend candidateStore eightStreams+ runSequentialMultiAppend controlStore fourStreams+ runProductionMultiAppend candidateStore fourStreams+ runSequentialMultiAppend controlStore eightStreams+ runProductionMultiAppend candidateStore eightStreams - appendControlCounter <- newIORef 0- appendCandidateCounter <- newIORef 0- runAppendWorkload preCategoryAppendAnyVersion appendControlStore appendControlCounter- runAppendWorkload SQL.appendAnyVersion appendCandidateStore appendCandidateCounter+ (controlStatement, candidateStatement) <- diagnosticStatements mode appendControlStore appendCandidateStore+ appendWarmCounter <- newIORef 0+ appendControlCounter <- newIORef 0+ appendCandidateCounter <- newIORef 0+ -- Prime each growing table before ANALYZE so prepared plans+ -- see a populated store rather than the empty migration template.+ mapM_+ ( \_ -> do+ runAppendWorkload controlStatement appendControlStore appendControlCounter+ runAppendWorkload candidateStatement appendCandidateStore appendCandidateCounter+ runAppendWorkload SQL.appendAnyVersion appendWarmStore appendWarmCounter+ )+ [1 .. 20 :: Int] - defaultMain- [ localOption WallTime $- bgroup- "append-multi-stream"- [ bench "sequential-control-4" $- whnfIO (runSequentialMultiAppend controlStore fourStreams)- , bcompareWithin 0 0.90 "sequential-control-4" $- bench "production-pipeline-4" $- whnfIO (runProductionMultiAppend candidateStore fourStreams)- , bench "sequential-control-8" $- whnfIO (runSequentialMultiAppend controlStore eightStreams)- , bcompareWithin 0 0.90 "sequential-control-8" $- bench "production-pipeline-8" $- whnfIO (runProductionMultiAppend candidateStore eightStreams)- ]- , -- BUG-2 / plan 91 G4: carrying the category onto each $all- -- row (migration 0012) adds a column and one partial-index- -- insert per event. The control runs the pre-0012 append on- -- a database without the index or CHECK.- localOption WallTime $- bgroup- "append-category-column"- [ bench "control-append-40" $- whnfIO (runAppendWorkload preCategoryAppendAnyVersion appendControlStore appendControlCounter)- , bcompareWithin 0 1.05 "control-append-40" $- bench "candidate-append-40" $- whnfIO (runAppendWorkload SQL.appendAnyVersion appendCandidateStore appendCandidateCounter)- ]- , -- BUG-2 / plan 91 G3: the index-range category reads against- -- the LATERAL statements they replaced, on one database seeded- -- with the category-scaling fixture. The unpartitioned read- -- must not be slower where LATERAL was already cheap, and both- -- caught-up polls on 20,000 streams must be 5x faster.- localOption WallTime $- bgroup- "category-read"- [ bench "control-exhausted-category" $- whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" categoryScalingHead)- , bcompareWithin 0 1.05 "control-exhausted-category" $- bench "candidate-exhausted-category" $- whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" categoryScalingHead)- , bench "control-page-200-streams-from-0" $- whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" 0)- , bcompareWithin 0 1.05 "control-page-200-streams-from-0" $- bench "candidate-page-200-streams-from-0" $- whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" 0)- , bench "control-page-20000-streams-from-0" $- whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" 0)- , bcompareWithin 0 1.05 "control-page-20000-streams-from-0" $- bench "candidate-page-20000-streams-from-0" $- whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" 0)- , bench "control-plain-caught-up-20000-streams" $- whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" categoryScalingHead)- , bcompareWithin 0 0.20 "control-plain-caught-up-20000-streams" $- bench "candidate-plain-caught-up-20000-streams" $- whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" categoryScalingHead)- , bench "control-group-caught-up-20000-streams" $- whnfIO (runGroupReads lateralCategoryGroupRead scalingStore "idle" categoryScalingHead)- , bcompareWithin 0 0.20 "control-group-caught-up-20000-streams" $- bench "candidate-group-caught-up-20000-streams" $- whnfIO (runGroupReads SQL.readCategoryForwardConsumerGroupStmt scalingStore "idle" categoryScalingHead)- ]- ]+ -- Settle fixture writes and refresh plans before measurement.+ runSetupScript scalingStore "CHECKPOINT"+ mapM_+ (\store -> runSetupScript store "VACUUM (ANALYZE)")+ [controlStore, candidateStore, appendControlStore, appendCandidateStore, appendWarmStore, scalingStore] + case mode of+ Nothing ->+ defaultMain+ [ -- ADR-10: the user accepted migration 0012's write cost.+ -- Protect the accepted implementation against later SQL+ -- regressions, keeping the historical comparison diagnostic.+ localOption (RelStDev 0.01) $+ localOption WallTime $+ bgroup+ "append-accepted-category"+ [ -- Keep this group first: the disposable cell absorbs+ -- post-setup effects without growing either append arm.+ localOption (RelStDev 0.05) $+ bench "warm-append-40" $+ whnfIO (runAppendWorkload SQL.appendAnyVersion appendWarmStore appendWarmCounter),+ bench "accepted-control-append-40x32" $+ whnfIO (replicateM_ 32 (runAppendWorkload controlStatement appendControlStore appendControlCounter)),+ bcompareWithin 0.5 1.05 "accepted-control-append-40x32" $+ bench "candidate-append-40x32" $+ whnfIO (replicateM_ 32 (runAppendWorkload candidateStatement appendCandidateStore appendCandidateCounter))+ ],+ withResource+ (warmPipelineStores controlStore candidateStore fourStreams eightStreams)+ (const (pure ()))+ $ \_ ->+ localOption WallTime $+ bgroup+ "append-multi-stream"+ [ localOption (RelStDev 0.01) $+ bench "sequential-control-4" $+ whnfIO (runSequentialMultiAppend controlStore fourStreams),+ bcompareWithin 0.5 0.90 "sequential-control-4" $+ localOption (RelStDev 0.01) $+ bench "production-pipeline-4" $+ whnfIO (runProductionMultiAppend candidateStore fourStreams),+ bench "sequential-control-8" $+ whnfIO (runSequentialMultiAppend controlStore eightStreams),+ bcompareWithin 0.5 0.90 "sequential-control-8" $+ bench "production-pipeline-8" $+ whnfIO (runProductionMultiAppend candidateStore eightStreams)+ ],+ -- BUG-2 / plan 91 G3: the index-range category reads against+ -- the LATERAL statements they replaced, on one database seeded+ -- with the category-scaling fixture. The unpartitioned read+ -- must not be slower where LATERAL was already cheap, and both+ -- caught-up polls on 20,000 streams must be 5x faster.+ localOption WallTime $+ bgroup+ "category-read"+ [ bench "control-exhausted-category" $+ whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" categoryScalingHead),+ bcompareWithin 0 1.05 "control-exhausted-category" $+ bench "candidate-exhausted-category" $+ whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" categoryScalingHead),+ bench "control-page-200-streams-from-0" $+ whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" 0),+ bcompareWithin 0 1.05 "control-page-200-streams-from-0" $+ bench "candidate-page-200-streams-from-0" $+ whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" 0),+ bench "control-page-20000-streams-from-0" $+ whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" 0),+ bcompareWithin 0 1.05 "control-page-20000-streams-from-0" $+ bench "candidate-page-20000-streams-from-0" $+ whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" 0),+ bench "control-plain-caught-up-20000-streams" $+ whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" categoryScalingHead),+ bcompareWithin 0 0.20 "control-plain-caught-up-20000-streams" $+ bench "candidate-plain-caught-up-20000-streams" $+ whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" categoryScalingHead),+ bench "control-group-caught-up-20000-streams" $+ whnfIO (runGroupReads lateralCategoryGroupRead scalingStore "idle" categoryScalingHead),+ bcompareWithin 0 0.20 "control-group-caught-up-20000-streams" $+ bench "candidate-group-caught-up-20000-streams" $+ whnfIO (runGroupReads SQL.readCategoryForwardConsumerGroupStmt scalingStore "idle" categoryScalingHead)+ ]+ ]+ `finally` do+ counts <- mapM readIORef [appendControlCounter, appendCandidateCounter]+ putStrLn ("append workload counts including setup: " <> show counts)+ Just "explain-combined-links" -> runAppendExplainDiagnostic True controlStatement candidateStatement appendControlStore appendCandidateStore+ Just "explain" -> runAppendExplainDiagnostic False controlStatement candidateStatement appendControlStore appendCandidateStore+ Just label -> runPairedAppendDiagnostic label controlStatement candidateStatement appendControlStore appendCandidateStore appendControlCounter appendCandidateCounter++runSetupScript :: KirokuStore -> Text -> IO ()+runSetupScript store script = do+ result <- Pool.use (store ^. #pool) (Session.script script)+ case result of+ Left err -> error ("workload gate quiescence failed: " <> show err)+ Right () -> pure ()++-- | Refresh a group whose pools/plans have been idle during the append cells.+-- No resource thunk reaches STM, and all setup stays outside measured actions.+warmPipelineStores :: KirokuStore -> KirokuStore -> [(StreamName, Text)] -> [(StreamName, Text)] -> IO ()+warmPipelineStores control candidate fourStreams eightStreams = do+ replicateM_ 20 $ do+ runSequentialMultiAppend control fourStreams+ runProductionMultiAppend candidate fourStreams+ runSequentialMultiAppend control eightStreams+ runProductionMultiAppend candidate eightStreams+ mapM_ (\store -> runSetupScript store "VACUUM (ANALYZE)") [control, candidate]+ runSequentialMultiAppend control fourStreams+ runProductionMultiAppend candidate fourStreams+ runSequentialMultiAppend control eightStreams+ runProductionMultiAppend candidate eightStreams++-- | Diagnostic comparisons retain the default gate's SQL and bounds. Each+-- invocation gets fresh stores. The A/A mode tests the harness with identical+-- SQL and schema; the other modes separate column/statement from index/CHECK cost.+diagnosticStatements ::+ Maybe String ->+ KirokuStore ->+ KirokuStore ->+ IO (Statement SQL.AppendParams (Maybe AppendResult), Statement SQL.AppendParams (Maybe AppendResult))+diagnosticStatements mode control candidate = case mode of+ Nothing -> pure (acceptedCategoryAppendAnyVersion, SQL.appendAnyVersion)+ Just "paired-accepted" -> pure (acceptedCategoryAppendAnyVersion, SQL.appendAnyVersion)+ Just "paired" -> historical+ Just "explain" -> historical+ Just "paired-aa" -> do+ runSetupScript control dropCategoryIndexAndCheck+ runSetupScript candidate dropCategoryIndexAndCheck+ pure (preCategoryAppendAnyVersion, preCategoryAppendAnyVersion)+ Just "paired-column" -> do+ runSetupScript control dropCategoryIndexAndCheck+ runSetupScript candidate dropCategoryIndexAndCheck+ pure (preCategoryAppendAnyVersion, SQL.appendAnyVersion)+ Just "paired-index-check" -> do+ runSetupScript control dropCategoryIndexAndCheck+ pure (SQL.appendAnyVersion, SQL.appendAnyVersion)+ Just "paired-no-publisher" -> do+ -- Diagnostic upper bound only: there are no subscribers in this fixture.+ -- Stopping this worker is not a production optimization or valid subscriber mode.+ Publisher.stopPublisher (candidate ^. #publisher)+ pure (SQL.appendAnyVersion, SQL.appendAnyVersion)+ Just "paired-combined-links" -> pure (SQL.appendAnyVersion, combinedLinksAppend)+ Just "explain-combined-links" -> pure (SQL.appendAnyVersion, combinedLinksAppend)+ Just invalid -> error ("unknown KIROKU_GATE_DIAGNOSE mode: " <> invalid)+ where+ historical = do+ runSetupScript control dropCategoryIndexAndCheck+ pure (preCategoryAppendAnyVersion, SQL.appendAnyVersion)+ dropCategoryIndexAndCheck =+ "DROP INDEX kiroku.ix_stream_events_all_by_category; ALTER TABLE kiroku.stream_events DROP CONSTRAINT ck_stream_events_all_category;"++-- | Benchmark-only hypothesis: one junction INSERT instead of two. This+-- preserves the production bind parameters, row values and result decoder.+combinedLinksAppend :: Statement SQL.AppendParams (Maybe AppendResult)+combinedLinksAppend = Statement.preparable sql SQL.appendParamsEncoder SQL.appendResultDecoder+ where+ production = Statement.toSql SQL.appendAnyVersion+ -- The exact production SQL is extracted below using stable CTE boundaries;+ -- fail rather than silently producing a different candidate after an edit.+ sql = combineJunctionInserts production++combineJunctionInserts :: Text -> Text+combineJunctionInserts production =+ let (beforeSource, sourceAndRest) = T.breakOn " source_links AS (" production+ (_, allUpdateAndRest) = T.breakOn " all_update AS (" sourceAndRest+ (allUpdate, allLinksAndResult) = T.breakOn " all_links AS (" allUpdateAndRest+ (_, result) = T.breakOn "SELECT su.stream_id," allLinksAndResult+ combined =+ """+ event_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx, NULL::text+ FROM new_events ne+ CROSS JOIN stream_upsert su+ UNION ALL+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ """+ in if T.null beforeSource || T.null allUpdate || T.null result+ then error "combined-links prototype could not identify production CTE boundaries"+ else beforeSource <> allUpdate <> combined <> "\n" <> result++-- | Twelve alternating pairs of equal-sized, growing workloads. Every round+-- starts at the same logical stream/event counts in both databases. These are+-- diagnostic samples, never a substitute passing result for the tasty gate.+runPairedAppendDiagnostic ::+ String ->+ Statement SQL.AppendParams (Maybe AppendResult) ->+ Statement SQL.AppendParams (Maybe AppendResult) ->+ KirokuStore ->+ KirokuStore ->+ IORef Int ->+ IORef Int ->+ IO ()+runPairedAppendDiagnostic label controlStatement candidateStatement control candidate controlCounter candidateCounter = do+ putStrLn "PAIR,variant,pair,first,control_ns,candidate_ns,control_before,candidate_before,control_after,candidate_after,pre_gc"+ mapM_ runPair [1 .. 12 :: Int]+ verifyCounts "control" control controlCounter+ verifyCounts "candidate" candidate candidateCounter+ where+ iterations = 32+ timed :: IO () -> IO Integer+ timed action = do+ -- Match tasty-bench: collect before the timer, rather than letting setup+ -- garbage fall unpredictably into one arm of this diagnostic comparison.+ performGC+ start <- getMonotonicTime+ action+ end <- getMonotonicTime+ pure (round ((end - start) * 1e9) :: Integer)+ controlAction = replicateM_ iterations (runAppendWorkload controlStatement control controlCounter)+ candidateAction = replicateM_ iterations (runAppendWorkload candidateStatement candidate candidateCounter)+ runPair pair = do+ beforeControl <- readIORef controlCounter+ beforeCandidate <- readIORef candidateCounter+ unless (beforeControl == beforeCandidate) $ error "diagnostic arms start at unequal workload counts"+ (controlNs, candidateNs) <-+ if odd pair+ then do+ c <- timed controlAction+ n <- timed candidateAction+ pure (c, n)+ else do+ n <- timed candidateAction+ c <- timed controlAction+ pure (c, n)+ afterControl <- readIORef controlCounter+ afterCandidate <- readIORef candidateCounter+ unless (afterControl == beforeControl + iterations && afterCandidate == beforeCandidate + iterations) $+ error "diagnostic arms did not complete equal work"+ putStrLn $+ intercalate+ ","+ [ "PAIR",+ label,+ show pair,+ if odd pair then "control" else "candidate",+ show controlNs,+ show candidateNs,+ show beforeControl,+ show beforeCandidate,+ show afterControl,+ show afterCandidate,+ "1"+ ]+ verifyCounts arm store counter = do+ completed <- readIORef counter+ let expectedEvents = fromIntegral (40 * completed) :: Int64+ expectedStreams = fromIntegral (20 * completed + 2) :: Int64+ result <- Pool.use (store ^. #pool) (Session.statement () diagnosticCounts)+ case result of+ Right counts@(streams, events, links, hotVersion, globalVersion) -> do+ unless (counts == (expectedStreams, expectedEvents, 2 * expectedEvents, expectedEvents `div` 2, expectedEvents)) $+ error ("diagnostic database counts differ for " <> arm <> ": " <> show counts)+ putStrLn $ intercalate "," ["COUNTS", label, arm, show streams, show events, show links, show hotVersion, show globalVersion]+ Left err -> error ("diagnostic count verification failed: " <> show err)++-- | Use the exact statements under comparison, rather than a copied EXPLAIN+-- query. These four plans show structure/buffers/WAL, not a latency estimate.+runAppendExplainDiagnostic ::+ Bool ->+ Statement SQL.AppendParams (Maybe AppendResult) ->+ Statement SQL.AppendParams (Maybe AppendResult) ->+ KirokuStore ->+ KirokuStore ->+ IO ()+runAppendExplainDiagnostic timings controlStatement candidateStatement control candidate = do+ mapM_+ ( \(arm, statement, store) -> do+ mapM_ (explainOne arm statement store) [("fresh", "append-gate-explain-fresh"), ("hot", "append-gate-hot")]+ rows <- Pool.use (store ^. #pool) (Session.statement () physicalRows)+ case rows of+ Right value -> putStrLn ("PHYSICAL," <> arm <> "," <> T.unpack value)+ Left err -> error ("diagnostic physical row query failed: " <> show err)+ )+ [("control", controlStatement, control), ("candidate", candidateStatement, candidate)]+ where+ jsonText = D.singleRow (D.column (D.nonNullable (D.jsonBytes (Right . decodeUtf8Lenient))))+ physicalRows =+ Statement.unpreparable+ "SELECT json_agg(row_to_json(x)) FROM (SELECT stream_id = 0 AS all_row, category IS NULL AS category_is_null, pg_column_size(se) AS row_bytes, count(*) AS samples FROM stream_events se GROUP BY 1,2,3 ORDER BY 1,2,3) x"+ mempty+ jsonText+ explainOne arm statement store (kind, name) = do+ enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]+ prepared <- prepareEvents enriched+ now <- getCurrentTime+ let explain =+ Statement.unpreparable+ ("EXPLAIN (ANALYZE, BUFFERS, WAL, TIMING " <> (if timings then "ON" else "OFF") <> ", FORMAT JSON) " <> Statement.toSql statement)+ SQL.appendParamsEncoder+ jsonText+ result <- Pool.use (store ^. #pool) (Session.statement (buildAppendParams name now prepared) explain)+ case result of+ Right value -> putStrLn ("PLAN," <> arm <> "," <> kind <> "," <> T.unpack value)+ Left err -> error ("diagnostic EXPLAIN failed: " <> show err)++diagnosticCounts :: Statement () (Int64, Int64, Int64, Int64, Int64)+diagnosticCounts =+ Statement.unpreparable+ "SELECT (SELECT count(*) FROM streams), (SELECT count(*) FROM events), (SELECT count(*) FROM stream_events), (SELECT stream_version FROM streams WHERE stream_name = 'append-gate-hot'), (SELECT stream_version FROM streams WHERE stream_id = 0)"+ mempty+ (D.singleRow ((,,,,) <$> int8 <*> int8 <*> int8 <*> int8 <*> int8))+ where+ int8 = D.column (D.nonNullable D.int8)+ -- | A migrated store seeded with 'categoryScalingFixtureSql'. withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a withCategoryScalingStore action =- withMigratedTestDatabase $ \connectionString ->- withStore (defaultConnectionSettings connectionString) $ \store -> do- seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)- case seeded of- Left err -> error ("category-read gate setup failed: " <> show err)- Right () -> action store+ withMigratedTestDatabase $ \connectionString ->+ withStore (defaultConnectionSettings connectionString) $ \store -> do+ seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)+ case seeded of+ Left err -> error ("category-read gate setup failed: " <> show err)+ Right () -> action store -{- | Ten executions of an unpartitioned category read (limit 100). Ten, not a-hundred: a LATERAL control poll on 20,000 streams costs about 15 ms, and a-hundred per iteration exceeds tasty-bench's timeout.--}+-- | Ten executions of an unpartitioned category read (limit 100). Ten, not a+-- hundred: a LATERAL control poll on 20,000 streams costs about 15 ms, and a+-- hundred per iteration exceeds tasty-bench's timeout. runPlainReads ::- Statement (Int64, Text, Int32) (V.Vector RecordedEvent) ->- KirokuStore ->- Text ->- Int64 ->- IO ()+ Statement (Int64, Text, Int32) (V.Vector RecordedEvent) ->+ KirokuStore ->+ Text ->+ Int64 ->+ IO () runPlainReads statement store category cursor =- mapM_- (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 100) statement) >>= forceReads)- [1 .. 10 :: Int]+ mapM_+ (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 100) statement) >>= forceReads)+ [1 .. 10 :: Int] -- | Ten executions of a consumer-group category read, member 1 of 2 (limit 100). runGroupReads ::- Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) ->- KirokuStore ->- Text ->- Int64 ->- IO ()+ Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) ->+ KirokuStore ->+ Text ->+ Int64 ->+ IO () runGroupReads statement store category cursor =- mapM_- (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 1, 2, 100) statement) >>= forceReads)- [1 .. 10 :: Int]+ mapM_+ (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 1, 2, 100) statement) >>= forceReads)+ [1 .. 10 :: Int] forceReads :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO () forceReads (Right events) = V.length events `seq` pure ()@@ -159,246 +419,292 @@ -- | 'SQL.readCategoryForwardStmt' as it was before plan 91 (git 12d50d5). lateralCategoryRead :: Statement (Int64, Text, Int32) (V.Vector RecordedEvent) lateralCategoryRead =- Statement.preparable- lateralCategoryReadSQL- SQL.readCategoryEncoder- (D.rowVector SQL.recordedEventRow)+ Statement.preparable+ lateralCategoryReadSQL+ SQL.readCategoryEncoder+ (D.rowVector SQL.recordedEventRow) -- | 'SQL.readCategoryForwardConsumerGroupStmt' as it was before plan 91 (git 12d50d5). lateralCategoryGroupRead :: Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) lateralCategoryGroupRead =- Statement.preparable- lateralCategoryGroupReadSQL- SQL.readCategoryConsumerGroupEncoder- (D.rowVector SQL.recordedEventRow)+ Statement.preparable+ lateralCategoryGroupReadSQL+ SQL.readCategoryConsumerGroupEncoder+ (D.rowVector SQL.recordedEventRow) lateralCategoryReadSQL :: Text lateralCategoryReadSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- e.data, e.metadata, e.causation_id, e.correlation_id,- e.created_at- FROM streams s- JOIN LATERAL (- SELECT se.*- FROM stream_events se- WHERE se.stream_id = 0- AND se.original_stream_id = s.stream_id- AND se.stream_version > $1- ORDER BY se.stream_version ASC- LIMIT $3- ) se ON true- JOIN events e ON e.event_id = se.event_id- WHERE s.category = $2+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ e.data, e.metadata, e.causation_id, e.correlation_id,+ e.created_at+ FROM streams s+ JOIN LATERAL (+ SELECT se.*+ FROM stream_events se+ WHERE se.stream_id = 0+ AND se.original_stream_id = s.stream_id+ AND se.stream_version > $1 ORDER BY se.stream_version ASC LIMIT $3- """+ ) se ON true+ JOIN events e ON e.event_id = se.event_id+ WHERE s.category = $2+ ORDER BY se.stream_version ASC+ LIMIT $3+ """ lateralCategoryGroupReadSQL :: Text lateralCategoryGroupReadSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- e.data, e.metadata, e.causation_id, e.correlation_id,- e.created_at- FROM streams s- JOIN LATERAL (- SELECT se.*- FROM stream_events se- WHERE se.stream_id = 0- AND se.original_stream_id = s.stream_id- AND se.stream_version > $1- ORDER BY se.stream_version ASC- LIMIT $5- ) se ON true- JOIN events e ON e.event_id = se.event_id- WHERE s.category = $2- AND (((hashtextextended(s.stream_id::text, 0) % $4) + $4) % $4) = $3+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ e.data, e.metadata, e.causation_id, e.correlation_id,+ e.created_at+ FROM streams s+ JOIN LATERAL (+ SELECT se.*+ FROM stream_events se+ WHERE se.stream_id = 0+ AND se.original_stream_id = s.stream_id+ AND se.stream_version > $1 ORDER BY se.stream_version ASC LIMIT $5- """+ ) se ON true+ JOIN events e ON e.event_id = se.event_id+ WHERE s.category = $2+ AND (((hashtextextended(s.stream_id::text, 0) % $4) + $4) % $4) = $3+ ORDER BY se.stream_version ASC+ LIMIT $5+ """ -{- | Two freshly migrated stores for the append-category-column gate. The-control database has the category index and CHECK from migration 0012-dropped, so it pays exactly the pre-0012 write cost when driven by-'preCategoryAppendAnyVersion'; the candidate is left as migrated.--}-withAppendCategoryStores :: (KirokuStore -> KirokuStore -> IO a) -> IO a+-- | Three fully migrated stores: the frozen accepted control, current+-- production candidate, and disposable warm-up. Historical diagnostic modes+-- explicitly remove the category index/CHECK from the appropriate arm.+withAppendCategoryStores :: (KirokuStore -> KirokuStore -> KirokuStore -> IO a) -> IO a withAppendCategoryStores action =- withMigratedTestDatabase $ \controlConnectionString ->- withMigratedTestDatabase $ \candidateConnectionString ->- withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->- withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore -> do- dropped <-- Pool.use- (controlStore ^. #pool)- ( Session.script- """- DROP INDEX kiroku.ix_stream_events_all_by_category;- ALTER TABLE kiroku.stream_events DROP CONSTRAINT ck_stream_events_all_category;- """- )- case dropped of- Left err -> error ("append-category control setup failed: " <> show err)- Right () -> action controlStore candidateStore+ withMigratedTestDatabase $ \controlConnectionString ->+ withMigratedTestDatabase $ \candidateConnectionString ->+ withMigratedTestDatabase $ \warmConnectionString ->+ withStore (defaultConnectionSettings warmConnectionString) $ \warmStore ->+ withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->+ withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->+ action controlStore candidateStore warmStore -{- | One gate iteration: 20 single-event appends to fresh streams, then 20 to-one hot stream. Each store gets its own counter, so control and candidate-create the same stream names and do the same work.--}+-- | One gate iteration: 20 single-event appends to fresh streams, then 20 to+-- one hot stream. Each store gets its own counter, so control and candidate+-- create the same stream names and do the same work. runAppendWorkload ::- Statement SQL.AppendParams (Maybe AppendResult) ->- KirokuStore ->- IORef Int ->- IO ()+ Statement SQL.AppendParams (Maybe AppendResult) ->+ KirokuStore ->+ IORef Int ->+ IO () runAppendWorkload statement store counter = do- iteration <- atomicModifyIORef' counter (\n -> (n + 1, n))- now <- getCurrentTime- let freshNames =- [ "append-gate-" <> T.pack (show iteration) <> "-" <> T.pack (show index)- | index <- [1 .. 20 :: Int]- ]- names = freshNames <> replicate 20 "append-gate-hot"- mapM_- ( \name -> do- enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]- prepared <- prepareEvents enriched- result <-- Pool.use (store ^. #pool) $- Session.statement (buildAppendParams name now prepared) statement- case result of- Right (Just appendResult) -> forceAppendResults [appendResult]- Right Nothing -> error "append-category gate append returned no row"- Left err -> error ("append-category gate append failed: " <> show err)- )- names+ iteration <- atomicModifyIORef' counter (\n -> (n + 1, n))+ now <- getCurrentTime+ let freshNames =+ [ "append-gate-" <> T.pack (show iteration) <> "-" <> T.pack (show index)+ | index <- [1 .. 20 :: Int]+ ]+ names = freshNames <> replicate 20 "append-gate-hot"+ mapM_+ ( \name -> do+ enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]+ prepared <- prepareEvents enriched+ result <-+ Pool.use (store ^. #pool) $+ Session.statement (buildAppendParams name now prepared) statement+ case result of+ Right (Just appendResult) -> forceAppendResults [appendResult]+ Right Nothing -> error "append-category gate append returned no row"+ Left err -> error ("append-category gate append failed: " <> show err)+ )+ names +-- | Frozen post-0012 append SQL from the user-accepted production source at+-- git 22ac4de. Keep this literal independent of SQL.appendAnyVersion so later+-- production changes are compared against the accepted implementation.+acceptedCategoryAppendAnyVersion :: Statement SQL.AppendParams (Maybe AppendResult)+acceptedCategoryAppendAnyVersion =+ Statement.preparable acceptedCategoryAppendAnyVersionSQL SQL.appendParamsEncoder SQL.appendResultDecoder++acceptedCategoryAppendAnyVersionSQL :: Text+acceptedCategoryAppendAnyVersionSQL =+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, (SELECT count(*) FROM new_events))+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_upsert)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_upsert su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_upsert)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_upsert su+ CROSS JOIN all_update au+ """+ -- | 'SQL.appendAnyVersion' as it was before migration 0012 (git 12d50d5). preCategoryAppendAnyVersion :: Statement SQL.AppendParams (Maybe AppendResult) preCategoryAppendAnyVersion =- Statement.preparable- preCategoryAppendAnyVersionSQL- SQL.appendParamsEncoder- SQL.appendResultDecoder+ Statement.preparable+ preCategoryAppendAnyVersionSQL+ SQL.appendParamsEncoder+ SQL.appendResultDecoder preCategoryAppendAnyVersionSQL :: Text preCategoryAppendAnyVersionSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_upsert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($8, (SELECT count(*) FROM new_events))- ON CONFLICT (stream_name)- DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)- WHERE streams.deleted_at IS NULL- RETURNING stream_id, stream_version - (SELECT count(*) FROM new_events) AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_upsert)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_upsert su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_upsert)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_upsert su- )- SELECT su.stream_id,- su.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_upsert su- CROSS JOIN all_update au- """+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, (SELECT count(*) FROM new_events))+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_upsert)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_upsert su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_upsert)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_upsert su+ CROSS JOIN all_update au+ """ namedStreams :: Text -> Int -> [(StreamName, Text)] namedStreams prefix count =- [ ( StreamName (prefix <> "-" <> T.pack (show index))- , "WorkloadGate" <> T.pack (show index)- )- | index <- [1 .. count]- ]+ [ ( StreamName (prefix <> "-" <> T.pack (show index)),+ "WorkloadGate" <> T.pack (show index)+ )+ | index <- [1 .. count]+ ] seedStreams :: KirokuStore -> [(StreamName, Text)] -> IO () seedStreams store streams =- mapM_- ( \(streamName, eventType) -> do- result <- runStoreIO store $ appendToStream streamName NoStream [makeEvent (eventType <> "Seed")]- forceStoreResults "seed append" (fmap pure result)- )- streams+ mapM_+ ( \(streamName, eventType) -> do+ result <- runStoreIO store $ appendToStream streamName NoStream [makeEvent (eventType <> "Seed")]+ forceStoreResults "seed append" (fmap pure result)+ )+ streams runProductionMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO () runProductionMultiAppend store streams = do- result <-- runStoreIO store $- appendMultiStream- [ (streamName, AnyVersion, [makeEvent eventType])- | (streamName, eventType) <- streams- ]- forceStoreResults "production appendMultiStream" result+ result <-+ runStoreIO store $+ appendMultiStream+ [ (streamName, AnyVersion, [makeEvent eventType])+ | (streamName, eventType) <- streams+ ]+ forceStoreResults "production appendMultiStream" result runSequentialMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO () runSequentialMultiAppend store streams = do- now <- getCurrentTime- preparedOps <-- forM streams $ \(streamName@(StreamName name), eventType) -> do- enriched <- enrichEvents (store ^. #storeSettings) [makeEvent eventType]- prepared <- prepareEvents enriched- pure (streamName, name, buildAppendParams name now prepared)- let names = V.fromList [name | (_, name, _) <- preparedOps]- transaction = do- Tx.statement names SQL.lockStreamsForMultiStmt- results <-- forM preparedOps $ \(_, _, params) ->- appendDispatchTx AnyVersion params- if any isNothing results- then Tx.condemn >> pure results- else pure results- result <-- Pool.use (store ^. #pool) $- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write transaction- case result of- Left err -> error ("sequential appendMultiStream control failed: " <> show err)- Right maybeResults -> do- unless (all isJustAppend maybeResults) $- error "sequential appendMultiStream control returned an empty append result"- forceAppendResults [appendResult | Just appendResult <- maybeResults]+ now <- getCurrentTime+ preparedOps <-+ forM streams $ \(streamName@(StreamName name), eventType) -> do+ enriched <- enrichEvents (store ^. #storeSettings) [makeEvent eventType]+ prepared <- prepareEvents enriched+ pure (streamName, name, buildAppendParams name now prepared)+ let names = V.fromList [name | (_, name, _) <- preparedOps]+ transaction = do+ Tx.statement names SQL.lockStreamsForMultiStmt+ results <-+ forM preparedOps $ \(_, _, params) ->+ appendDispatchTx AnyVersion params+ if any isNothing results+ then Tx.condemn >> pure results+ else pure results+ result <-+ Pool.use (store ^. #pool) $+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write transaction+ case result of+ Left err -> error ("sequential appendMultiStream control failed: " <> show err)+ Right maybeResults -> do+ unless (all isJustAppend maybeResults) $+ error "sequential appendMultiStream control returned an empty append result"+ forceAppendResults [appendResult | Just appendResult <- maybeResults] makeEvent :: Text -> EventData makeEvent eventType =- EventData- { eventId = Nothing- , eventType = EventType eventType- , payload = Aeson.object [("workloadGate", Aeson.Bool True)]- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }+ EventData+ { eventId = Nothing,+ eventType = EventType eventType,+ payload = Aeson.object [("workloadGate", Aeson.Bool True)],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ } isJustAppend :: Maybe AppendResult -> Bool isJustAppend (Just _) = True@@ -410,8 +716,8 @@ forceAppendResults :: [AppendResult] -> IO () forceAppendResults =- mapM_ $ \result ->- (result ^. #streamId) `seq`- (result ^. #streamVersion) `seq`- (result ^. #globalPosition) `seq`- pure ()+ mapM_ $ \result ->+ (result ^. #streamId) `seq`+ (result ^. #streamVersion) `seq`+ (result ^. #globalPosition) `seq`+ pure ()
bench/ShibuyaOverhead.hs view
@@ -1,16 +1,15 @@-{- | Benchmark measuring the overhead of the Shibuya adapter layer-compared to bare kiroku subscriptions. Three layers are measured:--1. Bare subscribe: handler receives events directly from the subscription worker-2. subscriptionStream: events pass through a TBQueue bridge to a Streamly stream-3. Shibuya adapter: events flow through subscriptionStream → morphInner → Shibuya pipeline--The bare subscribe handler processes events synchronously (one at a time),-while the stream-based approaches decouple the producer from the consumer-via a TBQueue, allowing the subscription worker to batch-fetch ahead.-This means subscriptionStream and Shibuya can be *faster* for catch-up —-the relevant overhead comparison is shibuya vs subscriptionStream.--}+-- | Benchmark measuring the overhead of the Shibuya adapter layer+-- compared to bare kiroku subscriptions. Three layers are measured:+--+-- 1. Bare subscribe: handler receives events directly from the subscription worker+-- 2. subscriptionStream: events pass through a TBQueue bridge to a Streamly stream+-- 3. Shibuya adapter: events flow through subscriptionStream → morphInner → Shibuya pipeline+--+-- The bare subscribe handler processes events synchronously (one at a time),+-- while the stream-based approaches decouple the producer from the consumer+-- via a TBQueue, allowing the subscription worker to batch-fetch ahead.+-- This means subscriptionStream and Shibuya can be *faster* for catch-up —+-- the relevant overhead comparison is shibuya vs subscriptionStream. module Main where import Control.Concurrent (threadDelay)@@ -35,7 +34,7 @@ import Shibuya.App (ProcessorId (..), defaultAppConfig, mkProcessor, runApp, stopApp) import Shibuya.Core.Ack (AckDecision (..)) import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..), Message)+import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..), Message) import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..)) import Shibuya.Telemetry.Effect (runTracingNoop) import Streamly.Data.Fold qualified as Fold@@ -48,269 +47,270 @@ main :: IO () main = do- config <- ephemeralConfig- result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do- migrateTestDatabase (Pg.connectionString db)- let settings = defaultConnectionSettings (Pg.connectionString db)- withStore settings $ \store -> do- putStrLn "=== Kiroku Shibuya Adapter Overhead Benchmark ==="- printf " %d iterations per measurement\n\n" iterations+ config <- ephemeralConfig+ result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do+ migrateTestDatabase (Pg.connectionString db)+ let settings = defaultConnectionSettings (Pg.connectionString db)+ withStore settings $ \store -> do+ putStrLn "=== Kiroku Shibuya Adapter Overhead Benchmark ==="+ printf " %d iterations per measurement\n\n" iterations - mapM_ (runBenchmarkSuite store) [100, 1000, 5000]+ mapM_ (runBenchmarkSuite store) [100, 1000, 5000] - case result of- Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)- Right () -> pure ()+ case result of+ Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)+ Right () -> pure () runBenchmarkSuite :: KirokuStore -> Int -> IO () runBenchmarkSuite store n = do- printf "--- %d events ---\n\n" n+ printf "--- %d events ---\n\n" n - -- Pre-populate events- counter <- newIORef (0 :: Int)- let nextId = atomicModifyIORef' counter (\i -> (i + 1, i))+ -- Pre-populate events+ counter <- newIORef (0 :: Int)+ let nextId = atomicModifyIORef' counter (\i -> (i + 1, i)) - let streamPrefix = "bench-" <> T.pack (show n) <> "-"- let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. n]- let chunked = chunksOf 10 events- mapM_- ( \(idx, chunk) -> do- let sn = StreamName (streamPrefix <> T.pack (show idx))- Right _ <- runStoreIO store $ appendToStream sn AnyVersion chunk- pure ()- )- (zip [(0 :: Int) ..] chunked)- threadDelay 500_000+ let streamPrefix = "bench-" <> T.pack (show n) <> "-"+ let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. n]+ let chunked = chunksOf 10 events+ mapM_+ ( \(idx, chunk) -> do+ let sn = StreamName (streamPrefix <> T.pack (show idx))+ Right _ <- runStoreIO store $ appendToStream sn AnyVersion chunk+ pure ()+ )+ (zip [(0 :: Int) ..] chunked)+ threadDelay 500_000 - -- Run each benchmark multiple times- bareTimes <- sequence [benchBareSubscribe store n nextId | _ <- [1 .. iterations]]- streamTimes <- sequence [benchSubscriptionStream store n nextId | _ <- [1 .. iterations]]- shibuyaTimes <- sequence [benchShibuyaAdapter store n nextId | _ <- [1 .. iterations]]+ -- Run each benchmark multiple times+ bareTimes <- sequence [benchBareSubscribe store n nextId | _ <- [1 .. iterations]]+ streamTimes <- sequence [benchSubscriptionStream store n nextId | _ <- [1 .. iterations]]+ shibuyaTimes <- sequence [benchShibuyaAdapter store n nextId | _ <- [1 .. iterations]] - let bareMedian = median bareTimes- streamMedian = median streamTimes- shibuyaMedian = median shibuyaTimes+ let bareMedian = median bareTimes+ streamMedian = median streamTimes+ shibuyaMedian = median shibuyaTimes - putRow " bare subscribe" n bareTimes- putRow " subscriptionStream" n streamTimes- putRow " shibuya adapter" n shibuyaTimes+ putRow " bare subscribe" n bareTimes+ putRow " subscriptionStream" n streamTimes+ putRow " shibuya adapter" n shibuyaTimes - putStrLn ""- let streamVsBare = (streamMedian - bareMedian) / bareMedian * 100- shibuyaVsBare = (shibuyaMedian - bareMedian) / bareMedian * 100- shibuyaVsStream = (shibuyaMedian - streamMedian) / streamMedian * 100- printf " stream vs bare: %s\n" (showPct streamVsBare)- printf " shibuya vs bare: %s\n" (showPct shibuyaVsBare)- printf " shibuya vs stream: %s (adapter + framework overhead)\n" (showPct shibuyaVsStream)- putStrLn ""+ putStrLn ""+ let streamVsBare = (streamMedian - bareMedian) / bareMedian * 100+ shibuyaVsBare = (shibuyaMedian - bareMedian) / bareMedian * 100+ shibuyaVsStream = (shibuyaMedian - streamMedian) / streamMedian * 100+ printf " stream vs bare: %s\n" (showPct streamVsBare)+ printf " shibuya vs bare: %s\n" (showPct shibuyaVsBare)+ printf " shibuya vs stream: %s (adapter + framework overhead)\n" (showPct shibuyaVsStream)+ putStrLn "" -- | Benchmark: bare subscribe with IO handler benchBareSubscribe :: KirokuStore -> Int -> IO Int -> IO Double benchBareSubscribe store n nextId = do- subId <- nextId- let subName = SubscriptionName ("bare-bench-" <> T.pack (show subId))- countVar <- newTVarIO (0 :: Int)+ subId <- nextId+ let subName = SubscriptionName ("bare-bench-" <> T.pack (show subId))+ countVar <- newTVarIO (0 :: Int) - t0 <- getCurrentTime- let handler _evt = do- atomically $ do- c <- readTVar countVar- writeTVar countVar (c + 1)- pure Continue- let cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = handler- , batchSize = either (error . show) Prelude.id (mkBatchSize 500)- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- waitForCount countVar n 30_000_000- cancel handle- t1 <- getCurrentTime- pure (realToFrac (diffUTCTime t1 t0))+ t0 <- getCurrentTime+ let handler _evt = do+ atomically $ do+ c <- readTVar countVar+ writeTVar countVar (c + 1)+ pure Continue+ let cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = handler,+ batchSize = either (error . show) Prelude.id (mkBatchSize 500),+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ waitForCount countVar n 30_000_000+ cancel handle+ t1 <- getCurrentTime+ pure (realToFrac (diffUTCTime t1 t0)) -- | Benchmark: subscriptionStream consumed via Streamly fold benchSubscriptionStream :: KirokuStore -> Int -> IO Int -> IO Double benchSubscriptionStream store n nextId = do- subId <- nextId- let subName = SubscriptionName ("stream-bench-" <> T.pack (show subId))+ subId <- nextId+ let subName = SubscriptionName ("stream-bench-" <> T.pack (show subId)) - let cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = \_ -> pure Continue- , batchSize = either (error . show) Prelude.id (mkBatchSize 500)- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }+ let cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = \_ -> pure Continue,+ batchSize = either (error . show) Prelude.id (mkBatchSize 500),+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ } - t0 <- getCurrentTime- (stream, cancelStream) <- subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256))- Stream.fold Fold.drain (Stream.take n stream)- cancelStream- t1 <- getCurrentTime- pure (realToFrac (diffUTCTime t1 t0))+ t0 <- getCurrentTime+ (stream, cancelStream) <- subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256))+ Stream.fold Fold.drain (Stream.take n stream)+ cancelStream+ t1 <- getCurrentTime+ pure (realToFrac (diffUTCTime t1 t0)) -- | Benchmark: Shibuya adapter with runApp benchShibuyaAdapter :: KirokuStore -> Int -> IO Int -> IO Double benchShibuyaAdapter store n nextId = do- subId <- nextId- let subName = SubscriptionName ("shibuya-bench-" <> T.pack (show subId))- countVar <- newTVarIO (0 :: Int)+ subId <- nextId+ let subName = SubscriptionName ("shibuya-bench-" <> T.pack (show subId))+ countVar <- newTVarIO (0 :: Int) - t0 <- getCurrentTime- runEff $ runTracingNoop $ do- let cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = \_ -> pure Continue- , batchSize = either (error . show) Prelude.id (mkBatchSize 500)- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }+ t0 <- getCurrentTime+ runEff $ runTracingNoop $ do+ let cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = \_ -> pure Continue,+ batchSize = either (error . show) Prelude.id (mkBatchSize 500),+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ } - (ioStream, cancelAction) <- liftIO $ subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256))+ (ioStream, cancelAction) <- liftIO $ subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256)) - let effStream = Stream.morphInner liftIO ioStream- ingestedStream = fmap (mkIngested cancelAction) effStream+ let effStream = Stream.morphInner liftIO ioStream+ ingestedStream = fmap (mkIngested cancelAction) effStream - let adapter =- Adapter- { adapterName = "kiroku-bench"- , source = ingestedStream- , shutdown = liftIO cancelAction- }+ let adapter =+ Adapter+ { adapterName = "kiroku-bench",+ source = ingestedStream,+ shutdown = liftIO cancelAction+ } - 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+ 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 defaultAppConfig [(ProcessorId "bench", mkProcessor adapter handler)]- case res of- Left err -> liftIO $ error ("runApp failed: " <> show err)- Right appHandle -> do- liftIO $ waitForCount countVar n 30_000_000- stopApp appHandle+ res <- runApp defaultAppConfig [(ProcessorId "bench", mkProcessor adapter handler)]+ case res of+ Left err -> liftIO $ error ("runApp failed: " <> show err)+ Right appHandle -> do+ liftIO $ waitForCount countVar n 30_000_000+ stopApp appHandle - t1 <- getCurrentTime- pure (realToFrac (diffUTCTime t1 t0))+ t1 <- getCurrentTime+ pure (realToFrac (diffUTCTime t1 t0)) -- Helpers mkIngested :: (IOE :> es) => IO () -> RecordedEvent -> Ingested es RecordedEvent mkIngested cancelAction event =- Ingested- { envelope =- Envelope- { messageId = MessageId (T.pack (show (event ^. #globalPosition)))- , cursor = let GlobalPosition pos = event ^. #globalPosition in Just (CursorInt (fromIntegral pos))- , partition = Nothing- , enqueuedAt = Just (event ^. #createdAt)- , traceContext = Nothing- , headers = Nothing- , attempt = Nothing- , attributes = HashMap.empty- , payload = event- }- , ack =- AckHandle- { finalize = \case- AckHalt _ -> liftIO cancelAction- _ -> pure ()- }- , lease = Nothing- }+ Ingested+ { envelope =+ Envelope+ { messageId = MessageId (T.pack (show (event ^. #globalPosition))),+ cursor = let GlobalPosition pos = event ^. #globalPosition in Just (CursorInt (fromIntegral pos)),+ partition = Nothing,+ enqueuedAt = Just (event ^. #createdAt),+ traceContext = Nothing,+ headers = Nothing,+ attempt = Nothing,+ attributes = HashMap.empty,+ payload = event+ },+ ack =+ AckHandle+ { finalize = \case+ AckHalt _ -> liftIO cancelAction+ _ -> pure ()+ },+ lease = Nothing,+ deliveryStatus = pure DeliveryCurrent+ } waitForCount :: STM.TVar Int -> Int -> Int -> IO () waitForCount countVar target timeoutMicros = do- timeoutVar <- registerDelay timeoutMicros- ok <-- atomically $- (readTVar countVar >>= \c -> STM.check (c >= target) >> pure True)- `STM.orElse` (readTVar timeoutVar >>= STM.check >> pure False)- actual <- readTVarIO countVar- if ok- then pure ()- else error ("Timed out: expected " <> show target <> " events, got " <> show actual)+ timeoutVar <- registerDelay timeoutMicros+ ok <-+ atomically $+ (readTVar countVar >>= \c -> STM.check (c >= target) >> pure True)+ `STM.orElse` (readTVar timeoutVar >>= STM.check >> pure False)+ actual <- readTVarIO countVar+ if ok+ then pure ()+ else error ("Timed out: expected " <> show target <> " events, got " <> show actual) median :: [Double] -> Double median xs =- let sorted = sort xs- len = length sorted- in if even len- then (sorted !! (len `div` 2 - 1) + sorted !! (len `div` 2)) / 2- else sorted !! (len `div` 2)+ let sorted = sort xs+ len = length sorted+ in if even len+ then (sorted !! (len `div` 2 - 1) + sorted !! (len `div` 2)) / 2+ else sorted !! (len `div` 2) putRow :: String -> Int -> [Double] -> IO () putRow label n times = do- let med = median times- lo = minimum times- hi = maximum times- throughput = fromIntegral n / med :: Double- perEvent = med / fromIntegral n * 1_000_000 :: Double- printf- "%s: median %s [%s .. %s] (%d events/s, %s/event)\n"- label- (showMs med)- (showMs lo)- (showMs hi)- (round throughput :: Int)- (showUs perEvent)- hFlush stdout+ let med = median times+ lo = minimum times+ hi = maximum times+ throughput = fromIntegral n / med :: Double+ perEvent = med / fromIntegral n * 1_000_000 :: Double+ printf+ "%s: median %s [%s .. %s] (%d events/s, %s/event)\n"+ label+ (showMs med)+ (showMs lo)+ (showMs hi)+ (round throughput :: Int)+ (showUs perEvent)+ hFlush stdout showMs :: Double -> String showMs s- | ms < 1 = printf "%.1f ms" ms- | ms < 100 = printf "%.0f ms" ms- | otherwise = printf "%.0f ms" ms+ | ms < 1 = printf "%.1f ms" ms+ | ms < 100 = printf "%.0f ms" ms+ | otherwise = printf "%.0f ms" ms where ms = s * 1000 showUs :: Double -> String showUs us- | us >= 1000 = printf "%.1f ms" (us / 1000)- | us >= 1 = printf "%.0f μs" us- | otherwise = printf "%.1f μs" us+ | us >= 1000 = printf "%.1f ms" (us / 1000)+ | us >= 1 = printf "%.0f μs" us+ | otherwise = printf "%.1f μs" us showPct :: Double -> String showPct p- | p >= 0 = printf "+%.0f%%" p- | otherwise = printf "%.0f%%" p+ | p >= 0 = printf "+%.0f%%" p+ | otherwise = printf "%.0f%%" p chunksOf :: Int -> [a] -> [[a]] chunksOf _ [] = []@@ -318,11 +318,11 @@ makeEvent :: Text -> EventData makeEvent typ =- EventData- { eventId = Nothing- , eventType = EventType typ- , payload = Aeson.object [("benchmark", Aeson.Bool True)]- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }+ EventData+ { eventId = Nothing,+ eventType = EventType typ,+ payload = Aeson.object [("benchmark", Aeson.Bool True)],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }
bench/StreamHeadCost.hs view
@@ -23,59 +23,59 @@ main :: IO () main = do- start <- getCurrentTime- withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->- withStore (defaultConnectionSettings connection) $ \store -> do- sql store streamHeadFixtureSql- sql store "VACUUM (ANALYZE) stream_events"- details <-- Pool.use (store ^. #pool) $- Session.statement () $- preparable- "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"- E.noParams- (D.singleRow (D.column (D.nonNullable D.text)))- either (error . show) (putStrLn . T.unpack) details- setup <- getCurrentTime- putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))- groups <- mapM (sizeGroup store) [("100", StreamName "bench-1"), ("100000", StreamName "long-1")]- warmed <- getCurrentTime- putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))- defaultMain groups `finally` do- finished <- getCurrentTime- putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))- putStrLn ("total_seconds=" <> show (diffUTCTime finished start))+ start <- getCurrentTime+ withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->+ withStore (defaultConnectionSettings connection) $ \store -> do+ sql store streamHeadFixtureSql+ sql store "VACUUM (ANALYZE) stream_events"+ details <-+ Pool.use (store ^. #pool) $+ Session.statement () $+ preparable+ "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.text)))+ either (error . show) (putStrLn . T.unpack) details+ setup <- getCurrentTime+ putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))+ groups <- mapM (sizeGroup store) [("100", StreamName "bench-1"), ("100000", StreamName "long-1")]+ warmed <- getCurrentTime+ putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))+ defaultMain groups `finally` do+ finished <- getCurrentTime+ putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))+ putStrLn ("total_seconds=" <> show (diffUTCTime finished start)) sizeGroup :: KirokuStore -> (String, StreamName) -> IO Benchmark sizeGroup store (label, name) = do- Right (Just expected) <- runFrozen store (getStream name)- unless (expected ^. #version == StreamVersion (read label)) (error "invalid fixture version")- let validate result = case result of- Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")- other -> error ("metadata read failed: " <> show other)- control = replicateM_ 100 (runFrozen store (getStream name) >>= validate)- production = replicateM_ 100 (runStoreIO store (getStream name) >>= validate)- -- Equality checks every StreamInfo field in successful calls in both arms.- control- production- let withHead = replicateM_ 100 $ do- result <- runStoreIO store (getStreamWithHead name)- case result of- Right (Just (actual, Just headPosition)) -> do- validate (Right (Just actual))- unless- (headPosition == GlobalPosition (if label == "100" then 99001 else 200000))- (error "unexpected originated head")- other -> error ("head read failed: " <> show other)- withHead- pure $- bgroup- label- [ bench "control-metadata" (whnfIO control)- , bcompareWithin 0 1.10 ("$(NF-1) == \"" <> label <> "\" && $NF == \"control-metadata\"") $- bench "production-metadata" (whnfIO production)- , bench "production-with-head" (whnfIO withHead)- ]+ Right (Just expected) <- runFrozen store (getStream name)+ unless (expected ^. #version == StreamVersion (read label)) (error "invalid fixture version")+ let validate result = case result of+ Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")+ other -> error ("metadata read failed: " <> show other)+ control = replicateM_ 100 (runFrozen store (getStream name) >>= validate)+ production = replicateM_ 100 (runStoreIO store (getStream name) >>= validate)+ -- Equality checks every StreamInfo field in successful calls in both arms.+ control+ production+ let withHead = replicateM_ 100 $ do+ result <- runStoreIO store (getStreamWithHead name)+ case result of+ Right (Just (actual, Just headPosition)) -> do+ validate (Right (Just actual))+ unless+ (headPosition == GlobalPosition (if label == "100" then 99001 else 200000))+ (error "unexpected originated head")+ other -> error ("head read failed: " <> show other)+ withHead+ pure $+ bgroup+ label+ [ bench "control-metadata" (whnfIO control),+ bcompareWithin 0 1.10 ("$(NF-1) == \"" <> label <> "\" && $NF == \"control-metadata\"") $+ bench "production-metadata" (whnfIO production),+ bench "production-with-head" (whnfIO withHead)+ ] sql :: KirokuStore -> T.Text -> IO () sql store command = Pool.use (store ^. #pool) (Session.script command) >>= either (error . show) pure@@ -87,33 +87,33 @@ frozenInterpreter :: (IOE :> es, Error StoreError :> es) => KirokuStore -> Eff (Store : es) a -> Eff es a frozenInterpreter store = interpret_ $ \case- GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)- _ -> error "unexpected operation in frozen metadata control"+ GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)+ _ -> error "unexpected operation in frozen metadata control" frozenPool :: (IOE :> es, Error StoreError :> es) => Pool.Pool -> Session.Session a -> Eff es a frozenPool pool session = do- result <- liftIO (Pool.use pool session)- case result of- Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))- Right a -> pure a+ result <- liftIO (Pool.use pool session)+ case result of+ Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))+ Right a -> pure a frozenStatement :: Statement T.Text (Maybe StreamInfo) frozenStatement =- preparable- """- SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before- FROM streams- WHERE stream_name = $1- """- (E.param (E.nonNullable E.text))- (D.rowMaybe frozenRow)+ preparable+ """+ SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before+ FROM streams+ WHERE stream_name = $1+ """+ (E.param (E.nonNullable E.text))+ (D.rowMaybe frozenRow) frozenRow :: D.Row StreamInfo frozenRow =- StreamInfo- <$> (StreamId <$> D.column (D.nonNullable D.int8))- <*> (StreamName <$> D.column (D.nonNullable D.text))- <*> (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))+ StreamInfo+ <$> (StreamId <$> D.column (D.nonNullable D.int8))+ <*> (StreamName <$> D.column (D.nonNullable D.text))+ <*> (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))
bench/StreamHeadPaired.hs view
@@ -26,82 +26,82 @@ -- ABBA/BAAB schedule. Each CSV row is one batch; no adaptive early stopping. main :: IO () main = do- [schedulePath, outputPath, callsText] <- getArgs- let calls = read callsText :: Int- unless (calls > 0 && calls <= 10000 && calls `mod` 5 == 0) (error "invalid batch size")- schedule <- map (T.splitOn ",") . T.lines . T.pack <$> readFile schedulePath- start <- getCurrentTime- withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->- withStore (defaultConnectionSettings connection) $ \store -> do- sql store streamHeadFixtureSql- sql store "VACUUM (ANALYZE) stream_events"- details <-- Pool.use (store ^. #pool) $- Session.statement () $- preparable- "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"- E.noParams- (D.singleRow (D.column (D.nonNullable D.text)))- either (error . show) (putStrLn . T.unpack) details- setup <- getCurrentTime- putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))- small <- actions store (StreamName "bench-1") (StreamVersion 100)- large <- actions store (StreamName "long-1") (StreamVersion 100000)- let (frozenSmall, productionSmall) = small- (frozenLarge, productionLarge) = large- groups =- [ ("legacy-100", (frozenSmall, productionSmall, calls))- , ("legacy-100000", (frozenLarge, productionLarge, calls))- , ("aa-100", (productionSmall, productionSmall, calls))- , ("aa-100000", (productionLarge, productionLarge, calls))- , ("positive-20pct", (productionLarge, productionLarge, calls * 6 `div` 5))+ [schedulePath, outputPath, callsText] <- getArgs+ let calls = read callsText :: Int+ unless (calls > 0 && calls <= 10000 && calls `mod` 5 == 0) (error "invalid batch size")+ schedule <- map (T.splitOn ",") . T.lines . T.pack <$> readFile schedulePath+ start <- getCurrentTime+ withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->+ withStore (defaultConnectionSettings connection) $ \store -> do+ sql store streamHeadFixtureSql+ sql store "VACUUM (ANALYZE) stream_events"+ details <-+ Pool.use (store ^. #pool) $+ Session.statement () $+ preparable+ "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.text)))+ either (error . show) (putStrLn . T.unpack) details+ setup <- getCurrentTime+ putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))+ small <- actions store (StreamName "bench-1") (StreamVersion 100)+ large <- actions store (StreamName "long-1") (StreamVersion 100000)+ let (frozenSmall, productionSmall) = small+ (frozenLarge, productionLarge) = large+ groups =+ [ ("legacy-100", (frozenSmall, productionSmall, calls)),+ ("legacy-100000", (frozenLarge, productionLarge, calls)),+ ("aa-100", (productionSmall, productionSmall, calls)),+ ("aa-100000", (productionLarge, productionLarge, calls)),+ ("positive-20pct", (productionLarge, productionLarge, calls * 6 `div` 5))+ ]+ -- Warm both actual implementations and connections before measurement.+ forM_ [frozenSmall, productionSmall, frozenLarge, productionLarge] (replicateM_ 1000)+ warmed <- getCurrentTime+ putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))+ withFile outputPath WriteMode $ \out -> do+ hSetBuffering out LineBuffering+ hPutStrLn out "round,case,order,slot,arm,calls,start_ns,end_ns,duration_ns"+ forM_ schedule $ \row -> case row of+ [roundText, caseName, order] -> do+ unless (order == "ABBA" || order == "BAAB") (error "invalid order")+ (armA, armB, callsB) <- maybe (error "unknown case") pure (lookup caseName groups)+ forM_ (zip [0 :: Int ..] (T.unpack order)) $ \(slot, arm) -> do+ let count = if arm == 'A' then calls else callsB+ action = if arm == 'A' then armA else armB+ before <- getMonotonicTimeNSec+ replicateM_ count action+ after <- getMonotonicTimeNSec+ hPutStrLn out $+ T.unpack $+ T.intercalate+ ","+ [ roundText,+ caseName,+ order,+ T.pack (show slot),+ T.singleton arm,+ T.pack (show count),+ T.pack (show before),+ T.pack (show after),+ T.pack (show (after - before)) ]- -- Warm both actual implementations and connections before measurement.- forM_ [frozenSmall, productionSmall, frozenLarge, productionLarge] (replicateM_ 1000)- warmed <- getCurrentTime- putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))- withFile outputPath WriteMode $ \out -> do- hSetBuffering out LineBuffering- hPutStrLn out "round,case,order,slot,arm,calls,start_ns,end_ns,duration_ns"- forM_ schedule $ \row -> case row of- [roundText, caseName, order] -> do- unless (order == "ABBA" || order == "BAAB") (error "invalid order")- (armA, armB, callsB) <- maybe (error "unknown case") pure (lookup caseName groups)- forM_ (zip [0 :: Int ..] (T.unpack order)) $ \(slot, arm) -> do- let count = if arm == 'A' then calls else callsB- action = if arm == 'A' then armA else armB- before <- getMonotonicTimeNSec- replicateM_ count action- after <- getMonotonicTimeNSec- hPutStrLn out $- T.unpack $- T.intercalate- ","- [ roundText- , caseName- , order- , T.pack (show slot)- , T.singleton arm- , T.pack (show count)- , T.pack (show before)- , T.pack (show after)- , T.pack (show (after - before))- ]- _ -> error "invalid schedule row"- finished <- getCurrentTime- putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))- putStrLn ("total_seconds=" <> show (diffUTCTime finished start))+ _ -> error "invalid schedule row"+ finished <- getCurrentTime+ putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))+ putStrLn ("total_seconds=" <> show (diffUTCTime finished start)) -- The A/A arms receive the same IO action, including validation. The positive -- control repeats that same action 20% more times, without normalizing it away. actions :: KirokuStore -> StreamName -> StreamVersion -> IO (IO (), IO ()) actions store name version = do- Right (Just expected) <- runFrozen store (getStream name)- unless (expected ^. #version == version) (error "invalid fixture version")- let validate result = case result of- Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")- other -> error ("metadata read failed: " <> show other)- pure (runFrozen store (getStream name) >>= validate, runStoreIO store (getStream name) >>= validate)+ Right (Just expected) <- runFrozen store (getStream name)+ unless (expected ^. #version == version) (error "invalid fixture version")+ let validate result = case result of+ Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")+ other -> error ("metadata read failed: " <> show other)+ pure (runFrozen store (getStream name) >>= validate, runStoreIO store (getStream name) >>= validate) sql :: KirokuStore -> T.Text -> IO () sql store command = Pool.use (store ^. #pool) (Session.script command) >>= either (error . show) pure@@ -113,33 +113,33 @@ frozenInterpreter :: (IOE :> es, Error StoreError :> es) => KirokuStore -> Eff (Store : es) a -> Eff es a frozenInterpreter store = interpret_ $ \case- GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)- _ -> error "unexpected operation in frozen metadata control"+ GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)+ _ -> error "unexpected operation in frozen metadata control" frozenPool :: (IOE :> es, Error StoreError :> es) => Pool.Pool -> Session.Session a -> Eff es a frozenPool pool session = do- result <- liftIO (Pool.use pool session)- case result of- Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))- Right a -> pure a+ result <- liftIO (Pool.use pool session)+ case result of+ Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))+ Right a -> pure a frozenStatement :: Statement T.Text (Maybe StreamInfo) frozenStatement =- preparable- """- SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before- FROM streams- WHERE stream_name = $1- """- (E.param (E.nonNullable E.text))- (D.rowMaybe frozenRow)+ preparable+ """+ SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before+ FROM streams+ WHERE stream_name = $1+ """+ (E.param (E.nonNullable E.text))+ (D.rowMaybe frozenRow) frozenRow :: D.Row StreamInfo frozenRow =- StreamInfo- <$> (StreamId <$> D.column (D.nonNullable D.int8))- <*> (StreamName <$> D.column (D.nonNullable D.text))- <*> (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))+ StreamInfo+ <$> (StreamId <$> D.column (D.nonNullable D.int8))+ <*> (StreamName <$> D.column (D.nonNullable D.text))+ <*> (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))
kiroku-store.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: kiroku-store-version: 0.11.0.1+version: 0.12.0.0 synopsis: High-performance PostgreSQL event store description: Kiroku is a PostgreSQL-backed event store for Haskell applications. It@@ -122,6 +122,7 @@ Test.HistoryRetention Test.HistoryRetentionMock Test.InterpreterHooks+ Test.NamespaceQueryPlans Test.NotifyGuard Test.PerformanceStructure Test.Properties@@ -146,6 +147,7 @@ Test.SubscriptionCheckpointWorker Test.SubscriptionDeadLetters Test.SubscriptionDeadLettersMock+ Test.SubscriptionNamespace Test.SubscriptionPauseResume Test.SubscriptionReconnect Test.SubscriptionRegistry@@ -258,7 +260,7 @@ , kiroku-store , kiroku-test-support , lens >=5.2 && <5.4- , shibuya-core >=0.10 && <0.11+ , shibuya-core >=0.12 && <0.13 , stm >=2.5 && <2.6 , streamly >=0.11 , streamly-core >=0.3 && <0.4
src/Kiroku/Store.hs view
@@ -1,14 +1,13 @@-{- | Kiroku Store — high-performance PostgreSQL event store.--Public API re-exports.--Note: The effectful @subscribe@ wrapper from "Kiroku.Store.Subscription.Effect"-is /not/ re-exported here to avoid a name clash with-"Kiroku.Store.Subscription.subscribe". Import @Kiroku.Store.Subscription.Effect@-explicitly to use the effectful version.--}-module Kiroku.Store (- module Kiroku.Store.Types,+-- | Kiroku Store — high-performance PostgreSQL event store.+--+-- Public API re-exports.+--+-- Note: The effectful @subscribe@ wrapper from "Kiroku.Store.Subscription.Effect"+-- is /not/ re-exported here to avoid a name clash with+-- "Kiroku.Store.Subscription.subscribe". Import @Kiroku.Store.Subscription.Effect@+-- explicitly to use the effectful version.+module Kiroku.Store+ ( module Kiroku.Store.Types, module Kiroku.Store.Connection, module Kiroku.Store.Effect, module Kiroku.Store.Effect.Resource,@@ -47,7 +46,8 @@ ConnectionStatus (..), ConnectionReadyForUseReason (..), ConnectionTerminationReason (..),-) where+ )+where import Hasql.Pool.Observation (ConnectionReadyForUseReason (..), ConnectionStatus (..), ConnectionTerminationReason (..), Observation (..)) import Kiroku.Store.Append
src/Kiroku/Store/Append.hs view
@@ -1,7 +1,8 @@-module Kiroku.Store.Append (- appendToStream,+module Kiroku.Store.Append+ ( appendToStream, appendMultiStream,-) where+ )+where import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (send)@@ -9,103 +10,101 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Append a batch of events to a single stream under the given-'ExpectedVersion' precondition.--When to use this vs.-'Kiroku.Store.Transaction.runTransactionAppending':--* Reach for 'appendToStream' by default. It is the cheapest path —- one CTE on a pool connection, no Haskell-layer @BEGIN@/@COMMIT@- envelope, no caller-driven retry semantics to reason about.-* Reach for 'Kiroku.Store.Transaction.runTransactionAppending' /only/- when you need to atomically combine the append with additional SQL- writes that the store doesn't perform (most prominently a- projection-row insert from a downstream projection table). The- wrapper opens an explicit 'BEGIN'/'COMMIT', enables PostgreSQL- serialization-conflict retry of the entire body by default, and- threads the caller's continuation into the same transaction.--Semantics:--* If the precondition fails, the entire batch is rejected — appends are- all-or-nothing per call.-* On success, events are visible to subsequent reads on /this/ store- handle (read-your-own-writes is guaranteed). Other store handles see- the events after their pool connection's transaction-visibility- horizon advances.-* The returned 'AppendResult' carries the position of the /last/ event- in the batch.--Idempotent retries: supply 'Kiroku.Store.Types.EventData.eventId' yourself-and retry on transient failures. A retry whose previous attempt actually-committed surfaces as 'Kiroku.Store.Error.DuplicateEvent' (when the-events_pkey detail is parseable). A retry that observed-'Kiroku.Store.Error.WrongExpectedVersion' on an 'ExactVersion' append-should be treated as ambiguous: either a concurrent writer raced you or-your previous attempt succeeded; the recovery in both cases is to-re-read the stream and decide.--Errors (all variants of 'Kiroku.Store.Error.StoreError'):--* 'Kiroku.Store.Error.WrongExpectedVersion' — 'ExactVersion' mismatch.-* 'Kiroku.Store.Error.StreamNotFound' — 'StreamExists' against a missing- or soft-deleted stream.-* 'Kiroku.Store.Error.ReservedStreamName' — the target is @$all@, which- is the global read stream and cannot be appended as an application- stream.-* 'Kiroku.Store.Error.StreamAlreadyExists' — 'NoStream' against an- 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-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'.--}+-- | Append a batch of events to a single stream under the given+-- 'ExpectedVersion' precondition.+--+-- When to use this vs.+-- 'Kiroku.Store.Transaction.runTransactionAppending':+--+-- * Reach for 'appendToStream' by default. It is the cheapest path —+-- one CTE on a pool connection, no Haskell-layer @BEGIN@/@COMMIT@+-- envelope, no caller-driven retry semantics to reason about.+-- * Reach for 'Kiroku.Store.Transaction.runTransactionAppending' /only/+-- when you need to atomically combine the append with additional SQL+-- writes that the store doesn't perform (most prominently a+-- projection-row insert from a downstream projection table). The+-- wrapper opens an explicit 'BEGIN'/'COMMIT', enables PostgreSQL+-- serialization-conflict retry of the entire body by default, and+-- threads the caller's continuation into the same transaction.+--+-- Semantics:+--+-- * If the precondition fails, the entire batch is rejected — appends are+-- all-or-nothing per call.+-- * On success, events are visible to subsequent reads on /this/ store+-- handle (read-your-own-writes is guaranteed). Other store handles see+-- the events after their pool connection's transaction-visibility+-- horizon advances.+-- * The returned 'AppendResult' carries the position of the /last/ event+-- in the batch.+--+-- Idempotent retries: supply 'Kiroku.Store.Types.EventData.eventId' yourself+-- and retry on transient failures. A retry whose previous attempt actually+-- committed surfaces as 'Kiroku.Store.Error.DuplicateEvent' (when the+-- events_pkey detail is parseable). A retry that observed+-- 'Kiroku.Store.Error.WrongExpectedVersion' on an 'ExactVersion' append+-- should be treated as ambiguous: either a concurrent writer raced you or+-- your previous attempt succeeded; the recovery in both cases is to+-- re-read the stream and decide.+--+-- Errors (all variants of 'Kiroku.Store.Error.StoreError'):+--+-- * 'Kiroku.Store.Error.WrongExpectedVersion' — 'ExactVersion' mismatch.+-- * 'Kiroku.Store.Error.StreamNotFound' — 'StreamExists' against a missing+-- or soft-deleted stream.+-- * 'Kiroku.Store.Error.ReservedStreamName' — the target is @$all@, which+-- is the global read stream and cannot be appended as an application+-- stream.+-- * 'Kiroku.Store.Error.StreamAlreadyExists' — 'NoStream' against an+-- 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+-- 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) =>- StreamName ->- ExpectedVersion ->- [EventData] ->- Eff es AppendResult+ (HasCallStack, Store :> es) =>+ StreamName ->+ ExpectedVersion ->+ [EventData] ->+ Eff es AppendResult appendToStream name expected events = send (AppendToStream name expected events) -{- | Atomically append events to multiple streams in a single-transaction.--Either every per-stream append succeeds or all of them roll back —-there is no partial-commit state. The interpreter pre-locks the named-streams in deterministic @stream_id@ order to avoid deadlocks when two-concurrent multi-stream calls touch overlapping streams in different-user orders.--@$all@ is reserved for the global read stream. If any operation targets-@$all@, the whole call is rejected before opening the transaction with-'Kiroku.Store.Error.ReservedStreamName'.--Per-stream errors are attributed to the stream that caused them: the-@Right results@ branch of the interpreter iterates the input list and-maps each empty CTE result back to the corresponding @(StreamName,-ExpectedVersion)@. Errors raised as PostgreSQL exceptions are-attributed via 'Kiroku.Store.Error.attributeMultiStreamError', which-parses the server-error detail for the offending stream name when-possible; the latent misattribution path (a future schema change that-introduces a constraint violation visible to the client) is covered-defensively.--The returned list mirrors the input order. Each 'AppendResult' carries-the corresponding stream's final state.--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.--}+-- | Atomically append events to multiple streams in a single+-- transaction.+--+-- Either every per-stream append succeeds or all of them roll back —+-- there is no partial-commit state. The interpreter pre-locks the named+-- streams in deterministic @stream_id@ order to avoid deadlocks when two+-- concurrent multi-stream calls touch overlapping streams in different+-- user orders.+--+-- @$all@ is reserved for the global read stream. If any operation targets+-- @$all@, the whole call is rejected before opening the transaction with+-- 'Kiroku.Store.Error.ReservedStreamName'.+--+-- Per-stream errors are attributed to the stream that caused them: the+-- @Right results@ branch of the interpreter iterates the input list and+-- maps each empty CTE result back to the corresponding @(StreamName,+-- ExpectedVersion)@. Errors raised as PostgreSQL exceptions are+-- attributed via 'Kiroku.Store.Error.attributeMultiStreamError', which+-- parses the server-error detail for the offending stream name when+-- possible; the latent misattribution path (a future schema change that+-- introduces a constraint violation visible to the client) is covered+-- defensively.+--+-- The returned list mirrors the input order. Each 'AppendResult' carries+-- the corresponding stream's final state.+--+-- 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) =>- [(StreamName, ExpectedVersion, [EventData])] ->- Eff es [AppendResult]+ (HasCallStack, Store :> es) =>+ [(StreamName, ExpectedVersion, [EventData])] ->+ Eff es [AppendResult] appendMultiStream ops = send (AppendMultiStream ops)
src/Kiroku/Store/Causation.hs view
@@ -1,25 +1,25 @@-{- | Smart constructors for the causation- and correlation-walking reads.--These wrap the 'Kiroku.Store.Effect.FindEvents' constructor with a filter-suited to a specific question:--* 'findCausationDescendants' — walk the causation graph forward from a- trigger event and return every event that descended from it.-* 'findCausationAncestors' — walk the causation graph backward from a- leaf event and return every event reachable by following- @causation_id@ upward.-* 'findByCorrelation' — return every event whose @correlation_id@ equals- the input.--All three reuse the existing partial indexes @ix_events_causation_id@ and-@ix_events_correlation_id@ on the @events@ table, so they do not require-any schema change.--}-module Kiroku.Store.Causation (- findCausationDescendants,+-- | Smart constructors for the causation- and correlation-walking reads.+--+-- These wrap the 'Kiroku.Store.Effect.FindEvents' constructor with a filter+-- suited to a specific question:+--+-- * 'findCausationDescendants' — walk the causation graph forward from a+-- trigger event and return every event that descended from it.+-- * 'findCausationAncestors' — walk the causation graph backward from a+-- leaf event and return every event reachable by following+-- @causation_id@ upward.+-- * 'findByCorrelation' — return every event whose @correlation_id@ equals+-- the input.+--+-- All three reuse the existing partial indexes @ix_events_causation_id@ and+-- @ix_events_correlation_id@ on the @events@ table, so they do not require+-- any schema change.+module Kiroku.Store.Causation+ ( findCausationDescendants, findCausationAncestors, findByCorrelation,-) where+ )+where import Data.UUID (UUID) import Data.Vector (Vector)@@ -29,38 +29,35 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Return the seed event and every event whose @causation_id@ chain leads-back to it, in ascending @global_position@ order. The seed event is-included as the depth-0 row when it exists; otherwise the result is-empty.--Uses the @ix_events_causation_id@ partial index. Cost is-@O(depth * log n)@ where @depth@ is the length of the longest chain-rooted at the seed and @n@ is the total event count.--}+-- | Return the seed event and every event whose @causation_id@ chain leads+-- back to it, in ascending @global_position@ order. The seed event is+-- included as the depth-0 row when it exists; otherwise the result is+-- empty.+--+-- Uses the @ix_events_causation_id@ partial index. Cost is+-- @O(depth * log n)@ where @depth@ is the length of the longest chain+-- rooted at the seed and @n@ is the total event count. findCausationDescendants ::- (HasCallStack, Store :> es) =>- EventId ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ EventId ->+ Eff es (Vector RecordedEvent) findCausationDescendants eid = send (FindEvents (FilterCausationDescendants eid)) -{- | Return the seed event and every ancestor reachable via @causation_id@,-in depth-ascending order (the seed is first, its immediate cause is-second, etc.). The seed event is included as the depth-0 row when it-exists; otherwise the result is empty.--}+-- | Return the seed event and every ancestor reachable via @causation_id@,+-- in depth-ascending order (the seed is first, its immediate cause is+-- second, etc.). The seed event is included as the depth-0 row when it+-- exists; otherwise the result is empty. findCausationAncestors ::- (HasCallStack, Store :> es) =>- EventId ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ EventId ->+ Eff es (Vector RecordedEvent) findCausationAncestors eid = send (FindEvents (FilterCausationAncestors eid)) -{- | Return every event whose @correlation_id@ equals the input, in-ascending @global_position@ order. Uses the @ix_events_correlation_id@-partial index.--}+-- | Return every event whose @correlation_id@ equals the input, in+-- ascending @global_position@ order. Uses the @ix_events_correlation_id@+-- partial index. findByCorrelation ::- (HasCallStack, Store :> es) =>- UUID ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ UUID ->+ Eff es (Vector RecordedEvent) findByCorrelation cid = send (FindEvents (FilterCorrelation cid))
src/Kiroku/Store/Connection.hs view
@@ -1,10 +1,11 @@-module Kiroku.Store.Connection (- KirokuStore (..),+module Kiroku.Store.Connection+ ( KirokuStore (..), ConnectionSettingsM (..), ConnectionSettings, defaultConnectionSettings, withStore,-) where+ )+where import Control.Concurrent.STM (TVar, newTVarIO) import Control.Exception (bracket, onException)@@ -35,90 +36,84 @@ -- | Connection settings for the store, parameterized by monad. data ConnectionSettingsM m = ConnectionSettings- { connString :: !Text- {- ^ PostgreSQL connection string (libpq URI or key=value format).- Reaches libpq verbatim; no application-level parsing or- substitution. May contain a password.- -}- , poolSize :: !Int- -- ^ Connection pool size (default: 10)- , schema :: !Text- {- ^ PostgreSQL schema that owns every Kiroku object- (default: @"kiroku"@).-- This field is authoritative for two things, kept in lockstep:-- * __Table resolution.__ Every pooled connection runs- @SET search_path TO \<schema\>, pg_catalog@ before any statement,- so the unqualified names in "Kiroku.Store.SQL" resolve to this- schema.- * __Notification channel.__ The- 'Kiroku.Store.Notification.Notifier' issues- @LISTEN \<schema\>.events@ on its dedicated connection, matching the- channel published by the migration-created @notify_events()@- trigger.-- To run more than one isolated 'KirokuStore' against the same database- (for example schema-per-tenant), migrate each schema and give each- store a distinct 'schema'; each gets its own set of Kiroku tables and- its own notification channel.- -}- , extraSearchPath :: ![Text]- {- ^ Additional schemas appended to every pooled connection's- @search_path@, after 'schema' and before @pg_catalog@ (default: @[]@).-- The store's own unqualified names always resolve to 'schema' first.- This is for consumers whose /application/ objects — typically inline- projections' read-model tables, queried on the same pool as the event- store — live in another schema (commonly @public@). Without it those- unqualified application tables are unreachable on the store pool, since- 'schema' replaces the default @search_path@. Entries are quoted as- identifiers; like 'schema' they are not validated at connection time- (PostgreSQL does not validate @search_path@ entries).- -}- , idleInTransactionTimeout :: !Int- -- ^ idle_in_transaction_session_timeout in seconds (default: 30)- , statementTimeout :: !(Maybe Int)- {- ^ When @Just s@, set @statement_timeout = 's'@ (in seconds) on- every pooled connection via @initSession@. Bounds the wall-clock- runtime of any single statement; protects against pathological- queries holding pool slots indefinitely. Default 'Nothing'- (PostgreSQL's session default applies — typically @0@,- meaning no timeout).-- A reasonable starting value for typical workloads is @Just 30@- (30 seconds) — long enough to absorb GC pauses and transient- slow disks, short enough to free the pool slot under genuine- pathology. See @docs\/PRODUCTION-TUNING.md@ for sizing- guidance.- -}- , observationHandler :: !(Maybe (Observation -> m ()))- -- ^ Optional callback for pool connection lifecycle events- , eventHandler :: !(Maybe (KirokuEvent -> m ()))- {- ^ Optional callback for store-emitted operational events. See- "Kiroku.Store.Observability" for the event taxonomy. Covers- notifier reconnection, publisher pool errors, subscription- lifecycle and per-phase database errors, and hard-delete- issuance — events that 'observationHandler' (which surfaces- @hasql-pool@'s connection-lifecycle observations) does not- cover.-- Invoked synchronously from the originating thread (notifier- loop, publisher loop, worker loop, store interpreter); slow- callbacks stall those loops. For callbacks that may block, fan- out asynchronously (e.g., write to a 'TBQueue' and drain in a- separate thread).- -}- , storeSettings :: !StoreSettings- {- ^ Interpreter-level hooks applied to 'EventData' on the append- path and to 'RecordedEvent' on the read and subscription paths.- Defaults to 'defaultStoreSettings' (no-op).-- See "Kiroku.Store.Settings" for the hook semantics and the- OpenTelemetry trace-context use case that motivates this seam.- -}- }- deriving stock (Generic)+ { -- | PostgreSQL connection string (libpq URI or key=value format).+ -- Reaches libpq verbatim; no application-level parsing or+ -- substitution. May contain a password.+ connString :: !Text,+ -- | Connection pool size (default: 10)+ poolSize :: !Int,+ -- | PostgreSQL schema that owns every Kiroku object+ -- (default: @"kiroku"@).+ --+ -- This field is authoritative for two things, kept in lockstep:+ --+ -- * __Table resolution.__ Every pooled connection runs+ -- @SET search_path TO \<schema\>, pg_catalog@ before any statement,+ -- so the unqualified names in "Kiroku.Store.SQL" resolve to this+ -- schema.+ -- * __Notification channel.__ The+ -- 'Kiroku.Store.Notification.Notifier' issues+ -- @LISTEN \<schema\>.events@ on its dedicated connection, matching the+ -- channel published by the migration-created @notify_events()@+ -- trigger.+ --+ -- To run more than one isolated 'KirokuStore' against the same database+ -- (for example schema-per-tenant), migrate each schema and give each+ -- store a distinct 'schema'; each gets its own set of Kiroku tables and+ -- its own notification channel.+ schema :: !Text,+ -- | Additional schemas appended to every pooled connection's+ -- @search_path@, after 'schema' and before @pg_catalog@ (default: @[]@).+ --+ -- The store's own unqualified names always resolve to 'schema' first.+ -- This is for consumers whose /application/ objects — typically inline+ -- projections' read-model tables, queried on the same pool as the event+ -- store — live in another schema (commonly @public@). Without it those+ -- unqualified application tables are unreachable on the store pool, since+ -- 'schema' replaces the default @search_path@. Entries are quoted as+ -- identifiers; like 'schema' they are not validated at connection time+ -- (PostgreSQL does not validate @search_path@ entries).+ extraSearchPath :: ![Text],+ -- | idle_in_transaction_session_timeout in seconds (default: 30)+ idleInTransactionTimeout :: !Int,+ -- | When @Just s@, set @statement_timeout = 's'@ (in seconds) on+ -- every pooled connection via @initSession@. Bounds the wall-clock+ -- runtime of any single statement; protects against pathological+ -- queries holding pool slots indefinitely. Default 'Nothing'+ -- (PostgreSQL's session default applies — typically @0@,+ -- meaning no timeout).+ --+ -- A reasonable starting value for typical workloads is @Just 30@+ -- (30 seconds) — long enough to absorb GC pauses and transient+ -- slow disks, short enough to free the pool slot under genuine+ -- pathology. See @docs\/PRODUCTION-TUNING.md@ for sizing+ -- guidance.+ statementTimeout :: !(Maybe Int),+ -- | Optional callback for pool connection lifecycle events+ observationHandler :: !(Maybe (Observation -> m ())),+ -- | Optional callback for store-emitted operational events. See+ -- "Kiroku.Store.Observability" for the event taxonomy. Covers+ -- notifier reconnection, publisher pool errors, subscription+ -- lifecycle and per-phase database errors, and hard-delete+ -- issuance — events that 'observationHandler' (which surfaces+ -- @hasql-pool@'s connection-lifecycle observations) does not+ -- cover.+ --+ -- Invoked synchronously from the originating thread (notifier+ -- loop, publisher loop, worker loop, store interpreter); slow+ -- callbacks stall those loops. For callbacks that may block, fan+ -- out asynchronously (e.g., write to a 'TBQueue' and drain in a+ -- separate thread).+ eventHandler :: !(Maybe (KirokuEvent -> m ())),+ -- | Interpreter-level hooks applied to 'EventData' on the append+ -- path and to 'RecordedEvent' on the read and subscription paths.+ -- Defaults to 'defaultStoreSettings' (no-op).+ --+ -- See "Kiroku.Store.Settings" for the hook semantics and the+ -- OpenTelemetry trace-context use case that motivates this seam.+ storeSettings :: !StoreSettings+ }+ deriving stock (Generic) -- | Connection settings defaulting to 'IO'. type ConnectionSettings = ConnectionSettingsM IO@@ -126,154 +121,149 @@ -- | Default connection settings. defaultConnectionSettings :: Text -> ConnectionSettings defaultConnectionSettings cs =- ConnectionSettings- { connString = cs- , poolSize = 10- , schema = "kiroku"- , extraSearchPath = []- , idleInTransactionTimeout = 30- , statementTimeout = Nothing- , observationHandler = Nothing- , eventHandler = Nothing- , storeSettings = defaultStoreSettings- }+ ConnectionSettings+ { connString = cs,+ poolSize = 10,+ schema = "kiroku",+ extraSearchPath = [],+ idleInTransactionTimeout = 30,+ statementTimeout = Nothing,+ observationHandler = Nothing,+ eventHandler = Nothing,+ storeSettings = defaultStoreSettings+ } -- | The store handle. Holds a connection pool, schema name, and subscription infrastructure. data KirokuStore = KirokuStore- { pool :: !Pool- , schema :: !Text- , notifier :: !Notifier- , publisher :: !EventPublisher- , eventHandler :: !(Maybe (KirokuEvent -> IO ()))- {- ^ Effective event handler captured from- 'ConnectionSettingsM.eventHandler' when 'withStore' acquires- the store. Surfaces hard-delete events emitted by- 'Kiroku.Store.Effect.runStorePool' and is the channel- 'Kiroku.Store.Subscription.subscribe' threads through to- 'Kiroku.Store.Subscription.Worker.runWorker'.- -}- , storeSettings :: !StoreSettings- {- ^ Interpreter-level hooks captured from- 'ConnectionSettingsM.storeSettings' when 'withStore' acquires- the store. Reached by 'Kiroku.Store.Effect.runStorePool' and the- subscription publisher\/worker for every event flowing through.- -}- , subscriptionRegistry :: !(TVar (Map (SubscriptionName, Int32) (Unique, TVar SubscriptionState)))- {- ^ Central registry of every live subscription worker's FSM-state cell,- keyed by (subscription name, consumer-group member; 0 for non-group).- Each entry also carries the worker's registry token, so cleanup from an- older worker cannot delete a newer worker's replacement entry for the same- key.- Each 'Kiroku.Store.Subscription.subscribe' call registers its worker's- 'stateVar' here on start and removes it on any exit (stop, cancel, crash)- via the worker's @finally@ cleanup. The cell is the same 'TVar' the worker- writes on every transition and 'currentState' reads — it is registered, not- copied — so the registry observes the live value with no extra write path.- A subscription is represented in the snapshot by /presence/ while live and- by /absence/ once it has stopped, been cancelled, or crashed; the FSM never- writes 'Kiroku.Store.Subscription.Fsm.Stopped' into the cell, so a not-live- subscription is never represented by a @"stopped"@ phase.- Read a consistent snapshot with- 'Kiroku.Store.Subscription.subscriptionStates'.- -}- }- deriving stock (Generic)--{- | Bracket-style store lifecycle.--Acquire phase, in order:--1. Acquire the connection pool from @hasql-pool@ with the configured- size and the @idle_in_transaction_session_timeout@ init session.-2. Start the 'Kiroku.Store.Notification.Notifier' on a dedicated- connection: @LISTEN \<schema\>.events@.-3. Start the 'Kiroku.Store.Subscription.EventPublisher' which consumes- notifier ticks and broadcasts new events to subscribers.--Release phase, in reverse order:--1. Cancel the 'Kiroku.Store.Subscription.EventPublisher' worker.-2. Stop the 'Kiroku.Store.Notification.Notifier' (cancel listener,- release connection).-3. Release the pool.+ { pool :: !Pool,+ schema :: !Text,+ notifier :: !Notifier,+ publisher :: !EventPublisher,+ -- | Effective event handler captured from+ -- 'ConnectionSettingsM.eventHandler' when 'withStore' acquires+ -- the store. Surfaces hard-delete events emitted by+ -- 'Kiroku.Store.Effect.runStorePool' and is the channel+ -- 'Kiroku.Store.Subscription.subscribe' threads through to+ -- 'Kiroku.Store.Subscription.Worker.runWorker'.+ eventHandler :: !(Maybe (KirokuEvent -> IO ())),+ -- | Interpreter-level hooks captured from+ -- 'ConnectionSettingsM.storeSettings' when 'withStore' acquires+ -- the store. Reached by 'Kiroku.Store.Effect.runStorePool' and the+ -- subscription publisher\/worker for every event flowing through.+ storeSettings :: !StoreSettings,+ -- | Central registry of every live subscription worker's FSM-state cell,+ -- keyed by (subscription name, consumer-group member; 0 for non-group).+ -- Each entry also carries the worker's registry token, so cleanup from an+ -- older worker cannot delete a newer worker's replacement entry for the same+ -- key.+ -- Each 'Kiroku.Store.Subscription.subscribe' call registers its worker's+ -- 'stateVar' here on start and removes it on any exit (stop, cancel, crash)+ -- via the worker's @finally@ cleanup. The cell is the same 'TVar' the worker+ -- writes on every transition and 'currentState' reads — it is registered, not+ -- copied — so the registry observes the live value with no extra write path.+ -- A subscription is represented in the snapshot by /presence/ while live and+ -- by /absence/ once it has stopped, been cancelled, or crashed; the FSM never+ -- writes 'Kiroku.Store.Subscription.Fsm.Stopped' into the cell, so a not-live+ -- subscription is never represented by a @"stopped"@ phase.+ -- Read a consistent snapshot with+ -- 'Kiroku.Store.Subscription.subscriptionStates'.+ subscriptionRegistry :: !(TVar (Map (SubscriptionName, Int32) (Unique, TVar SubscriptionState)))+ }+ deriving stock (Generic) -The @bracket@ semantics guarantee release runs on either normal exit-or an exception in the body. The 'MonadUnliftIO' constraint matches-'Control.Exception.bracket'; consumers in pure 'IO' get an exact match,-consumers in effectful monads with an unlift in scope (e.g., a-'ReaderT'-like stack) get the same guarantee transparently.--}+-- | Bracket-style store lifecycle.+--+-- Acquire phase, in order:+--+-- 1. Acquire the connection pool from @hasql-pool@ with the configured+-- size and the @idle_in_transaction_session_timeout@ init session.+-- 2. Start the 'Kiroku.Store.Notification.Notifier' on a dedicated+-- connection: @LISTEN \<schema\>.events@.+-- 3. Start the 'Kiroku.Store.Subscription.EventPublisher' which consumes+-- notifier ticks and broadcasts new events to subscribers.+--+-- Release phase, in reverse order:+--+-- 1. Cancel the 'Kiroku.Store.Subscription.EventPublisher' worker.+-- 2. Stop the 'Kiroku.Store.Notification.Notifier' (cancel listener,+-- release connection).+-- 3. Release the pool.+--+-- The @bracket@ semantics guarantee release runs on either normal exit+-- or an exception in the body. The 'MonadUnliftIO' constraint matches+-- 'Control.Exception.bracket'; consumers in pure 'IO' get an exact match,+-- consumers in effectful monads with an unlift in scope (e.g., a+-- 'ReaderT'-like stack) get the same guarantee transparently. withStore :: (MonadUnliftIO m) => ConnectionSettings -> (KirokuStore -> m a) -> m a withStore settings action = withRunInIO $ \runInIO ->- bracket acquire release (runInIO . action)+ bracket acquire release (runInIO . action) where initScript :: Text initScript =- T.intercalate "; " $- -- Resolve unqualified Kiroku table names (see "Kiroku.Store.SQL")- -- to the configured schema on every pooled connection, followed by- -- any 'extraSearchPath' schemas (for application objects such as- -- inline-projection read-model tables queried on the same pool).- -- Setting a not-yet-created schema is harmless: PostgreSQL does not- -- validate search_path entries. Runtime queries still require- -- migrations to have created the schemas before the store is opened.- ( "SET search_path TO "- <> T.intercalate- ", "- (quoteIdentifier (settings ^. #schema) : map quoteIdentifier (settings ^. #extraSearchPath))- <> ", pg_catalog"- )- : ("SET idle_in_transaction_session_timeout = '" <> T.pack (show (settings ^. #idleInTransactionTimeout)) <> "s'")- : maybe- []- (\t -> ["SET statement_timeout = '" <> T.pack (show t) <> "s'"])- (settings ^. #statementTimeout)+ T.intercalate "; " $+ -- Resolve unqualified Kiroku table names (see "Kiroku.Store.SQL")+ -- to the configured schema on every pooled connection, followed by+ -- any 'extraSearchPath' schemas (for application objects such as+ -- inline-projection read-model tables queried on the same pool).+ -- Setting a not-yet-created schema is harmless: PostgreSQL does not+ -- validate search_path entries. Runtime queries still require+ -- migrations to have created the schemas before the store is opened.+ ( "SET search_path TO "+ <> T.intercalate+ ", "+ (quoteIdentifier (settings ^. #schema) : map quoteIdentifier (settings ^. #extraSearchPath))+ <> ", pg_catalog"+ )+ : ("SET idle_in_transaction_session_timeout = '" <> T.pack (show (settings ^. #idleInTransactionTimeout)) <> "s'")+ : maybe+ []+ (\t -> ["SET statement_timeout = '" <> T.pack (show t) <> "s'"])+ (settings ^. #statementTimeout) poolConfig :: Pool.Config.Config poolConfig =- Pool.Config.settings $- [ Pool.Config.staticConnectionSettings (Conn.connectionString (settings ^. #connString))- , Pool.Config.size (settings ^. #poolSize)- , Pool.Config.initSession (Session.script initScript)- ]- ++ maybe [] (\h -> [Pool.Config.observationHandler h]) (settings ^. #observationHandler)+ Pool.Config.settings $+ [ Pool.Config.staticConnectionSettings (Conn.connectionString (settings ^. #connString)),+ Pool.Config.size (settings ^. #poolSize),+ Pool.Config.initSession (Session.script initScript)+ ]+ ++ maybe [] (\h -> [Pool.Config.observationHandler h]) (settings ^. #observationHandler) acquire = do- p <- Pool.acquire poolConfig- flip onException (Pool.release p) $ do- let s = settings ^. #schema- cs = settings ^. #connString- evtHandler = settings ^. #eventHandler- stSettings = settings ^. #storeSettings- -- Start Notifier (dedicated LISTEN connection)- n <- Notifier.startNotifier cs s evtHandler- flip onException (Notifier.stopNotifier n) $ do- -- Start EventPublisher (depends on Notifier's TChan)- pub <- Publisher.startPublisher p (Notifier.tickChan n) evtHandler stSettings- -- Central, in-memory subscription-state registry: starts empty- -- and is populated/cleaned by each `subscribe` call's worker- -- lifecycle. Purely in-memory; discarded when the store closes.- reg <- newTVarIO Map.empty- pure- KirokuStore- { pool = p- , schema = s- , notifier = n- , publisher = pub- , eventHandler = evtHandler- , storeSettings = stSettings- , subscriptionRegistry = reg- }+ p <- Pool.acquire poolConfig+ flip onException (Pool.release p) $ do+ let s = settings ^. #schema+ cs = settings ^. #connString+ evtHandler = settings ^. #eventHandler+ stSettings = settings ^. #storeSettings+ -- Start Notifier (dedicated LISTEN connection)+ n <- Notifier.startNotifier cs s evtHandler+ flip onException (Notifier.stopNotifier n) $ do+ -- Start EventPublisher (depends on Notifier's TChan)+ pub <- Publisher.startPublisher p (Notifier.tickChan n) evtHandler stSettings+ -- Central, in-memory subscription-state registry: starts empty+ -- and is populated/cleaned by each `subscribe` call's worker+ -- lifecycle. Purely in-memory; discarded when the store closes.+ reg <- newTVarIO Map.empty+ pure+ KirokuStore+ { pool = p,+ schema = s,+ notifier = n,+ publisher = pub,+ eventHandler = evtHandler,+ storeSettings = stSettings,+ subscriptionRegistry = reg+ } release store = do- -- Stop in reverse order: Publisher first, then Notifier, then pool- Publisher.stopPublisher (store ^. #publisher)- Notifier.stopNotifier (store ^. #notifier)- Pool.release (store ^. #pool)+ -- Stop in reverse order: Publisher first, then Notifier, then pool+ Publisher.stopPublisher (store ^. #publisher)+ Notifier.stopNotifier (store ^. #notifier)+ Pool.release (store ^. #pool) -{- | Quote a 'Text' as a PostgreSQL identifier: wrap it in double quotes and-double any embedded double quote. Used to set the configured schema in-@search_path@ without risking SQL injection from a hostile schema setting.--}+-- | Quote a 'Text' as a PostgreSQL identifier: wrap it in double quotes and+-- double any embedded double quote. Used to set the configured schema in+-- @search_path@ without risking SQL injection from a hostile schema setting. quoteIdentifier :: Text -> Text quoteIdentifier ident = "\"" <> T.replace "\"" "\"\"" ident <> "\""
src/Kiroku/Store/Effect.hs view
@@ -1,709 +1,696 @@ {-# LANGUAGE TypeFamilies #-} -module Kiroku.Store.Effect (- -- * The Store effect- Store (..),-- -- * Interpreters- runStorePool,- runStoreResource,- runStoreIO,-- -- * Internal building blocks-- --- -- $internal- PreparedEvent,- prepareEvents,- buildAppendParams,- appendDispatchTx,-) where--import Control.Lens ((^.))-import Control.Monad.Except qualified as Except-import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.Aeson (Value)-import Data.Generics.Labels ()-import Data.Int (Int32)-import Data.List (find)-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Maybe (isNothing)-import Data.Text (Text)-import Data.Time.Clock (UTCTime, getCurrentTime)-import Data.UUID (UUID)-import Data.UUID.V7 qualified as V7-import Data.Vector (Vector)-import Data.Vector qualified as V-import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, runEff, (:>))-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, isTransientSerializationError, mapGenericUsageError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName)-import Kiroku.Store.HistoryRetention.Internal qualified as HistoryRetention-import Kiroku.Store.HistoryRetention.Types-import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop)-import Kiroku.Store.SQL qualified as SQL-import Kiroku.Store.Settings (StoreSettings (..), enrichEvents)-import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL-import Kiroku.Store.Subscription.CheckpointInventory.SQL qualified as CheckpointInventorySQL-import Kiroku.Store.Subscription.DeadLetter.SQL qualified as DeadLetterSQL-import Kiroku.Store.Subscription.Types (- CheckpointInitialization,- MissingCheckpointPolicy,- SubscriptionCheckpointInventory,- SubscriptionCheckpointMissing,- SubscriptionDeadLetterPage,- SubscriptionDeadLetterQuery,- SubscriptionName,- )-import Kiroku.Store.Types---- ------------------------------------------------------------------------------ Store effect--- ------------------------------------------------------------------------------- | The Store effect — dynamically dispatched, mockable.-data Store :: Effect where- AppendToStream :: StreamName -> ExpectedVersion -> [EventData] -> Store m AppendResult- ReadStreamForward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)- ReadStreamBackward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)- ReadAllForward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)- ReadAllBackward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)- {- | Read the greatest position whose @$all@ junction still survives, or- zero when no event remains visible. Unlike- 'Kiroku.Store.Subscription.subscriptionCheckpointInventory', this is a- visibility cursor rather than the authoritative append frontier.-- Surfaced as 'Kiroku.Store.Read.visibleGlobalHeadPosition'.- -}- GetVisibleGlobalHeadPosition :: Store m GlobalPosition- GetStream :: StreamName -> Store m (Maybe StreamInfo)- {- | One-statement metadata and newest surviving originated head observation.- Outer 'Nothing' means absent; inner 'Nothing' means no originated events,- including link-only streams and @$all@. Logical lifecycle markers do not- hide the head. Custom exhaustive interpreters must handle this constructor.- -}- GetStreamWithHead :: StreamName -> Store m (Maybe (StreamInfo, Maybe GlobalPosition))- {- | Resolve a 'StreamName' to its surrogate 'StreamId' without- materializing the full 'StreamInfo' row. Mirrors 'GetStream'\'s- soft-delete semantics: returns 'Just' for both live and soft-deleted- streams, 'Nothing' for hard-deleted or never-created streams.-- Cheaper than 'GetStream' — decodes one @int8@ column instead of five.- 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- are absent.-- The primary use is recovering source stream names from the- 'RecordedEvent.originalStreamId' of events obtained via fan-in reads- (@$all@, categories, causation/correlation queries, subscriptions): collect- the distinct ids from a batch and resolve them once, rather than per event.- Surfaced as 'Kiroku.Store.Read.lookupStreamNames' (and the singular- 'Kiroku.Store.Read.lookupStreamName').- -}- LookupStreamNames :: [StreamId] -> Store m (Map StreamId StreamName)- {- | Byte-ordered names; optional exact category, literal prefix and exclusive cursor.- Includes soft-deleted streams, excludes only the reserved stream row.- -}- ListStreams :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Store m (Vector StreamInfo)- -- | Deployment-collation category order with an exclusive cursor.- ListCategories :: Maybe CategoryName -> BrowsePageSize -> Store m (Vector CategoryName)- -- | The canonical global-log event; absent after hard deletion.- GetEvent :: EventId -> Store m (Maybe RecordedEvent)- LinkToStream :: StreamName -> [EventId] -> Store m LinkResult- ReadCategoryForward :: CategoryName -> GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)- AppendMultiStream :: [(StreamName, ExpectedVersion, [EventData])] -> Store m [AppendResult]- {- | Fetch a set of 'RecordedEvent' rows that match an 'EventFilter'.- Surfaced to consumers as the smart constructors in- "Kiroku.Store.Causation": 'Kiroku.Store.Causation.findByCorrelation',- 'Kiroku.Store.Causation.findCausationDescendants', and- 'Kiroku.Store.Causation.findCausationAncestors'.-- The filter is a closed sum ('EventFilter'); mock interpreters can- pattern-match exhaustively.- -}- FindEvents :: EventFilter -> Store m (Vector RecordedEvent)- 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)- {- | Read the global store position and all durable subscription checkpoint- rows from one PostgreSQL statement snapshot.-- Surfaced as- 'Kiroku.Store.Subscription.subscriptionCheckpointInventory'.- -}- GetSubscriptionCheckpointInventory :: Store m SubscriptionCheckpointInventory- -- | Read-only keyset page; no event decode hook or checkpoint write.- ListSubscriptionDeadLetters :: SubscriptionDeadLetterQuery -> Store m SubscriptionDeadLetterPage- {- | Resolve one exact subscription checkpoint key according to its- missing-row policy. Existing rows always take precedence.-- Surfaced as- 'Kiroku.Store.Subscription.initializeSubscriptionCheckpoint'.- -}- InitializeSubscriptionCheckpoint ::- SubscriptionName ->- Int32 ->- MissingCheckpointPolicy ->- Store m (Either SubscriptionCheckpointMissing CheckpointInitialization)- AcquireHistoryRetentionLease :: HistoryRetentionLeaseRequest -> Store m HistoryRetentionLease- RenewHistoryRetentionLease ::- HistoryRetentionLeaseHandle ->- HistoryRetentionLeaseDuration ->- Store m (Either HistoryRetentionRenewalError HistoryRetentionLease)- ReleaseHistoryRetentionLease ::- HistoryRetentionLeaseHandle ->- Store m HistoryRetentionReleaseResult- GetHistoryRetentionLeaseInventory ::- HistoryRetentionInventoryQuery ->- Store m (Vector HistoryRetentionLease)- PruneHistoryRetentionLeases :: UTCTime -> Store m HistoryRetentionPruneResult- {- | 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- expected to reject this constructor.- -}- RunTransaction :: Tx.Transaction a -> Store m a- {- | Like 'RunTransaction' but uses- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood —- the body is run exactly once even on PostgreSQL serialization- conflicts.- -}- RunTransactionNoRetry :: Tx.Transaction a -> Store m a--type instance DispatchOf Store = Dynamic---- ------------------------------------------------------------------------------ PostgreSQL interpreter--- ------------------------------------------------------------------------------- | Interpret Store operations against PostgreSQL via hasql-pool.-runStorePool ::- (IOE :> es, Error StoreError :> es) =>- KirokuStore ->- Eff (Store : es) a ->- Eff es a-runStorePool store = interpret_ $ \case- AppendToStream (StreamName name) expected events -> do- 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- 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)- Right Nothing ->- throwError (emptyResultError name expected)- Right (Just r) ->- pure r- ReadStreamForward (StreamName name) (StreamVersion startVer) limit -> do- evs <-- usePool (store ^. #pool) $- Session.statement (name, startVer, limit) SQL.readStreamForwardStmt- decodeReadEvents (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, cursor, limit) SQL.readStreamBackwardStmt- decodeReadEvents (store ^. #storeSettings) evs- ReadAllForward (GlobalPosition startPos) limit -> do- evs <-- usePool (store ^. #pool) $- Session.statement (startPos, limit) SQL.readAllForwardStmt- decodeReadEvents (store ^. #storeSettings) evs- ReadAllBackward (GlobalPosition startPos) limit -> do- let cursor = if startPos == 0 then maxBound else startPos- evs <-- usePool (store ^. #pool) $- Session.statement (cursor, limit) SQL.readAllBackwardStmt- decodeReadEvents (store ^. #storeSettings) evs- GetVisibleGlobalHeadPosition ->- usePool (store ^. #pool) $- Session.statement () SQL.visibleGlobalHeadPositionStmt- GetStreamWithHead (StreamName name) ->- usePool (store ^. #pool) $- Session.statement name SQL.getStreamWithHeadStmt- GetStream (StreamName name) ->- usePool (store ^. #pool) $- Session.statement name SQL.getStreamStmt- LookupStreamId (StreamName name) ->- 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)- ( usePool (store ^. #pool) $- Session.statement [s | StreamId s <- sids] SQL.lookupStreamNamesStmt- )- ListStreams category prefix after limit ->- usePool (store ^. #pool) (SQL.listStreamsSession category prefix after limit)- ListCategories after limit ->- fmap (V.map CategoryName) $- usePool (store ^. #pool) $- Session.statement (fmap (\(CategoryName value) -> value) after, browsePageSizeValue limit) (SQL.listCategoriesStmt (not (isNothing after)))- GetEvent (EventId eid) -> do- found <- usePool (store ^. #pool) $ Session.statement eid SQL.getEventStmt- decoded <- decodeReadEvents (store ^. #storeSettings) (maybe V.empty V.singleton found)- pure (decoded V.!? 0)- LinkToStream (StreamName name) eventIds -> do- rejectInvalidApplicationStream name- let uuids = V.fromList [uid | EventId uid <- eventIds]- result <-- liftIO $- Pool.use (store ^. #pool) $- Session.statement (uuids, name) SQL.linkToStreamStmt- case result of- 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- decodeReadEvents (store ^. #storeSettings) evs- AppendMultiStream [] ->- pure []- AppendMultiStream ops -> do- 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 <-- mapM- ( \(sn, ev, evts) -> do- evts' <- liftIO $ enrichEvents (store ^. #storeSettings) evts- prepared <- prepareEvents evts'- pure (sn, ev, prepared)- )- ops- let names = V.fromList [n | (StreamName n, _, _) <- ops]- let runOnce =- Pool.use (store ^. #pool) $- 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)- Right results -> do- -- Check for any Nothing results (version conflicts)- let indexed = zip ops results- mapM- ( \((StreamName sn, ev, _), mResult) ->- case mResult of- Nothing -> throwError (emptyResultError sn ev)- Just r -> pure r- )- indexed- FindEvents filt -> do- evs <- case filt of- FilterCorrelation cid ->- usePool (store ^. #pool) $- Session.statement cid SQL.findByCorrelationStmt- FilterCausationDescendants (EventId eid) ->- usePool (store ^. #pool) $- Session.statement eid SQL.findCausationDescendantsStmt- FilterCausationAncestors (EventId eid) ->- usePool (store ^. #pool) $- Session.statement eid SQL.findCausationAncestorsStmt- decodeReadEvents (store ^. #storeSettings) evs- SoftDeleteStream (StreamName name) -> do- rejectInvalidApplicationStream name- usePool (store ^. #pool) $- Session.statement name SQL.softDeleteStreamStmt- HardDeleteStream (StreamName name) -> do- rejectInvalidApplicationStream name- let txn = do- Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"- HistoryRetention.lockHistoryRetentionCoordinatorTx- mSid <- Tx.statement name SQL.findStreamIdStmt- case mSid of- Nothing -> pure (Right Nothing)- Just sid -> do- conflict <- HistoryRetention.activeHistoryRetentionConflictTx- case conflict of- Just active -> pure (Left active)- Nothing -> do- HistoryRetention.lockAffectedStreamsForHardDeleteTx sid- 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 (Right (Just (StreamId sid)))- result <-- usePool (store ^. #pool) $- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn- -- Emit a fail-safe audit signal when the delete actually removed- -- rows. Compliance-grade audit should still record an- -- application-level event before calling hardDeleteStream — see- -- docs/PRODUCTION-DEPLOYMENT.md.- case result of- Right (Just sid) ->- liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHardDeleteIssued (StreamName name) sid)- Right Nothing -> pure ()- Left conflict -> do- liftIO $- emitOrDrop- (store ^. #eventHandler)- (KirokuEventHardDeleteHistoryRetentionConflict (StreamName name) conflict)- throwError (HistoryRetentionActive (StreamName name) conflict)- pure (either (const Nothing) (\value -> value) result)- UndeleteStream (StreamName name) -> do- 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- ListSubscriptionDeadLetters query ->- usePool (store ^. #pool) (DeadLetterSQL.listSubscriptionDeadLettersSession query)- GetSubscriptionCheckpointInventory ->- usePool (store ^. #pool) $- Session.statement () CheckpointInventorySQL.getSubscriptionCheckpointInventoryStmt- InitializeSubscriptionCheckpoint subscriptionName member policy ->- usePool (store ^. #pool) $- CheckpointSQL.initializeSubscriptionCheckpointSession subscriptionName member policy- AcquireHistoryRetentionLease request -> do- lease <- runTxOnPool (store ^. #pool) TxSessions.transaction (HistoryRetention.acquireHistoryRetentionLeaseTx request)- let HistoryRetentionLease{leaseId, owner, protectedThrough, expiresAt} = lease- liftIO $- emitOrDrop- (store ^. #eventHandler)- (KirokuEventHistoryRetentionLeaseAcquired leaseId owner protectedThrough expiresAt)- pure lease- RenewHistoryRetentionLease handle duration -> do- result <-- runTxOnPool- (store ^. #pool)- TxSessions.transaction- (HistoryRetention.renewHistoryRetentionLeaseTx handle duration)- case result of- Right lease ->- let HistoryRetentionLease{leaseId, owner, expiresAt} = lease- in liftIO $- emitOrDrop- (store ^. #eventHandler)- (KirokuEventHistoryRetentionLeaseRenewed leaseId owner expiresAt)- Left _ -> pure ()- pure result- ReleaseHistoryRetentionLease handle -> do- result <-- runTxOnPool- (store ^. #pool)- TxSessions.transaction- (HistoryRetention.releaseHistoryRetentionLeaseTx handle)- case result of- HistoryRetentionReleased HistoryRetentionLease{leaseId, owner, releasedAt = Just releasedAt} ->- liftIO $- emitOrDrop- (store ^. #eventHandler)- (KirokuEventHistoryRetentionLeaseReleased leaseId owner releasedAt)- _ -> pure ()- pure result- GetHistoryRetentionLeaseInventory query ->- runTxOnPool- (store ^. #pool)- TxSessions.transaction- (HistoryRetention.historyRetentionLeaseInventoryTx query)- PruneHistoryRetentionLeases cutoff -> do- result <-- runTxOnPool- (store ^. #pool)- TxSessions.transaction- (HistoryRetention.pruneHistoryRetentionLeasesTx cutoff)- liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHistoryRetentionLeasesPruned result)- pure result- RunTransaction tx ->- runTxOnPool (store ^. #pool) TxSessions.transaction tx- RunTransactionNoRetry tx ->- runTxOnPool (store ^. #pool) TxSessions.transactionNoRetry tx---- | Convenience: run a Store computation to IO.-runStoreIO ::- KirokuStore ->- Eff '[Store, Error StoreError, IOE] a ->- IO (Either StoreError a)-runStoreIO store = runEff . runErrorNoCallStack . runStorePool store---- | Interpret Store by reading the store handle from 'KirokuStoreResource'.-runStoreResource ::- (IOE :> es, Error StoreError :> es, KirokuStoreResource :> es) =>- Eff (Store : es) a ->- Eff es a-runStoreResource action = do- store <- getKirokuStore- runStorePool store action---- ------------------------------------------------------------------------------ Internal pool helper--- ------------------------------------------------------------------------------- | Run a hasql session against the pool, mapping pool errors to 'StoreError'.-usePool ::- (IOE :> es, Error StoreError :> es) =>- Pool ->- Session.Session a ->- Eff es a-usePool pool session = do- result <- liftIO (Pool.use pool session)- case result of- Left usageErr -> throwError (mapGenericUsageError usageErr)- Right a -> pure a--{- | Run a 'Tx.Transaction' against the pool using the supplied entry-point ('TxSessions.transaction' or 'TxSessions.transactionNoRetry'),-mapping pool errors to 'StoreError'. The isolation level and access-mode mirror 'appendMultiStream' / 'HardDeleteStream'.--}-runTxOnPool ::- (IOE :> es, Error StoreError :> es) =>- Pool ->- (TxSessions.IsolationLevel -> TxSessions.Mode -> Tx.Transaction a -> Session.Session a) ->- Tx.Transaction a ->- Eff es a-runTxOnPool pool entry tx = do- result <-- liftIO $- Pool.use pool $- entry TxSessions.ReadCommitted TxSessions.Write tx- case result of- Left usageErr -> throwError (mapTransactionUsageError usageErr)- Right a -> pure a--rejectInvalidApplicationStream ::- (Error StoreError :> es) =>- Text ->- Eff es ()-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)--- ------------------------------------------------------------------------------- | An event with a guaranteed event ID (pre-generated if needed).-data PreparedEvent = PreparedEvent- { peEventId :: !UUID- , peEventType :: !EventType- , pePayload :: !Value- , peMetadata :: !(Maybe Value)- , peCausationId :: !(Maybe UUID)- , peCorrelationId :: !(Maybe UUID)- }- deriving stock (Generic)--{- | Prepare events by generating UUIDv7s for any event that doesn't-have a caller-supplied event ID.--}-prepareEvents :: (MonadIO m) => [EventData] -> m [PreparedEvent]-prepareEvents evts = liftIO $ do- let needCount = length (filter (\(EventData eid _ _ _ _ _) -> isNothing eid) evts)- newIds <-- if needCount > 0- then V7.genUUIDs (fromIntegral needCount)- else pure []- pure (assign evts newIds)- where- assign :: [EventData] -> [UUID] -> [PreparedEvent]- assign [] _ = []- assign (EventData mEid eType ePayload eMeta eCaus eCorr : es) ids =- case mEid of- Just (EventId uid) ->- PreparedEvent uid eType ePayload eMeta eCaus eCorr- : assign es ids- Nothing -> case ids of- (uid : rest) ->- PreparedEvent uid eType ePayload eMeta eCaus eCorr- : assign es rest- [] -> error "prepareEvents: ran out of pre-generated UUIDs (bug)"---- | Build SQL parameters from prepared events.-buildAppendParams :: Text -> UTCTime -> [PreparedEvent] -> SQL.AppendParams-buildAppendParams name now prepared =- SQL.AppendParams- { eventIds = V.fromList (map (^. #peEventId) prepared)- , eventTypes = V.fromList (map (\e -> let EventType t = e ^. #peEventType in t) prepared)- , causationIds = V.fromList (map (^. #peCausationId) prepared)- , correlationIds = V.fromList (map (^. #peCorrelationId) prepared)- , payloads = V.fromList (map (^. #pePayload) prepared)- , metadatas = V.fromList (map (^. #peMetadata) prepared)- , 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'.--This is the shared building block used by 'AppendMultiStream'\'s-interpreter branch and by 'Kiroku.Store.Transaction.appendToStreamTx'.-'AppendToStream' keeps its 'Session.statement'-flavored dispatch — see-the M2 entry in the Decision Log on plan 11 for why a 'Tx.Transaction'-wrapping a single statement was rejected as a refactoring target.--@'Nothing'@ comes back when the underlying CTE returns 0 rows — i.e.-the precondition failed silently. Callers map that to either-'Kiroku.Store.Error.AppendConflict' (the Tx surface) or-'Kiroku.Store.Error.StoreError' (the @Eff@ surface).--}-appendDispatchTx :: ExpectedVersion -> SQL.AppendParams -> Tx.Transaction (Maybe AppendResult)-appendDispatchTx expected params = case expected of- ExactVersion (StreamVersion v) ->- Tx.statement (params, v) SQL.appendExpectedVersion- StreamExists ->- Tx.statement params SQL.appendStreamExists- NoStream ->- Tx.statement params SQL.appendNoStream- AnyVersion ->- Tx.statement params SQL.appendAnyVersion--{- $internal-These bindings are intentionally exposed so that-"Kiroku.Store.Transaction" can compose appends with arbitrary-'Tx.Transaction' work without re-implementing UUID prep, parameter-packing, or per-version dispatch. They are not part of the supported-public surface and may change without notice.--}---- No hook preserves the direct read passthrough. Successful hooks map directly--- into a result vector, avoiding the subscription-specific wrapper/unwrapper.-decodeReadEvents :: (IOE :> es, Error StoreError :> es) => StoreSettings -> Vector RecordedEvent -> Eff es (Vector RecordedEvent)-decodeReadEvents settings events = case decodeHook settings of- Nothing -> pure events- Just hook -> V.mapM (\event -> liftIO (hook event) >>= either (throwError . EventDecodeFailed) pure) events+module Kiroku.Store.Effect+ ( -- * The Store effect+ Store (..),++ -- * Interpreters+ runStorePool,+ runStoreResource,+ runStoreIO,++ -- * Internal building blocks++ --+ -- $internal+ PreparedEvent,+ prepareEvents,+ buildAppendParams,+ appendDispatchTx,+ )+where++import Control.Lens ((^.))+import Control.Monad.Except qualified as Except+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Aeson (Value)+import Data.Generics.Labels ()+import Data.Int (Int32)+import Data.List (find)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (isNothing)+import Data.Text (Text)+import Data.Time.Clock (UTCTime, getCurrentTime)+import Data.UUID (UUID)+import Data.UUID.V7 qualified as V7+import Data.Vector (Vector)+import Data.Vector qualified as V+import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, runEff, (:>))+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, preparable)+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, isTransientSerializationError, mapGenericUsageError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName)+import Kiroku.Store.HistoryRetention.Internal qualified as HistoryRetention+import Kiroku.Store.HistoryRetention.Types+import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop)+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Settings (StoreSettings (..), enrichEvents)+import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL+import Kiroku.Store.Subscription.CheckpointInventory.SQL qualified as CheckpointInventorySQL+import Kiroku.Store.Subscription.DeadLetter.SQL qualified as DeadLetterSQL+import Kiroku.Store.Subscription.Types+ ( CheckpointInitialization,+ MissingCheckpointPolicy,+ SubscriptionCheckpointInventory,+ SubscriptionCheckpointMissing,+ SubscriptionDeadLetterPage,+ SubscriptionDeadLetterQuery,+ SubscriptionName,+ )+import Kiroku.Store.Types++-- ---------------------------------------------------------------------------+-- Store effect+-- ---------------------------------------------------------------------------++-- | The Store effect — dynamically dispatched, mockable.+data Store :: Effect where+ AppendToStream :: StreamName -> ExpectedVersion -> [EventData] -> Store m AppendResult+ ReadStreamForward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)+ ReadStreamBackward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)+ ReadAllForward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)+ ReadAllBackward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)+ -- | Read the greatest position whose @$all@ junction still survives, or+ -- zero when no event remains visible. Unlike+ -- 'Kiroku.Store.Subscription.subscriptionCheckpointInventory', this is a+ -- visibility cursor rather than the authoritative append frontier.+ --+ -- Surfaced as 'Kiroku.Store.Read.visibleGlobalHeadPosition'.+ GetVisibleGlobalHeadPosition :: Store m GlobalPosition+ GetStream :: StreamName -> Store m (Maybe StreamInfo)+ -- | One-statement metadata and newest surviving originated head observation.+ -- Outer 'Nothing' means absent; inner 'Nothing' means no originated events,+ -- including link-only streams and @$all@. Logical lifecycle markers do not+ -- hide the head. Custom exhaustive interpreters must handle this constructor.+ GetStreamWithHead :: StreamName -> Store m (Maybe (StreamInfo, Maybe GlobalPosition))+ -- | Resolve a 'StreamName' to its surrogate 'StreamId' without+ -- materializing the full 'StreamInfo' row. Mirrors 'GetStream'\'s+ -- soft-delete semantics: returns 'Just' for both live and soft-deleted+ -- streams, 'Nothing' for hard-deleted or never-created streams.+ --+ -- Cheaper than 'GetStream' — decodes one @int8@ column instead of five.+ -- 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+ -- are absent.+ --+ -- The primary use is recovering source stream names from the+ -- 'RecordedEvent.originalStreamId' of events obtained via fan-in reads+ -- (@$all@, categories, causation/correlation queries, subscriptions): collect+ -- the distinct ids from a batch and resolve them once, rather than per event.+ -- Surfaced as 'Kiroku.Store.Read.lookupStreamNames' (and the singular+ -- 'Kiroku.Store.Read.lookupStreamName').+ LookupStreamNames :: [StreamId] -> Store m (Map StreamId StreamName)+ -- | Byte-ordered names; optional exact category, literal prefix and exclusive cursor.+ -- Includes soft-deleted streams, excludes only the reserved stream row.+ ListStreams :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Store m (Vector StreamInfo)+ -- | Deployment-collation category order with an exclusive cursor.+ ListCategories :: Maybe CategoryName -> BrowsePageSize -> Store m (Vector CategoryName)+ -- | The canonical global-log event; absent after hard deletion.+ GetEvent :: EventId -> Store m (Maybe RecordedEvent)+ LinkToStream :: StreamName -> [EventId] -> Store m LinkResult+ ReadCategoryForward :: CategoryName -> GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)+ AppendMultiStream :: [(StreamName, ExpectedVersion, [EventData])] -> Store m [AppendResult]+ -- | Fetch a set of 'RecordedEvent' rows that match an 'EventFilter'.+ -- Surfaced to consumers as the smart constructors in+ -- "Kiroku.Store.Causation": 'Kiroku.Store.Causation.findByCorrelation',+ -- 'Kiroku.Store.Causation.findCausationDescendants', and+ -- 'Kiroku.Store.Causation.findCausationAncestors'.+ --+ -- The filter is a closed sum ('EventFilter'); mock interpreters can+ -- pattern-match exhaustively.+ FindEvents :: EventFilter -> Store m (Vector RecordedEvent)+ 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)+ -- | Read the global store position and all durable subscription checkpoint+ -- rows from one PostgreSQL statement snapshot.+ --+ -- Surfaced as+ -- 'Kiroku.Store.Subscription.subscriptionCheckpointInventory'.+ GetSubscriptionCheckpointInventory :: Store m SubscriptionCheckpointInventory+ -- | Read-only keyset page; no event decode hook or checkpoint write.+ ListSubscriptionDeadLetters :: SubscriptionDeadLetterQuery -> Store m SubscriptionDeadLetterPage+ -- | Resolve one exact subscription checkpoint key according to its+ -- missing-row policy. Existing rows always take precedence.+ --+ -- Surfaced as+ -- 'Kiroku.Store.Subscription.initializeSubscriptionCheckpoint'.+ InitializeSubscriptionCheckpoint ::+ SubscriptionName ->+ Int32 ->+ MissingCheckpointPolicy ->+ Store m (Either SubscriptionCheckpointMissing CheckpointInitialization)+ AcquireHistoryRetentionLease :: HistoryRetentionLeaseRequest -> Store m HistoryRetentionLease+ RenewHistoryRetentionLease ::+ HistoryRetentionLeaseHandle ->+ HistoryRetentionLeaseDuration ->+ Store m (Either HistoryRetentionRenewalError HistoryRetentionLease)+ ReleaseHistoryRetentionLease ::+ HistoryRetentionLeaseHandle ->+ Store m HistoryRetentionReleaseResult+ GetHistoryRetentionLeaseInventory ::+ HistoryRetentionInventoryQuery ->+ Store m (Vector HistoryRetentionLease)+ PruneHistoryRetentionLeases :: UTCTime -> Store m HistoryRetentionPruneResult+ -- | 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+ -- expected to reject this constructor.+ RunTransaction :: Tx.Transaction a -> Store m a+ -- | Like 'RunTransaction' but uses+ -- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood —+ -- the body is run exactly once even on PostgreSQL serialization+ -- conflicts.+ RunTransactionNoRetry :: Tx.Transaction a -> Store m a++type instance DispatchOf Store = Dynamic++-- ---------------------------------------------------------------------------+-- PostgreSQL interpreter+-- ---------------------------------------------------------------------------++-- | Interpret Store operations against PostgreSQL via hasql-pool.+runStorePool ::+ (IOE :> es, Error StoreError :> es) =>+ KirokuStore ->+ Eff (Store : es) a ->+ Eff es a+runStorePool store = interpret_ $ \case+ AppendToStream (StreamName name) expected events -> do+ 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+ 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)+ Right Nothing ->+ throwError (emptyResultError name expected)+ Right (Just r) ->+ pure r+ ReadStreamForward (StreamName name) (StreamVersion startVer) limit -> do+ evs <-+ usePool (store ^. #pool) $+ Session.statement (name, startVer, limit) SQL.readStreamForwardStmt+ decodeReadEvents (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, cursor, limit) SQL.readStreamBackwardStmt+ decodeReadEvents (store ^. #storeSettings) evs+ ReadAllForward (GlobalPosition startPos) limit -> do+ evs <-+ usePool (store ^. #pool) $+ Session.statement (startPos, limit) SQL.readAllForwardStmt+ decodeReadEvents (store ^. #storeSettings) evs+ ReadAllBackward (GlobalPosition startPos) limit -> do+ let cursor = if startPos == 0 then maxBound else startPos+ evs <-+ usePool (store ^. #pool) $+ Session.statement (cursor, limit) SQL.readAllBackwardStmt+ decodeReadEvents (store ^. #storeSettings) evs+ GetVisibleGlobalHeadPosition ->+ usePool (store ^. #pool) $+ Session.statement () SQL.visibleGlobalHeadPositionStmt+ GetStreamWithHead (StreamName name) ->+ usePool (store ^. #pool) $+ Session.statement name SQL.getStreamWithHeadStmt+ GetStream (StreamName name) ->+ usePool (store ^. #pool) $+ Session.statement name SQL.getStreamStmt+ LookupStreamId (StreamName name) ->+ 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)+ ( usePool (store ^. #pool) $+ Session.statement [s | StreamId s <- sids] SQL.lookupStreamNamesStmt+ )+ ListStreams category prefix after limit ->+ usePool (store ^. #pool) (SQL.listStreamsSession category prefix after limit)+ ListCategories after limit ->+ fmap (V.map CategoryName) $+ usePool (store ^. #pool) $+ Session.statement (fmap (\(CategoryName value) -> value) after, browsePageSizeValue limit) (SQL.listCategoriesStmt (not (isNothing after)))+ GetEvent (EventId eid) -> do+ found <- usePool (store ^. #pool) $ Session.statement eid SQL.getEventStmt+ decoded <- decodeReadEvents (store ^. #storeSettings) (maybe V.empty V.singleton found)+ pure (decoded V.!? 0)+ LinkToStream (StreamName name) eventIds -> do+ rejectInvalidApplicationStream name+ let uuids = V.fromList [uid | EventId uid <- eventIds]+ result <-+ liftIO $+ Pool.use (store ^. #pool) $+ Session.statement (uuids, name) SQL.linkToStreamStmt+ case result of+ 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+ decodeReadEvents (store ^. #storeSettings) evs+ AppendMultiStream [] ->+ pure []+ AppendMultiStream ops -> do+ 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 <-+ mapM+ ( \(sn, ev, evts) -> do+ evts' <- liftIO $ enrichEvents (store ^. #storeSettings) evts+ prepared <- prepareEvents evts'+ pure (sn, ev, prepared)+ )+ ops+ let names = V.fromList [n | (StreamName n, _, _) <- ops]+ let runOnce =+ Pool.use (store ^. #pool) $+ 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)+ Right results -> do+ -- Check for any Nothing results (version conflicts)+ let indexed = zip ops results+ mapM+ ( \((StreamName sn, ev, _), mResult) ->+ case mResult of+ Nothing -> throwError (emptyResultError sn ev)+ Just r -> pure r+ )+ indexed+ FindEvents filt -> do+ evs <- case filt of+ FilterCorrelation cid ->+ usePool (store ^. #pool) $+ Session.statement cid SQL.findByCorrelationStmt+ FilterCausationDescendants (EventId eid) ->+ usePool (store ^. #pool) $+ Session.statement eid SQL.findCausationDescendantsStmt+ FilterCausationAncestors (EventId eid) ->+ usePool (store ^. #pool) $+ Session.statement eid SQL.findCausationAncestorsStmt+ decodeReadEvents (store ^. #storeSettings) evs+ SoftDeleteStream (StreamName name) -> do+ rejectInvalidApplicationStream name+ usePool (store ^. #pool) $+ Session.statement name SQL.softDeleteStreamStmt+ HardDeleteStream (StreamName name) -> do+ rejectInvalidApplicationStream name+ let txn = do+ Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+ HistoryRetention.lockHistoryRetentionCoordinatorTx+ mSid <- Tx.statement name SQL.findStreamIdStmt+ case mSid of+ Nothing -> pure (Right Nothing)+ Just sid -> do+ conflict <- HistoryRetention.activeHistoryRetentionConflictTx+ case conflict of+ Just active -> pure (Left active)+ Nothing -> do+ HistoryRetention.lockAffectedStreamsForHardDeleteTx sid+ 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 (Right (Just (StreamId sid)))+ result <-+ usePool (store ^. #pool) $+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+ -- Emit a fail-safe audit signal when the delete actually removed+ -- rows. Compliance-grade audit should still record an+ -- application-level event before calling hardDeleteStream — see+ -- docs/PRODUCTION-DEPLOYMENT.md.+ case result of+ Right (Just sid) ->+ liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHardDeleteIssued (StreamName name) sid)+ Right Nothing -> pure ()+ Left conflict -> do+ liftIO $+ emitOrDrop+ (store ^. #eventHandler)+ (KirokuEventHardDeleteHistoryRetentionConflict (StreamName name) conflict)+ throwError (HistoryRetentionActive (StreamName name) conflict)+ pure (either (const Nothing) (\value -> value) result)+ UndeleteStream (StreamName name) -> do+ 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+ ListSubscriptionDeadLetters query ->+ usePool (store ^. #pool) (DeadLetterSQL.listSubscriptionDeadLettersSession query)+ GetSubscriptionCheckpointInventory ->+ usePool (store ^. #pool) $+ Session.statement () CheckpointInventorySQL.getSubscriptionCheckpointInventoryStmt+ InitializeSubscriptionCheckpoint subscriptionName member policy ->+ usePool (store ^. #pool) $+ CheckpointSQL.initializeSubscriptionCheckpointSession subscriptionName member policy+ AcquireHistoryRetentionLease request -> do+ lease <- runTxOnPool (store ^. #pool) TxSessions.transaction (HistoryRetention.acquireHistoryRetentionLeaseTx request)+ let HistoryRetentionLease {leaseId, owner, protectedThrough, expiresAt} = lease+ liftIO $+ emitOrDrop+ (store ^. #eventHandler)+ (KirokuEventHistoryRetentionLeaseAcquired leaseId owner protectedThrough expiresAt)+ pure lease+ RenewHistoryRetentionLease handle duration -> do+ result <-+ runTxOnPool+ (store ^. #pool)+ TxSessions.transaction+ (HistoryRetention.renewHistoryRetentionLeaseTx handle duration)+ case result of+ Right lease ->+ let HistoryRetentionLease {leaseId, owner, expiresAt} = lease+ in liftIO $+ emitOrDrop+ (store ^. #eventHandler)+ (KirokuEventHistoryRetentionLeaseRenewed leaseId owner expiresAt)+ Left _ -> pure ()+ pure result+ ReleaseHistoryRetentionLease handle -> do+ result <-+ runTxOnPool+ (store ^. #pool)+ TxSessions.transaction+ (HistoryRetention.releaseHistoryRetentionLeaseTx handle)+ case result of+ HistoryRetentionReleased HistoryRetentionLease {leaseId, owner, releasedAt = Just releasedAt} ->+ liftIO $+ emitOrDrop+ (store ^. #eventHandler)+ (KirokuEventHistoryRetentionLeaseReleased leaseId owner releasedAt)+ _ -> pure ()+ pure result+ GetHistoryRetentionLeaseInventory query ->+ runTxOnPool+ (store ^. #pool)+ TxSessions.transaction+ (HistoryRetention.historyRetentionLeaseInventoryTx query)+ PruneHistoryRetentionLeases cutoff -> do+ result <-+ runTxOnPool+ (store ^. #pool)+ TxSessions.transaction+ (HistoryRetention.pruneHistoryRetentionLeasesTx cutoff)+ liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHistoryRetentionLeasesPruned result)+ pure result+ RunTransaction tx ->+ runTxOnPool (store ^. #pool) TxSessions.transaction tx+ RunTransactionNoRetry tx ->+ runTxOnPool (store ^. #pool) TxSessions.transactionNoRetry tx++-- | Convenience: run a Store computation to IO.+runStoreIO ::+ KirokuStore ->+ Eff '[Store, Error StoreError, IOE] a ->+ IO (Either StoreError a)+runStoreIO store = runEff . runErrorNoCallStack . runStorePool store++-- | Interpret Store by reading the store handle from 'KirokuStoreResource'.+runStoreResource ::+ (IOE :> es, Error StoreError :> es, KirokuStoreResource :> es) =>+ Eff (Store : es) a ->+ Eff es a+runStoreResource action = do+ store <- getKirokuStore+ runStorePool store action++-- ---------------------------------------------------------------------------+-- Internal pool helper+-- ---------------------------------------------------------------------------++-- | Run a hasql session against the pool, mapping pool errors to 'StoreError'.+usePool ::+ (IOE :> es, Error StoreError :> es) =>+ Pool ->+ Session.Session a ->+ Eff es a+usePool pool session = do+ result <- liftIO (Pool.use pool session)+ case result of+ Left usageErr -> throwError (mapGenericUsageError usageErr)+ Right a -> pure a++-- | Run a 'Tx.Transaction' against the pool using the supplied entry+-- point ('TxSessions.transaction' or 'TxSessions.transactionNoRetry'),+-- mapping pool errors to 'StoreError'. The isolation level and access+-- mode mirror 'appendMultiStream' / 'HardDeleteStream'.+runTxOnPool ::+ (IOE :> es, Error StoreError :> es) =>+ Pool ->+ (TxSessions.IsolationLevel -> TxSessions.Mode -> Tx.Transaction a -> Session.Session a) ->+ Tx.Transaction a ->+ Eff es a+runTxOnPool pool entry tx = do+ result <-+ liftIO $+ Pool.use pool $+ entry TxSessions.ReadCommitted TxSessions.Write tx+ case result of+ Left usageErr -> throwError (mapTransactionUsageError usageErr)+ Right a -> pure a++rejectInvalidApplicationStream ::+ (Error StoreError :> es) =>+ Text ->+ Eff es ()+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)+-- ---------------------------------------------------------------------------++-- | An event with a guaranteed event ID (pre-generated if needed).+data PreparedEvent = PreparedEvent+ { peEventId :: !UUID,+ peEventType :: !EventType,+ pePayload :: !Value,+ peMetadata :: !(Maybe Value),+ peCausationId :: !(Maybe UUID),+ peCorrelationId :: !(Maybe UUID)+ }+ deriving stock (Generic)++-- | Prepare events by generating UUIDv7s for any event that doesn't+-- have a caller-supplied event ID.+prepareEvents :: (MonadIO m) => [EventData] -> m [PreparedEvent]+prepareEvents evts = liftIO $ do+ let needCount = length (filter (\(EventData eid _ _ _ _ _) -> isNothing eid) evts)+ newIds <-+ if needCount > 0+ then V7.genUUIDs (fromIntegral needCount)+ else pure []+ pure (assign evts newIds)+ where+ assign :: [EventData] -> [UUID] -> [PreparedEvent]+ assign [] _ = []+ assign (EventData mEid eType ePayload eMeta eCaus eCorr : es) ids =+ case mEid of+ Just (EventId uid) ->+ PreparedEvent uid eType ePayload eMeta eCaus eCorr+ : assign es ids+ Nothing -> case ids of+ (uid : rest) ->+ PreparedEvent uid eType ePayload eMeta eCaus eCorr+ : assign es rest+ [] -> error "prepareEvents: ran out of pre-generated UUIDs (bug)"++-- | Build SQL parameters from prepared events.+buildAppendParams :: Text -> UTCTime -> [PreparedEvent] -> SQL.AppendParams+buildAppendParams name now prepared =+ SQL.AppendParams+ { eventIds = V.fromList (map (^. #peEventId) prepared),+ eventTypes = V.fromList (map (\e -> let EventType t = e ^. #peEventType in t) prepared),+ causationIds = V.fromList (map (^. #peCausationId) prepared),+ correlationIds = V.fromList (map (^. #peCorrelationId) prepared),+ payloads = V.fromList (map (^. #pePayload) prepared),+ metadatas = V.fromList (map (^. #peMetadata) prepared),+ createdAts = V.fromList (replicate (length prepared) now),+ streamName = name+ }++-- Cache transaction-control statements just like hasql-transaction; reparsing+-- them on every multi-stream call erodes the pipeline's round-trip savings.+multiAppendBeginStmt :: Statement () ()+multiAppendBeginStmt = preparable "BEGIN" E.noParams D.noResult++multiAppendCommitStmt :: Statement () ()+multiAppendCommitStmt = preparable "COMMIT" E.noParams D.noResult++multiAppendRollbackStmt :: Statement () ()+multiAppendRollbackStmt = preparable "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'.+--+-- This is the shared building block used by 'AppendMultiStream'\'s+-- interpreter branch and by 'Kiroku.Store.Transaction.appendToStreamTx'.+-- 'AppendToStream' keeps its 'Session.statement'-flavored dispatch — see+-- the M2 entry in the Decision Log on plan 11 for why a 'Tx.Transaction'+-- wrapping a single statement was rejected as a refactoring target.+--+-- @'Nothing'@ comes back when the underlying CTE returns 0 rows — i.e.+-- the precondition failed silently. Callers map that to either+-- 'Kiroku.Store.Error.AppendConflict' (the Tx surface) or+-- 'Kiroku.Store.Error.StoreError' (the @Eff@ surface).+appendDispatchTx :: ExpectedVersion -> SQL.AppendParams -> Tx.Transaction (Maybe AppendResult)+appendDispatchTx expected params = case expected of+ ExactVersion (StreamVersion v) ->+ Tx.statement (params, v) SQL.appendExpectedVersion+ StreamExists ->+ Tx.statement params SQL.appendStreamExists+ NoStream ->+ Tx.statement params SQL.appendNoStream+ AnyVersion ->+ Tx.statement params SQL.appendAnyVersion++-- $internal+-- These bindings are intentionally exposed so that+-- "Kiroku.Store.Transaction" can compose appends with arbitrary+-- 'Tx.Transaction' work without re-implementing UUID prep, parameter+-- packing, or per-version dispatch. They are not part of the supported+-- public surface and may change without notice.++-- No hook preserves the direct read passthrough. Successful hooks map directly+-- into a result vector, avoiding the subscription-specific wrapper/unwrapper.+decodeReadEvents :: (IOE :> es, Error StoreError :> es) => StoreSettings -> Vector RecordedEvent -> Eff es (Vector RecordedEvent)+decodeReadEvents settings events = case decodeHook settings of+ Nothing -> pure events+ Just hook -> V.mapM (\event -> liftIO (hook event) >>= either (throwError . EventDecodeFailed) pure) events
src/Kiroku/Store/Effect/Resource.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE TypeFamilies #-} -module Kiroku.Store.Effect.Resource (- -- * The KirokuStoreResource effect+module Kiroku.Store.Effect.Resource+ ( -- * The KirokuStoreResource effect KirokuStoreResource, -- * Operations@@ -12,7 +12,8 @@ -- * Runner over an existing handle runKirokuStoreWith,-) where+ )+where import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, UnliftStrategy (..), withEffToIO, (:>)) import Effectful.Dispatch.Static (SideEffects (..), StaticRep, evalStaticRep, getStaticRep)@@ -22,18 +23,17 @@ -- KirokuStoreResource static effect -- --------------------------------------------------------------------------- -{- | Static effect carrying a 'KirokuStore' handle.--Static rather than dynamic because the store handle is acquired exactly-once per program and is not meant to be mocked — the dynamic mocking-surface lives on the 'Kiroku.Store.Effect.Store' effect that operates-against the handle. Splitting the resource (static) from the operations-(dynamic) keeps mocking ergonomic without requiring callers to swap the-handle itself.--@Static WithSideEffects@ rather than @Static NoSideEffects@ because the-underlying connection pool performs IO during operation lookup.--}+-- | Static effect carrying a 'KirokuStore' handle.+--+-- Static rather than dynamic because the store handle is acquired exactly+-- once per program and is not meant to be mocked — the dynamic mocking+-- surface lives on the 'Kiroku.Store.Effect.Store' effect that operates+-- against the handle. Splitting the resource (static) from the operations+-- (dynamic) keeps mocking ergonomic without requiring callers to swap the+-- handle itself.+--+-- @Static WithSideEffects@ rather than @Static NoSideEffects@ because the+-- underlying connection pool performs IO during operation lookup. data KirokuStoreResource :: Effect type instance DispatchOf KirokuStoreResource = Static WithSideEffects@@ -43,34 +43,33 @@ -- | Retrieve the 'KirokuStore' handle from the effect stack. getKirokuStore :: (KirokuStoreResource :> es) => Eff es KirokuStore getKirokuStore = do- KirokuStoreResource store <- getStaticRep- pure store+ KirokuStoreResource store <- getStaticRep+ pure store -- | Bracket-style runner: acquire the store, install the effect, run the action, release. withKirokuStore ::- (IOE :> es) =>- ConnectionSettings ->- Eff (KirokuStoreResource : es) a ->- Eff es a+ (IOE :> es) =>+ ConnectionSettings ->+ Eff (KirokuStoreResource : es) a ->+ Eff es a withKirokuStore settings action = withEffToIO SeqUnlift $ \unlift ->- withStore settings $ \store ->- unlift (evalStaticRep (KirokuStoreResource store) action)--{- | Install an /already acquired/ 'KirokuStore' into the effect stack.--Unlike 'withKirokuStore', this neither acquires nor releases: the caller owns-the handle's lifetime. Use it when a process has opened exactly one store (the-documented arrangement) and needs to run several independent effectful actions-against it. Acquiring a store per action instead costs a connection pool, a-dedicated @LISTEN@ connection, and a publisher thread each time.+ withStore settings $ \store ->+ unlift (evalStaticRep (KirokuStoreResource store) action) -There is no bracket and no unlifting here, but @IOE@ is still required:-'KirokuStoreResource' is @Static WithSideEffects@, and 'evalStaticRep' demands-@IOE@ for any effect declared that way.--}+-- | Install an /already acquired/ 'KirokuStore' into the effect stack.+--+-- Unlike 'withKirokuStore', this neither acquires nor releases: the caller owns+-- the handle's lifetime. Use it when a process has opened exactly one store (the+-- documented arrangement) and needs to run several independent effectful actions+-- against it. Acquiring a store per action instead costs a connection pool, a+-- dedicated @LISTEN@ connection, and a publisher thread each time.+--+-- There is no bracket and no unlifting here, but @IOE@ is still required:+-- 'KirokuStoreResource' is @Static WithSideEffects@, and 'evalStaticRep' demands+-- @IOE@ for any effect declared that way. runKirokuStoreWith ::- (IOE :> es) =>- KirokuStore ->- Eff (KirokuStoreResource : es) a ->- Eff es a+ (IOE :> es) =>+ KirokuStore ->+ Eff (KirokuStoreResource : es) a ->+ Eff es a runKirokuStoreWith = evalStaticRep . KirokuStoreResource
src/Kiroku/Store/Error.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Error (- StoreError (..),+module Kiroku.Store.Error+ ( StoreError (..), maxStreamNameBytes, validateStreamName, @@ -17,7 +17,8 @@ isTransientSerializationError, -- Internal pure helper exposed for unit testing extractStreamNameFromDetail,-) where+ )+where import Control.Applicative ((<|>)) import Control.Exception (Exception)@@ -36,142 +37,127 @@ import Kiroku.Store.Settings (DecodeFailure) import Kiroku.Store.Types -{- | Errors that can occur during store operations.--The constructor set is designed for additive evolution: new failure modes-are added rather than changing existing constructors. Pattern matches that-do not handle a new constructor will surface as @-Wincomplete-patterns@-warnings, never as silent misclassification.--The 'ConnectionError' catch-all is retained for backward compatibility:-anything not matched by a more specific constructor falls through to it.-Consumers should match on the specific constructors first when they want-to make retry-vs-escalate decisions.--'StoreError' derives 'Exception' so it can be thrown with 'throwIO' from-any 'IO' or 'MonadIO' context. The standard pattern for store callers-remains @runStoreIO :: IO (Either StoreError a)@; the 'Exception' instance-is for callers who prefer the exception-based idiom (e.g., when bridging-to libraries that expect 'SomeException').--}+-- | Errors that can occur during store operations.+--+-- The constructor set is designed for additive evolution: new failure modes+-- are added rather than changing existing constructors. Pattern matches that+-- do not handle a new constructor will surface as @-Wincomplete-patterns@+-- warnings, never as silent misclassification.+--+-- The 'ConnectionError' catch-all is retained for backward compatibility:+-- anything not matched by a more specific constructor falls through to it.+-- Consumers should match on the specific constructors first when they want+-- to make retry-vs-escalate decisions.+--+-- 'StoreError' derives 'Exception' so it can be thrown with 'throwIO' from+-- any 'IO' or 'MonadIO' context. The standard pattern for store callers+-- remains @runStoreIO :: IO (Either StoreError a)@; the 'Exception' instance+-- is for callers who prefer the exception-based idiom (e.g., when bridging+-- to libraries that expect 'SomeException'). data StoreError- = {- | The actual stream version did not match the caller's- 'ExactVersion' expectation. The third field is the actual- 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- | {- | A supported hard delete found one or more active replay-history- retention leases and committed no destructive work.- -}- HistoryRetentionActive !StreamName !HistoryRetentionConflict- | {- | The named stream is reserved for store internals and cannot be- used as an application stream target. For now this applies only- to @$all@, which is the global read stream backed by the seeded- @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.- -}- StreamAlreadyExists !StreamName- | {- | A caller-supplied @event_id@ collides with an existing event.-- Append maps both @events_pkey@ and the composite- @stream_events_pkey@ to this constructor.-- The constructor carries 'Just' the id when the PostgreSQL detail- string could be parsed, 'Nothing' otherwise. A 'Nothing' payload- is rare in practice; it occurs when the server's locale changes- the detail-string format. Consumers that want to surface the- 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- that the database is unreachable.- -}- PoolAcquisitionTimeout- | {- | A network or session-level error tore down the connection- mid-operation. The 'Text' carries the underlying hasql error- description for diagnostics. Retryable in most cases.- -}- ConnectionLost !Text- | {- | PostgreSQL aborted the transaction with a class-40- (transaction rollback) @SQLSTATE@: @40001@ serialization_failure- or @40P01@ deadlock_detected. The first 'Text' is the @SQLSTATE@- code, the second is the human-readable message.-- __Retryable.__ PostgreSQL rolled the transaction back completely- and expects the client to run it again; nothing was committed.- The store already retries an append once on these codes before- surfacing them, so reaching a caller means the conflict repeated- — retry with backoff rather than escalating.-- Appends are safe to retry when the caller supplies- 'Kiroku.Store.Types.EventData.eventId' values, which makes the- retry idempotent; see the "Idempotent retries" note on- 'Kiroku.Store.Append.appendToStream'.- -}- TransientTransactionFailure !Text !Text- | {- | PostgreSQL raised a server error whose @SQLSTATE@ code is- outside the set this store recognises, or an append violated- @ux_stream_events_stream_version@ (@23505@), indicating an- internal stream-version invariant failure. The- first 'Text' is the @SQLSTATE@ code, the second is the- human-readable message. This is *not* generally retryable —- investigate.- -}- UnexpectedServerError !Text !Text- | {- | Catch-all for everything not matched by a more specific- constructor above. Retained for backward compatibility with- consumers that already pattern-match on it; new code should- prefer the specific constructors.- -}- ConnectionError !Text- | -- | A decode hook refused an event; the read returns no partial result.- EventDecodeFailed !DecodeFailure- deriving stock (Eq, Show, Generic)- deriving anyclass (Exception)+ = -- | The actual stream version did not match the caller's+ -- 'ExactVersion' expectation. The third field is the actual+ -- 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+ | -- | A supported hard delete found one or more active replay-history+ -- retention leases and committed no destructive work.+ HistoryRetentionActive !StreamName !HistoryRetentionConflict+ | -- | The named stream is reserved for store internals and cannot be+ -- used as an application stream target. For now this applies only+ -- to @$all@, which is the global read stream backed by the seeded+ -- @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.+ StreamAlreadyExists !StreamName+ | -- | A caller-supplied @event_id@ collides with an existing event.+ --+ -- Append maps both @events_pkey@ and the composite+ -- @stream_events_pkey@ to this constructor.+ --+ -- The constructor carries 'Just' the id when the PostgreSQL detail+ -- string could be parsed, 'Nothing' otherwise. A 'Nothing' payload+ -- is rare in practice; it occurs when the server's locale changes+ -- the detail-string format. Consumers that want to surface the+ -- 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+ -- that the database is unreachable.+ PoolAcquisitionTimeout+ | -- | A network or session-level error tore down the connection+ -- mid-operation. The 'Text' carries the underlying hasql error+ -- description for diagnostics. Retryable in most cases.+ ConnectionLost !Text+ | -- | PostgreSQL aborted the transaction with a class-40+ -- (transaction rollback) @SQLSTATE@: @40001@ serialization_failure+ -- or @40P01@ deadlock_detected. The first 'Text' is the @SQLSTATE@+ -- code, the second is the human-readable message.+ --+ -- __Retryable.__ PostgreSQL rolled the transaction back completely+ -- and expects the client to run it again; nothing was committed.+ -- The store already retries an append once on these codes before+ -- surfacing them, so reaching a caller means the conflict repeated+ -- — retry with backoff rather than escalating.+ --+ -- Appends are safe to retry when the caller supplies+ -- 'Kiroku.Store.Types.EventData.eventId' values, which makes the+ -- retry idempotent; see the "Idempotent retries" note on+ -- 'Kiroku.Store.Append.appendToStream'.+ TransientTransactionFailure !Text !Text+ | -- | PostgreSQL raised a server error whose @SQLSTATE@ code is+ -- outside the set this store recognises, or an append violated+ -- @ux_stream_events_stream_version@ (@23505@), indicating an+ -- internal stream-version invariant failure. The+ -- first 'Text' is the @SQLSTATE@ code, the second is the+ -- human-readable message. This is *not* generally retryable —+ -- investigate.+ UnexpectedServerError !Text !Text+ | -- | Catch-all for everything not matched by a more specific+ -- constructor above. Retained for backward compatibility with+ -- consumers that already pattern-match on it; new code should+ -- prefer the specific constructors.+ ConnectionError !Text+ | -- | A decode hook refused an event; the read returns no partial result.+ EventDecodeFailed !DecodeFailure+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Exception) -- | Maximum stream-name length in UTF-8 bytes. maxStreamNameBytes :: Int@@ -180,379 +166,364 @@ -- | 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 ()+ | 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:- UsageError -> SessionUsageError -> StatementSessionError -> ServerStatementError -> ServerError- UsageError -> SessionUsageError -> ScriptSessionError -> ServerError--Scripts and statements share server classification. Connection establishment-and session connection loss use 'ConnectionLost'; acquisition timeout uses-'PoolAcquisitionTimeout'. Unsupported non-server errors retain 'ConnectionError'.--PostgreSQL error code mapping:- 23505 (unique_violation) + events_pkey -> 'DuplicateEvent'- 23505 (unique_violation) + stream_events_pkey -> 'DuplicateEvent'- 23505 + ux_stream_events_stream_version -> 'UnexpectedServerError'- 23505 (unique_violation) + ix_streams_stream_name -> 'StreamAlreadyExists'- 23505 (unique_violation) + other -> 'WrongExpectedVersion'- 23503 (foreign_key_violation) -> 'StreamNotFound'- 40001 / 40P01 -> 'TransientTransactionFailure'- any other server code -> 'UnexpectedServerError'--Constraint names are compared exactly, with the quoted message taking-precedence over a delimiter-aware detail fallback. Matching depends on the-four literal names above. 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/migrations@.--}+-- | Map a hasql 'UsageError' to a 'StoreError'.+--+-- Pattern matches on the error hierarchy:+-- UsageError -> SessionUsageError -> StatementSessionError -> ServerStatementError -> ServerError+-- UsageError -> SessionUsageError -> ScriptSessionError -> ServerError+--+-- Scripts and statements share server classification. Connection establishment+-- and session connection loss use 'ConnectionLost'; acquisition timeout uses+-- 'PoolAcquisitionTimeout'. Unsupported non-server errors retain 'ConnectionError'.+--+-- PostgreSQL error code mapping:+-- 23505 (unique_violation) + events_pkey -> 'DuplicateEvent'+-- 23505 (unique_violation) + stream_events_pkey -> 'DuplicateEvent'+-- 23505 + ux_stream_events_stream_version -> 'UnexpectedServerError'+-- 23505 (unique_violation) + ix_streams_stream_name -> 'StreamAlreadyExists'+-- 23505 (unique_violation) + other -> 'WrongExpectedVersion'+-- 23503 (foreign_key_violation) -> 'StreamNotFound'+-- 40001 / 40P01 -> 'TransientTransactionFailure'+-- any other server code -> 'UnexpectedServerError'+--+-- Constraint names are compared exactly, with the quoted message taking+-- precedence over a delimiter-aware detail fallback. Matching depends on the+-- four literal names above. 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/migrations@. mapUsageError :: Text -> ExpectedVersion -> UsageError -> StoreError mapUsageError streamName expected = \case- SessionUsageError sessionErr ->- mapSessionError streamName expected sessionErr- ConnectionUsageError connErr ->- ConnectionLost (T.pack (show connErr))- AcquisitionTimeoutUsageError ->- PoolAcquisitionTimeout--{- | Map a hasql 'UsageError' raised inside an opaque transaction body.+ SessionUsageError sessionErr ->+ mapSessionError streamName expected sessionErr+ ConnectionUsageError connErr ->+ ConnectionLost (T.pack (show connErr))+ AcquisitionTimeoutUsageError ->+ PoolAcquisitionTimeout -The body has no stream context, so append duplicate violations are mapped-directly to 'DuplicateEvent'. Every other failure uses the generic mapper so-its SQLSTATE and server message survive without inventing stream context.--}+-- | 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'. Every other failure uses the generic mapper so+-- its SQLSTATE and server message survive without inventing stream context. mapTransactionUsageError :: UsageError -> StoreError mapTransactionUsageError usageErr =- case extractServerError usageErr of- Just (Errors.ServerError "23505" message detail _ _)- | uniqueConstraintName message detail == Just "events_pkey" ->- DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))- | uniqueConstraintName message detail == Just "stream_events_pkey" ->- DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))- _ ->- mapGenericUsageError usageErr+ case extractServerError usageErr of+ Just (Errors.ServerError "23505" message detail _ _)+ | uniqueConstraintName message detail == Just "events_pkey" ->+ DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))+ | uniqueConstraintName message detail == Just "stream_events_pkey" ->+ DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))+ _ ->+ mapGenericUsageError usageErr -- | 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 (Errors.ConnectionSessionError detail) ->- ConnectionLost detail- SessionUsageError sessionErr ->- case extractServerError (SessionUsageError sessionErr) of- Just (Errors.ServerError code message _ _ _)- | isTransientTransactionCode code ->- TransientTransactionFailure code message- | otherwise ->- UnexpectedServerError code message- Nothing ->- ConnectionError ("Session error: " <> T.pack (show sessionErr))+ ConnectionUsageError connErr ->+ ConnectionLost (T.pack (show connErr))+ AcquisitionTimeoutUsageError ->+ PoolAcquisitionTimeout+ SessionUsageError (Errors.ConnectionSessionError detail) ->+ ConnectionLost detail+ SessionUsageError sessionErr ->+ case extractServerError (SessionUsageError sessionErr) of+ Just (Errors.ServerError code message _ _ _)+ | isTransientTransactionCode code ->+ TransientTransactionFailure code message+ | otherwise ->+ 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 _ _)- | uniqueConstraintName message detail == Just "stream_events_pkey" ->- EventAlreadyLinked target (extractCompositeEventId detail)- Just (Errors.ServerError "23502" _ _ _ _) ->- LinkSourceEventMissing target- _ ->- mapGenericUsageError usageErr+ case extractServerError usageErr of+ Just (Errors.ServerError "23505" message detail _ _)+ | uniqueConstraintName message detail == Just "stream_events_pkey" ->+ EventAlreadyLinked target (extractCompositeEventId detail)+ Just (Errors.ServerError "23502" _ _ _ _) ->+ LinkSourceEventMissing target+ _ ->+ mapGenericUsageError usageErr where extractCompositeEventId (Just d) = EventId <$> extractFirstUuidFromCompositeDetail d extractCompositeEventId Nothing = Nothing mapSessionError :: Text -> ExpectedVersion -> Errors.SessionError -> StoreError mapSessionError streamName expected = \case- Errors.StatementSessionError _ _ _ _ _ stmtErr ->- mapStatementError streamName expected stmtErr- Errors.ScriptSessionError _ serverErr ->- mapServerError streamName expected serverErr- Errors.ConnectionSessionError detail ->- ConnectionLost detail- other ->- ConnectionError ("Session error: " <> T.pack (show other))+ Errors.StatementSessionError _ _ _ _ _ stmtErr ->+ mapStatementError streamName expected stmtErr+ Errors.ScriptSessionError _ serverErr ->+ mapServerError streamName expected serverErr+ Errors.ConnectionSessionError detail ->+ ConnectionLost detail+ other ->+ ConnectionError ("Session error: " <> T.pack (show other)) mapStatementError :: Text -> ExpectedVersion -> Errors.StatementError -> StoreError mapStatementError streamName expected = \case- Errors.ServerStatementError serverErr ->- mapServerError streamName expected serverErr- other ->- ConnectionError ("Statement error: " <> T.pack (show other))+ Errors.ServerStatementError serverErr ->+ mapServerError streamName expected serverErr+ other ->+ ConnectionError ("Statement error: " <> T.pack (show other)) mapServerError :: Text -> ExpectedVersion -> Errors.ServerError -> StoreError mapServerError streamName expected (Errors.ServerError code message detail _hint _position)- | code == "23505" = mapUniqueViolation streamName expected message detail- | code == "23503" = StreamNotFound (StreamName streamName)- | isTransientTransactionCode code = TransientTransactionFailure code message- | otherwise = UnexpectedServerError code message--{- | Map a unique_violation (23505) to an StoreError.--PostgreSQL reports constraint violations with:- - message: "duplicate key value violates unique constraint \"events_pkey\""- - detail: "Key (event_id)=(uuid-value) already exists."--We extract one constraint name, preferring the quoted message over detail,-then compare it exactly. Composite link keys use their first UUID. A-stream-version index violation is an invariant failure, not a precondition-conflict, and preserves the SQLSTATE and original message.+ | code == "23505" = mapUniqueViolation streamName expected message detail+ | code == "23503" = StreamNotFound (StreamName streamName)+ | isTransientTransactionCode code = TransientTransactionFailure code message+ | otherwise = UnexpectedServerError code message -When the events_pkey case fires but the detail string cannot be parsed-(e.g., the server's locale produced an unexpected format), the-'DuplicateEvent' constructor carries 'Nothing' rather than a fabricated-all-zeroes UUID.--}+-- | Map a unique_violation (23505) to an StoreError.+--+-- PostgreSQL reports constraint violations with:+-- - message: "duplicate key value violates unique constraint \"events_pkey\""+-- - detail: "Key (event_id)=(uuid-value) already exists."+--+-- We extract one constraint name, preferring the quoted message over detail,+-- then compare it exactly. Composite link keys use their first UUID. A+-- stream-version index violation is an invariant failure, not a precondition+-- conflict, and preserves the SQLSTATE and original message.+--+-- When the events_pkey case fires but the detail string cannot be parsed+-- (e.g., the server's locale produced an unexpected format), the+-- 'DuplicateEvent' constructor carries 'Nothing' rather than a fabricated+-- all-zeroes UUID. mapUniqueViolation :: Text -> ExpectedVersion -> Text -> Maybe Text -> StoreError mapUniqueViolation streamName expected message detail =- case uniqueConstraintName message detail of- Just "events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))- Just "stream_events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))- Just "ix_streams_stream_name" -> StreamAlreadyExists (StreamName streamName)- Just "ux_stream_events_stream_version" -> UnexpectedServerError "23505" message- _ ->- -- Generic unique violation — treat as version conflict- WrongExpectedVersion (StreamName streamName) expected (StreamVersion 0)+ case uniqueConstraintName message detail of+ Just "events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))+ Just "stream_events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))+ Just "ix_streams_stream_name" -> StreamAlreadyExists (StreamName streamName)+ Just "ux_stream_events_stream_version" -> UnexpectedServerError "23505" message+ _ ->+ -- Generic unique violation — treat as version conflict+ WrongExpectedVersion (StreamName streamName) expected (StreamVersion 0) -{- | PostgreSQL's quoted constraint wins even when it is unknown. Legacy-detail-only errors may name an owned constraint as a complete identifier;-underscores and dollar signs are identifier characters, not delimiters.-This helper is used only after a failed statement has returned SQLSTATE 23505.--}+-- | PostgreSQL's quoted constraint wins even when it is unknown. Legacy+-- detail-only errors may name an owned constraint as a complete identifier;+-- underscores and dollar signs are identifier characters, not delimiters.+-- This helper is used only after a failed statement has returned SQLSTATE 23505. uniqueConstraintName :: Text -> Maybe Text -> Maybe Text uniqueConstraintName message detail =- quotedConstraint message <|> (detail >>= fromDetail)+ quotedConstraint message <|> (detail >>= fromDetail) where quotedConstraint input =- case T.breakOn "unique constraint \"" input of- (_, rest)- | not (T.null rest) ->- let (name, closing) = T.breakOn "\"" (T.drop (T.length "unique constraint \"") rest)- in if T.null name || T.null closing then Nothing else Just name- _ -> Nothing+ case T.breakOn "unique constraint \"" input of+ (_, rest)+ | not (T.null rest) ->+ let (name, closing) = T.breakOn "\"" (T.drop (T.length "unique constraint \"") rest)+ in if T.null name || T.null closing then Nothing else Just name+ _ -> Nothing fromDetail input =- quotedConstraint input <|> find (`elem` ownedConstraints) (T.split (not . identifierChar) input)+ quotedConstraint input <|> find (`elem` ownedConstraints) (T.split (not . identifierChar) input) identifierChar c = isAlphaNum c || c == '_' || c == '$' ownedConstraints = ["events_pkey", "stream_events_pkey", "ix_streams_stream_name", "ux_stream_events_stream_version"] -{- | Append-precondition failures observable inside a-'Hasql.Transaction.Transaction' body.--'Kiroku.Store.Transaction.appendToStreamTx' returns-@'Either' 'AppendConflict' 'AppendResult'@ rather than throwing, because-'Hasql.Transaction.Transaction' has no exception channel — the caller-decides whether to call 'Hasql.Transaction.condemn', branch around the-conflict, or recover. Reserved-stream rejection is /not/ part of this-sum because callers of 'Kiroku.Store.Transaction.appendToStreamTx' are-expected to validate the stream name themselves before entering the-transaction body (the high-level wrapper-'Kiroku.Store.Transaction.runTransactionAppending' does so prior to-opening the transaction and surfaces 'ReservedStreamName' as a-'StoreError' instead).--The constructors are 1:1 with the corresponding 'StoreError' variants —-see 'appendConflictToStoreError'.--}+-- | Append-precondition failures observable inside a+-- 'Hasql.Transaction.Transaction' body.+--+-- 'Kiroku.Store.Transaction.appendToStreamTx' returns+-- @'Either' 'AppendConflict' 'AppendResult'@ rather than throwing, because+-- 'Hasql.Transaction.Transaction' has no exception channel — the caller+-- decides whether to call 'Hasql.Transaction.condemn', branch around the+-- conflict, or recover. Reserved-stream rejection is /not/ part of this+-- sum because callers of 'Kiroku.Store.Transaction.appendToStreamTx' are+-- expected to validate the stream name themselves before entering the+-- transaction body (the high-level wrapper+-- 'Kiroku.Store.Transaction.runTransactionAppending' does so prior to+-- opening the transaction and surfaces 'ReservedStreamName' as a+-- 'StoreError' instead).+--+-- The constructors are 1:1 with the corresponding 'StoreError' variants —+-- see 'appendConflictToStoreError'. data AppendConflict- = {- | Mirror of 'WrongExpectedVersion'. The third field is the- 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'.- StreamAlreadyExistsConflict !StreamName- deriving stock (Eq, Show, Generic)+ = -- | Mirror of 'WrongExpectedVersion'. The third field is the+ -- 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'.+ StreamAlreadyExistsConflict !StreamName+ deriving stock (Eq, Show, Generic) -{- | Project an 'AppendConflict' onto the corresponding 'StoreError'-constructor. Used by 'Kiroku.Store.Transaction.runTransactionAppending'-when surfacing conflicts at the @Eff@ boundary.--}+-- | Project an 'AppendConflict' onto the corresponding 'StoreError'+-- constructor. Used by 'Kiroku.Store.Transaction.runTransactionAppending'+-- when surfacing conflicts at the @Eff@ boundary. 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--{- | Infer the appropriate 'AppendConflict' from an empty CTE result.--Mirror of 'emptyResultError' for the 'AppendConflict' surface. When the-CTE returns 0 rows, the version check or existence check failed-silently — the constructor depends on the supplied 'ExpectedVersion':+ WrongExpectedVersionConflict sn ev sv -> WrongExpectedVersion sn ev sv+ EmptyAppendBatchConflict sn -> EmptyAppendBatch sn+ StreamNotFoundConflict sn -> StreamNotFound sn+ StreamAlreadyExistsConflict sn -> StreamAlreadyExists sn - ExactVersion v -> WrongExpectedVersionConflict (version mismatch or- soft-deleted)- StreamExists -> StreamNotFoundConflict (missing or soft-deleted)- NoStream -> StreamAlreadyExistsConflict- AnyVersion -> StreamNotFoundConflict (only happens when the- existing row is soft-deleted and the upsert's DO- UPDATE WHERE filter rejects it)--}+-- | Infer the appropriate 'AppendConflict' from an empty CTE result.+--+-- Mirror of 'emptyResultError' for the 'AppendConflict' surface. When the+-- CTE returns 0 rows, the version check or existence check failed+-- silently — the constructor depends on the supplied 'ExpectedVersion':+--+-- ExactVersion v -> WrongExpectedVersionConflict (version mismatch or+-- soft-deleted)+-- StreamExists -> StreamNotFoundConflict (missing or soft-deleted)+-- NoStream -> StreamAlreadyExistsConflict+-- AnyVersion -> StreamNotFoundConflict (only happens when the+-- existing row is soft-deleted and the upsert's DO+-- UPDATE WHERE filter rejects it) emptyResultConflict :: StreamName -> ExpectedVersion -> AppendConflict emptyResultConflict sn = \case- ExactVersion v ->- WrongExpectedVersionConflict sn (ExactVersion v) (StreamVersion 0)- StreamExists ->- StreamNotFoundConflict sn- NoStream ->- StreamAlreadyExistsConflict sn- AnyVersion ->- StreamNotFoundConflict sn--{- | Infer the appropriate error from an empty CTE result.+ ExactVersion v ->+ WrongExpectedVersionConflict sn (ExactVersion v) (StreamVersion 0)+ StreamExists ->+ StreamNotFoundConflict sn+ NoStream ->+ StreamAlreadyExistsConflict sn+ AnyVersion ->+ StreamNotFoundConflict sn -When the CTE returns 0 rows (no ServerError raised), the version check-or existence check failed silently. Map based on the ExpectedVersion:- ExactVersion v -> WrongExpectedVersion (version mismatch, or soft-deleted)- StreamExists -> StreamNotFound (stream doesn't exist, or soft-deleted)- NoStream -> StreamAlreadyExists (stream already exists)- AnyVersion -> StreamNotFound (only happens when the existing row is- soft-deleted and the upsert's DO UPDATE WHERE filter- rejects it; the soft-delete CTE filter was added in- EP-1 F2, so this branch is the soft-deleted-stream case)--}+-- | Infer the appropriate error from an empty CTE result.+--+-- When the CTE returns 0 rows (no ServerError raised), the version check+-- or existence check failed silently. Map based on the ExpectedVersion:+-- ExactVersion v -> WrongExpectedVersion (version mismatch, or soft-deleted)+-- StreamExists -> StreamNotFound (stream doesn't exist, or soft-deleted)+-- NoStream -> StreamAlreadyExists (stream already exists)+-- AnyVersion -> StreamNotFound (only happens when the existing row is+-- soft-deleted and the upsert's DO UPDATE WHERE filter+-- rejects it; the soft-delete CTE filter was added in+-- EP-1 F2, so this branch is the soft-deleted-stream case) emptyResultError :: Text -> ExpectedVersion -> StoreError emptyResultError streamName expected =- appendConflictToStoreError- (emptyResultConflict (StreamName streamName) expected)+ appendConflictToStoreError+ (emptyResultConflict (StreamName streamName) expected) -{- | Extract a UUID from a PostgreSQL detail string like:-"Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists."--}+-- | Extract a UUID from a PostgreSQL detail string like:+-- "Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists." extractUuidFromDetail :: Text -> Maybe UUID extractUuidFromDetail detail =- case T.breakOn "=(" detail of- (_, rest)- | not (T.null rest) ->- let afterParen = T.drop 2 rest -- skip "=("- uuidText = T.takeWhile (/= ')') afterParen- in UUID.fromText uuidText- _ -> Nothing+ case T.breakOn "=(" detail of+ (_, rest)+ | not (T.null rest) ->+ let afterParen = T.drop 2 rest -- skip "=("+ uuidText = T.takeWhile (/= ')') afterParen+ 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."--}+-- | 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."@.+ 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 -Returns @Nothing@ when the format is unrecognized — most commonly because-the server emitted a non-English locale variant or because a future schema-change altered the constraint's column. Callers should use a sensible-fallback (e.g., the first stream in a multi-stream operation) rather than-treat 'Nothing' as a fatal condition.--}+-- | Extract a stream name from a PostgreSQL unique-violation detail string+-- like @"Key (stream_name)=(orders-1) already exists."@.+--+-- Returns @Nothing@ when the format is unrecognized — most commonly because+-- the server emitted a non-English locale variant or because a future schema+-- change altered the constraint's column. Callers should use a sensible+-- fallback (e.g., the first stream in a multi-stream operation) rather than+-- treat 'Nothing' as a fatal condition. extractStreamNameFromDetail :: Text -> Maybe Text extractStreamNameFromDetail detail =- case T.breakOn "=(" detail of- (_, rest)- | not (T.null rest) ->- let afterParen = T.drop 2 rest -- skip "=("- inner = T.takeWhile (/= ')') afterParen- in if T.null inner then Nothing else Just inner- _ -> Nothing--{- | For 'appendMultiStream' errors, recover the offending stream from the-PostgreSQL detail string when possible.--The multi-stream interpreter's transaction returns a single-@Either UsageError result@; per-statement attribution is not visible at the-@hasql@ layer. When the failure is a @23505@ unique violation on-@ix_streams_stream_name@, the PostgreSQL detail string carries the-offending stream name (e.g., @"Key (stream_name)=(multi-c) already exists."@)-and we look up the matching op to recover its 'ExpectedVersion'.--When the detail cannot be parsed (any other failure mode, including-@events_pkey@ violations whose 'DuplicateEvent' constructor carries no-stream attribution, generic server errors, and connection errors), we fall-back to attributing against the first stream in the input list and let-'mapUsageError' map the rest.+ case T.breakOn "=(" detail of+ (_, rest)+ | not (T.null rest) ->+ let afterParen = T.drop 2 rest -- skip "=("+ inner = T.takeWhile (/= ')') afterParen+ in if T.null inner then Nothing else Just inner+ _ -> Nothing -This is defensive — the current SQL paths in @kiroku-store/src/Kiroku/Store/SQL.hs@-do not raise @ix_streams_stream_name@ violations because every append CTE-uses @ON CONFLICT DO NOTHING@/@DO UPDATE@ — but a future schema change could-introduce a path that does, and the attribution should be correct from day-one.--}+-- | For 'appendMultiStream' errors, recover the offending stream from the+-- PostgreSQL detail string when possible.+--+-- The multi-stream interpreter's transaction returns a single+-- @Either UsageError result@; per-statement attribution is not visible at the+-- @hasql@ layer. When the failure is a @23505@ unique violation on+-- @ix_streams_stream_name@, the PostgreSQL detail string carries the+-- offending stream name (e.g., @"Key (stream_name)=(multi-c) already exists."@)+-- and we look up the matching op to recover its 'ExpectedVersion'.+--+-- When the detail cannot be parsed (any other failure mode, including+-- @events_pkey@ violations whose 'DuplicateEvent' constructor carries no+-- stream attribution, generic server errors, and connection errors), we fall+-- back to attributing against the first stream in the input list and let+-- 'mapUsageError' map the rest.+--+-- This is defensive — the current SQL paths in @kiroku-store/src/Kiroku/Store/SQL.hs@+-- do not raise @ix_streams_stream_name@ violations because every append CTE+-- uses @ON CONFLICT DO NOTHING@/@DO UPDATE@ — but a future schema change could+-- introduce a path that does, and the attribution should be correct from day+-- one. attributeMultiStreamError ::- {- | The (name, expected) pairs from the multi-stream ops, in the order- the caller supplied them.- -}- [(StreamName, ExpectedVersion)] ->- UsageError ->- StoreError+ -- | The (name, expected) pairs from the multi-stream ops, in the order+ -- the caller supplied them.+ [(StreamName, ExpectedVersion)] ->+ UsageError ->+ StoreError attributeMultiStreamError [] usageErr =- -- Defensive: an empty multi-stream call should not reach the- -- transaction layer, but if it somehow does, surface the raw error.- ConnectionError ("Empty multi-stream usage error: " <> T.pack (show usageErr))+ -- Defensive: an empty multi-stream call should not reach the+ -- transaction layer, but if it somehow does, surface the raw error.+ ConnectionError ("Empty multi-stream usage error: " <> T.pack (show usageErr)) attributeMultiStreamError ops@((StreamName firstName, firstExpected) : _) usageErr =- case extractServerError usageErr of- Just (Errors.ServerError "23505" message (Just detail) _ _)- | uniqueConstraintName message (Just detail) == Just "ix_streams_stream_name"- , Just sn <- extractStreamNameFromDetail detail- , Just (StreamName name, expected) <- lookupStream sn ops ->- mapUsageError name expected usageErr- _ ->- mapUsageError firstName firstExpected usageErr+ case extractServerError usageErr of+ Just (Errors.ServerError "23505" message (Just detail) _ _)+ | uniqueConstraintName message (Just detail) == Just "ix_streams_stream_name",+ Just sn <- extractStreamNameFromDetail detail,+ Just (StreamName name, expected) <- lookupStream sn ops ->+ mapUsageError name expected usageErr+ _ ->+ mapUsageError firstName firstExpected usageErr where lookupStream tgt = lookup' tgt lookup' _ [] = Nothing lookup' tgt (op@(StreamName n, _) : rest)- | n == tgt = Just op- | otherwise = lookup' tgt rest+ | n == tgt = Just op+ | otherwise = lookup' tgt rest -- | Walk a 'UsageError' down to the underlying 'Errors.ServerError', if any. extractServerError :: UsageError -> Maybe Errors.ServerError extractServerError = \case- SessionUsageError (Errors.ScriptSessionError _ serverErr) ->- Just serverErr- SessionUsageError (Errors.StatementSessionError _ _ _ _ _ stmtErr) ->- case stmtErr of- Errors.ServerStatementError serverErr -> Just serverErr- _ -> Nothing- _ -> Nothing--{- | True for PostgreSQL class-40 (transaction rollback) @SQLSTATE@ codes:-@40001@ serialization_failure and @40P01@ deadlock_detected.+ SessionUsageError (Errors.ScriptSessionError _ serverErr) ->+ Just serverErr+ SessionUsageError (Errors.StatementSessionError _ _ _ _ _ stmtErr) ->+ case stmtErr of+ Errors.ServerStatementError serverErr -> Just serverErr+ _ -> Nothing+ _ -> Nothing -These are the codes hasql-transaction retries, and the ones the store maps to-'TransientTransactionFailure'. Kept as one predicate so the retry decision and-the error classification can never drift apart.--}+-- | True for PostgreSQL class-40 (transaction rollback) @SQLSTATE@ codes:+-- @40001@ serialization_failure and @40P01@ deadlock_detected.+--+-- These are the codes hasql-transaction retries, and the ones the store maps to+-- 'TransientTransactionFailure'. Kept as one predicate so the retry decision and+-- the error classification can never drift apart. isTransientTransactionCode :: Text -> Bool isTransientTransactionCode code = code == "40001" || code == "40P01" -- | True for PostgreSQL transient transaction aborts retried by hasql-transaction. isTransientSerializationError :: UsageError -> Bool isTransientSerializationError usageErr =- case extractServerError usageErr of- Just (Errors.ServerError code _ _ _ _) ->- isTransientTransactionCode code- Nothing ->- False+ case extractServerError usageErr of+ Just (Errors.ServerError code _ _ _ _) ->+ isTransientTransactionCode code+ Nothing ->+ False
src/Kiroku/Store/HistoryRetention.hs view
@@ -1,22 +1,21 @@-{- | Durable replay-history retention and transaction-scoped stream guards.--A lease protects the retained global event set from Kiroku hard delete and-authorized direct SQL deletion until its database-derived expiry. Transaction-combinators persist durable evidence but cannot emit process-local observability-events from inside an opaque caller-owned transaction.--Lease duration is one second through one hour. Expiry is passive: a crashed-owner blocks destructive work only until PostgreSQL time reaches @expiresAt@.-The owner is an accidental-mutation guard, not an authorization credential;-database roles and grants remain the security boundary. Direct destructive SQL-receives SQLSTATE @KR001@ while any lease is active.--When one transaction needs both a lease operation and a stream-history guard,-perform the lease operation first. This preserves Kiroku's coordinator-before--stream-row lock order.--}-module Kiroku.Store.HistoryRetention (- module Kiroku.Store.HistoryRetention.Types,+-- | Durable replay-history retention and transaction-scoped stream guards.+--+-- A lease protects the retained global event set from Kiroku hard delete and+-- authorized direct SQL deletion until its database-derived expiry. Transaction+-- combinators persist durable evidence but cannot emit process-local observability+-- events from inside an opaque caller-owned transaction.+--+-- Lease duration is one second through one hour. Expiry is passive: a crashed+-- owner blocks destructive work only until PostgreSQL time reaches @expiresAt@.+-- The owner is an accidental-mutation guard, not an authorization credential;+-- database roles and grants remain the security boundary. Direct destructive SQL+-- receives SQLSTATE @KR001@ while any lease is active.+--+-- When one transaction needs both a lease operation and a stream-history guard,+-- perform the lease operation first. This preserves Kiroku's coordinator-before-+-- stream-row lock order.+module Kiroku.Store.HistoryRetention+ ( module Kiroku.Store.HistoryRetention.Types, acquireHistoryRetentionLeaseTx, renewHistoryRetentionLeaseTx, releaseHistoryRetentionLeaseTx,@@ -29,7 +28,8 @@ pruneHistoryRetentionLeases, lockStreamHistoryForReplayTx, readStreamForwardTx,-) where+ )+where import Data.Int (Int32) import Data.Time.Clock (UTCTime)@@ -44,104 +44,98 @@ import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Types (RecordedEvent, StreamInfo, StreamName (..), StreamVersion (..)) -{- | Acquire a new lease and atomically capture the authoritative @$all@-frontier as the inclusive @protectedThrough@ rebuild ceiling. PostgreSQL-supplies every timestamp. Rollback leaves no lease row.--}+-- | Acquire a new lease and atomically capture the authoritative @$all@+-- frontier as the inclusive @protectedThrough@ rebuild ceiling. PostgreSQL+-- supplies every timestamp. Rollback leaves no lease row. acquireHistoryRetentionLeaseTx :: HistoryRetentionLeaseRequest -> Tx.Transaction HistoryRetentionLease acquireHistoryRetentionLeaseTx = Internal.acquireHistoryRetentionLeaseTx -{- | Renew a still-active lease without shortening its current expiry.-Unknown, wrong-owner, expired, and released leases return distinct typed-errors and are never resurrected.--}+-- | Renew a still-active lease without shortening its current expiry.+-- Unknown, wrong-owner, expired, and released leases return distinct typed+-- errors and are never resurrected. renewHistoryRetentionLeaseTx ::- HistoryRetentionLeaseHandle ->- HistoryRetentionLeaseDuration ->- Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)+ HistoryRetentionLeaseHandle ->+ HistoryRetentionLeaseDuration ->+ Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease) renewHistoryRetentionLeaseTx = Internal.renewHistoryRetentionLeaseTx -- | Release a live lease. Repetition is typed and does not rewrite the row. releaseHistoryRetentionLeaseTx ::- HistoryRetentionLeaseHandle ->- Tx.Transaction HistoryRetentionReleaseResult+ HistoryRetentionLeaseHandle ->+ Tx.Transaction HistoryRetentionReleaseResult releaseHistoryRetentionLeaseTx = Internal.releaseHistoryRetentionLeaseTx -{- | Read a deterministic, database-time-derived, bounded inventory. Active,-expired, and released state is derived at statement time; no expiry worker is-required.--}+-- | Read a deterministic, database-time-derived, bounded inventory. Active,+-- expired, and released state is derived at statement time; no expiry worker is+-- required. historyRetentionLeaseInventoryTx ::- HistoryRetentionInventoryQuery ->- Tx.Transaction (Vector HistoryRetentionLease)+ HistoryRetentionInventoryQuery ->+ Tx.Transaction (Vector HistoryRetentionLease) historyRetentionLeaseInventoryTx = Internal.historyRetentionLeaseInventoryTx -- | Remove only expired or released rows strictly older than the supplied cutoff. pruneHistoryRetentionLeasesTx :: UTCTime -> Tx.Transaction HistoryRetentionPruneResult pruneHistoryRetentionLeasesTx = Internal.pruneHistoryRetentionLeasesTx -{- | Acquire a lease through the mockable 'Store' effect. The production-interpreter emits the committed acquisition event after the transaction ends.--}+-- | Acquire a lease through the mockable 'Store' effect. The production+-- interpreter emits the committed acquisition event after the transaction ends. acquireHistoryRetentionLease :: (Store :> es) => HistoryRetentionLeaseRequest -> Eff es HistoryRetentionLease acquireHistoryRetentionLease request = send (AcquireHistoryRetentionLease request) -- | Renew a lease through the mockable 'Store' effect and emit only a committed success. renewHistoryRetentionLease ::- (Store :> es) =>- HistoryRetentionLeaseHandle ->- HistoryRetentionLeaseDuration ->- Eff es (Either HistoryRetentionRenewalError HistoryRetentionLease)+ (Store :> es) =>+ HistoryRetentionLeaseHandle ->+ HistoryRetentionLeaseDuration ->+ Eff es (Either HistoryRetentionRenewalError HistoryRetentionLease) renewHistoryRetentionLease handle duration = send (RenewHistoryRetentionLease handle duration) -- | Release a lease through the mockable 'Store' effect. Repeated release emits no false transition. releaseHistoryRetentionLease ::- (Store :> es) =>- HistoryRetentionLeaseHandle ->- Eff es HistoryRetentionReleaseResult+ (Store :> es) =>+ HistoryRetentionLeaseHandle ->+ Eff es HistoryRetentionReleaseResult releaseHistoryRetentionLease handle = send (ReleaseHistoryRetentionLease handle) -- | Read the bounded durable inventory through the mockable 'Store' effect. historyRetentionLeaseInventory ::- (Store :> es) =>- HistoryRetentionInventoryQuery ->- Eff es (Vector HistoryRetentionLease)+ (Store :> es) =>+ HistoryRetentionInventoryQuery ->+ Eff es (Vector HistoryRetentionLease) historyRetentionLeaseInventory query = send (GetHistoryRetentionLeaseInventory query) -- | Prune terminal rows through the mockable 'Store' effect. pruneHistoryRetentionLeases ::- (Store :> es) =>- UTCTime ->- Eff es HistoryRetentionPruneResult+ (Store :> es) =>+ UTCTime ->+ Eff es HistoryRetentionPruneResult pruneHistoryRetentionLeases cutoff = send (PruneHistoryRetentionLeases cutoff) -{- | Lock one application's stream row in share mode until the surrounding-transaction ends. The returned metadata includes soft-delete and logical-truncate state so a repair can reject incomplete history before mutating its-target. @$all@ is reserved.--When composing this guard with a lease operation, acquire or renew the lease-first and take this guard second.--}+-- | Lock one application's stream row in share mode until the surrounding+-- transaction ends. The returned metadata includes soft-delete and logical+-- truncate state so a repair can reject incomplete history before mutating its+-- target. @$all@ is reserved.+--+-- When composing this guard with a lease operation, acquire or renew the lease+-- first and take this guard second. lockStreamHistoryForReplayTx ::- StreamName ->- Tx.Transaction (Either StreamHistoryUnavailable StreamInfo)+ StreamName ->+ Tx.Transaction (Either StreamHistoryUnavailable StreamInfo) lockStreamHistoryForReplayTx stream@(StreamName name)- | name == "$all" = pure (Left (StreamHistoryReserved stream))- | otherwise = do- locked <- Tx.statement name HistoryRetentionSQL.lockStreamHistoryStmt- pure $ maybe (Left (StreamHistoryNotFound stream)) Right locked+ | name == "$all" = pure (Left (StreamHistoryReserved stream))+ | otherwise = do+ locked <- Tx.statement name HistoryRetentionSQL.lockStreamHistoryStmt+ pure $ maybe (Left (StreamHistoryNotFound stream)) Right locked -{- | Read a page inside the caller's transaction using the production-exclusive-lower cursor and ascending-order statement. This function deliberately-does not run 'Kiroku.Store.Settings.decodeHook': 'Tx.Transaction' has no 'IO'.-The caller must take 'lockStreamHistoryForReplayTx' earlier in the same-transaction when stable one-stream history is required.--}+-- | Read a page inside the caller's transaction using the production+-- exclusive-lower cursor and ascending-order statement. This function deliberately+-- does not run 'Kiroku.Store.Settings.decodeHook': 'Tx.Transaction' has no 'IO'.+-- The caller must take 'lockStreamHistoryForReplayTx' earlier in the same+-- transaction when stable one-stream history is required. readStreamForwardTx ::- StreamName ->- StreamVersion ->- Int32 ->- Tx.Transaction (Vector RecordedEvent)+ StreamName ->+ StreamVersion ->+ Int32 ->+ Tx.Transaction (Vector RecordedEvent) readStreamForwardTx (StreamName name) (StreamVersion cursor) limit =- Tx.statement (name, cursor, limit) SQL.readStreamForwardStmt+ Tx.statement (name, cursor, limit) SQL.readStreamForwardStmt
src/Kiroku/Store/HistoryRetention/Internal.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.HistoryRetention.Internal (- acquireHistoryRetentionLeaseTx,+module Kiroku.Store.HistoryRetention.Internal+ ( acquireHistoryRetentionLeaseTx, renewHistoryRetentionLeaseTx, releaseHistoryRetentionLeaseTx, historyRetentionLeaseInventoryTx,@@ -7,7 +7,8 @@ lockHistoryRetentionCoordinatorTx, activeHistoryRetentionConflictTx, lockAffectedStreamsForHardDeleteTx,-) where+ )+where import Data.Int (Int64) import Data.Time.Clock (UTCTime)@@ -18,74 +19,74 @@ import Kiroku.Store.HistoryRetention.Types acquireHistoryRetentionLeaseTx :: HistoryRetentionLeaseRequest -> Tx.Transaction HistoryRetentionLease-acquireHistoryRetentionLeaseTx HistoryRetentionLeaseRequest{owner, reason, duration} =- Tx.statement- ( historyRetentionLeaseOwnerText owner- , historyRetentionLeaseReasonText reason- , historyRetentionLeaseDurationValue duration- )- SQL.acquireLeaseStmt+acquireHistoryRetentionLeaseTx HistoryRetentionLeaseRequest {owner, reason, duration} =+ Tx.statement+ ( historyRetentionLeaseOwnerText owner,+ historyRetentionLeaseReasonText reason,+ historyRetentionLeaseDurationValue duration+ )+ SQL.acquireLeaseStmt renewHistoryRetentionLeaseTx ::- HistoryRetentionLeaseHandle ->- HistoryRetentionLeaseDuration ->- Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)-renewHistoryRetentionLeaseTx HistoryRetentionLeaseHandle{leaseId, owner = requestedOwner} duration = do- lockHistoryRetentionCoordinatorTx- current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt- case current of- Nothing -> pure (Left HistoryRetentionRenewalUnknown)- Just HistoryRetentionLease{owner = actualOwner, state}- | actualOwner /= requestedOwner -> pure (Left HistoryRetentionRenewalOwnerMismatch)- | state == HistoryRetentionLeaseReleased -> pure (Left HistoryRetentionRenewalReleased)- | state == HistoryRetentionLeaseExpired -> pure (Left HistoryRetentionRenewalExpired)- | otherwise ->- Right- <$> Tx.statement- (leaseUuid leaseId, historyRetentionLeaseDurationValue duration)- SQL.renewLeaseStmt+ HistoryRetentionLeaseHandle ->+ HistoryRetentionLeaseDuration ->+ Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)+renewHistoryRetentionLeaseTx HistoryRetentionLeaseHandle {leaseId, owner = requestedOwner} duration = do+ lockHistoryRetentionCoordinatorTx+ current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt+ case current of+ Nothing -> pure (Left HistoryRetentionRenewalUnknown)+ Just HistoryRetentionLease {owner = actualOwner, state}+ | actualOwner /= requestedOwner -> pure (Left HistoryRetentionRenewalOwnerMismatch)+ | state == HistoryRetentionLeaseReleased -> pure (Left HistoryRetentionRenewalReleased)+ | state == HistoryRetentionLeaseExpired -> pure (Left HistoryRetentionRenewalExpired)+ | otherwise ->+ Right+ <$> Tx.statement+ (leaseUuid leaseId, historyRetentionLeaseDurationValue duration)+ SQL.renewLeaseStmt releaseHistoryRetentionLeaseTx ::- HistoryRetentionLeaseHandle ->- Tx.Transaction HistoryRetentionReleaseResult-releaseHistoryRetentionLeaseTx HistoryRetentionLeaseHandle{leaseId, owner = requestedOwner} = do- lockHistoryRetentionCoordinatorTx- current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt- case current of- Nothing -> pure HistoryRetentionReleaseUnknown- Just lease@HistoryRetentionLease{owner = actualOwner, state}- | actualOwner /= requestedOwner -> pure HistoryRetentionReleaseOwnerMismatch- | state == HistoryRetentionLeaseReleased -> pure (HistoryRetentionAlreadyReleased lease)- | state == HistoryRetentionLeaseExpired -> pure (HistoryRetentionReleaseExpired lease)- | otherwise ->- HistoryRetentionReleased- <$> Tx.statement (leaseUuid leaseId) SQL.releaseLeaseStmt+ HistoryRetentionLeaseHandle ->+ Tx.Transaction HistoryRetentionReleaseResult+releaseHistoryRetentionLeaseTx HistoryRetentionLeaseHandle {leaseId, owner = requestedOwner} = do+ lockHistoryRetentionCoordinatorTx+ current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt+ case current of+ Nothing -> pure HistoryRetentionReleaseUnknown+ Just lease@HistoryRetentionLease {owner = actualOwner, state}+ | actualOwner /= requestedOwner -> pure HistoryRetentionReleaseOwnerMismatch+ | state == HistoryRetentionLeaseReleased -> pure (HistoryRetentionAlreadyReleased lease)+ | state == HistoryRetentionLeaseExpired -> pure (HistoryRetentionReleaseExpired lease)+ | otherwise ->+ HistoryRetentionReleased+ <$> Tx.statement (leaseUuid leaseId) SQL.releaseLeaseStmt historyRetentionLeaseInventoryTx ::- HistoryRetentionInventoryQuery ->- Tx.Transaction (Vector HistoryRetentionLease)-historyRetentionLeaseInventoryTx HistoryRetentionInventoryQuery{limit} =- Tx.statement (historyRetentionInventoryLimitValue limit) SQL.leaseInventoryStmt+ HistoryRetentionInventoryQuery ->+ Tx.Transaction (Vector HistoryRetentionLease)+historyRetentionLeaseInventoryTx HistoryRetentionInventoryQuery {limit} =+ Tx.statement (historyRetentionInventoryLimitValue limit) SQL.leaseInventoryStmt pruneHistoryRetentionLeasesTx ::- UTCTime ->- Tx.Transaction HistoryRetentionPruneResult+ UTCTime ->+ Tx.Transaction HistoryRetentionPruneResult pruneHistoryRetentionLeasesTx cutoff = do- lockHistoryRetentionCoordinatorTx- Tx.statement cutoff SQL.pruneLeasesStmt+ lockHistoryRetentionCoordinatorTx+ Tx.statement cutoff SQL.pruneLeasesStmt lockHistoryRetentionCoordinatorTx :: Tx.Transaction () lockHistoryRetentionCoordinatorTx = do- _ <- Tx.statement () SQL.lockCoordinatorStmt- pure ()+ _ <- Tx.statement () SQL.lockCoordinatorStmt+ pure () activeHistoryRetentionConflictTx :: Tx.Transaction (Maybe HistoryRetentionConflict) activeHistoryRetentionConflictTx = Tx.statement () SQL.activeConflictStmt lockAffectedStreamsForHardDeleteTx :: Int64 -> Tx.Transaction () lockAffectedStreamsForHardDeleteTx streamId = do- _ <- Tx.statement streamId SQL.lockAffectedStreamsForHardDeleteStmt- pure ()+ _ <- Tx.statement streamId SQL.lockAffectedStreamsForHardDeleteStmt+ pure () leaseUuid :: HistoryRetentionLeaseId -> UUID leaseUuid (HistoryRetentionLeaseId value) = value
src/Kiroku/Store/HistoryRetention/SQL.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.HistoryRetention.SQL (- acquireLeaseStmt,+module Kiroku.Store.HistoryRetention.SQL+ ( acquireLeaseStmt, lockCoordinatorStmt, readLeaseForUpdateStmt, renewLeaseStmt,@@ -11,7 +11,8 @@ activeConflictStmt, lockStreamHistoryStmt, lockAffectedStreamsForHardDeleteStmt,-) where+ )+where import Contravariant.Extras (contrazip2, contrazip3) import Data.Int (Int32, Int64)@@ -27,250 +28,250 @@ acquireLeaseStmt :: Statement (Text, Text, DiffTime) HistoryRetentionLease acquireLeaseStmt =- preparable- """- WITH coordinator AS MATERIALIZED (- SELECT singleton- FROM history_retention_coordinator- WHERE singleton- FOR UPDATE- ), snapshot AS MATERIALIZED (- SELECT streams.stream_version AS protected_through,- clock_timestamp() AS database_now- FROM streams- CROSS JOIN coordinator- WHERE streams.stream_id = 0- ), inserted AS (- INSERT INTO history_retention_leases- (owner, reason, protected_through, created_at, renewed_at, expires_at)- SELECT $1, $2, protected_through, database_now, database_now, database_now + $3- FROM snapshot- RETURNING lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at- )- SELECT lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at,- 'active'::text- FROM inserted- """- (contrazip3 textParam textParam intervalParam)- (D.singleRow leaseRow)+ preparable+ """+ WITH coordinator AS MATERIALIZED (+ SELECT singleton+ FROM history_retention_coordinator+ WHERE singleton+ FOR UPDATE+ ), snapshot AS MATERIALIZED (+ SELECT streams.stream_version AS protected_through,+ clock_timestamp() AS database_now+ FROM streams+ CROSS JOIN coordinator+ WHERE streams.stream_id = 0+ ), inserted AS (+ INSERT INTO history_retention_leases+ (owner, reason, protected_through, created_at, renewed_at, expires_at)+ SELECT $1, $2, protected_through, database_now, database_now, database_now + $3+ FROM snapshot+ RETURNING lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at+ )+ SELECT lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at,+ 'active'::text+ FROM inserted+ """+ (contrazip3 textParam textParam intervalParam)+ (D.singleRow leaseRow) lockCoordinatorStmt :: Statement () Bool lockCoordinatorStmt =- preparable- """- SELECT singleton- FROM history_retention_coordinator- WHERE singleton- FOR UPDATE- """- E.noParams- (D.singleRow (column D.bool))+ preparable+ """+ SELECT singleton+ FROM history_retention_coordinator+ WHERE singleton+ FOR UPDATE+ """+ E.noParams+ (D.singleRow (column D.bool)) readLeaseForUpdateStmt :: Statement UUID (Maybe HistoryRetentionLease) readLeaseForUpdateStmt =- preparable- """- SELECT lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at,- CASE- WHEN released_at IS NOT NULL THEN 'released'- WHEN expires_at <= clock_timestamp() THEN 'expired'- ELSE 'active'- END::text- FROM history_retention_leases- WHERE lease_id = $1- FOR UPDATE- """- uuidParam- (D.rowMaybe leaseRow)+ preparable+ """+ SELECT lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at,+ CASE+ WHEN released_at IS NOT NULL THEN 'released'+ WHEN expires_at <= clock_timestamp() THEN 'expired'+ ELSE 'active'+ END::text+ FROM history_retention_leases+ WHERE lease_id = $1+ FOR UPDATE+ """+ uuidParam+ (D.rowMaybe leaseRow) renewLeaseStmt :: Statement (UUID, DiffTime) HistoryRetentionLease renewLeaseStmt =- preparable- """- WITH renewal_time AS MATERIALIZED (- SELECT clock_timestamp() AS database_now- ), renewed AS (- UPDATE history_retention_leases- SET renewed_at = renewal_time.database_now,- expires_at = GREATEST(- history_retention_leases.expires_at,- renewal_time.database_now + $2- )- FROM renewal_time- WHERE lease_id = $1- RETURNING lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at- )- SELECT lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at,- 'active'::text- FROM renewed- """- (contrazip2 uuidParam intervalParam)- (D.singleRow leaseRow)+ preparable+ """+ WITH renewal_time AS MATERIALIZED (+ SELECT clock_timestamp() AS database_now+ ), renewed AS (+ UPDATE history_retention_leases+ SET renewed_at = renewal_time.database_now,+ expires_at = GREATEST(+ history_retention_leases.expires_at,+ renewal_time.database_now + $2+ )+ FROM renewal_time+ WHERE lease_id = $1+ RETURNING lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at+ )+ SELECT lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at,+ 'active'::text+ FROM renewed+ """+ (contrazip2 uuidParam intervalParam)+ (D.singleRow leaseRow) releaseLeaseStmt :: Statement UUID HistoryRetentionLease releaseLeaseStmt =- preparable- """- WITH released AS (- UPDATE history_retention_leases- SET released_at = clock_timestamp()- WHERE lease_id = $1- RETURNING lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at- )- SELECT lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at,- 'released'::text- FROM released- """- uuidParam- (D.singleRow leaseRow)+ preparable+ """+ WITH released AS (+ UPDATE history_retention_leases+ SET released_at = clock_timestamp()+ WHERE lease_id = $1+ RETURNING lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at+ )+ SELECT lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at,+ 'released'::text+ FROM released+ """+ uuidParam+ (D.singleRow leaseRow) leaseInventoryStmt :: Statement Int32 (Vector HistoryRetentionLease) leaseInventoryStmt =- preparable- """- WITH inventory_time AS MATERIALIZED (- SELECT clock_timestamp() AS database_now- )- SELECT lease_id, owner, reason, protected_through,- created_at, renewed_at, expires_at, released_at,- CASE- WHEN released_at IS NOT NULL THEN 'released'- WHEN expires_at <= inventory_time.database_now THEN 'expired'- ELSE 'active'- END::text- FROM history_retention_leases- CROSS JOIN inventory_time- ORDER BY created_at, lease_id- LIMIT $1- """- int4Param- (D.rowVector leaseRow)+ preparable+ """+ WITH inventory_time AS MATERIALIZED (+ SELECT clock_timestamp() AS database_now+ )+ SELECT lease_id, owner, reason, protected_through,+ created_at, renewed_at, expires_at, released_at,+ CASE+ WHEN released_at IS NOT NULL THEN 'released'+ WHEN expires_at <= inventory_time.database_now THEN 'expired'+ ELSE 'active'+ END::text+ FROM history_retention_leases+ CROSS JOIN inventory_time+ ORDER BY created_at, lease_id+ LIMIT $1+ """+ int4Param+ (D.rowVector leaseRow) pruneLeasesStmt :: Statement UTCTime HistoryRetentionPruneResult pruneLeasesStmt =- preparable- """- WITH released AS (- DELETE FROM history_retention_leases- WHERE released_at IS NOT NULL- AND released_at < $1- RETURNING 1- ), expired AS (- DELETE FROM history_retention_leases- WHERE released_at IS NULL- AND expires_at < $1- RETURNING 1- )- SELECT (SELECT count(*) FROM expired),- (SELECT count(*) FROM released)- """- timestamptzParam- ( D.singleRow- ( HistoryRetentionPruneResult- <$> column D.int8- <*> column D.int8- )+ preparable+ """+ WITH released AS (+ DELETE FROM history_retention_leases+ WHERE released_at IS NOT NULL+ AND released_at < $1+ RETURNING 1+ ), expired AS (+ DELETE FROM history_retention_leases+ WHERE released_at IS NULL+ AND expires_at < $1+ RETURNING 1+ )+ SELECT (SELECT count(*) FROM expired),+ (SELECT count(*) FROM released)+ """+ timestamptzParam+ ( D.singleRow+ ( HistoryRetentionPruneResult+ <$> column D.int8+ <*> column D.int8 )+ ) activeConflictStmt :: Statement () (Maybe HistoryRetentionConflict) activeConflictStmt =- preparable- """- SELECT count(*), min(expires_at)- FROM history_retention_leases- WHERE released_at IS NULL- AND expires_at > clock_timestamp()- HAVING count(*) > 0- """- E.noParams- ( D.rowMaybe- ( HistoryRetentionConflict- <$> column D.int8- <*> column D.timestamptz- )+ preparable+ """+ SELECT count(*), min(expires_at)+ FROM history_retention_leases+ WHERE released_at IS NULL+ AND expires_at > clock_timestamp()+ HAVING count(*) > 0+ """+ E.noParams+ ( D.rowMaybe+ ( HistoryRetentionConflict+ <$> column D.int8+ <*> column D.timestamptz )+ ) lockStreamHistoryStmt :: Statement Text (Maybe StreamInfo) lockStreamHistoryStmt =- preparable- """- SELECT stream_id, stream_name, stream_version,- created_at, deleted_at, truncate_before- FROM streams- WHERE stream_name = $1- FOR SHARE- """- textParam- (D.rowMaybe streamInfoRow)+ preparable+ """+ SELECT stream_id, stream_name, stream_version,+ created_at, deleted_at, truncate_before+ FROM streams+ WHERE stream_name = $1+ FOR SHARE+ """+ textParam+ (D.rowMaybe streamInfoRow) lockAffectedStreamsForHardDeleteStmt :: Statement Int64 (Vector Int64) lockAffectedStreamsForHardDeleteStmt =- preparable- """- WITH affected_streams AS (- SELECT $1::bigint AS stream_id- UNION- SELECT stream_events.stream_id- FROM stream_events- WHERE stream_events.original_stream_id = $1- AND stream_events.stream_id <> 0- )- SELECT streams.stream_id- FROM streams- JOIN affected_streams USING (stream_id)- ORDER BY streams.stream_id- FOR UPDATE OF streams- """- int8Param- (D.rowVector (column D.int8))+ preparable+ """+ WITH affected_streams AS (+ SELECT $1::bigint AS stream_id+ UNION+ SELECT stream_events.stream_id+ FROM stream_events+ WHERE stream_events.original_stream_id = $1+ AND stream_events.stream_id <> 0+ )+ SELECT streams.stream_id+ FROM streams+ JOIN affected_streams USING (stream_id)+ ORDER BY streams.stream_id+ FOR UPDATE OF streams+ """+ int8Param+ (D.rowVector (column D.int8)) leaseRow :: D.Row HistoryRetentionLease leaseRow =- makeLease- <$> column D.uuid- <*> column D.text- <*> column D.text- <*> column D.int8- <*> column D.timestamptz- <*> column D.timestamptz- <*> column D.timestamptz- <*> D.column (D.nullable D.timestamptz)- <*> column D.text+ makeLease+ <$> column D.uuid+ <*> column D.text+ <*> column D.text+ <*> column D.int8+ <*> column D.timestamptz+ <*> column D.timestamptz+ <*> column D.timestamptz+ <*> D.column (D.nullable D.timestamptz)+ <*> column D.text makeLease :: UUID -> Text -> Text -> Int64 -> UTCTime -> UTCTime -> UTCTime -> Maybe UTCTime -> Text -> HistoryRetentionLease makeLease leaseUuid ownerText reasonText frontier created renewed expires released stateText =- HistoryRetentionLease- { leaseId = HistoryRetentionLeaseId leaseUuid- , owner = requireValidated (mkHistoryRetentionLeaseOwner ownerText)- , reason = requireValidated (mkHistoryRetentionLeaseReason reasonText)- , protectedThrough = GlobalPosition frontier- , createdAt = created- , renewedAt = renewed- , expiresAt = expires- , releasedAt = released- , state = case stateText of- "active" -> HistoryRetentionLeaseActive- "expired" -> HistoryRetentionLeaseExpired- "released" -> HistoryRetentionLeaseReleased- unexpected -> error ("unknown history retention lease state from database: " <> show unexpected)- }+ HistoryRetentionLease+ { leaseId = HistoryRetentionLeaseId leaseUuid,+ owner = requireValidated (mkHistoryRetentionLeaseOwner ownerText),+ reason = requireValidated (mkHistoryRetentionLeaseReason reasonText),+ protectedThrough = GlobalPosition frontier,+ createdAt = created,+ renewedAt = renewed,+ expiresAt = expires,+ releasedAt = released,+ state = case stateText of+ "active" -> HistoryRetentionLeaseActive+ "expired" -> HistoryRetentionLeaseExpired+ "released" -> HistoryRetentionLeaseReleased+ unexpected -> error ("unknown history retention lease state from database: " <> show unexpected)+ } streamInfoRow :: D.Row StreamInfo streamInfoRow =- StreamInfo- <$> (StreamId <$> column D.int8)- <*> (StreamName <$> column D.text)- <*> (StreamVersion <$> column D.int8)- <*> column D.timestamptz- <*> D.column (D.nullable D.timestamptz)- <*> (StreamVersion <$> column D.int8)+ StreamInfo+ <$> (StreamId <$> column D.int8)+ <*> (StreamName <$> column D.text)+ <*> (StreamVersion <$> column D.int8)+ <*> column D.timestamptz+ <*> D.column (D.nullable D.timestamptz)+ <*> (StreamVersion <$> column D.int8) requireValidated :: Either error value -> value requireValidated = either (const (error "database returned an invalid history retention lease")) (\value -> value)
src/Kiroku/Store/HistoryRetention/Types.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.HistoryRetention.Types (- HistoryRetentionLeaseId (..),+module Kiroku.Store.HistoryRetention.Types+ ( HistoryRetentionLeaseId (..), HistoryRetentionLeaseOwner, HistoryRetentionLeaseReason, HistoryRetentionLeaseDuration,@@ -25,7 +25,8 @@ historyRetentionLeaseDurationValue, historyRetentionInventoryLimitValue, maxHistoryRetentionLeaseDuration,-) where+ )+where import Data.ByteString qualified as ByteString import Data.Int (Int32, Int64)@@ -38,131 +39,131 @@ -- | Database-generated identity of one durable retention lease. newtype HistoryRetentionLeaseId = HistoryRetentionLeaseId UUID- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -- | Validated operational owner label (1 through 512 UTF-8 bytes). newtype HistoryRetentionLeaseOwner = HistoryRetentionLeaseOwner Text- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -- | Validated human-readable reason (1 through 2,048 UTF-8 bytes). newtype HistoryRetentionLeaseReason = HistoryRetentionLeaseReason Text- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -- | Requested remaining lifetime, from one second through one hour. newtype HistoryRetentionLeaseDuration = HistoryRetentionLeaseDuration DiffTime- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) data HistoryRetentionLeaseRequest = HistoryRetentionLeaseRequest- { owner :: !HistoryRetentionLeaseOwner- , reason :: !HistoryRetentionLeaseReason- , duration :: !HistoryRetentionLeaseDuration- }- deriving stock (Eq, Show, Generic)+ { owner :: !HistoryRetentionLeaseOwner,+ reason :: !HistoryRetentionLeaseReason,+ duration :: !HistoryRetentionLeaseDuration+ }+ deriving stock (Eq, Show, Generic) data HistoryRetentionLeaseHandle = HistoryRetentionLeaseHandle- { leaseId :: !HistoryRetentionLeaseId- , owner :: !HistoryRetentionLeaseOwner- }- deriving stock (Eq, Show, Generic)+ { leaseId :: !HistoryRetentionLeaseId,+ owner :: !HistoryRetentionLeaseOwner+ }+ deriving stock (Eq, Show, Generic) data HistoryRetentionLeaseState- = HistoryRetentionLeaseActive- | HistoryRetentionLeaseExpired- | HistoryRetentionLeaseReleased- deriving stock (Eq, Ord, Show, Generic)+ = HistoryRetentionLeaseActive+ | HistoryRetentionLeaseExpired+ | HistoryRetentionLeaseReleased+ deriving stock (Eq, Ord, Show, Generic) data HistoryRetentionLease = HistoryRetentionLease- { leaseId :: !HistoryRetentionLeaseId- , owner :: !HistoryRetentionLeaseOwner- , reason :: !HistoryRetentionLeaseReason- , protectedThrough :: !GlobalPosition- , createdAt :: !UTCTime- , renewedAt :: !UTCTime- , expiresAt :: !UTCTime- , releasedAt :: !(Maybe UTCTime)- , state :: !HistoryRetentionLeaseState- }- deriving stock (Eq, Show, Generic)+ { leaseId :: !HistoryRetentionLeaseId,+ owner :: !HistoryRetentionLeaseOwner,+ reason :: !HistoryRetentionLeaseReason,+ protectedThrough :: !GlobalPosition,+ createdAt :: !UTCTime,+ renewedAt :: !UTCTime,+ expiresAt :: !UTCTime,+ releasedAt :: !(Maybe UTCTime),+ state :: !HistoryRetentionLeaseState+ }+ deriving stock (Eq, Show, Generic) data HistoryRetentionRenewalError- = HistoryRetentionRenewalUnknown- | HistoryRetentionRenewalOwnerMismatch- | HistoryRetentionRenewalExpired- | HistoryRetentionRenewalReleased- deriving stock (Eq, Show, Generic)+ = HistoryRetentionRenewalUnknown+ | HistoryRetentionRenewalOwnerMismatch+ | HistoryRetentionRenewalExpired+ | HistoryRetentionRenewalReleased+ deriving stock (Eq, Show, Generic) data HistoryRetentionReleaseResult- = HistoryRetentionReleased !HistoryRetentionLease- | HistoryRetentionAlreadyReleased !HistoryRetentionLease- | HistoryRetentionReleaseExpired !HistoryRetentionLease- | HistoryRetentionReleaseUnknown- | HistoryRetentionReleaseOwnerMismatch- deriving stock (Eq, Show, Generic)+ = HistoryRetentionReleased !HistoryRetentionLease+ | HistoryRetentionAlreadyReleased !HistoryRetentionLease+ | HistoryRetentionReleaseExpired !HistoryRetentionLease+ | HistoryRetentionReleaseUnknown+ | HistoryRetentionReleaseOwnerMismatch+ deriving stock (Eq, Show, Generic) data HistoryRetentionConflict = HistoryRetentionConflict- { activeLeaseCount :: !Int64- , earliestExpiry :: !UTCTime- }- deriving stock (Eq, Show, Generic)+ { activeLeaseCount :: !Int64,+ earliestExpiry :: !UTCTime+ }+ deriving stock (Eq, Show, Generic) -- | Validated maximum number of inventory rows (1 through 1,000). newtype HistoryRetentionInventoryLimit = HistoryRetentionInventoryLimit Int32- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) data HistoryRetentionInventoryQuery = HistoryRetentionInventoryQuery- { limit :: !HistoryRetentionInventoryLimit- }- deriving stock (Eq, Show, Generic)+ { limit :: !HistoryRetentionInventoryLimit+ }+ deriving stock (Eq, Show, Generic) data HistoryRetentionPruneResult = HistoryRetentionPruneResult- { expiredPruned :: !Int64- , releasedPruned :: !Int64- }- deriving stock (Eq, Show, Generic)+ { expiredPruned :: !Int64,+ releasedPruned :: !Int64+ }+ deriving stock (Eq, Show, Generic) data HistoryRetentionRequestError- = HistoryRetentionLeaseOwnerEmpty- | HistoryRetentionLeaseOwnerTooLong !Int- | HistoryRetentionLeaseReasonEmpty- | HistoryRetentionLeaseReasonTooLong !Int- | HistoryRetentionLeaseDurationOutOfRange !DiffTime- deriving stock (Eq, Show, Generic)+ = HistoryRetentionLeaseOwnerEmpty+ | HistoryRetentionLeaseOwnerTooLong !Int+ | HistoryRetentionLeaseReasonEmpty+ | HistoryRetentionLeaseReasonTooLong !Int+ | HistoryRetentionLeaseDurationOutOfRange !DiffTime+ deriving stock (Eq, Show, Generic) data HistoryRetentionInventoryError- = HistoryRetentionInventoryLimitOutOfRange !Int32- deriving stock (Eq, Show, Generic)+ = HistoryRetentionInventoryLimitOutOfRange !Int32+ deriving stock (Eq, Show, Generic) data StreamHistoryUnavailable- = StreamHistoryNotFound !StreamName- | StreamHistoryReserved !StreamName- deriving stock (Eq, Show, Generic)+ = StreamHistoryNotFound !StreamName+ | StreamHistoryReserved !StreamName+ deriving stock (Eq, Show, Generic) mkHistoryRetentionLeaseOwner :: Text -> Either HistoryRetentionRequestError HistoryRetentionLeaseOwner mkHistoryRetentionLeaseOwner value- | bytes == 0 = Left HistoryRetentionLeaseOwnerEmpty- | bytes > 512 = Left (HistoryRetentionLeaseOwnerTooLong bytes)- | otherwise = Right (HistoryRetentionLeaseOwner value)+ | bytes == 0 = Left HistoryRetentionLeaseOwnerEmpty+ | bytes > 512 = Left (HistoryRetentionLeaseOwnerTooLong bytes)+ | otherwise = Right (HistoryRetentionLeaseOwner value) where bytes = ByteString.length (Text.encodeUtf8 value) mkHistoryRetentionLeaseReason :: Text -> Either HistoryRetentionRequestError HistoryRetentionLeaseReason mkHistoryRetentionLeaseReason value- | bytes == 0 = Left HistoryRetentionLeaseReasonEmpty- | bytes > 2048 = Left (HistoryRetentionLeaseReasonTooLong bytes)- | otherwise = Right (HistoryRetentionLeaseReason value)+ | bytes == 0 = Left HistoryRetentionLeaseReasonEmpty+ | bytes > 2048 = Left (HistoryRetentionLeaseReasonTooLong bytes)+ | otherwise = Right (HistoryRetentionLeaseReason value) where bytes = ByteString.length (Text.encodeUtf8 value) mkHistoryRetentionLeaseDuration :: DiffTime -> Either HistoryRetentionRequestError HistoryRetentionLeaseDuration mkHistoryRetentionLeaseDuration value- | value < secondsToDiffTime 1 = Left (HistoryRetentionLeaseDurationOutOfRange value)- | value > maxHistoryRetentionLeaseDuration = Left (HistoryRetentionLeaseDurationOutOfRange value)- | otherwise = Right (HistoryRetentionLeaseDuration value)+ | value < secondsToDiffTime 1 = Left (HistoryRetentionLeaseDurationOutOfRange value)+ | value > maxHistoryRetentionLeaseDuration = Left (HistoryRetentionLeaseDurationOutOfRange value)+ | otherwise = Right (HistoryRetentionLeaseDuration value) mkHistoryRetentionInventoryLimit :: Int32 -> Either HistoryRetentionInventoryError HistoryRetentionInventoryLimit mkHistoryRetentionInventoryLimit value- | value < 1 || value > 1000 = Left (HistoryRetentionInventoryLimitOutOfRange value)- | otherwise = Right (HistoryRetentionInventoryLimit value)+ | value < 1 || value > 1000 = Left (HistoryRetentionInventoryLimitOutOfRange value)+ | otherwise = Right (HistoryRetentionInventoryLimit value) historyRetentionLeaseOwnerText :: HistoryRetentionLeaseOwner -> Text historyRetentionLeaseOwnerText (HistoryRetentionLeaseOwner value) = value
src/Kiroku/Store/Lifecycle.hs view
@@ -1,10 +1,11 @@-module Kiroku.Store.Lifecycle (- softDeleteStream,+module Kiroku.Store.Lifecycle+ ( softDeleteStream, hardDeleteStream, undeleteStream, setStreamTruncateBefore, clearStreamTruncateBefore,-) where+ )+where import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (send)@@ -12,153 +13,148 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Mark a stream as deleted without removing its rows.--Soft-deleted streams are invisible to 'Kiroku.Store.Read.readStreamForward'-(returns empty), to 'Kiroku.Store.Read.readStreamBackward', and to-'Kiroku.Store.Append.appendToStream' (returns-'Kiroku.Store.Error.StreamNotFound' or-'Kiroku.Store.Error.StreamAlreadyExists' depending on-'Kiroku.Store.Types.ExpectedVersion'). They remain visible in the-@$all@ stream — the global event log is append-only — and to-'Kiroku.Store.Read.readCategory'. Use 'undeleteStream' to restore.--The exact stream name @$all@ is reserved for the global read stream.-Lifecycle operations reject it with-'Kiroku.Store.Error.ReservedStreamName' so callers cannot hide, remove,-or restore the internal global stream row.--Returns @Just streamId@ on success, or @Nothing@ if the stream did not-exist or was already soft-deleted. Reverse with 'undeleteStream' or-'hardDeleteStream'.--}+-- | Mark a stream as deleted without removing its rows.+--+-- Soft-deleted streams are invisible to 'Kiroku.Store.Read.readStreamForward'+-- (returns empty), to 'Kiroku.Store.Read.readStreamBackward', and to+-- 'Kiroku.Store.Append.appendToStream' (returns+-- 'Kiroku.Store.Error.StreamNotFound' or+-- 'Kiroku.Store.Error.StreamAlreadyExists' depending on+-- 'Kiroku.Store.Types.ExpectedVersion'). They remain visible in the+-- @$all@ stream — the global event log is append-only — and to+-- 'Kiroku.Store.Read.readCategory'. Use 'undeleteStream' to restore.+--+-- The exact stream name @$all@ is reserved for the global read stream.+-- Lifecycle operations reject it with+-- 'Kiroku.Store.Error.ReservedStreamName' so callers cannot hide, remove,+-- or restore the internal global stream row.+--+-- Returns @Just streamId@ on success, or @Nothing@ if the stream did not+-- exist or was already soft-deleted. Reverse with 'undeleteStream' or+-- 'hardDeleteStream'. softDeleteStream ::- (HasCallStack, Store :> es) =>- StreamName ->- Eff es (Maybe StreamId)+ (HasCallStack, Store :> es) =>+ StreamName ->+ Eff es (Maybe StreamId) softDeleteStream name = send (SoftDeleteStream name) -{- | Permanently remove a stream, its events (where they are not-referenced by other streams), and its links.--== Authorization model--Hard delete is gated by a session-local PostgreSQL GUC-(@kiroku.enable_hard_deletes@) that the interpreter sets inside its-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/migrations@.--/The GUC is an advisory protection, not a security boundary./ Any-PostgreSQL session with @DELETE@ privilege on @events@,-@stream_events@, and @streams@ can issue @SET LOCAL-kiroku.enable_hard_deletes = \'on\'@ before its own @DELETE@ —-PostgreSQL grants @SET LOCAL@ to every session. The trigger exists-to make accidental issuance of @DELETE@ (a typo, an ad-hoc operator-query, an ORM that does not know the table is meant to be-append-only) fail loudly rather than to enforce role-based access-control.--In practice the model is "applications running with full @DELETE@-privilege on the data tables are trusted to call hard-delete-correctly". Production deployments that need stricter control should:--* Run the application as a low-privileged role with only @INSERT,- UPDATE, SELECT@ on the data tables (not @DELETE@); soft-delete- via 'softDeleteStream' is unaffected. Issue hard-deletes from a- separate, more privileged role gated by your own access controls.--* /Or/ wrap calls to 'hardDeleteStream' in your application's- authorization layer before they reach this function. Reading the- @protect_deletion@ trigger as a security boundary is incorrect.--Replay-history retention is an independent safety layer. While any-'Kiroku.Store.HistoryRetention.Types.HistoryRetentionLease' is active,-supported hard delete returns-'Kiroku.Store.Error.HistoryRetentionActive' before changing rows. A-GUC-enabled direct @DELETE@ or @TRUNCATE@ receives SQLSTATE @KR001@. There is-no ordinary bypass: release the lease or wait for its database-derived expiry.--== Event preservation semantics--The interpreter cleans up junction rows ('stream_events') first,-then deletes orphaned 'events' rows — events still linked to other-streams from this one's hard-deleted source junctions are removed;-events linked to streams /not/ owned by this deletion are preserved.--Before deletion, Kiroku locks the target and every stream containing a link to-one of the target's originated events in ascending internal ID order. This-serializes hard delete with-'Kiroku.Store.HistoryRetention.lockStreamHistoryForReplayTx' guards, including-a guard held on a linked stream.--== Result--Returns @Just streamId@ on success, @Nothing@ if the stream did not-exist. The exact stream name @$all@ is rejected with-'Kiroku.Store.Error.ReservedStreamName'. There is no \"undo\" — for reversible deletes use-'softDeleteStream' instead. The deletion emits no in-band audit row;-operators relying on an audit log must capture hard-deletes through-the store event handler (see-'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler') or-record an application-level event /before/ calling this function.--}+-- | Permanently remove a stream, its events (where they are not+-- referenced by other streams), and its links.+--+-- == Authorization model+--+-- Hard delete is gated by a session-local PostgreSQL GUC+-- (@kiroku.enable_hard_deletes@) that the interpreter sets inside its+-- 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/migrations@.+--+-- /The GUC is an advisory protection, not a security boundary./ Any+-- PostgreSQL session with @DELETE@ privilege on @events@,+-- @stream_events@, and @streams@ can issue @SET LOCAL+-- kiroku.enable_hard_deletes = \'on\'@ before its own @DELETE@ —+-- PostgreSQL grants @SET LOCAL@ to every session. The trigger exists+-- to make accidental issuance of @DELETE@ (a typo, an ad-hoc operator+-- query, an ORM that does not know the table is meant to be+-- append-only) fail loudly rather than to enforce role-based access+-- control.+--+-- In practice the model is "applications running with full @DELETE@+-- privilege on the data tables are trusted to call hard-delete+-- correctly". Production deployments that need stricter control should:+--+-- * Run the application as a low-privileged role with only @INSERT,+-- UPDATE, SELECT@ on the data tables (not @DELETE@); soft-delete+-- via 'softDeleteStream' is unaffected. Issue hard-deletes from a+-- separate, more privileged role gated by your own access controls.+--+-- * /Or/ wrap calls to 'hardDeleteStream' in your application's+-- authorization layer before they reach this function. Reading the+-- @protect_deletion@ trigger as a security boundary is incorrect.+--+-- Replay-history retention is an independent safety layer. While any+-- 'Kiroku.Store.HistoryRetention.Types.HistoryRetentionLease' is active,+-- supported hard delete returns+-- 'Kiroku.Store.Error.HistoryRetentionActive' before changing rows. A+-- GUC-enabled direct @DELETE@ or @TRUNCATE@ receives SQLSTATE @KR001@. There is+-- no ordinary bypass: release the lease or wait for its database-derived expiry.+--+-- == Event preservation semantics+--+-- The interpreter cleans up junction rows ('stream_events') first,+-- then deletes orphaned 'events' rows — events still linked to other+-- streams from this one's hard-deleted source junctions are removed;+-- events linked to streams /not/ owned by this deletion are preserved.+--+-- Before deletion, Kiroku locks the target and every stream containing a link to+-- one of the target's originated events in ascending internal ID order. This+-- serializes hard delete with+-- 'Kiroku.Store.HistoryRetention.lockStreamHistoryForReplayTx' guards, including+-- a guard held on a linked stream.+--+-- == Result+--+-- Returns @Just streamId@ on success, @Nothing@ if the stream did not+-- exist. The exact stream name @$all@ is rejected with+-- 'Kiroku.Store.Error.ReservedStreamName'. There is no \"undo\" — for reversible deletes use+-- 'softDeleteStream' instead. The deletion emits no in-band audit row;+-- operators relying on an audit log must capture hard-deletes through+-- the store event handler (see+-- 'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler') or+-- record an application-level event /before/ calling this function. hardDeleteStream ::- (HasCallStack, Store :> es) =>- StreamName ->- Eff es (Maybe StreamId)+ (HasCallStack, Store :> es) =>+ StreamName ->+ Eff es (Maybe StreamId) hardDeleteStream name = send (HardDeleteStream name) -{- | Restore a soft-deleted stream by clearing its @deleted_at@ row.--Reads of the restored stream return its full event history. Subsequent-appends behave as if the soft-delete never happened. Returns-@Just streamId@ on success, @Nothing@ if the stream is missing or was-not soft-deleted (already live). The exact stream name @$all@ is-rejected with 'Kiroku.Store.Error.ReservedStreamName'.--}+-- | Restore a soft-deleted stream by clearing its @deleted_at@ row.+--+-- Reads of the restored stream return its full event history. Subsequent+-- appends behave as if the soft-delete never happened. Returns+-- @Just streamId@ on success, @Nothing@ if the stream is missing or was+-- not soft-deleted (already live). The exact stream name @$all@ is+-- rejected with 'Kiroku.Store.Error.ReservedStreamName'. undeleteStream ::- (HasCallStack, Store :> es) =>- StreamName ->- Eff es (Maybe StreamId)+ (HasCallStack, Store :> es) =>+ 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.--}+-- | 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)+ (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'.--}+-- | 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)+ (HasCallStack, Store :> es) =>+ StreamName ->+ Eff es (Maybe StreamId) clearStreamTruncateBefore name = setStreamTruncateBefore name (StreamVersion 0)
src/Kiroku/Store/Link.hs view
@@ -1,6 +1,7 @@-module Kiroku.Store.Link (- linkToStream,-) where+module Kiroku.Store.Link+ ( linkToStream,+ )+where import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (send)@@ -8,49 +9,48 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | 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-@stream_events@ is created. Linked events keep their original-'Kiroku.Store.Types.GlobalPosition' and 'Kiroku.Store.Types.RecordedEvent.originalStreamId' /-'originalVersion' fields; the target's 'Kiroku.Store.Types.RecordedEvent.streamVersion'-reflects the link's position in the target.--Preconditions:--* 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 — 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- linking into it would bypass the append-only global ordering contract;- the interpreter rejects this with- 'Kiroku.Store.Error.ReservedStreamName'.-* Linking the same event into the same target stream twice fails with- '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.--Empty input @[]@ is a no-op programming mistake; do not call.--}+-- | 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+-- @stream_events@ is created. Linked events keep their original+-- 'Kiroku.Store.Types.GlobalPosition' and 'Kiroku.Store.Types.RecordedEvent.originalStreamId' /+-- 'originalVersion' fields; the target's 'Kiroku.Store.Types.RecordedEvent.streamVersion'+-- reflects the link's position in the target.+--+-- Preconditions:+--+-- * 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 — 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+-- linking into it would bypass the append-only global ordering contract;+-- the interpreter rejects this with+-- 'Kiroku.Store.Error.ReservedStreamName'.+-- * Linking the same event into the same target stream twice fails with+-- '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.+--+-- Empty input @[]@ is a no-op programming mistake; do not call. linkToStream ::- (HasCallStack, Store :> es) =>- StreamName ->- [EventId] ->- Eff es LinkResult+ (HasCallStack, Store :> es) =>+ StreamName ->+ [EventId] ->+ Eff es LinkResult linkToStream targetStream eventIds = send (LinkToStream targetStream eventIds)
src/Kiroku/Store/Notification.hs view
@@ -1,22 +1,27 @@-module Kiroku.Store.Notification (- Notifier (..),+module Kiroku.Store.Notification+ ( Notifier (..), NotifierStartError (..), startNotifier, stopNotifier,-) where+ NamespaceRegistry,+ registerNamespace,+ notifyNamespace,+ )+where import Control.Concurrent (threadDelay) 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.Concurrent.STM (STM, TChan, TVar, atomically, modifyTVar', newBroadcastTChanIO, newTVar, newTVarIO, readTVar, readTVarIO, writeTChan, writeTVar) import Control.Exception (Exception, SomeAsyncException, SomeException, asyncExceptionFromException, bracketOnError, catch, throwIO, toException)-import Control.Monad (void)+import Control.Monad (forM_, unless, void) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.ByteString (ByteString) import Data.ByteString.Char8 qualified as BC import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text)+import Data.Text qualified as T import Data.Text.Encoding (decodeUtf8Lenient) import Data.Word (Word64) import Hasql.Connection (Connection)@@ -32,46 +37,81 @@ 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,-allowing subscribers to wake without polling.--The current connection is held in a 'TVar' so that-'Kiroku.Store.Notification.stopNotifier' releases whichever connection-the listener loop most recently acquired (rather than the original one,-which may have been replaced by reconnection).--}+-- | A Notifier manages a dedicated PostgreSQL connection for LISTEN/NOTIFY.+-- It writes a @()@ tick to a broadcast 'TChan' on every notification,+-- allowing subscribers to wake without polling.+--+-- The current connection is held in a 'TVar' so that+-- 'Kiroku.Store.Notification.stopNotifier' releases whichever connection+-- the listener loop most recently acquired (rather than the original one,+-- which may have been replaced by reconnection). data Notifier = Notifier- { tickChan :: !(TChan ())- -- ^ Broadcast channel; consumers must use 'dupTChan' to get a personal copy- , listenerThread :: !(Async ())- -- ^ The async thread running the LISTEN loop- , listenerConnRef :: !(TVar Connection)- {- ^ The /current/ dedicated listener connection. Updated by the loop on- each successful reconnection so 'stopNotifier' always releases the- live socket.- -}- , categoryGenerations :: !(TVar (Map Text Word64))- {- ^ Per-category wake counter. The listener callback increments a- category's entry on every NOTIFY for a stream in that category. A- 'Kiroku.Store.Subscription.Types.Category' subscription worker blocks until- its category's counter advances, so an idle category does zero database work- while other categories receive traffic. Notifications lost across a listener- reconnect are reconciled by the worker's safety-poll timeout.- -}- }+ { -- | Broadcast channel; consumers must use 'dupTChan' to get a personal copy+ tickChan :: !(TChan ()),+ -- | The async thread running the LISTEN loop+ listenerThread :: !(Async ()),+ -- | The /current/ dedicated listener connection. Updated by the loop on+ -- each successful reconnection so 'stopNotifier' always releases the+ -- live socket.+ listenerConnRef :: !(TVar Connection),+ namespaceRegistry :: !NamespaceRegistry,+ -- | Per-category wake counter. The listener callback increments a+ -- category's entry on every NOTIFY for a stream in that category. A+ -- 'Kiroku.Store.Subscription.Types.Category' subscription worker blocks until+ -- its category's counter advances, so an idle category does zero database work+ -- while other categories receive traffic. Notifications lost across a listener+ -- reconnect are reconciled by the worker's safety-poll timeout.+ categoryGenerations :: !(TVar (Map Text Word64))+ } -{- | Raised by 'startNotifier' when the initial dedicated @LISTEN@ setup fails.+-- | Only active namespace keys are retained. Duplicate workers share one cell.+type NamespaceRegistry = TVar (Map Text (Int, TVar Word64)) -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.--}+-- | Register before catch-up. The release is idempotent and cell-conditional,+-- so an old release cannot remove a replacement registration.+registerNamespace :: NamespaceRegistry -> Text -> STM (TVar Word64, STM ())+registerNamespace registry namespace = do+ entries <- readTVar registry+ (count, cell) <- case Map.lookup namespace entries of+ Just entry -> pure entry+ Nothing -> (0,) <$> newTVar 0+ writeTVar registry (Map.insert namespace (count + 1, cell) entries)+ released <- newTVar False+ let release = do+ done <- readTVar released+ unless done $ do+ writeTVar released True+ modifyTVar' registry $+ Map.update+ ( \(refs, current) ->+ if current /= cell+ then Just (refs, current)+ else if refs == 1 then Nothing else Just (refs - 1, current)+ )+ namespace+ pure (cell, release)++-- | Bump exact category and colon ancestors in the callback transaction.+-- An empty registry does no ancestor construction or map mutation.+notifyNamespace :: NamespaceRegistry -> Text -> STM ()+notifyNamespace registry category = do+ entries <- readTVar registry+ unless (Map.null entries) $+ forM_ (scanl1 (\prefix segment -> prefix <> ":" <> segment) (T.splitOn ":" category)) $ \candidate ->+ case Map.lookup candidate entries of+ Nothing -> pure ()+ Just (_, cell) -> modifyTVar' cell (+ 1)++-- | Raised by 'startNotifier' when the initial dedicated @LISTEN@ setup fails.+--+-- 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. data NotifierStartError- = NotifierConnectError !ConnectionError- | NotifierListenError !SomeException- deriving stock (Show)- deriving anyclass (Exception)+ = NotifierConnectError !ConnectionError+ | NotifierListenError !SomeException+ deriving stock (Show)+ deriving anyclass (Exception) -- Cap reconnect backoff at 30 seconds; the EventPublisher's safety poll -- runs at the same cadence, so the worst-case latency between a@@ -85,69 +125,69 @@ -- failure, @n >= 1@. Schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... reconnectDelayMicros :: Int -> Int reconnectDelayMicros n- | n <= 0 = 1_000_000- | otherwise = min maxReconnectDelayMicros (1_000_000 * 2 ^ (min (n - 1) 30))--{- | Start a Notifier. Acquires a dedicated connection, sets-@application_name@ to @kiroku-listener@ for operator visibility, issues-LISTEN on the @\<schema\>.events@ channel, and spawns a thread that-writes @()@ to the broadcast TChan on every notification.--On connection failure during the loop, releases the dead connection,-waits a backoff interval (capped exponential: 1s, 2s, 4s, 8s, 16s, 30s-thereafter), acquires a replacement, re-LISTENs, and resumes. The-replacement is published into 'listenerConnRef' so 'stopNotifier'-releases it on shutdown.--If an 'eventHandler' callback is supplied, the notifier emits-'Kiroku.Store.Observability.KirokuEventNotifierReconnecting' on each-attempted reconnect (with the consecutive failure count and the-underlying exception) and 'KirokuEventNotifierReconnected' on each-successful reconnect. The failure counter resets after a successful-reconnect.--On 'Async.AsyncCancelled' (from cancellation), exits cleanly.+ | n <= 0 = 1_000_000+ | otherwise = min maxReconnectDelayMicros (1_000_000 * 2 ^ (min (n - 1) 30)) -Initial acquire or LISTEN failure raises 'NotifierStartError'.--}+-- | Start a Notifier. Acquires a dedicated connection, sets+-- @application_name@ to @kiroku-listener@ for operator visibility, issues+-- LISTEN on the @\<schema\>.events@ channel, and spawns a thread that+-- writes @()@ to the broadcast TChan on every notification.+--+-- On connection failure during the loop, releases the dead connection,+-- waits a backoff interval (capped exponential: 1s, 2s, 4s, 8s, 16s, 30s+-- thereafter), acquires a replacement, re-LISTENs, and resumes. The+-- replacement is published into 'listenerConnRef' so 'stopNotifier'+-- releases it on shutdown.+--+-- If an 'eventHandler' callback is supplied, the notifier emits+-- 'Kiroku.Store.Observability.KirokuEventNotifierReconnecting' on each+-- attempted reconnect (with the consecutive failure count and the+-- underlying exception) and 'KirokuEventNotifierReconnected' on each+-- successful reconnect. The failure counter resets after a successful+-- reconnect.+--+-- On 'Async.AsyncCancelled' (from cancellation), exits cleanly.+--+-- Initial acquire or LISTEN failure raises 'NotifierStartError'. startNotifier ::- (MonadIO m) =>- -- | libpq connection string- Text ->- -- | schema name (used to construct the LISTEN channel)- Text ->- -- | optional event handler for reconnect observability- Maybe (KirokuEvent -> IO ()) ->- m Notifier+ (MonadIO m) =>+ -- | libpq connection string+ Text ->+ -- | schema name (used to construct the LISTEN channel)+ Text ->+ -- | optional event handler for reconnect observability+ Maybe (KirokuEvent -> IO ()) ->+ m Notifier startNotifier connString schema mHandler = liftIO $ do- chan <- newBroadcastTChanIO- catGenVar <- newTVarIO Map.empty- let channel = toPgIdentifier (schema <> ".events")- 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- }+ chan <- newBroadcastTChanIO+ catGenVar <- newTVarIO Map.empty+ namespaces <- newTVarIO Map.empty+ let channel = toPgIdentifier (schema <> ".events")+ 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 namespaces connRef channel connString mHandler)+ pure+ Notifier+ { tickChan = chan,+ listenerThread = thread,+ listenerConnRef = connRef,+ categoryGenerations = catGenVar,+ namespaceRegistry = namespaces+ } -{- | Stop the Notifier. Cancels the listener thread, waits for it to-finish, and releases whichever connection the loop is currently holding.--}+-- | Stop the Notifier. Cancels the listener thread, waits for it to+-- finish, and releases whichever connection the loop is currently holding. stopNotifier :: (MonadIO m) => Notifier -> m () stopNotifier notifier = liftIO $ do- Async.cancel (listenerThread notifier)- void $ Async.waitCatch (listenerThread notifier)- conn <- readTVarIO (listenerConnRef notifier)- Connection.release conn+ Async.cancel (listenerThread notifier)+ void $ Async.waitCatch (listenerThread notifier)+ conn <- readTVarIO (listenerConnRef notifier)+ Connection.release conn -- Internal: the listener loop. Calls 'waitForNotifications' on the current -- connection; that call blocks indefinitely under normal operation. If it@@ -161,58 +201,59 @@ -- on the 'KirokuEventNotifierReconnecting' event; the wait-returned-without- -- exception path uses a synthetic message. listenerLoop ::- TChan () ->- TVar (Map Text Word64) ->- TVar Connection ->- PgIdentifier ->- Text ->- Maybe (KirokuEvent -> IO ()) ->- IO ()-listenerLoop chan catGenVar connRef channel connStr mHandler = go+ TChan () ->+ TVar (Map Text Word64) ->+ NamespaceRegistry ->+ TVar Connection ->+ PgIdentifier ->+ Text ->+ Maybe (KirokuEvent -> IO ()) ->+ IO ()+listenerLoop chan catGenVar namespaces connRef channel connStr mHandler = go where go =- ( do- currentConn <- readTVarIO connRef- waitForNotifications (handleNotification chan catGenVar) currentConn- -- waitForNotifications returns only if the underlying connection- -- went bad and Hasql converted the error into a Left result; in- -- that case we must reconnect rather than spin re-invoking the- -- same dead conn.- reconnect 1 (toException ListenerWaitReturned)- )- `catch` \(e :: SomeException) ->- case asyncExceptionFromException e of- Just (_ :: Async.AsyncCancelled) -> pure ()- Nothing -> reconnect 1 e+ ( do+ currentConn <- readTVarIO connRef+ waitForNotifications (handleNotification chan catGenVar namespaces) currentConn+ -- waitForNotifications returns only if the underlying connection+ -- went bad and Hasql converted the error into a Left result; in+ -- that case we must reconnect rather than spin re-invoking the+ -- same dead conn.+ reconnect 1 (toException ListenerWaitReturned)+ )+ `catch` \(e :: SomeException) ->+ case asyncExceptionFromException e of+ Just (_ :: Async.AsyncCancelled) -> pure ()+ Nothing -> reconnect 1 e reconnect attempt cause = do- emit (KirokuEventNotifierReconnecting attempt cause)- oldConn <- readTVarIO connRef- Connection.release oldConn- threadDelay (reconnectDelayMicros attempt)- -- bracketOnError releases the freshly acquired connection if an- -- async exception lands between acquire and the TVar write — without- -- it the new connection would be unreachable from stopNotifier and- -- would leak.- result <-- ( Right- <$> bracketOnError- (acquireOrThrow connStr)- Connection.release- ( \newConn -> do- Notifications.listen newConn channel- atomically (writeTVar connRef newConn)- )- )- `catch` (\(e :: SomeException) -> pure (Left e))- case result of- Left e ->- case asyncExceptionFromException e of- Just (_ :: Async.AsyncCancelled) -> throwIO e- Nothing -> reconnect (attempt + 1) e- Right () -> do- emit KirokuEventNotifierReconnected- go+ emit (KirokuEventNotifierReconnecting attempt cause)+ oldConn <- readTVarIO connRef+ Connection.release oldConn+ threadDelay (reconnectDelayMicros attempt)+ -- bracketOnError releases the freshly acquired connection if an+ -- async exception lands between acquire and the TVar write — without+ -- it the new connection would be unreachable from stopNotifier and+ -- would leak.+ result <-+ ( Right+ <$> bracketOnError+ (acquireOrThrow connStr)+ Connection.release+ ( \newConn -> do+ Notifications.listen newConn channel+ atomically (writeTVar connRef newConn)+ )+ )+ `catch` (\(e :: SomeException) -> pure (Left e))+ case result of+ Left e ->+ case asyncExceptionFromException e of+ Just (_ :: Async.AsyncCancelled) -> throwIO e+ Nothing -> reconnect (attempt + 1) e+ Right () -> do+ emit KirokuEventNotifierReconnected+ go emit = emitOrDrop mHandler @@ -222,11 +263,13 @@ -- worker blocked on that category unblocks. Both updates happen in one STM -- transaction. The two callback arguments are the channel and the payload; we -- ignore the channel (we only LISTEN on one) and parse the payload.-handleNotification :: TChan () -> TVar (Map Text Word64) -> ByteString -> ByteString -> IO ()-handleNotification chan catGenVar _channel payload =- atomically $ do- writeTChan chan ()- modifyTVar' catGenVar (Map.insertWith (+) (categoryFromPayload payload) 1)+handleNotification :: TChan () -> TVar (Map Text Word64) -> NamespaceRegistry -> ByteString -> ByteString -> IO ()+handleNotification chan catGenVar namespaces _channel payload =+ atomically $ do+ writeTChan chan ()+ let category = categoryFromPayload payload+ modifyTVar' catGenVar (Map.insertWith (+) category 1)+ notifyNamespace namespaces category -- Recover the originating stream's category from a @notify_events@ payload. The -- payload is @stream_name,stream_id,stream_version@; @stream_id@ and@@ -237,34 +280,34 @@ -- the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column. categoryFromPayload :: ByteString -> Text categoryFromPayload payload =- let fields = BC.split ',' payload- streamNameBytes = BC.intercalate "," (take (max 0 (length fields - 2)) fields)- CategoryName category = categoryName (StreamName (decodeUtf8Lenient streamNameBytes))- in category+ let fields = BC.split ',' payload+ 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 -- result and dropped the diagnostic). The reconnect-event payload still -- needs an exception value — this is it. data ListenerWaitReturned = ListenerWaitReturned- deriving stock (Show)- deriving anyclass (Exception)+ deriving stock (Show)+ deriving anyclass (Exception) -- Internal: acquire a connection, set its application_name, or throw on failure. -- 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 (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- -- default application_name silently rather than aborting startup.- _ <- Connection.use conn (Session.statement () setAppNameStmt)- pure conn+ result <- Connection.acquire (Conn.connectionString connStr)+ case result of+ 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+ -- default application_name silently rather than aborting startup.+ _ <- Connection.use conn (Session.statement () setAppNameStmt)+ pure conn setAppNameStmt :: Statement () () setAppNameStmt =- unpreparable "SET application_name = 'kiroku-listener'" E.noParams D.noResult+ unpreparable "SET application_name = 'kiroku-listener'" E.noParams D.noResult
src/Kiroku/Store/Observability.hs view
@@ -1,55 +1,55 @@-{- | Structured operational events emitted by 'Kiroku.Store'.--This module provides the 'KirokuEvent' sum type and its supporting-enumerations. It complements-'Kiroku.Store.Connection.ConnectionSettings.observationHandler' (which-covers @hasql-pool@'s connection-lifecycle events) with events the-package emits itself:--* Notifier reconnection (the dedicated @LISTEN@ connection went bad and- is being re-established).-* EventPublisher pool errors (the publisher's read query failed and- will retry on the next tick or 30-second safety poll).-* Per-subscription database errors (checkpoint load, batch fetch,- checkpoint save).-* Subscription lifecycle (started, caught-up, paused, resumed, reconnecting,- stopped).-* Subscription batch delivery (one per non-empty batch handed to the- handler, on every target and in both catch-up and live phases).-* Subscription live fetches (one per DB-driven live-mode fetch).-* Hard-delete issuance (a fail-safe audit signal — see- @docs\/PRODUCTION-DEPLOYMENT.md@ for the recommended in-band audit- pattern).-* Committed replay-history lease acquisition, renewal, actual release, pruning,- and supported hard-delete refusal.--Lease reasons remain in the durable inventory and are deliberately absent from-these process-local events. Do not turn free-form reasons into metrics labels.-Direct @Tx.Transaction@ combinators cannot emit process-local events; the-mockable effect wrappers emit only after their database transaction finishes.--Wire 'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler' to a-callback that forwards to your structured logger or metrics pipeline.-The callback runs synchronously on the emit-site thread (notifier loop,-publisher loop, worker loop, store interpreter); slow callbacks therefore-stall those loops. For callbacks that may block, fan out asynchronously-(write to a 'Control.Concurrent.STM.TBQueue' and drain in a separate-thread).--The constructor set is /additive/: new events are added rather than-existing constructors changed. Pattern matches that do not handle a-new constructor will surface as @-Wincomplete-patterns@ warnings, never-as silent regressions.--}-module Kiroku.Store.Observability (- KirokuEvent (..),+-- | Structured operational events emitted by 'Kiroku.Store'.+--+-- This module provides the 'KirokuEvent' sum type and its supporting+-- enumerations. It complements+-- 'Kiroku.Store.Connection.ConnectionSettings.observationHandler' (which+-- covers @hasql-pool@'s connection-lifecycle events) with events the+-- package emits itself:+--+-- * Notifier reconnection (the dedicated @LISTEN@ connection went bad and+-- is being re-established).+-- * EventPublisher pool errors (the publisher's read query failed and+-- will retry on the next tick or 30-second safety poll).+-- * Per-subscription database errors (checkpoint load, batch fetch,+-- checkpoint save).+-- * Subscription lifecycle (started, caught-up, paused, resumed, reconnecting,+-- stopped).+-- * Subscription batch delivery (one per non-empty batch handed to the+-- handler, on every target and in both catch-up and live phases).+-- * Subscription live fetches (one per DB-driven live-mode fetch).+-- * Hard-delete issuance (a fail-safe audit signal — see+-- @docs\/PRODUCTION-DEPLOYMENT.md@ for the recommended in-band audit+-- pattern).+-- * Committed replay-history lease acquisition, renewal, actual release, pruning,+-- and supported hard-delete refusal.+--+-- Lease reasons remain in the durable inventory and are deliberately absent from+-- these process-local events. Do not turn free-form reasons into metrics labels.+-- Direct @Tx.Transaction@ combinators cannot emit process-local events; the+-- mockable effect wrappers emit only after their database transaction finishes.+--+-- Wire 'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler' to a+-- callback that forwards to your structured logger or metrics pipeline.+-- The callback runs synchronously on the emit-site thread (notifier loop,+-- publisher loop, worker loop, store interpreter); slow callbacks therefore+-- stall those loops. For callbacks that may block, fan out asynchronously+-- (write to a 'Control.Concurrent.STM.TBQueue' and drain in a separate+-- thread).+--+-- The constructor set is /additive/: new events are added rather than+-- existing constructors changed. Pattern matches that do not handle a+-- new constructor will surface as @-Wincomplete-patterns@ warnings, never+-- as silent regressions.+module Kiroku.Store.Observability+ ( KirokuEvent (..), SubscriptionDbPhase (..), SubscriptionDeliveryPhase (..), SubscriptionStopReason (..), SubscriptionGroupContext (..), DeadLetterReason (..), emitOrDrop,-) where+ )+where import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO) import Data.Foldable (for_)@@ -60,246 +60,222 @@ import Kiroku.Store.HistoryRetention.Types (HistoryRetentionConflict, HistoryRetentionLeaseId, HistoryRetentionLeaseOwner, HistoryRetentionPruneResult) import Kiroku.Store.Settings (DecodeFailure) import Kiroku.Store.Subscription.Fsm (DeadLetterReason (..), SubscriptionStopReason (..))-import Kiroku.Store.Subscription.Types (- CheckpointInitialization,+import Kiroku.Store.Subscription.Types+ ( CheckpointInitialization, ConsumerGroupSizeMismatch, SubscriptionCheckpointMissing, SubscriptionName, SubscriptionTarget,- )+ SubscriptionTargetMismatch,+ ) import Kiroku.Store.Types (EventId, GlobalPosition, StreamId, StreamName) -{- | A structured operational event emitted by 'Kiroku.Store' itself.--Events are emitted synchronously from the originating thread; consumer-callbacks should be fast or fan out to an asynchronous worker. See the-module Haddock for context.--}+-- | A structured operational event emitted by 'Kiroku.Store' itself.+--+-- Events are emitted synchronously from the originating thread; consumer+-- callbacks should be fast or fan out to an asynchronous worker. See the+-- module Haddock for context. data KirokuEvent- = KirokuEventSubscriptionTargetBound !SubscriptionName !SubscriptionTarget !SubscriptionGroupContext- | KirokuEventSubscriptionGroupSizeMismatch !ConsumerGroupSizeMismatch !SubscriptionGroupContext- | {- | The dedicated @LISTEN@ connection encountered a non-async- exception and the listener loop is about to attempt reconnection.- The 'Int' is the consecutive failure count starting at @1@; it- drives the exponential-backoff delay (capped at 30 seconds) and- is useful as a metric label for sustained-outage alerting.- -}- KirokuEventNotifierReconnecting !Int !SomeException- | {- | The listener loop successfully re-established the @LISTEN@- connection. Pairs with the most recent- 'KirokuEventNotifierReconnecting'; the failure counter resets to- @0@ on observing this event.- -}- KirokuEventNotifierReconnected- | {- | The 'Kiroku.Store.Subscription.EventPublisher.EventPublisher'- read query returned a 'UsageError'. The publisher will retry on- the next notification tick or the 30-second safety poll. Sustained- emissions indicate either pool exhaustion (the publisher shares- 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- | -- | Typed hook failure at broadcast time; publisher still advances.- KirokuEventPublisherDecodeFailed !GlobalPosition !EventId !DecodeFailure- | -- | One handler invocation remains pending after the configured interval.- KirokuEventSubscriptionHandlerStalled !SubscriptionName !GlobalPosition !EventId !NominalDiffTime !SubscriptionGroupContext- | {- | A subscription's worker thread encountered a 'UsageError' in- 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- | {- | Startup resolved an exact checkpoint key before the worker emitted- 'KirokuEventSubscriptionStarted'. 'ExistingCheckpoint' means the worker- resumed durable progress; 'InitializedCheckpoint' identifies whether it- seeded zero or the current store head. The event contains no payload data.- -}- KirokuEventSubscriptionCheckpointResolved !CheckpointInitialization !SubscriptionGroupContext- | {- | Startup applied 'Kiroku.Store.Subscription.Types.FailIfMissing' to- an absent exact checkpoint key. The worker emits this event and throws the- carried typed exception before emitting 'KirokuEventSubscriptionStarted'- or invoking the handler.- -}- KirokuEventSubscriptionCheckpointMissing !SubscriptionCheckpointMissing !SubscriptionGroupContext- | {- | A subscription's worker thread has just started; the worker- will begin from the durable 'GlobalPosition' reported by the immediately- preceding checkpoint-resolution event. The trailing- 'SubscriptionGroupContext' identifies which consumer-group member (if any)- started.- -}- KirokuEventSubscriptionStarted !SubscriptionName !GlobalPosition !SubscriptionGroupContext- | {- | The subscription has reached the EventPublisher's- @lastPublished@ position and is switching from catch-up to- live mode at the indicated 'GlobalPosition'. Fires at most- once per worker run. The trailing 'SubscriptionGroupContext'- identifies which consumer-group member (if any) caught up.- -}- KirokuEventSubscriptionCaughtUp !SubscriptionName !GlobalPosition !SubscriptionGroupContext- | {- | The subscription's worker has stopped at the indicated- 'GlobalPosition'. The 'SubscriptionStopReason' discriminates- normal completion (handler returned 'Stop') from cancellation,- overflow, and worker-thread crashes. The trailing- 'SubscriptionGroupContext' identifies which consumer-group member- (if any) stopped.- -}- KirokuEventSubscriptionStopped !SubscriptionName !GlobalPosition !SubscriptionStopReason !SubscriptionGroupContext- | {- | A subscription's worker hit recoverable backpressure: its bounded- queue filled under the 'Kiroku.Store.Subscription.Types.PauseAndResume'- overflow policy, so the worker paused delivery at the indicated- 'GlobalPosition'. Pairs with a subsequent- 'KirokuEventSubscriptionResumed'. The trailing 'SubscriptionGroupContext'- identifies which consumer-group member (if any) paused.- -}- KirokuEventSubscriptionPaused !SubscriptionName !GlobalPosition !SubscriptionGroupContext- | {- | A subscription's worker resumed after a pause: it drained the stale- queue and re-catches-up from its checkpoint (the indicated- 'GlobalPosition') so every event skipped while paused is re-read from the- database. Pairs with a preceding 'KirokuEventSubscriptionPaused'.- -}- KirokuEventSubscriptionResumed !SubscriptionName !GlobalPosition !SubscriptionGroupContext- | {- | A subscription's worker lost its database connection while live (a- 'UsageError' on a live-mode fetch) and is reconnecting: it backs off and- re-catches-up from its checkpoint rather than dying. The 'Int' is the- consecutive reconnect-attempt count starting at @1@; it drives the- capped backoff and is a useful sustained-outage metric label. Emitted by- Category and consumer-group subscriptions (AllStreams live delivery is fed- by the publisher, which owns its own reconnect). The trailing- 'SubscriptionGroupContext' identifies the consumer-group member (if any).- -}- KirokuEventSubscriptionReconnecting !SubscriptionName !Int !SubscriptionGroupContext- | {- | A live DB-driven subscription loop- ('Kiroku.Store.Subscription.Worker') issued one category/partition fetch- in live mode, returning the given row count ('Int'). Emitted by the- @Category@ NOTIFY-driven loop and the consumer-group loop on every live- fetch (not on the catch-up path). Lets operators observe the- per-subscription live-fetch rate and lets tests assert that an idle- category does no work. The trailing 'SubscriptionGroupContext' identifies- the consumer-group member (if any).- -}- KirokuEventSubscriptionFetched !SubscriptionName !Int !SubscriptionGroupContext- | {- | A subscription's worker delivered one non-empty batch of events to- the handler through the single delivery primitive- 'Kiroku.Store.Subscription.Worker.processEvents'. Emitted once per batch on- __every__ delivery path — catch-up for every target, @AllStreams@ live, and- the @Category@\/consumer-group DB-driven live loops — so it is a uniform- per-batch delivery signal. The 'Int' is the batch row count (always @>= 1@).- The 'SubscriptionDeliveryPhase' says whether the worker was catching up or- live when it delivered. The trailing 'SubscriptionGroupContext' identifies- the consumer-group member (if any).-- This is distinct from 'KirokuEventSubscriptionFetched', which only the- DB-driven live loops emit (per /fetch/, including empty fetches) and which- this constructor does /not/ replace. A DB-driven live batch therefore emits- both 'KirokuEventSubscriptionFetched' (the fetch) and- 'KirokuEventSubscriptionDelivered' (the delivery).- -}- KirokuEventSubscriptionDelivered !SubscriptionName !Int !SubscriptionDeliveryPhase !SubscriptionGroupContext- | {- | A subscription handler returned- 'Kiroku.Store.Subscription.Types.Retry' for the event at the indicated- 'GlobalPosition' and the worker is about to redeliver it. The 'Int' is the- redelivery attempt count starting at @1@ (so @1@ is the first redelivery- after the initial delivery). Bounded by the subscription's- 'Kiroku.Store.Subscription.Types.RetryPolicy'; exhaustion produces a- 'KirokuEventSubscriptionDeadLettered' with- 'Kiroku.Store.Subscription.Types.DeadLetterMaxAttempts'. The trailing- 'SubscriptionGroupContext' identifies the consumer-group member (if any).- -}- KirokuEventSubscriptionRetrying !SubscriptionName !GlobalPosition !Int !SubscriptionGroupContext- | {- | The worker recorded the event at the indicated 'GlobalPosition' in- @kiroku.dead_letters@ with the given 'DeadLetterReason' and atomically- advanced the checkpoint past it. Emitted when a handler returns- 'Kiroku.Store.Subscription.Types.DeadLetter' or when retry attempts are- exhausted. The trailing 'SubscriptionGroupContext' identifies the- consumer-group member (if any).- -}- KirokuEventSubscriptionDeadLettered !SubscriptionName !GlobalPosition !DeadLetterReason !SubscriptionGroupContext- | {- | A hard-delete transaction completed successfully. Operators- relying on a fail-safe audit log can capture this event;- compliance-grade audit should still record an application-level- event /before/ calling- 'Kiroku.Store.Lifecycle.hardDeleteStream' (see- @docs\/PRODUCTION-DEPLOYMENT.md@). Not emitted when the named- stream did not exist.- -}- KirokuEventHardDeleteIssued !StreamName !StreamId- | {- | A lease acquisition committed. The lease row is the durable audit- evidence; this process-local event contains no event payload.- -}- KirokuEventHistoryRetentionLeaseAcquired !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !GlobalPosition !UTCTime- | -- | A live lease renewal committed; reason text is not copied into the event.- KirokuEventHistoryRetentionLeaseRenewed !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime- | -- | A previously active lease was actually released and committed.- KirokuEventHistoryRetentionLeaseReleased !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime- | -- | A prune operation committed with the reported terminal-row counts.- KirokuEventHistoryRetentionLeasesPruned !HistoryRetentionPruneResult- | -- | Supported hard delete was refused before changing history.- KirokuEventHardDeleteHistoryRetentionConflict !StreamName !HistoryRetentionConflict- deriving stock (Show)--{- | Invoke the optional observability handler, dropping any synchronous exception-it throws. Asynchronous exceptions, including thread cancellation, are rethrown.+ = KirokuEventSubscriptionTargetBound !SubscriptionName !SubscriptionTarget !SubscriptionGroupContext+ | KirokuEventSubscriptionGroupSizeMismatch !ConsumerGroupSizeMismatch !SubscriptionGroupContext+ | KirokuEventSubscriptionTargetMismatch !SubscriptionTargetMismatch !SubscriptionGroupContext+ | -- | The dedicated @LISTEN@ connection encountered a non-async+ -- exception and the listener loop is about to attempt reconnection.+ -- The 'Int' is the consecutive failure count starting at @1@; it+ -- drives the exponential-backoff delay (capped at 30 seconds) and+ -- is useful as a metric label for sustained-outage alerting.+ KirokuEventNotifierReconnecting !Int !SomeException+ | -- | The listener loop successfully re-established the @LISTEN@+ -- connection. Pairs with the most recent+ -- 'KirokuEventNotifierReconnecting'; the failure counter resets to+ -- @0@ on observing this event.+ KirokuEventNotifierReconnected+ | -- | The 'Kiroku.Store.Subscription.EventPublisher.EventPublisher'+ -- read query returned a 'UsageError'. The publisher will retry on+ -- the next notification tick or the 30-second safety poll. Sustained+ -- emissions indicate either pool exhaustion (the publisher shares+ -- 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+ | -- | Typed hook failure at broadcast time; publisher still advances.+ KirokuEventPublisherDecodeFailed !GlobalPosition !EventId !DecodeFailure+ | -- | One handler invocation remains pending after the configured interval.+ KirokuEventSubscriptionHandlerStalled !SubscriptionName !GlobalPosition !EventId !NominalDiffTime !SubscriptionGroupContext+ | -- | A subscription's worker thread encountered a 'UsageError' in+ -- 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+ | -- | Startup resolved an exact checkpoint key before the worker emitted+ -- 'KirokuEventSubscriptionStarted'. 'ExistingCheckpoint' means the worker+ -- resumed durable progress; 'InitializedCheckpoint' identifies whether it+ -- seeded zero or the current store head. The event contains no payload data.+ KirokuEventSubscriptionCheckpointResolved !CheckpointInitialization !SubscriptionGroupContext+ | -- | Startup applied 'Kiroku.Store.Subscription.Types.FailIfMissing' to+ -- an absent exact checkpoint key. The worker emits this event and throws the+ -- carried typed exception before emitting 'KirokuEventSubscriptionStarted'+ -- or invoking the handler.+ KirokuEventSubscriptionCheckpointMissing !SubscriptionCheckpointMissing !SubscriptionGroupContext+ | -- | A subscription's worker thread has just started; the worker+ -- will begin from the durable 'GlobalPosition' reported by the immediately+ -- preceding checkpoint-resolution event. The trailing+ -- 'SubscriptionGroupContext' identifies which consumer-group member (if any)+ -- started.+ KirokuEventSubscriptionStarted !SubscriptionName !GlobalPosition !SubscriptionGroupContext+ | -- | The subscription has reached the EventPublisher's+ -- @lastPublished@ position and is switching from catch-up to+ -- live mode at the indicated 'GlobalPosition'. Fires at most+ -- once per worker run. The trailing 'SubscriptionGroupContext'+ -- identifies which consumer-group member (if any) caught up.+ KirokuEventSubscriptionCaughtUp !SubscriptionName !GlobalPosition !SubscriptionGroupContext+ | -- | The subscription's worker has stopped at the indicated+ -- 'GlobalPosition'. The 'SubscriptionStopReason' discriminates+ -- normal completion (handler returned 'Stop') from cancellation,+ -- overflow, and worker-thread crashes. The trailing+ -- 'SubscriptionGroupContext' identifies which consumer-group member+ -- (if any) stopped.+ KirokuEventSubscriptionStopped !SubscriptionName !GlobalPosition !SubscriptionStopReason !SubscriptionGroupContext+ | -- | A subscription's worker hit recoverable backpressure: its bounded+ -- queue filled under the 'Kiroku.Store.Subscription.Types.PauseAndResume'+ -- overflow policy, so the worker paused delivery at the indicated+ -- 'GlobalPosition'. Pairs with a subsequent+ -- 'KirokuEventSubscriptionResumed'. The trailing 'SubscriptionGroupContext'+ -- identifies which consumer-group member (if any) paused.+ KirokuEventSubscriptionPaused !SubscriptionName !GlobalPosition !SubscriptionGroupContext+ | -- | A subscription's worker resumed after a pause: it drained the stale+ -- queue and re-catches-up from its checkpoint (the indicated+ -- 'GlobalPosition') so every event skipped while paused is re-read from the+ -- database. Pairs with a preceding 'KirokuEventSubscriptionPaused'.+ KirokuEventSubscriptionResumed !SubscriptionName !GlobalPosition !SubscriptionGroupContext+ | -- | A subscription's worker lost its database connection while live (a+ -- 'UsageError' on a live-mode fetch) and is reconnecting: it backs off and+ -- re-catches-up from its checkpoint rather than dying. The 'Int' is the+ -- consecutive reconnect-attempt count starting at @1@; it drives the+ -- capped backoff and is a useful sustained-outage metric label. Emitted by+ -- Category and consumer-group subscriptions (AllStreams live delivery is fed+ -- by the publisher, which owns its own reconnect). The trailing+ -- 'SubscriptionGroupContext' identifies the consumer-group member (if any).+ KirokuEventSubscriptionReconnecting !SubscriptionName !Int !SubscriptionGroupContext+ | -- | A live DB-driven subscription loop+ -- ('Kiroku.Store.Subscription.Worker') issued one category/partition fetch+ -- in live mode, returning the given row count ('Int'). Emitted by the+ -- @Category@ NOTIFY-driven loop and the consumer-group loop on every live+ -- fetch (not on the catch-up path). Lets operators observe the+ -- per-subscription live-fetch rate and lets tests assert that an idle+ -- category does no work. The trailing 'SubscriptionGroupContext' identifies+ -- the consumer-group member (if any).+ KirokuEventSubscriptionFetched !SubscriptionName !Int !SubscriptionGroupContext+ | -- | A subscription's worker delivered one non-empty batch of events to+ -- the handler through the single delivery primitive+ -- 'Kiroku.Store.Subscription.Worker.processEvents'. Emitted once per batch on+ -- __every__ delivery path — catch-up for every target, @AllStreams@ live, and+ -- the @Category@\/consumer-group DB-driven live loops — so it is a uniform+ -- per-batch delivery signal. The 'Int' is the batch row count (always @>= 1@).+ -- The 'SubscriptionDeliveryPhase' says whether the worker was catching up or+ -- live when it delivered. The trailing 'SubscriptionGroupContext' identifies+ -- the consumer-group member (if any).+ --+ -- This is distinct from 'KirokuEventSubscriptionFetched', which only the+ -- DB-driven live loops emit (per /fetch/, including empty fetches) and which+ -- this constructor does /not/ replace. A DB-driven live batch therefore emits+ -- both 'KirokuEventSubscriptionFetched' (the fetch) and+ -- 'KirokuEventSubscriptionDelivered' (the delivery).+ KirokuEventSubscriptionDelivered !SubscriptionName !Int !SubscriptionDeliveryPhase !SubscriptionGroupContext+ | -- | A subscription handler returned+ -- 'Kiroku.Store.Subscription.Types.Retry' for the event at the indicated+ -- 'GlobalPosition' and the worker is about to redeliver it. The 'Int' is the+ -- redelivery attempt count starting at @1@ (so @1@ is the first redelivery+ -- after the initial delivery). Bounded by the subscription's+ -- 'Kiroku.Store.Subscription.Types.RetryPolicy'; exhaustion produces a+ -- 'KirokuEventSubscriptionDeadLettered' with+ -- 'Kiroku.Store.Subscription.Types.DeadLetterMaxAttempts'. The trailing+ -- 'SubscriptionGroupContext' identifies the consumer-group member (if any).+ KirokuEventSubscriptionRetrying !SubscriptionName !GlobalPosition !Int !SubscriptionGroupContext+ | -- | The worker recorded the event at the indicated 'GlobalPosition' in+ -- @kiroku.dead_letters@ with the given 'DeadLetterReason' and atomically+ -- advanced the checkpoint past it. Emitted when a handler returns+ -- 'Kiroku.Store.Subscription.Types.DeadLetter' or when retry attempts are+ -- exhausted. The trailing 'SubscriptionGroupContext' identifies the+ -- consumer-group member (if any).+ KirokuEventSubscriptionDeadLettered !SubscriptionName !GlobalPosition !DeadLetterReason !SubscriptionGroupContext+ | -- | A hard-delete transaction completed successfully. Operators+ -- relying on a fail-safe audit log can capture this event;+ -- compliance-grade audit should still record an application-level+ -- event /before/ calling+ -- 'Kiroku.Store.Lifecycle.hardDeleteStream' (see+ -- @docs\/PRODUCTION-DEPLOYMENT.md@). Not emitted when the named+ -- stream did not exist.+ KirokuEventHardDeleteIssued !StreamName !StreamId+ | -- | A lease acquisition committed. The lease row is the durable audit+ -- evidence; this process-local event contains no event payload.+ KirokuEventHistoryRetentionLeaseAcquired !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !GlobalPosition !UTCTime+ | -- | A live lease renewal committed; reason text is not copied into the event.+ KirokuEventHistoryRetentionLeaseRenewed !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime+ | -- | A previously active lease was actually released and committed.+ KirokuEventHistoryRetentionLeaseReleased !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime+ | -- | A prune operation committed with the reported terminal-row counts.+ KirokuEventHistoryRetentionLeasesPruned !HistoryRetentionPruneResult+ | -- | Supported hard delete was refused before changing history.+ KirokuEventHardDeleteHistoryRetentionConflict !StreamName !HistoryRetentionConflict+ deriving stock (Show) -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.--}+-- | 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 ()+ 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 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- returned an error. The worker retries the same cursor with capped- backoff instead of treating the error as an empty result.- -}- FetchBatch- | {- | The worker's @saveCheckpoint@ statement failed. The- subscription continues running but the next restart with the- same name re-processes events the handler has already seen.- -}- SaveCheckpoint- deriving stock (Eq, Show)+ = -- | 'Kiroku.Store.Subscription.Worker' failed to read the saved+ -- 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+ -- returned an error. The worker retries the same cursor with capped+ -- backoff instead of treating the error as an empty result.+ FetchBatch+ | -- | The worker's @saveCheckpoint@ statement failed. The+ -- subscription continues running but the next restart with the+ -- same name re-processes events the handler has already seen.+ SaveCheckpoint+ deriving stock (Eq, Show) -{- | Which FSM phase a 'KirokuEventSubscriptionDelivered' batch was delivered in:-the worker was either still catching up from history ('DeliveredCatchUp') or-already live ('DeliveredLive'). Derived from the driving 'SubscriptionState' the-worker wrote before the batch (see 'Kiroku.Store.Subscription.Worker.processEvents').--}+-- | Which FSM phase a 'KirokuEventSubscriptionDelivered' batch was delivered in:+-- the worker was either still catching up from history ('DeliveredCatchUp') or+-- already live ('DeliveredLive'). Derived from the driving 'SubscriptionState' the+-- worker wrote before the batch (see 'Kiroku.Store.Subscription.Worker.processEvents'). data SubscriptionDeliveryPhase- = -- | The batch was delivered while the worker was in 'CatchingUp'.- DeliveredCatchUp- | -- | The batch was delivered while the worker was 'Live'.- DeliveredLive- deriving stock (Eq, Show)+ = -- | The batch was delivered while the worker was in 'CatchingUp'.+ DeliveredCatchUp+ | -- | The batch was delivered while the worker was 'Live'.+ DeliveredLive+ deriving stock (Eq, Show) -{- | Consumer-group context attached to subscription lifecycle events. A plain-(non-grouped) subscription reports 'NonGroup'; a member of a group reports-@GroupMember member size@ so operators can attribute a lifecycle event to a-specific @(member, size)@.--}+-- | Consumer-group context attached to subscription lifecycle events. A plain+-- (non-grouped) subscription reports 'NonGroup'; a member of a group reports+-- @GroupMember member size@ so operators can attribute a lifecycle event to a+-- specific @(member, size)@. data SubscriptionGroupContext- = -- | Ordinary, non-grouped subscription.- NonGroup- | -- | Member of a group: @GroupMember member size@.- GroupMember !Int32 !Int32- deriving stock (Eq, Show)+ = -- | Ordinary, non-grouped subscription.+ NonGroup+ | -- | Member of a group: @GroupMember member size@.+ GroupMember !Int32 !Int32+ deriving stock (Eq, Show)
src/Kiroku/Store/Read.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Read (- readStreamForward,+module Kiroku.Store.Read+ ( readStreamForward, readStreamForwardStream, readStreamBackward, readAllForward,@@ -15,7 +15,8 @@ eventExistsInStream, lookupStreamName, lookupStreamNames,-) where+ )+where import Control.Lens ((^.)) import Data.Generics.Labels ()@@ -33,265 +34,251 @@ import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream -{- | Read events from a named stream in forward (ascending version)-order.--The cursor is exclusive: events with @streamVersion > startVer@ are-returned. To read the entire stream from the beginning, pass-@'StreamVersion' 0@. Returns an empty vector for nonexistent or-soft-deleted streams. The @limit@ caps the batch size; pass a large-value for \"read everything\".--}+-- | Read events from a named stream in forward (ascending version)+-- order.+--+-- The cursor is exclusive: events with @streamVersion > startVer@ are+-- returned. To read the entire stream from the beginning, pass+-- @'StreamVersion' 0@. Returns an empty vector for nonexistent or+-- soft-deleted streams. The @limit@ caps the batch size; pass a large+-- value for \"read everything\". readStreamForward ::- (HasCallStack, Store :> es) =>- StreamName ->- StreamVersion ->- Int32 ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ StreamName ->+ StreamVersion ->+ Int32 ->+ Eff es (Vector RecordedEvent) readStreamForward name startVer limit = send (ReadStreamForward name startVer limit) -{- | Forward read a single stream as a constant-memory Streamly 'Stream'.--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 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-nonexistent streams terminate the stream immediately with zero elements.--The recommended @pageSize@ is @256@. Callers reading very wide events (large-payloads / metadata) should pass a smaller value to keep per-page memory-bounded; callers reading very long streams of small events may pass a larger-value to reduce round-trip count.--}+-- | Forward read a single stream as a constant-memory Streamly 'Stream'.+--+-- 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 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+-- nonexistent streams terminate the stream immediately with zero elements.+--+-- The recommended @pageSize@ is @256@. Callers reading very wide events (large+-- payloads / metadata) should pass a smaller value to keep per-page memory+-- bounded; callers reading very long streams of small events may pass a larger+-- value to reduce round-trip count. readStreamForwardStream ::- (HasCallStack, Store :> es) =>- StreamName ->- StreamVersion ->- Int32 ->- Stream (Eff es) RecordedEvent+ (HasCallStack, Store :> es) =>+ StreamName ->+ StreamVersion ->+ Int32 ->+ Stream (Eff es) RecordedEvent readStreamForwardStream name startVer pageSize =- Stream.concatMap fromVector pages+ Stream.concatMap fromVector pages where 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 do- let nextState- | V.length events < fromIntegral pageSize = Nothing- | otherwise = Just (V.last events ^. #streamVersion)- pure (Just (events, nextState))+ events <- readStreamForward name cursor pageSize+ if V.null events+ then pure Nothing+ 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 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.--}+-- | 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 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 ::- (HasCallStack, Store :> es) =>- StreamName ->- StreamVersion ->- Int32 ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ StreamName ->+ StreamVersion ->+ Int32 ->+ Eff es (Vector RecordedEvent) readStreamBackward name startVer limit = send (ReadStreamBackward name startVer limit) -{- | Read events from the global @$all@ stream in forward-('GlobalPosition'-ascending) order.--Cursor exclusive: events with @globalPosition > startPos@ are returned.-@$all@ contains every event ever appended (including events from-soft-deleted streams; they survive in @$all@ even after their owning-stream is hidden). Hard-deleted streams' events do /not/ appear in-@$all@. The seed row at @globalPosition = 0@ is internal and is never-returned.--}+-- | Read events from the global @$all@ stream in forward+-- ('GlobalPosition'-ascending) order.+--+-- Cursor exclusive: events with @globalPosition > startPos@ are returned.+-- @$all@ contains every event ever appended (including events from+-- soft-deleted streams; they survive in @$all@ even after their owning+-- stream is hidden). Hard-deleted streams' events do /not/ appear in+-- @$all@. The seed row at @globalPosition = 0@ is internal and is never+-- returned. readAllForward ::- (HasCallStack, Store :> es) =>- GlobalPosition ->- Int32 ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ GlobalPosition ->+ Int32 ->+ Eff es (Vector RecordedEvent) readAllForward startPos limit = send (ReadAllForward startPos limit) -{- | Read events from the global @$all@ stream in backward-('GlobalPosition'-descending) order.--Cursor exclusive. To start from the most recent event, pass-@'GlobalPosition' 0@. The interpreter maps 0 to the maximum cursor value,-so it never collides with a real global position; positions start at 1.--}+-- | Read events from the global @$all@ stream in backward+-- ('GlobalPosition'-descending) order.+--+-- Cursor exclusive. To start from the most recent event, pass+-- @'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) =>- GlobalPosition ->- Int32 ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ GlobalPosition ->+ Int32 ->+ Eff es (Vector RecordedEvent) readAllBackward startPos limit = send (ReadAllBackward startPos limit) -{- | Return the greatest 'GlobalPosition' still visible in the global @$all@-stream, or @'GlobalPosition' 0@ when no event remains.--Logical truncation and soft deletion leave @$all@ junctions intact, so neither-changes this value. Hard deletion removes junctions and can make the visible-head move backward or return to zero. This differs from-'Kiroku.Store.Subscription.Types.storePosition' in-'Kiroku.Store.Subscription.Types.SubscriptionCheckpointInventory', which is-the authoritative append frontier and remains monotonic after hard deletion.--The value is observed by one database statement. A concurrent append or hard-deletion may change the visible head immediately after this function returns;-the result does not retain a database snapshot.--}+-- | Return the greatest 'GlobalPosition' still visible in the global @$all@+-- stream, or @'GlobalPosition' 0@ when no event remains.+--+-- Logical truncation and soft deletion leave @$all@ junctions intact, so neither+-- changes this value. Hard deletion removes junctions and can make the visible+-- head move backward or return to zero. This differs from+-- 'Kiroku.Store.Subscription.Types.storePosition' in+-- 'Kiroku.Store.Subscription.Types.SubscriptionCheckpointInventory', which is+-- the authoritative append frontier and remains monotonic after hard deletion.+--+-- The value is observed by one database statement. A concurrent append or hard+-- deletion may change the visible head immediately after this function returns;+-- the result does not retain a database snapshot. visibleGlobalHeadPosition ::- (HasCallStack, Store :> es) =>- Eff es GlobalPosition+ (HasCallStack, Store :> es) =>+ Eff es GlobalPosition visibleGlobalHeadPosition = send GetVisibleGlobalHeadPosition -{- | Read events whose source stream's category prefix matches the given-'CategoryName', in 'GlobalPosition' order.--The category is the substring of a 'StreamName' before the first @-@:-@StreamName "orders-1"@ has @CategoryName "orders"@. Linked events-appear at their /source/ position; the category is the source's-category, not the link target's.--}+-- | Read events whose source stream's category prefix matches the given+-- 'CategoryName', in 'GlobalPosition' order.+--+-- The category is the substring of a 'StreamName' before the first @-@:+-- @StreamName "orders-1"@ has @CategoryName "orders"@. Linked events+-- appear at their /source/ position; the category is the source's+-- category, not the link target's. readCategory ::- (HasCallStack, Store :> es) =>- CategoryName ->- GlobalPosition ->- Int32 ->- Eff es (Vector RecordedEvent)+ (HasCallStack, Store :> es) =>+ CategoryName ->+ GlobalPosition ->+ Int32 ->+ Eff es (Vector RecordedEvent) readCategory cat startPos limit = send (ReadCategoryForward cat startPos limit) -{- | Query stream metadata.--Returns 'Just' for both live and soft-deleted streams (with @deletedAt@-populated). Returns 'Nothing' for hard-deleted streams and streams that-have never been created.--}+-- | Query stream metadata.+--+-- Returns 'Just' for both live and soft-deleted streams (with @deletedAt@+-- populated). Returns 'Nothing' for hard-deleted streams and streams that+-- have never been created. getStream ::- (HasCallStack, Store :> es) =>- StreamName ->- Eff es (Maybe StreamInfo)+ (HasCallStack, Store :> es) =>+ StreamName ->+ Eff es (Maybe StreamInfo) getStream name = send (GetStream name) -{- | Read metadata and the newest surviving /originated/ event's global-position in one SQL statement, from one database snapshot.--Outer 'Nothing' means absent or hard-deleted. Inner 'Nothing' means the-stream exists without surviving originated events, including empty and-link-only streams. @$all@ always has an inner 'Nothing': it aggregates events-but originates none. Links advance the stream version without advancing this-head. Soft deletion and logical truncation preserve the originated head;-physical retention can remove it. This is neither the store-wide visible-head nor the monotonically allocated append frontier.--For an origin-only stream with retained required history, capture this pair,-require @version >= N@ and @Just head@, then wait for the projection cursor to-reach @head@. Waiting and timeouts belong to the consumer. The observation does-not lock history or freeze subsequent appends; later physical removal can-require a timeout. A positive linked version gives no such guarantee.--Only this opt-in operation pays for the indexed head probe. 'getStream' and-'StreamInfo' retain their existing representation and cost.--}+-- | Read metadata and the newest surviving /originated/ event's global+-- position in one SQL statement, from one database snapshot.+--+-- Outer 'Nothing' means absent or hard-deleted. Inner 'Nothing' means the+-- stream exists without surviving originated events, including empty and+-- link-only streams. @$all@ always has an inner 'Nothing': it aggregates events+-- but originates none. Links advance the stream version without advancing this+-- head. Soft deletion and logical truncation preserve the originated head;+-- physical retention can remove it. This is neither the store-wide visible+-- head nor the monotonically allocated append frontier.+--+-- For an origin-only stream with retained required history, capture this pair,+-- require @version >= N@ and @Just head@, then wait for the projection cursor to+-- reach @head@. Waiting and timeouts belong to the consumer. The observation does+-- not lock history or freeze subsequent appends; later physical removal can+-- require a timeout. A positive linked version gives no such guarantee.+--+-- Only this opt-in operation pays for the indexed head probe. 'getStream' and+-- 'StreamInfo' retain their existing representation and cost. getStreamWithHead ::- (HasCallStack, Store :> es) =>- StreamName ->- Eff es (Maybe (StreamInfo, Maybe GlobalPosition))+ (HasCallStack, Store :> es) =>+ StreamName ->+ Eff es (Maybe (StreamInfo, Maybe GlobalPosition)) getStreamWithHead name = send (GetStreamWithHead name) -{- | Look up a stream's surrogate id by name.--Returns 'Just' the 'StreamId' for both live and soft-deleted streams (mirroring-'getStream'\'s soft-delete behavior). Returns 'Nothing' for streams that have-never been created and for streams that have been hard-deleted.--This is a lighter-weight alternative to 'getStream' when the caller only needs-the surrogate id: it decodes one @int8@ column instead of the five columns-that 'StreamInfo' carries. Equivalent to projecting @info ^. #id@ from a-successful 'getStream' result, but cheaper.--}+-- | Look up a stream's surrogate id by name.+--+-- Returns 'Just' the 'StreamId' for both live and soft-deleted streams (mirroring+-- 'getStream'\'s soft-delete behavior). Returns 'Nothing' for streams that have+-- never been created and for streams that have been hard-deleted.+--+-- This is a lighter-weight alternative to 'getStream' when the caller only needs+-- the surrogate id: it decodes one @int8@ column instead of the five columns+-- that 'StreamInfo' carries. Equivalent to projecting @info ^. #id@ from a+-- successful 'getStream' result, but cheaper. lookupStreamId ::- (HasCallStack, Store :> es) =>- StreamName ->- Eff es (Maybe StreamId)+ (HasCallStack, Store :> es) =>+ StreamName ->+ 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.--}+-- | 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+ (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-included, mirroring 'lookupStreamId'.--This is the inverse of 'lookupStreamId' and the supported way to recover the-human-readable source stream for events obtained from /fan-in/ reads — the-global @$all@ stream, 'readCategory', the "Kiroku.Store.Causation" queries, and-subscriptions — where each 'RecordedEvent' carries only its surrogate-@originalStreamId@. Collect the distinct ids from a batch and resolve them once,-rather than paying a round trip per event:--@-let ids = 'Data.List.nub' (map (^. #originalStreamId) events)-names <- 'lookupStreamNames' ids--- names '!?' (event '^.' #originalStreamId) :: Maybe StreamName-@--Passing @[]@ returns an empty map without any database round trip because-the interpreter short-circuits.--}+-- | 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+-- included, mirroring 'lookupStreamId'.+--+-- This is the inverse of 'lookupStreamId' and the supported way to recover the+-- human-readable source stream for events obtained from /fan-in/ reads — the+-- global @$all@ stream, 'readCategory', the "Kiroku.Store.Causation" queries, and+-- subscriptions — where each 'RecordedEvent' carries only its surrogate+-- @originalStreamId@. Collect the distinct ids from a batch and resolve them once,+-- rather than paying a round trip per event:+--+-- @+-- let ids = 'Data.List.nub' (map (^. #originalStreamId) events)+-- names <- 'lookupStreamNames' ids+-- -- names '!?' (event '^.' #originalStreamId) :: Maybe StreamName+-- @+--+-- Passing @[]@ returns an empty map without any database round trip because+-- the interpreter short-circuits. lookupStreamNames ::- (HasCallStack, Store :> es) =>- [StreamId] ->- Eff es (Map StreamId StreamName)+ (HasCallStack, Store :> es) =>+ [StreamId] ->+ Eff es (Map StreamId StreamName) lookupStreamNames sids = send (LookupStreamNames sids) -{- | Resolve a single surrogate 'StreamId' to its 'StreamName', or 'Nothing' if-no such stream exists. A convenience wrapper over 'lookupStreamNames'; prefer-the batch form when resolving the ids of a whole read batch, to avoid one round-trip per id.--}+-- | Resolve a single surrogate 'StreamId' to its 'StreamName', or 'Nothing' if+-- no such stream exists. A convenience wrapper over 'lookupStreamNames'; prefer+-- the batch form when resolving the ids of a whole read batch, to avoid one round+-- trip per id. lookupStreamName ::- (HasCallStack, Store :> es) =>- StreamId ->- Eff es (Maybe StreamName)+ (HasCallStack, Store :> es) =>+ StreamId ->+ Eff es (Maybe StreamName) lookupStreamName sid = Map.lookup sid <$> lookupStreamNames [sid] -{- | Page streams in stable UTF-8 byte order, independent of database locale.-Category equality includes its bare name; prefix matching is literal, including-@%@ and @_@. Use the last returned name as the exclusive cursor. Soft-deleted-streams remain visible, hard-deleted streams do not. Only the reserved row is-excluded, so @$all-x@ and empty categories are valid. A short page proves current-exhaustion. Concurrent lifecycle changes are not a snapshot-isolation guarantee.-TypeID timestamps give generation order, not event/commit order.--}+-- | Page streams in stable UTF-8 byte order, independent of database locale.+-- Category equality includes its bare name; prefix matching is literal, including+-- @%@ and @_@. Use the last returned name as the exclusive cursor. Soft-deleted+-- streams remain visible, hard-deleted streams do not. Only the reserved row is+-- excluded, so @$all-x@ and empty categories are valid. A short page proves current+-- exhaustion. Concurrent lifecycle changes are not a snapshot-isolation guarantee.+-- TypeID timestamps give generation order, not event/commit order. listStreams :: (HasCallStack, Store :> es) => Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Eff es (Vector StreamInfo) listStreams category prefix after limit = send (ListStreams category prefix after limit)
src/Kiroku/Store/SQL.hs view
@@ -1,1574 +1,1655 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.SQL (- -- * Append statements- AppendParams (..),- appendParamsEncoder,- appendResultDecoder,- appendExpectedVersion,- appendStreamExists,- appendNoStream,- appendAnyVersion,-- -- * Link statements- linkToStreamStmt,-- -- * Read statements- readStreamForwardStmt,- readStreamBackwardStmt,- readAllForwardStmt,- readAllBackwardStmt,- readCategoryForwardStmt,- readCategoryEncoder,- getStreamStmt,- getStreamWithHeadStmt,- eventExistsInStreamStmt,- lookupStreamNamesStmt,- listStreamsSession,- StreamRangeBound (..),- listStreamsRangeStmt,- listStreamsPairStmt,- listCategoriesStmt,- getEventStmt,- currentGlobalPositionStmt,- visibleGlobalHeadPositionStmt,-- -- * Consumer-group read statements- readCategoryForwardConsumerGroupStmt,- readCategoryConsumerGroupEncoder,- readAllForwardConsumerGroupStmt,-- -- * Row decoders- recordedEventRow,-- -- * Causation / correlation statements- findByCorrelationStmt,- findCausationDescendantsStmt,- findCausationAncestorsStmt,-- -- * Lifecycle statements- softDeleteStreamStmt,- undeleteStreamStmt,- setStreamTruncateBeforeStmt,-- -- * Hard-delete statements (used in sequence inside one transaction)- findStreamIdStmt,- deleteAllRowsForOriginStmt,- deleteJunctionsByEventIdsStmt,- deleteStreamOwnJunctionsStmt,- deleteDeadLettersForOrphanedEventsStmt,- deleteOrphanedEventsStmt,- deleteStreamRowStmt,-- -- * Multi-stream pre-lock (avoids row-lock deadlocks)- lockStreamsForMultiStmt,-- -- * Checkpoint statements- getCheckpointStmt,- saveCheckpointStmt,- getCheckpointMemberStmt,- saveCheckpointMemberStmt,- saveAllCheckpointMemberStmt,- saveCategoryCheckpointMemberStmt,-- -- * Dead-letter statements- DeadLetterParams (..),- DeadLetterRecord (..),- insertDeadLetterAndCheckpointStmt,- readDeadLettersStmt,- listSubscriptionDeadLettersStmt,- listSubscriptionDeadLettersFromStartStmt,- listSubscriptionMemberDeadLettersStmt,- listSubscriptionMemberDeadLettersFromStartStmt,-) where--import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)-import Control.Lens ((^.))-import Data.Aeson (Value)-import Data.Char (chr, ord)-import Data.Functor.Contravariant ((>$<))-import Data.Generics.Labels ()-import Data.Int (Int32, Int64)-import Data.Text (Text)-import Data.Text qualified as T-import Data.Time (UTCTime)-import Data.UUID (UUID)-import Data.Vector (Vector)-import GHC.Generics (Generic)-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Session qualified as Session-import Hasql.Statement (Statement, preparable)-import Kiroku.Store.Subscription.DeadLetter.SQL (listSubscriptionDeadLettersFromStartStmt, listSubscriptionDeadLettersStmt, listSubscriptionMemberDeadLettersFromStartStmt, listSubscriptionMemberDeadLettersStmt)-import Kiroku.Store.Types---- | Parameters for append CTE variants (the 7 parallel arrays + stream name).-data AppendParams = AppendParams- { eventIds :: !(Vector UUID)- , eventTypes :: !(Vector Text)- , causationIds :: !(Vector (Maybe UUID))- , correlationIds :: !(Vector (Maybe UUID))- , payloads :: !(Vector Value)- , metadatas :: !(Vector (Maybe Value))- , createdAts :: !(Vector UTCTime)- , streamName :: !Text- }- deriving stock (Show, Generic)---- | Encoder for the common 8 parameters shared by all append variants.-appendParamsEncoder :: E.Params AppendParams-appendParamsEncoder =- ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))- <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))- <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))- <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))- <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))- <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))- <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))- <> ((^. #streamName) >$< E.param (E.nonNullable E.text))---- | Encoder for append_expected_version: base params + expected version (Int64).-appendExpectedEncoder :: E.Params (AppendParams, Int64)-appendExpectedEncoder =- contrazip2 appendParamsEncoder (E.param (E.nonNullable E.int8))--{- | Decoder for append results: stream_id, stream_version, global_position.-Returns Nothing if the CTE produced 0 rows (version conflict / stream not found).--}-appendResultDecoder :: D.Result (Maybe AppendResult)-appendResultDecoder =- D.rowMaybe $- AppendResult- <$> (StreamId <$> D.column (D.nonNullable D.int8))- <*> (StreamVersion <$> D.column (D.nonNullable D.int8))- <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))--{- | Append with exact expected version (optimistic concurrency).-Returns Nothing if the stream doesn't exist or version doesn't match.--}-appendExpectedVersion :: Statement (AppendParams, Int64) (Maybe AppendResult)-appendExpectedVersion =- preparable- appendExpectedVersionSQL- appendExpectedEncoder- appendResultDecoder--{- | Append to an existing stream at any version.-Returns Nothing if the stream doesn't exist.--}-appendStreamExists :: Statement AppendParams (Maybe AppendResult)-appendStreamExists =- preparable- appendStreamExistsSQL- appendParamsEncoder- appendResultDecoder--{- | Append to a new stream (must not already exist).-Returns Nothing if the stream already exists.--}-appendNoStream :: Statement AppendParams (Maybe AppendResult)-appendNoStream =- preparable- appendNoStreamSQL- appendParamsEncoder- appendResultDecoder--{- | Append to a stream, creating it if it doesn't exist.-Should always return Just (unless duplicate event ID constraint violation).--}-appendAnyVersion :: Statement AppendParams (Maybe AppendResult)-appendAnyVersion =- preparable- appendAnyVersionSQL- appendParamsEncoder- appendResultDecoder---- ------------------------------------------------------------------------------ SQL Templates--- ------------------------------------------------------------------------------- | CTE with exact version check: UPDATE streams WHERE stream_version = $9.-appendExpectedVersionSQL :: Text-appendExpectedVersionSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_name = $8- AND stream_version = $9- AND deleted_at IS NULL- RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_update)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_update su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_update)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_update su- )- SELECT su.stream_id,- su.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_update su- CROSS JOIN all_update au- """---- | CTE without version check: UPDATE streams WHERE stream_name = $8 (no $9).-appendStreamExistsSQL :: Text-appendStreamExistsSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_name = $8- AND deleted_at IS NULL- RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_update)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_update su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_update)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_update su- )- SELECT su.stream_id,- su.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_update su- CROSS JOIN all_update au- """---- | CTE for new stream creation: INSERT INTO streams ... ON CONFLICT DO NOTHING.-appendNoStreamSQL :: Text-appendNoStreamSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_insert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($8, (SELECT count(*) FROM new_events))- ON CONFLICT (stream_name) DO NOTHING- RETURNING stream_id, category, 0::bigint AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_insert)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, si.stream_id, si.initial_version + ne.idx, si.stream_id, si.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_insert si- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_insert)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, si.stream_id, si.initial_version + ne.idx, si.category- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_insert si- )- SELECT si.stream_id,- si.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_insert si- CROSS JOIN all_update au- """--{- | CTE for create-or-append using INSERT ... ON CONFLICT DO UPDATE (upsert).-A plain INSERT + separate UPDATE in the same CTE won't work because-data-modifying CTEs cannot see each other's changes. Instead, we use-a single upsert that both creates the stream and bumps its version atomically.--}-appendAnyVersionSQL :: Text-appendAnyVersionSQL =- """- WITH- new_events AS (- SELECT *- FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])- WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)- ),- stream_upsert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($8, (SELECT count(*) FROM new_events))- ON CONFLICT (stream_name)- DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)- WHERE streams.deleted_at IS NULL- RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version- ),- inserted_events AS (- INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)- SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at- FROM new_events- WHERE EXISTS (SELECT 1 FROM stream_upsert)- ORDER BY idx- ),- source_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx- FROM new_events ne- CROSS JOIN stream_upsert su- ),- all_update AS (- UPDATE streams- SET stream_version = stream_version + (SELECT count(*) FROM new_events)- WHERE stream_id = 0- AND EXISTS (SELECT 1 FROM stream_upsert)- RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version- ),- all_links AS (- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category- FROM new_events ne- CROSS JOIN all_update au- CROSS JOIN stream_upsert su- )- SELECT su.stream_id,- su.initial_version + (SELECT count(*) FROM new_events),- au.initial_global_version + (SELECT count(*) FROM new_events)- FROM stream_upsert su- CROSS JOIN all_update au- """---- ------------------------------------------------------------------------------ Read Statements--- ------------------------------------------------------------------------------- | Shared decoder for a RecordedEvent row (11 columns).-recordedEventRow :: D.Row RecordedEvent-recordedEventRow =- RecordedEvent- <$> (EventId <$> D.column (D.nonNullable D.uuid))- <*> (EventType <$> D.column (D.nonNullable D.text))- <*> (StreamVersion <$> D.column (D.nonNullable D.int8))- <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))- <*> (StreamId <$> D.column (D.nonNullable D.int8))- <*> (StreamVersion <$> D.column (D.nonNullable D.int8))- <*> D.column (D.nonNullable D.jsonb)- <*> D.column (D.nullable D.jsonb)- <*> D.column (D.nullable D.uuid)- <*> D.column (D.nullable D.uuid)- <*> D.column (D.nonNullable D.timestamptz)---- | Shared decoder for a StreamInfo row (6 columns).-streamInfoRow :: D.Row StreamInfo-streamInfoRow =- StreamInfo- <$> (StreamId <$> D.column (D.nonNullable D.int8))- <*> (StreamName <$> D.column (D.nonNullable D.text))- <*> (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)-readStreamEncoder =- contrazip3- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.int4))---- | Encoder for $all read params: (start_position, limit).-readAllEncoder :: E.Params (Int64, Int32)-readAllEncoder =- contrazip2- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.int4))---- | Read events from a named stream in forward order.-readStreamForwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)-readStreamForwardStmt =- preparable- readStreamForwardSQL- readStreamEncoder- (D.rowVector recordedEventRow)---- | Read events from a named stream in backward order.-readStreamBackwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)-readStreamBackwardStmt =- preparable- readStreamBackwardSQL- readStreamEncoder- (D.rowVector recordedEventRow)---- | Read events from the global $all stream in forward order.-readAllForwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)-readAllForwardStmt =- preparable- readAllForwardSQL- readAllEncoder- (D.rowVector recordedEventRow)---- | Read events from the global $all stream in backward order.-readAllBackwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)-readAllBackwardStmt =- preparable- readAllBackwardSQL- readAllEncoder- (D.rowVector recordedEventRow)---- | Read the current tail of the global $all stream.-currentGlobalPositionStmt :: Statement () Int64-currentGlobalPositionStmt =- preparable- "SELECT stream_version FROM streams WHERE stream_id = 0"- E.noParams- (D.singleRow (D.column (D.nonNullable D.int8)))---- | Read the greatest position still visible in the global $all stream.-visibleGlobalHeadPositionStmt :: Statement () GlobalPosition-visibleGlobalHeadPositionStmt =- preparable- """- SELECT COALESCE((- SELECT stream_version- FROM stream_events- WHERE stream_id = 0- ORDER BY stream_version DESC- LIMIT 1- ), 0)- """- E.noParams- (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))---- | Metadata and the newest surviving originated global position in one snapshot.-getStreamWithHeadStmt :: Statement Text (Maybe (StreamInfo, Maybe GlobalPosition))-getStreamWithHeadStmt =- preparable- getStreamWithHeadSQL- (E.param (E.nonNullable E.text))- (D.rowMaybe ((,) <$> streamInfoRow <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))))--getStreamWithHeadSQL :: Text-getStreamWithHeadSQL =- """- SELECT s.stream_id, s.stream_name, s.stream_version,- s.created_at, s.deleted_at, s.truncate_before,- (SELECT se.stream_version- FROM stream_events AS se- WHERE se.stream_id = 0- AND se.original_stream_id = s.stream_id- ORDER BY se.stream_version DESC- LIMIT 1) AS head_global_position- FROM streams AS s- WHERE s.stream_name = $1- """---- | Get stream metadata by name.-getStreamStmt :: Statement Text (Maybe StreamInfo)-getStreamStmt =- preparable- getStreamSQL- (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.-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-readStreamForwardSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, 0::bigint AS global_position,- se.original_stream_id, se.original_stream_version,- 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- 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.-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 =- """- SELECT e.event_id, e.event_type,- se.stream_version, 0::bigint AS global_position,- se.original_stream_id, se.original_stream_version,- 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- 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 =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- 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 = 0- AND se.stream_version > $1- ORDER BY se.stream_version ASC- LIMIT $2- """--{- | 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 =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- 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 = 0- AND se.stream_version < $1- ORDER BY se.stream_version DESC- LIMIT $2- """---- | Get stream metadata by name.-getStreamSQL :: Text-getStreamSQL =- """- SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before- FROM streams- WHERE stream_name = $1- """---- ------------------------------------------------------------------------------ Causation / Correlation Statements--- -----------------------------------------------------------------------------{- | Return every event whose @correlation_id@ equals the input, in ascending-@global_position@ order. Uses the @ix_events_correlation_id@ partial index.--The @stream_events se ON se.stream_id = 0@ join resolves each event to its-single row in the global @$all@ stream, which is also where the global-position is materialized as @stream_version@.--}-findByCorrelationStmt :: Statement UUID (Vector RecordedEvent)-findByCorrelationStmt =- preparable- findByCorrelationSQL- (E.param (E.nonNullable E.uuid))- (D.rowVector recordedEventRow)--findByCorrelationSQL :: Text-findByCorrelationSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- e.data, e.metadata, e.causation_id, e.correlation_id,- e.created_at- FROM events e- JOIN stream_events se- ON se.event_id = e.event_id AND se.stream_id = 0- WHERE e.correlation_id = $1- ORDER BY se.stream_version ASC- """--{- | Walk the causation graph forward from a seed event, returning the seed-itself and every event whose @causation_id@ chain leads back to it. The-result is ordered by ascending @global_position@.--The recursive CTE follows @causation_id@ links downstream: each child's-@causation_id@ equals a parent's @event_id@. Cost is @O(depth * log n)@-backed by the @ix_events_causation_id@ partial index.--}-findCausationDescendantsStmt :: Statement UUID (Vector RecordedEvent)-findCausationDescendantsStmt =- preparable- findCausationDescendantsSQL- (E.param (E.nonNullable E.uuid))- (D.rowVector recordedEventRow)--findCausationDescendantsSQL :: Text-findCausationDescendantsSQL =- """- WITH RECURSIVE chain (event_id, depth) AS (- SELECT event_id, 0- FROM events- WHERE event_id = $1- UNION ALL- SELECT e.event_id, c.depth + 1- FROM events e- JOIN chain c ON e.causation_id = c.event_id- )- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- e.data, e.metadata, e.causation_id, e.correlation_id,- e.created_at- FROM chain c- JOIN events e ON e.event_id = c.event_id- JOIN stream_events se- ON se.event_id = e.event_id AND se.stream_id = 0- ORDER BY se.stream_version ASC- """--{- | Walk the causation graph backward from a seed event, returning the seed-itself and every ancestor reachable via @causation_id@. Result is ordered-by ascending @depth@ (the seed is depth 0, its immediate cause is depth 1,-etc.).--The recursive CTE follows @causation_id@ links upstream: for each row-@current@ already in the working set, its parent is the row of @events@-whose @event_id@ equals @current.causation_id@.--}-findCausationAncestorsStmt :: Statement UUID (Vector RecordedEvent)-findCausationAncestorsStmt =- preparable- findCausationAncestorsSQL- (E.param (E.nonNullable E.uuid))- (D.rowVector recordedEventRow)--findCausationAncestorsSQL :: Text-findCausationAncestorsSQL =- """- WITH RECURSIVE chain (event_id, depth) AS (- SELECT event_id, 0- FROM events- WHERE event_id = $1- UNION ALL- SELECT parent.event_id, c.depth + 1- FROM events parent- JOIN events current ON parent.event_id = current.causation_id- JOIN chain c ON c.event_id = current.event_id- )- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- e.data, e.metadata, e.causation_id, e.correlation_id,- e.created_at- FROM chain c- JOIN events e ON e.event_id = c.event_id- JOIN stream_events se- ON se.event_id = e.event_id AND se.stream_id = 0- ORDER BY c.depth ASC- """---- ------------------------------------------------------------------------------ Link Statements--- -----------------------------------------------------------------------------{- | Link existing events into a target stream (upsert semantics).-Returns @Nothing@ when the target stream exists and is soft-deleted (the-@DO UPDATE WHERE streams.deleted_at IS NULL@ filter rejects the upsert,-so the stream_upsert CTE produces no rows). The interpreter maps @Nothing@-to @StreamNotFound@ for symmetry with @appendAnyVersion@'s soft-deleted-behavior added in EP-1 F2.--}-linkToStreamStmt :: Statement (Vector UUID, Text) (Maybe LinkResult)-linkToStreamStmt =- preparable- linkToStreamSQL- linkEncoder- linkResultDecoder--linkEncoder :: E.Params (Vector UUID, Text)-linkEncoder =- contrazip2- (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))- (E.param (E.nonNullable E.text))--linkResultDecoder :: D.Result (Maybe LinkResult)-linkResultDecoder =- D.rowMaybe $- LinkResult- <$> (StreamId <$> D.column (D.nonNullable D.int8))- <*> (StreamVersion <$> D.column (D.nonNullable D.int8))--linkToStreamSQL :: Text-linkToStreamSQL =- """- WITH- event_list AS (- SELECT event_id, idx- FROM unnest($1::uuid[]) WITH ORDINALITY AS t(event_id, idx)- ),- stream_upsert AS (- INSERT INTO streams (stream_name, stream_version)- VALUES ($2, (SELECT count(*) FROM event_list))- ON CONFLICT (stream_name)- DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM event_list)- WHERE streams.deleted_at IS NULL- RETURNING stream_id, stream_version - (SELECT count(*) FROM event_list) AS initial_version- ),- link_inserts AS (- -- LEFT JOIN LATERAL surfaces missing-event rows as NULLs for original_*; the- -- NOT NULL constraint on stream_events.original_stream_id then aborts the- -- entire CTE, rolling back the stream_upsert's version bump. Before the F3- -- fix this was a plain JOIN LATERAL, which silently dropped missing-event- -- rows while still bumping stream_version → silent gap in the link target.- INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT el.event_id, su.stream_id, su.initial_version + el.idx,- orig.original_stream_id, orig.original_stream_version- FROM event_list el- CROSS JOIN stream_upsert su- LEFT JOIN LATERAL (- SELECT se.original_stream_id, se.original_stream_version- FROM stream_events se- WHERE se.event_id = el.event_id AND se.stream_id <> 0- LIMIT 1- ) orig ON true- )- SELECT su.stream_id, su.initial_version + (SELECT count(*) FROM event_list)- FROM stream_upsert su- """---- ------------------------------------------------------------------------------ Category Read Statements--- -----------------------------------------------------------------------------{- | Read events from streams matching a category, in global position order.--Scans @ix_stream_events_all_by_category@ from @(category, startPosition)@ and-stops at the limit, so a poll's cost follows the rows it returns, not the-number of streams in the category (BUG-2). The @category@ column on @$all@-junction rows is written by the append statements (migration @0012@). Params:-@(startPosition, category, limit)@.--}-readCategoryForwardStmt :: Statement (Int64, Text, Int32) (Vector RecordedEvent)-readCategoryForwardStmt =- preparable- readCategoryForwardSQL- readCategoryEncoder- (D.rowVector recordedEventRow)--readCategoryEncoder :: E.Params (Int64, Text, Int32)-readCategoryEncoder =- contrazip3- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))--readCategoryForwardSQL :: Text-readCategoryForwardSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- 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 = 0- AND se.category = $2- AND se.stream_version > $1- ORDER BY se.stream_version ASC- LIMIT $3- """---- ------------------------------------------------------------------------------ Consumer-Group Read Statements--- -----------------------------------------------------------------------------{- | Partition-filtered category read for one consumer-group member.--Mirrors 'readCategoryForwardStmt' but returns only events whose originating-stream is assigned to member @$3@ of a group of size @$4@. The assignment rule-(MasterPlan IP-1) hashes the originating stream's surrogate id and folds the-signed result into @[0, size)@:--@member_of(stream_id) = (((hashtextextended(stream_id::text, 0) % size) + size) % size)@--The read scans @ix_stream_events_all_by_category@ from-@(category, startPosition)@ and applies the predicate to-@se.original_stream_id@, which the index carries as an @INCLUDE@ column, so-other members' rows are skipped on index tuples. A poll touches about-@limit * size@ index entries at most, never one probe per stream in the-category (BUG-2). Params: @(startPosition, category, member, size, limit)@.--}-readCategoryForwardConsumerGroupStmt ::- Statement (Int64, Text, Int32, Int32, Int32) (Vector RecordedEvent)-readCategoryForwardConsumerGroupStmt =- preparable- readCategoryForwardConsumerGroupSQL- readCategoryConsumerGroupEncoder- (D.rowVector recordedEventRow)--readCategoryConsumerGroupEncoder :: E.Params (Int64, Text, Int32, Int32, Int32)-readCategoryConsumerGroupEncoder =- contrazip5- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.int4))--readCategoryForwardConsumerGroupSQL :: Text-readCategoryForwardConsumerGroupSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- 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 = 0- AND se.category = $2- AND se.stream_version > $1- AND (((hashtextextended(se.original_stream_id::text, 0) % $4) + $4) % $4) = $3- ORDER BY se.stream_version ASC- LIMIT $5- """--{- | Partition-filtered @$all@ read for one consumer-group member.--Mirrors 'readAllForwardStmt' but returns only events whose originating stream is-assigned to member @$2@ of a group of size @$3@, using the same MasterPlan IP-1-rule as 'readCategoryForwardConsumerGroupStmt'. The predicate is applied to-@se.original_stream_id@ — the real originating stream of each @$all@ junction row-(never the reserved id 0 for normal appends). Params:-@(startPosition, member, size, limit)@.--}-readAllForwardConsumerGroupStmt ::- Statement (Int64, Int32, Int32, Int32) (Vector RecordedEvent)-readAllForwardConsumerGroupStmt =- preparable- readAllForwardConsumerGroupSQL- readAllConsumerGroupEncoder- (D.rowVector recordedEventRow)--readAllConsumerGroupEncoder :: E.Params (Int64, Int32, Int32, Int32)-readAllConsumerGroupEncoder =- contrazip4- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.int4))--readAllForwardConsumerGroupSQL :: Text-readAllForwardConsumerGroupSQL =- """- SELECT e.event_id, e.event_type,- se.stream_version, se.stream_version AS global_position,- se.original_stream_id, se.original_stream_version,- 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 = 0- AND se.stream_version > $1- AND (((hashtextextended(se.original_stream_id::text, 0) % $3) + $3) % $3) = $2- ORDER BY se.stream_version ASC- LIMIT $4- """---- ------------------------------------------------------------------------------ Lifecycle Statements--- ------------------------------------------------------------------------------- | Soft-delete a stream by setting deleted_at. Returns Nothing if stream doesn't exist or is already deleted.-softDeleteStreamStmt :: Statement Text (Maybe StreamId)-softDeleteStreamStmt =- preparable- softDeleteStreamSQL- (E.param (E.nonNullable E.text))- (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))---- | Undelete a soft-deleted stream by clearing deleted_at. Returns Nothing if stream doesn't exist or is not deleted.-undeleteStreamStmt :: Statement Text (Maybe StreamId)-undeleteStreamStmt =- preparable- undeleteStreamSQL- (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.--}-findStreamIdStmt :: Statement Text (Maybe Int64)-findStreamIdStmt =- preparable- "SELECT stream_id FROM streams WHERE stream_name = $1"- (E.param (E.nonNullable E.text))- (D.rowMaybe (D.column (D.nonNullable D.int8)))--{- | Resolve a batch of surrogate stream ids to their (id, name) pairs in one-round trip. Ids that do not name an existing stream simply produce no row, so-the caller's 'Data.Map.Strict.Map' omits them. Surfaced as-'Kiroku.Store.Read.lookupStreamNames'.--}-lookupStreamNamesStmt :: Statement [Int64] (Vector (Int64, Text))-lookupStreamNamesStmt =- preparable- "SELECT stream_id, stream_name FROM streams WHERE stream_id = ANY($1::bigint[])"- (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int8))))- ( D.rowVector- ( (,)- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.text)- )- )--{- | Delete the @$all@ junction rows for events that originated in the target-stream, returning exactly those originated event ids.--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 caller then passes the returned ids to 'deleteJunctionsByEventIdsStmt' to-remove the remaining home/link junction rows for those originated events.--}-deleteAllRowsForOriginStmt :: Statement Int64 (Vector UUID)-deleteAllRowsForOriginStmt =- preparable- """- 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).--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 =- preparable- """- DELETE FROM events- WHERE event_id = ANY($1::uuid[])- AND NOT EXISTS (- SELECT 1 FROM stream_events- WHERE stream_events.event_id = events.event_id- )- """- (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))- D.noResult--{- | Delete the @streams@ row for the given stream id. Used as the final step-of hard-delete after junction rows and orphan events are cleared.--}-deleteStreamRowStmt :: Statement Int64 ()-deleteStreamRowStmt =- preparable- "DELETE FROM streams WHERE stream_id = $1"- (E.param (E.nonNullable E.int8))- D.noResult--{- | Pre-acquire row locks on the named streams in deterministic (stream_id)-order. Used by AppendMultiStream to avoid row-lock deadlocks between-concurrent multi-stream transactions that touch overlapping streams in-different orders.--\$all is intentionally NOT included in the pre-lock — its row lock is-acquired by each per-stream CTE inside the transaction, after the source-stream's row lock. See EP-1 F4.--Streams that don't yet exist (NoStream variant on a fresh stream) are not-matched by the WHERE clause, so they aren't pre-locked here. Concurrent-INSERTs of a fresh stream do serialize on the unique index on-@stream_name@, but that serialization does /not/ preserve the-source-before-$all acquisition order this pre-lock exists to establish,-and a multi-stream append can still deadlock against a concurrent-single-stream append:-- * multi locks stream A, then @$all@ (both inside A's CTE), then stream B;- * a single-stream append to the still-fresh B locks B, then wants @$all@.--Each then waits on the other. PostgreSQL detects it and aborts one side-with @40P01@; the interpreter retries the append once, and a repeated-conflict reaches the caller as-'Kiroku.Store.Error.TransientTransactionFailure', which is documented-retryable. Eliminating the cycle would mean establishing every source-lock before any @$all@ lock, including for streams that do not exist yet-— tracked as IR-7, deliberately not done here because it is a throughput-question that needs benchmarking, not a correctness gap.--}-lockStreamsForMultiStmt :: Statement (Vector Text) ()-lockStreamsForMultiStmt =- preparable- """- SELECT 1 FROM streams- WHERE stream_name = ANY($1::text[])- ORDER BY stream_id- FOR UPDATE- """- (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))- D.noResult---- ------------------------------------------------------------------------------ Lifecycle SQL Templates--- -----------------------------------------------------------------------------softDeleteStreamSQL :: Text-softDeleteStreamSQL =- """- UPDATE streams- SET deleted_at = now()- WHERE stream_name = $1- AND deleted_at IS NULL- RETURNING stream_id- """--undeleteStreamSQL :: Text-undeleteStreamSQL =- """- UPDATE streams- 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- """---- ------------------------------------------------------------------------------ Checkpoint Statements--- ------------------------------------------------------------------------------- | Read the last-seen global position for a subscription.-getCheckpointStmt :: Statement Text (Maybe Int64)-getCheckpointStmt =- preparable- getCheckpointSQL- (E.param (E.nonNullable E.text))- (D.rowMaybe (D.column (D.nonNullable D.int8)))---- | Upsert a checkpoint: insert or update the last-seen position.-saveCheckpointStmt :: Statement (Text, Int64) ()-saveCheckpointStmt =- preparable- saveCheckpointSQL- ( contrazip2- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int8))- )- D.noResult--getCheckpointSQL :: Text-getCheckpointSQL =- """- SELECT last_seen- FROM subscriptions- WHERE subscription_name = $1- """--saveCheckpointSQL :: Text-saveCheckpointSQL =- """- INSERT INTO subscriptions (subscription_name, last_seen, updated_at)- VALUES ($1, $2, now())- ON CONFLICT (subscription_name, consumer_group_member)- DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now()- """---- | Read the last-seen position for one consumer-group member of a subscription.-getCheckpointMemberStmt :: Statement (Text, Int32) (Maybe Int64)-getCheckpointMemberStmt =- preparable- getCheckpointMemberSQL- ( contrazip2- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- )- (D.rowMaybe (D.column (D.nonNullable D.int8)))--{- | Upsert the last-seen position for one consumer-group member, keyed on the-composite @(subscription_name, consumer_group_member)@ unique index. Uses the-same @GREATEST(...)@ monotonicity as 'saveCheckpointStmt' so a save never moves-a member's checkpoint backward.--}-saveCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text, Maybe Text) ()-saveCheckpointMemberStmt =- preparable- saveCheckpointMemberSQL- ( contrazip6- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.text))- (E.param (E.nullable E.text))- )- D.noResult---- | Bound targets use a fixed kind rather than encoding a constant per save.-saveAllCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32) ()-saveAllCheckpointMemberStmt =- preparable- (saveCheckpointMemberSQLWith "'all', NULL")- (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))- D.noResult--saveCategoryCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()-saveCategoryCheckpointMemberStmt =- preparable- (saveCheckpointMemberSQLWith "'category', $5")- (contrazip5 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.text)))- D.noResult--getCheckpointMemberSQL :: Text-getCheckpointMemberSQL =- """- SELECT last_seen- FROM subscriptions- WHERE subscription_name = $1- AND consumer_group_member = $2- """--saveCheckpointMemberSQL :: Text-saveCheckpointMemberSQL = saveCheckpointMemberSQLWith "$5, $6"--saveCheckpointMemberSQLWith :: Text -> Text-saveCheckpointMemberSQLWith binding =- "INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category) VALUES ($1, $2, $3, now(), $4, "- <> binding- <> ") "- <> """- ON CONFLICT (subscription_name, consumer_group_member)- DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category- """---- ------------------------------------------------------------------------------ Dead-letter Statements--- ------------------------------------------------------------------------------- | Parameters for 'insertDeadLetterAndCheckpointStmt'.-data DeadLetterParams = DeadLetterParams- { dlSubscriptionName :: !Text- , dlMember :: !Int32- , dlTargetKind :: !Text- , dlTargetCategory :: !(Maybe Text)- , dlGroupSize :: !Int32- , dlGlobalPosition :: !Int64- , dlEventId :: !UUID- , dlReason :: !Value- -- ^ structured JSONB reason (@reason@ column)- , dlReasonSummary :: !Text- -- ^ operator-facing summary (@reason_summary@ column)- , dlAttemptCount :: !Int32- }- deriving stock (Show, Generic)---- | A row of @kiroku.dead_letters@, returned by 'readDeadLettersStmt'.-data DeadLetterRecord = DeadLetterRecord- { deadLetterGlobalPosition :: !Int64- , deadLetterEventId :: !UUID- , deadLetterReason :: !Value- , deadLetterReasonSummary :: !Text- , deadLetterAttemptCount :: !Int32- , deadLetterCreatedAt :: !UTCTime- }- deriving stock (Show, Generic)--deadLetterParamsEncoder :: E.Params DeadLetterParams-deadLetterParamsEncoder =- ((^. #dlSubscriptionName) >$< E.param (E.nonNullable E.text))- <> ((^. #dlMember) >$< E.param (E.nonNullable E.int4))- <> ((^. #dlGlobalPosition) >$< E.param (E.nonNullable E.int8))- <> ((^. #dlEventId) >$< E.param (E.nonNullable E.uuid))- <> ((^. #dlReason) >$< E.param (E.nonNullable E.jsonb))- <> ((^. #dlReasonSummary) >$< E.param (E.nonNullable E.text))- <> ((^. #dlAttemptCount) >$< E.param (E.nonNullable E.int4))- <> ((^. #dlGroupSize) >$< E.param (E.nonNullable E.int4))- <> ((^. #dlTargetKind) >$< E.param (E.nonNullable E.text))- <> ((^. #dlTargetCategory) >$< E.param (E.nullable E.text))--{- | Atomically record an event in @kiroku.dead_letters@ and advance the-subscription's checkpoint past it, in a single statement.--The dead-letter insert and the checkpoint upsert run in one round trip: a-data-modifying CTE performs the insert (idempotent via @ON CONFLICT DO NOTHING@-on the natural key), and the final statement upserts the checkpoint with the-same @GREATEST(...)@ monotonicity as 'saveCheckpointMemberStmt'. If the insert-fails the whole statement aborts and the checkpoint does not advance, satisfying-the dead-letter atomicity criterion. The checkpoint reuses @$1@/@$2@/@$3@-(name / member / global_position) from the dead-letter params.--}-insertDeadLetterAndCheckpointStmt :: Statement DeadLetterParams ()-insertDeadLetterAndCheckpointStmt =- preparable- insertDeadLetterAndCheckpointSQL- deadLetterParamsEncoder- D.noResult--insertDeadLetterAndCheckpointSQL :: Text-insertDeadLetterAndCheckpointSQL =- """- WITH dl AS (- INSERT INTO dead_letters- (subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count)- VALUES ($1, $2, $3, $4, $5, $6, $7)- ON CONFLICT (subscription_name, consumer_group_member, global_position, event_id) DO NOTHING- )- INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)- VALUES ($1, $2, $3, now(), $8, $9, $10)- ON CONFLICT (subscription_name, consumer_group_member)- DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category- """---- | Read the dead letters recorded for one subscription member, newest first.-readDeadLettersStmt :: Statement (Text, Int32) (Vector DeadLetterRecord)-readDeadLettersStmt =- preparable- readDeadLettersSQL- ( contrazip2- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- )- (D.rowVector deadLetterRecordRow)--deadLetterRecordRow :: D.Row DeadLetterRecord-deadLetterRecordRow =- DeadLetterRecord- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.uuid)- <*> D.column (D.nonNullable D.jsonb)- <*> D.column (D.nonNullable D.text)- <*> D.column (D.nonNullable D.int4)- <*> D.column (D.nonNullable D.timestamptz)--readDeadLettersSQL :: Text-readDeadLettersSQL =- """- SELECT global_position, event_id, reason, reason_summary, attempt_count, created_at- FROM dead_letters- WHERE subscription_name = $1- AND consumer_group_member = $2- ORDER BY global_position DESC, dead_letter_id DESC- """---- Catalog statements have a finite family of prepared shapes. Upper bounds--- are deliberately checked after the ordered LIMIT, keeping generic plans--- from bitmap-scanning and sorting an entire matching prefix.-data StreamRangeBound = InclusiveLower | ExclusiveLower | ExactName- deriving stock (Eq, Show)--streamColumns :: Text-streamColumns = "stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before"--streamRangeSQL :: StreamRangeBound -> Text -> Text -> Text -> Text-streamRangeSQL bound lower upper limit =- "SELECT * FROM (SELECT "- <> streamColumns- <> " FROM streams WHERE stream_id <> 0 AND stream_name COLLATE \"C\" "- <> operator- <> " "- <> lower- <> " ORDER BY stream_name COLLATE \"C\" LIMIT "- <> limit- <> ") bounded WHERE ("- <> upper- <> "::text IS NULL OR stream_name COLLATE \"C\" < "- <> upper- <> ")"- where- operator = case bound of InclusiveLower -> ">="; ExclusiveLower -> ">"; ExactName -> "="--listStreamsRangeStmt :: StreamRangeBound -> Statement (Text, Maybe Text, Int32) (Vector StreamInfo)-listStreamsRangeStmt bound =- preparable- (streamRangeSQL bound "$1" "$2" "$3" <> " ORDER BY stream_name COLLATE \"C\"")- (contrazip3 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))- (D.rowVector streamInfoRow)--listStreamsPairStmt :: StreamRangeBound -> StreamRangeBound -> Statement (Text, Maybe Text, Text, Maybe Text, Int32) (Vector StreamInfo)-listStreamsPairStmt first second =- preparable- ( "SELECT * FROM (("- <> streamRangeSQL first "$1" "$2" "$5"- <> ") UNION ALL ("- <> streamRangeSQL second "$3" "$4" "$5"- <> ")) matching ORDER BY stream_name COLLATE \"C\" LIMIT $5"- )- ( contrazip5- (E.param (E.nonNullable E.text))- (E.param (E.nullable E.text))- (E.param (E.nonNullable E.text))- (E.param (E.nullable E.text))- (E.param (E.nonNullable E.int4))- )- (D.rowVector streamInfoRow)--{- | One statement and one checkout, including the bare-category/descendant union.-All text is encoded as parameters; only closed operator choices build SQL.--}-listStreamsSession :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Session.Session (Vector StreamInfo)-listStreamsSession category prefix after page = case ranges of- [] -> pure mempty- [(kind, lower, upper)] -> Session.statement (lower, upper, limit) (listStreamsRangeStmt kind)- [(kind, lower, upper), (kind2, lower2, upper2)] ->- Session.statement (lower, upper, lower2, upper2, limit) (listStreamsPairStmt kind kind2)- _ -> error "catalog range invariant: at most two disjoint ranges"- where- limit = browsePageSizeValue page- cursor = fmap (\(StreamName value) -> value) after- ranges = case category of- Nothing -> maybe [] pure (interval "" Nothing)- Just (CategoryName value)- | T.any (== '-') value -> []- | otherwise -> bare value <> maybe [] pure (interval (value <> "-") (prefixEnd (value <> "-")))- bare value- | maybe True (`T.isPrefixOf` value) prefix && maybe True (< value) cursor = [(ExactName, value, Nothing)]- | otherwise = []- interval start end =- let lower = max start (maybe "" (\value -> value) prefix)- upper = minimumEnd end (prefix >>= prefixEnd)- (kind, seek) = case cursor of- Just value | value >= lower -> (ExclusiveLower, value)- _ -> (InclusiveLower, lower)- in if maybe False (<= seek) upper then Nothing else Just (kind, seek, upper)- minimumEnd Nothing b = b- minimumEnd a Nothing = a- minimumEnd (Just a) (Just b) = Just (min a b)---- Valid UTF-8 byte order follows Unicode scalar order. The all-maximum and--- empty prefixes have no finite upper bound; PostgreSQL text has no NUL.-prefixEnd :: Text -> Maybe Text-prefixEnd value = case T.unsnoc value of- Nothing -> Nothing- Just (initial, lastChar)- | ord lastChar == 0x10ffff -> prefixEnd initial- | otherwise -> Just (T.snoc initial (chr (if ord lastChar + 1 == 0xd800 then 0xe000 else ord lastChar + 1)))---- First and later category pages have separate cursor shapes. Category order--- retains the deployment collation and its existing category index.-listCategoriesStmt :: Bool -> Statement (Maybe Text, Int32) (Vector Text)-listCategoriesStmt after =- preparable- query- (contrazip2 (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))- (D.rowVector (D.column (D.nonNullable D.text)))- where- first = if after then "AND s.category > $1" else "AND $1::text IS NULL"- query =- "WITH RECURSIVE next_category AS (SELECT (SELECT s.category FROM streams s WHERE s.stream_id <> 0 "- <> first- <> " ORDER BY s.category LIMIT 1) AS category UNION ALL SELECT (SELECT s.category FROM streams s "- <> "WHERE s.stream_id <> 0 AND s.category > n.category ORDER BY s.category LIMIT 1) "- <> "FROM next_category n WHERE n.category IS NOT NULL) SELECT category FROM next_category "- <> "WHERE category IS NOT NULL LIMIT $2"--getEventStmt :: Statement UUID (Maybe RecordedEvent)-getEventStmt =- preparable- "SELECT e.event_id,e.event_type,se.stream_version,se.stream_version AS global_position, se.original_stream_id,se.original_stream_version,e.data,e.metadata,e.causation_id,e.correlation_id,e.created_at FROM events e JOIN stream_events se ON se.event_id=e.event_id AND se.stream_id=0 WHERE e.event_id=$1"- (E.param (E.nonNullable E.uuid))- (D.rowMaybe recordedEventRow)+module Kiroku.Store.SQL+ ( -- * Append statements+ AppendParams (..),+ appendParamsEncoder,+ appendResultDecoder,+ appendExpectedVersion,+ appendStreamExists,+ appendNoStream,+ appendAnyVersion,++ -- * Link statements+ linkToStreamStmt,++ -- * Read statements+ readStreamForwardStmt,+ readStreamBackwardStmt,+ readAllForwardStmt,+ readAllBackwardStmt,+ readCategoryForwardStmt,+ readCategoryEncoder,+ NamespaceWindow (..),+ readNamespaceWindowStmt,+ readNamespaceGroupWindowStmt,+ getStreamStmt,+ getStreamWithHeadStmt,+ eventExistsInStreamStmt,+ lookupStreamNamesStmt,+ listStreamsSession,+ StreamRangeBound (..),+ listStreamsRangeStmt,+ listStreamsPairStmt,+ listCategoriesStmt,+ getEventStmt,+ currentGlobalPositionStmt,+ visibleGlobalHeadPositionStmt,++ -- * Consumer-group read statements+ readCategoryForwardConsumerGroupStmt,+ readCategoryConsumerGroupEncoder,+ readAllForwardConsumerGroupStmt,++ -- * Row decoders+ recordedEventRow,++ -- * Causation / correlation statements+ findByCorrelationStmt,+ findCausationDescendantsStmt,+ findCausationAncestorsStmt,++ -- * Lifecycle statements+ softDeleteStreamStmt,+ undeleteStreamStmt,+ setStreamTruncateBeforeStmt,++ -- * Hard-delete statements (used in sequence inside one transaction)+ findStreamIdStmt,+ deleteAllRowsForOriginStmt,+ deleteJunctionsByEventIdsStmt,+ deleteStreamOwnJunctionsStmt,+ deleteDeadLettersForOrphanedEventsStmt,+ deleteOrphanedEventsStmt,+ deleteStreamRowStmt,++ -- * Multi-stream pre-lock (avoids row-lock deadlocks)+ lockStreamsForMultiStmt,++ -- * Checkpoint statements+ getCheckpointStmt,+ saveCheckpointStmt,+ getCheckpointMemberStmt,+ saveCheckpointMemberStmt,+ saveAllCheckpointMemberStmt,+ saveCategoryCheckpointMemberStmt,+ saveNamespaceCheckpointMemberStmt,++ -- * Dead-letter statements+ DeadLetterParams (..),+ DeadLetterRecord (..),+ insertDeadLetterAndCheckpointStmt,+ readDeadLettersStmt,+ listSubscriptionDeadLettersStmt,+ listSubscriptionDeadLettersFromStartStmt,+ listSubscriptionMemberDeadLettersStmt,+ listSubscriptionMemberDeadLettersFromStartStmt,+ )+where++import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)+import Control.Lens ((^.))+import Data.Aeson (Value)+import Data.Char (chr, ord)+import Data.Functor.Contravariant ((>$<))+import Data.Generics.Labels ()+import Data.Int (Int32, Int64)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time (UTCTime)+import Data.UUID (UUID)+import Data.Vector (Vector)+import Data.Vector qualified as V+import GHC.Generics (Generic)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Kiroku.Store.Subscription.DeadLetter.SQL (listSubscriptionDeadLettersFromStartStmt, listSubscriptionDeadLettersStmt, listSubscriptionMemberDeadLettersFromStartStmt, listSubscriptionMemberDeadLettersStmt)+import Kiroku.Store.Types++-- | Parameters for append CTE variants (the 7 parallel arrays + stream name).+data AppendParams = AppendParams+ { eventIds :: !(Vector UUID),+ eventTypes :: !(Vector Text),+ causationIds :: !(Vector (Maybe UUID)),+ correlationIds :: !(Vector (Maybe UUID)),+ payloads :: !(Vector Value),+ metadatas :: !(Vector (Maybe Value)),+ createdAts :: !(Vector UTCTime),+ streamName :: !Text+ }+ deriving stock (Show, Generic)++-- | Encoder for the common 8 parameters shared by all append variants.+appendParamsEncoder :: E.Params AppendParams+appendParamsEncoder =+ ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+ <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+ <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+ <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+ <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))+ <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))+ <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))+ <> ((^. #streamName) >$< E.param (E.nonNullable E.text))++-- | Encoder for append_expected_version: base params + expected version (Int64).+appendExpectedEncoder :: E.Params (AppendParams, Int64)+appendExpectedEncoder =+ contrazip2 appendParamsEncoder (E.param (E.nonNullable E.int8))++-- | Decoder for append results: stream_id, stream_version, global_position.+-- Returns Nothing if the CTE produced 0 rows (version conflict / stream not found).+appendResultDecoder :: D.Result (Maybe AppendResult)+appendResultDecoder =+ D.rowMaybe $+ AppendResult+ <$> (StreamId <$> D.column (D.nonNullable D.int8))+ <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+ <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))++-- | Append with exact expected version (optimistic concurrency).+-- Returns Nothing if the stream doesn't exist or version doesn't match.+appendExpectedVersion :: Statement (AppendParams, Int64) (Maybe AppendResult)+appendExpectedVersion =+ preparable+ appendExpectedVersionSQL+ appendExpectedEncoder+ appendResultDecoder++-- | Append to an existing stream at any version.+-- Returns Nothing if the stream doesn't exist.+appendStreamExists :: Statement AppendParams (Maybe AppendResult)+appendStreamExists =+ preparable+ appendStreamExistsSQL+ appendParamsEncoder+ appendResultDecoder++-- | Append to a new stream (must not already exist).+-- Returns Nothing if the stream already exists.+appendNoStream :: Statement AppendParams (Maybe AppendResult)+appendNoStream =+ preparable+ appendNoStreamSQL+ appendParamsEncoder+ appendResultDecoder++-- | Append to a stream, creating it if it doesn't exist.+-- Should always return Just (unless duplicate event ID constraint violation).+appendAnyVersion :: Statement AppendParams (Maybe AppendResult)+appendAnyVersion =+ preparable+ appendAnyVersionSQL+ appendParamsEncoder+ appendResultDecoder++-- ---------------------------------------------------------------------------+-- SQL Templates+-- ---------------------------------------------------------------------------++-- | CTE with exact version check: UPDATE streams WHERE stream_version = $9.+appendExpectedVersionSQL :: Text+appendExpectedVersionSQL =+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_name = $8+ AND stream_version = $9+ AND deleted_at IS NULL+ RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_update)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_update su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_update)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_update su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_update su+ CROSS JOIN all_update au+ """++-- | CTE without version check: UPDATE streams WHERE stream_name = $8 (no $9).+appendStreamExistsSQL :: Text+appendStreamExistsSQL =+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_name = $8+ AND deleted_at IS NULL+ RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_update)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_update su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_update)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_update su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_update su+ CROSS JOIN all_update au+ """++-- | CTE for new stream creation: INSERT INTO streams ... ON CONFLICT DO NOTHING.+appendNoStreamSQL :: Text+appendNoStreamSQL =+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_insert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, (SELECT count(*) FROM new_events))+ ON CONFLICT (stream_name) DO NOTHING+ RETURNING stream_id, category, 0::bigint AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_insert)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, si.stream_id, si.initial_version + ne.idx, si.stream_id, si.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_insert si+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_insert)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, si.stream_id, si.initial_version + ne.idx, si.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_insert si+ )+ SELECT si.stream_id,+ si.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_insert si+ CROSS JOIN all_update au+ """++-- | CTE for create-or-append using INSERT ... ON CONFLICT DO UPDATE (upsert).+-- A plain INSERT + separate UPDATE in the same CTE won't work because+-- data-modifying CTEs cannot see each other's changes. Instead, we use+-- a single upsert that both creates the stream and bumps its version atomically.+appendAnyVersionSQL :: Text+appendAnyVersionSQL =+ """+ WITH+ new_events AS (+ SELECT *+ FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+ WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($8, (SELECT count(*) FROM new_events))+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+ ),+ inserted_events AS (+ INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+ SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+ FROM new_events+ WHERE EXISTS (SELECT 1 FROM stream_upsert)+ ORDER BY idx+ ),+ source_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+ FROM new_events ne+ CROSS JOIN stream_upsert su+ ),+ all_update AS (+ UPDATE streams+ SET stream_version = stream_version + (SELECT count(*) FROM new_events)+ WHERE stream_id = 0+ AND EXISTS (SELECT 1 FROM stream_upsert)+ RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+ ),+ all_links AS (+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+ FROM new_events ne+ CROSS JOIN all_update au+ CROSS JOIN stream_upsert su+ )+ SELECT su.stream_id,+ su.initial_version + (SELECT count(*) FROM new_events),+ au.initial_global_version + (SELECT count(*) FROM new_events)+ FROM stream_upsert su+ CROSS JOIN all_update au+ """++-- ---------------------------------------------------------------------------+-- Read Statements+-- ---------------------------------------------------------------------------++-- | Shared decoder for a RecordedEvent row (11 columns).+recordedEventRow :: D.Row RecordedEvent+recordedEventRow =+ RecordedEvent+ <$> (EventId <$> D.column (D.nonNullable D.uuid))+ <*> (EventType <$> D.column (D.nonNullable D.text))+ <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+ <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+ <*> (StreamId <$> D.column (D.nonNullable D.int8))+ <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+ <*> D.column (D.nonNullable D.jsonb)+ <*> D.column (D.nullable D.jsonb)+ <*> D.column (D.nullable D.uuid)+ <*> D.column (D.nullable D.uuid)+ <*> D.column (D.nonNullable D.timestamptz)++-- | Shared decoder for a StreamInfo row (6 columns).+streamInfoRow :: D.Row StreamInfo+streamInfoRow =+ StreamInfo+ <$> (StreamId <$> D.column (D.nonNullable D.int8))+ <*> (StreamName <$> D.column (D.nonNullable D.text))+ <*> (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)+readStreamEncoder =+ contrazip3+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.int4))++-- | Encoder for $all read params: (start_position, limit).+readAllEncoder :: E.Params (Int64, Int32)+readAllEncoder =+ contrazip2+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.int4))++-- | Read events from a named stream in forward order.+readStreamForwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)+readStreamForwardStmt =+ preparable+ readStreamForwardSQL+ readStreamEncoder+ (D.rowVector recordedEventRow)++-- | Read events from a named stream in backward order.+readStreamBackwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)+readStreamBackwardStmt =+ preparable+ readStreamBackwardSQL+ readStreamEncoder+ (D.rowVector recordedEventRow)++-- | Read events from the global $all stream in forward order.+readAllForwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)+readAllForwardStmt =+ preparable+ readAllForwardSQL+ readAllEncoder+ (D.rowVector recordedEventRow)++-- | Read events from the global $all stream in backward order.+readAllBackwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)+readAllBackwardStmt =+ preparable+ readAllBackwardSQL+ readAllEncoder+ (D.rowVector recordedEventRow)++-- | Read the current tail of the global $all stream.+currentGlobalPositionStmt :: Statement () Int64+currentGlobalPositionStmt =+ preparable+ "SELECT stream_version FROM streams WHERE stream_id = 0"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.int8)))++-- | Read the greatest position still visible in the global $all stream.+visibleGlobalHeadPositionStmt :: Statement () GlobalPosition+visibleGlobalHeadPositionStmt =+ preparable+ """+ SELECT COALESCE((+ SELECT stream_version+ FROM stream_events+ WHERE stream_id = 0+ ORDER BY stream_version DESC+ LIMIT 1+ ), 0)+ """+ E.noParams+ (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))++-- | Metadata and the newest surviving originated global position in one snapshot.+getStreamWithHeadStmt :: Statement Text (Maybe (StreamInfo, Maybe GlobalPosition))+getStreamWithHeadStmt =+ preparable+ getStreamWithHeadSQL+ (E.param (E.nonNullable E.text))+ (D.rowMaybe ((,) <$> streamInfoRow <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))))++getStreamWithHeadSQL :: Text+getStreamWithHeadSQL =+ """+ SELECT s.stream_id, s.stream_name, s.stream_version,+ s.created_at, s.deleted_at, s.truncate_before,+ (SELECT se.stream_version+ FROM stream_events AS se+ WHERE se.stream_id = 0+ AND se.original_stream_id = s.stream_id+ ORDER BY se.stream_version DESC+ LIMIT 1) AS head_global_position+ FROM streams AS s+ WHERE s.stream_name = $1+ """++-- | Get stream metadata by name.+getStreamStmt :: Statement Text (Maybe StreamInfo)+getStreamStmt =+ preparable+ getStreamSQL+ (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.+-- 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+readStreamForwardSQL =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, 0::bigint AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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+ 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.+-- 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 =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, 0::bigint AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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+ 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 =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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 = 0+ AND se.stream_version > $1+ ORDER BY se.stream_version ASC+ LIMIT $2+ """++-- | 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 =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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 = 0+ AND se.stream_version < $1+ ORDER BY se.stream_version DESC+ LIMIT $2+ """++-- | Get stream metadata by name.+getStreamSQL :: Text+getStreamSQL =+ """+ SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before+ FROM streams+ WHERE stream_name = $1+ """++-- ---------------------------------------------------------------------------+-- Causation / Correlation Statements+-- ---------------------------------------------------------------------------++-- | Return every event whose @correlation_id@ equals the input, in ascending+-- @global_position@ order. Uses the @ix_events_correlation_id@ partial index.+--+-- The @stream_events se ON se.stream_id = 0@ join resolves each event to its+-- single row in the global @$all@ stream, which is also where the global+-- position is materialized as @stream_version@.+findByCorrelationStmt :: Statement UUID (Vector RecordedEvent)+findByCorrelationStmt =+ preparable+ findByCorrelationSQL+ (E.param (E.nonNullable E.uuid))+ (D.rowVector recordedEventRow)++findByCorrelationSQL :: Text+findByCorrelationSQL =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ e.data, e.metadata, e.causation_id, e.correlation_id,+ e.created_at+ FROM events e+ JOIN stream_events se+ ON se.event_id = e.event_id AND se.stream_id = 0+ WHERE e.correlation_id = $1+ ORDER BY se.stream_version ASC+ """++-- | Walk the causation graph forward from a seed event, returning the seed+-- itself and every event whose @causation_id@ chain leads back to it. The+-- result is ordered by ascending @global_position@.+--+-- The recursive CTE follows @causation_id@ links downstream: each child's+-- @causation_id@ equals a parent's @event_id@. Cost is @O(depth * log n)@+-- backed by the @ix_events_causation_id@ partial index.+findCausationDescendantsStmt :: Statement UUID (Vector RecordedEvent)+findCausationDescendantsStmt =+ preparable+ findCausationDescendantsSQL+ (E.param (E.nonNullable E.uuid))+ (D.rowVector recordedEventRow)++findCausationDescendantsSQL :: Text+findCausationDescendantsSQL =+ """+ WITH RECURSIVE chain (event_id, depth) AS (+ SELECT event_id, 0+ FROM events+ WHERE event_id = $1+ UNION ALL+ SELECT e.event_id, c.depth + 1+ FROM events e+ JOIN chain c ON e.causation_id = c.event_id+ )+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ e.data, e.metadata, e.causation_id, e.correlation_id,+ e.created_at+ FROM chain c+ JOIN events e ON e.event_id = c.event_id+ JOIN stream_events se+ ON se.event_id = e.event_id AND se.stream_id = 0+ ORDER BY se.stream_version ASC+ """++-- | Walk the causation graph backward from a seed event, returning the seed+-- itself and every ancestor reachable via @causation_id@. Result is ordered+-- by ascending @depth@ (the seed is depth 0, its immediate cause is depth 1,+-- etc.).+--+-- The recursive CTE follows @causation_id@ links upstream: for each row+-- @current@ already in the working set, its parent is the row of @events@+-- whose @event_id@ equals @current.causation_id@.+findCausationAncestorsStmt :: Statement UUID (Vector RecordedEvent)+findCausationAncestorsStmt =+ preparable+ findCausationAncestorsSQL+ (E.param (E.nonNullable E.uuid))+ (D.rowVector recordedEventRow)++findCausationAncestorsSQL :: Text+findCausationAncestorsSQL =+ """+ WITH RECURSIVE chain (event_id, depth) AS (+ SELECT event_id, 0+ FROM events+ WHERE event_id = $1+ UNION ALL+ SELECT parent.event_id, c.depth + 1+ FROM events parent+ JOIN events current ON parent.event_id = current.causation_id+ JOIN chain c ON c.event_id = current.event_id+ )+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ e.data, e.metadata, e.causation_id, e.correlation_id,+ e.created_at+ FROM chain c+ JOIN events e ON e.event_id = c.event_id+ JOIN stream_events se+ ON se.event_id = e.event_id AND se.stream_id = 0+ ORDER BY c.depth ASC+ """++-- ---------------------------------------------------------------------------+-- Link Statements+-- ---------------------------------------------------------------------------++-- | Link existing events into a target stream (upsert semantics).+-- Returns @Nothing@ when the target stream exists and is soft-deleted (the+-- @DO UPDATE WHERE streams.deleted_at IS NULL@ filter rejects the upsert,+-- so the stream_upsert CTE produces no rows). The interpreter maps @Nothing@+-- to @StreamNotFound@ for symmetry with @appendAnyVersion@'s soft-deleted+-- behavior added in EP-1 F2.+linkToStreamStmt :: Statement (Vector UUID, Text) (Maybe LinkResult)+linkToStreamStmt =+ preparable+ linkToStreamSQL+ linkEncoder+ linkResultDecoder++linkEncoder :: E.Params (Vector UUID, Text)+linkEncoder =+ contrazip2+ (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+ (E.param (E.nonNullable E.text))++linkResultDecoder :: D.Result (Maybe LinkResult)+linkResultDecoder =+ D.rowMaybe $+ LinkResult+ <$> (StreamId <$> D.column (D.nonNullable D.int8))+ <*> (StreamVersion <$> D.column (D.nonNullable D.int8))++linkToStreamSQL :: Text+linkToStreamSQL =+ """+ WITH+ event_list AS (+ SELECT event_id, idx+ FROM unnest($1::uuid[]) WITH ORDINALITY AS t(event_id, idx)+ ),+ stream_upsert AS (+ INSERT INTO streams (stream_name, stream_version)+ VALUES ($2, (SELECT count(*) FROM event_list))+ ON CONFLICT (stream_name)+ DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM event_list)+ WHERE streams.deleted_at IS NULL+ RETURNING stream_id, stream_version - (SELECT count(*) FROM event_list) AS initial_version+ ),+ link_inserts AS (+ -- LEFT JOIN LATERAL surfaces missing-event rows as NULLs for original_*; the+ -- NOT NULL constraint on stream_events.original_stream_id then aborts the+ -- entire CTE, rolling back the stream_upsert's version bump. Before the F3+ -- fix this was a plain JOIN LATERAL, which silently dropped missing-event+ -- rows while still bumping stream_version → silent gap in the link target.+ INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT el.event_id, su.stream_id, su.initial_version + el.idx,+ orig.original_stream_id, orig.original_stream_version+ FROM event_list el+ CROSS JOIN stream_upsert su+ LEFT JOIN LATERAL (+ SELECT se.original_stream_id, se.original_stream_version+ FROM stream_events se+ WHERE se.event_id = el.event_id AND se.stream_id <> 0+ LIMIT 1+ ) orig ON true+ )+ SELECT su.stream_id, su.initial_version + (SELECT count(*) FROM event_list)+ FROM stream_upsert su+ """++-- ---------------------------------------------------------------------------+-- Category Read Statements+-- ---------------------------------------------------------------------------++-- | Read events from streams matching a category, in global position order.+--+-- Scans @ix_stream_events_all_by_category@ from @(category, startPosition)@ and+-- stops at the limit, so a poll's cost follows the rows it returns, not the+-- number of streams in the category (BUG-2). The @category@ column on @$all@+-- junction rows is written by the append statements (migration @0012@). Params:+-- @(startPosition, category, limit)@.+readCategoryForwardStmt :: Statement (Int64, Text, Int32) (Vector RecordedEvent)+readCategoryForwardStmt =+ preparable+ readCategoryForwardSQL+ readCategoryEncoder+ (D.rowVector recordedEventRow)++readCategoryEncoder :: E.Params (Int64, Text, Int32)+readCategoryEncoder =+ contrazip3+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))++readCategoryForwardSQL :: Text+readCategoryForwardSQL =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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 = 0+ AND se.category = $2+ AND se.stream_version > $1+ ORDER BY se.stream_version ASC+ LIMIT $3+ """++-- ---------------------------------------------------------------------------+-- Consumer-Group Read Statements+-- ---------------------------------------------------------------------------++-- | Partition-filtered category read for one consumer-group member.+--+-- Mirrors 'readCategoryForwardStmt' but returns only events whose originating+-- stream is assigned to member @$3@ of a group of size @$4@. The assignment rule+-- (MasterPlan IP-1) hashes the originating stream's surrogate id and folds the+-- signed result into @[0, size)@:+--+-- @member_of(stream_id) = (((hashtextextended(stream_id::text, 0) % size) + size) % size)@+--+-- The read scans @ix_stream_events_all_by_category@ from+-- @(category, startPosition)@ and applies the predicate to+-- @se.original_stream_id@, which the index carries as an @INCLUDE@ column, so+-- other members' rows are skipped on index tuples. A poll touches about+-- @limit * size@ index entries at most, never one probe per stream in the+-- category (BUG-2). Params: @(startPosition, category, member, size, limit)@.+readCategoryForwardConsumerGroupStmt ::+ Statement (Int64, Text, Int32, Int32, Int32) (Vector RecordedEvent)+readCategoryForwardConsumerGroupStmt =+ preparable+ readCategoryForwardConsumerGroupSQL+ readCategoryConsumerGroupEncoder+ (D.rowVector recordedEventRow)++readCategoryConsumerGroupEncoder :: E.Params (Int64, Text, Int32, Int32, Int32)+readCategoryConsumerGroupEncoder =+ contrazip5+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.int4))++readCategoryForwardConsumerGroupSQL :: Text+readCategoryForwardConsumerGroupSQL =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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 = 0+ AND se.category = $2+ AND se.stream_version > $1+ AND (((hashtextextended(se.original_stream_id::text, 0) % $4) + $4) % $4) = $3+ ORDER BY se.stream_version ASC+ LIMIT $5+ """++-- | Partition-filtered @$all@ read for one consumer-group member.+--+-- Mirrors 'readAllForwardStmt' but returns only events whose originating stream is+-- assigned to member @$2@ of a group of size @$3@, using the same MasterPlan IP-1+-- rule as 'readCategoryForwardConsumerGroupStmt'. The predicate is applied to+-- @se.original_stream_id@ — the real originating stream of each @$all@ junction row+-- (never the reserved id 0 for normal appends). Params:+-- @(startPosition, member, size, limit)@.+readAllForwardConsumerGroupStmt ::+ Statement (Int64, Int32, Int32, Int32) (Vector RecordedEvent)+readAllForwardConsumerGroupStmt =+ preparable+ readAllForwardConsumerGroupSQL+ readAllConsumerGroupEncoder+ (D.rowVector recordedEventRow)++readAllConsumerGroupEncoder :: E.Params (Int64, Int32, Int32, Int32)+readAllConsumerGroupEncoder =+ contrazip4+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.int4))++readAllForwardConsumerGroupSQL :: Text+readAllForwardConsumerGroupSQL =+ """+ SELECT e.event_id, e.event_type,+ se.stream_version, se.stream_version AS global_position,+ se.original_stream_id, se.original_stream_version,+ 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 = 0+ AND se.stream_version > $1+ AND (((hashtextextended(se.original_stream_id::text, 0) % $3) + $3) % $3) = $2+ ORDER BY se.stream_version ASC+ LIMIT $4+ """++-- | One bounded global scan, including progress when there are no matches.+-- The payloads and frontier come from one statement snapshot.+data NamespaceWindow = NamespaceWindow+ { namespaceEvents :: !(Vector RecordedEvent),+ scannedThrough :: !GlobalPosition,+ scannedCount :: !Int32,+ exhausted :: !Bool+ }+ deriving stock (Show)++-- | @(exclusive cursor, captured head, literal namespace, window size)@.+readNamespaceWindowStmt :: Statement (Int64, Int64, Text, Int32) NamespaceWindow+readNamespaceWindowStmt =+ preparable+ (namespaceWindowSQL False)+ (contrazip4 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)))+ namespaceWindowDecoder++-- | Group filtering happens after bounding the scan, using the originating id.+readNamespaceGroupWindowStmt :: Statement (Int64, Int64, Text, Int32, Int32, Int32) NamespaceWindow+readNamespaceGroupWindowStmt =+ preparable+ (namespaceWindowSQL True)+ (contrazip6 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int4)))+ namespaceWindowDecoder++namespaceWindowSQL :: Bool -> Text+namespaceWindowSQL grouped =+ """+ WITH scanned AS MATERIALIZED (+ SELECT event_id, stream_version, original_stream_id, original_stream_version, category+ FROM stream_events+ -- stream_id is integral: this range selects exactly $all. Keeping both+ -- index keys in the ordering prevents custom tail plans from choosing+ -- the partial category index and sorting an unrestricted metadata scan.+ WHERE stream_id >= 0 AND stream_id < 1 AND stream_version > $1+ ORDER BY stream_id, stream_version LIMIT $4+ ), eligible AS MATERIALIZED (+ SELECT * FROM scanned WHERE stream_version <= $2+ ), progress AS (+ SELECT count(*)::int4 AS scanned_count,+ count(*) < $4 OR coalesce(max(stream_version) = $2, false) AS exhausted,+ CASE WHEN count(*) < $4 OR coalesce(max(stream_version) = $2, false)+ THEN $2 ELSE max(stream_version) END AS frontier+ FROM eligible+ ), matching AS MATERIALIZED (+ SELECT * FROM eligible+ WHERE (category COLLATE "C" = $3 OR starts_with(category COLLATE "C", $3 || ':'))+ """+ <> (if grouped then " AND (((hashtextextended(original_stream_id::text, 0) % $6) + $6) % $6) = $5" else "")+ <> """+ )+ , payloads AS MATERIALIZED (+ SELECT m.stream_version,+ ROW(e.event_id, e.event_type, m.stream_version, m.stream_version,+ m.original_stream_id, m.original_stream_version,+ e.data, e.metadata, e.causation_id, e.correlation_id, e.created_at) AS event+ FROM matching m+ -- OFFSET 0 keeps event-id probes behind matching in generic plans.+ LEFT JOIN LATERAL (SELECT * FROM events WHERE event_id = m.event_id OFFSET 0) e ON true+ )+ SELECT p.frontier, p.scanned_count, p.exhausted, payloads.event+ FROM progress p+ -- Generate the sentinel after payload probing, so an empty matching set+ -- never invokes even a zero-I/O NULL event-id lookup.+ LEFT JOIN payloads ON true+ ORDER BY payloads.stream_version+ """++-- The sentinel's NULL record is explicit. A selected junction without a payload+-- produces a non-null record with null required fields and fails decoding.+namespaceWindowDecoder :: D.Result NamespaceWindow+namespaceWindowDecoder = assemble <$> D.rowVector row+ where+ row =+ (,,,)+ <$> (GlobalPosition <$> D.column (D.nonNullable D.int8))+ <*> D.column (D.nonNullable D.int4)+ <*> D.column (D.nonNullable D.bool)+ <*> D.column (D.nullable (D.record eventRecord))+ assemble rows =+ let (frontier, count, done, _) = V.head rows+ in NamespaceWindow (V.fromList (mapMaybe (\(_, _, _, event) -> event) (V.toList rows))) frontier count done+ eventRecord =+ RecordedEvent+ <$> (EventId <$> D.field (D.nonNullable D.uuid))+ <*> (EventType <$> D.field (D.nonNullable D.text))+ <*> (StreamVersion <$> D.field (D.nonNullable D.int8))+ <*> (GlobalPosition <$> D.field (D.nonNullable D.int8))+ <*> (StreamId <$> D.field (D.nonNullable D.int8))+ <*> (StreamVersion <$> D.field (D.nonNullable D.int8))+ <*> D.field (D.nonNullable D.jsonb)+ <*> D.field (D.nullable D.jsonb)+ <*> D.field (D.nullable D.uuid)+ <*> D.field (D.nullable D.uuid)+ <*> D.field (D.nonNullable D.timestamptz)++-- ---------------------------------------------------------------------------+-- Lifecycle Statements+-- ---------------------------------------------------------------------------++-- | Soft-delete a stream by setting deleted_at. Returns Nothing if stream doesn't exist or is already deleted.+softDeleteStreamStmt :: Statement Text (Maybe StreamId)+softDeleteStreamStmt =+ preparable+ softDeleteStreamSQL+ (E.param (E.nonNullable E.text))+ (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))++-- | Undelete a soft-deleted stream by clearing deleted_at. Returns Nothing if stream doesn't exist or is not deleted.+undeleteStreamStmt :: Statement Text (Maybe StreamId)+undeleteStreamStmt =+ preparable+ undeleteStreamSQL+ (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.+findStreamIdStmt :: Statement Text (Maybe Int64)+findStreamIdStmt =+ preparable+ "SELECT stream_id FROM streams WHERE stream_name = $1"+ (E.param (E.nonNullable E.text))+ (D.rowMaybe (D.column (D.nonNullable D.int8)))++-- | Resolve a batch of surrogate stream ids to their (id, name) pairs in one+-- round trip. Ids that do not name an existing stream simply produce no row, so+-- the caller's 'Data.Map.Strict.Map' omits them. Surfaced as+-- 'Kiroku.Store.Read.lookupStreamNames'.+lookupStreamNamesStmt :: Statement [Int64] (Vector (Int64, Text))+lookupStreamNamesStmt =+ preparable+ "SELECT stream_id, stream_name FROM streams WHERE stream_id = ANY($1::bigint[])"+ (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int8))))+ ( D.rowVector+ ( (,)+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.text)+ )+ )++-- | Delete the @$all@ junction rows for events that originated in the target+-- stream, returning exactly those originated event ids.+--+-- 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 caller then passes the returned ids to 'deleteJunctionsByEventIdsStmt' to+-- remove the remaining home/link junction rows for those originated events.+deleteAllRowsForOriginStmt :: Statement Int64 (Vector UUID)+deleteAllRowsForOriginStmt =+ preparable+ """+ 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).+--+-- 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 =+ preparable+ """+ DELETE FROM events+ WHERE event_id = ANY($1::uuid[])+ AND NOT EXISTS (+ SELECT 1 FROM stream_events+ WHERE stream_events.event_id = events.event_id+ )+ """+ (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+ D.noResult++-- | Delete the @streams@ row for the given stream id. Used as the final step+-- of hard-delete after junction rows and orphan events are cleared.+deleteStreamRowStmt :: Statement Int64 ()+deleteStreamRowStmt =+ preparable+ "DELETE FROM streams WHERE stream_id = $1"+ (E.param (E.nonNullable E.int8))+ D.noResult++-- | Pre-acquire row locks on the named streams in deterministic (stream_id)+-- order. Used by AppendMultiStream to avoid row-lock deadlocks between+-- concurrent multi-stream transactions that touch overlapping streams in+-- different orders.+--+-- \$all is intentionally NOT included in the pre-lock — its row lock is+-- acquired by each per-stream CTE inside the transaction, after the source+-- stream's row lock. See EP-1 F4.+--+-- Streams that don't yet exist (NoStream variant on a fresh stream) are not+-- matched by the WHERE clause, so they aren't pre-locked here. Concurrent+-- INSERTs of a fresh stream do serialize on the unique index on+-- @stream_name@, but that serialization does /not/ preserve the+-- source-before-$all acquisition order this pre-lock exists to establish,+-- and a multi-stream append can still deadlock against a concurrent+-- single-stream append:+--+-- * multi locks stream A, then @$all@ (both inside A's CTE), then stream B;+-- * a single-stream append to the still-fresh B locks B, then wants @$all@.+--+-- Each then waits on the other. PostgreSQL detects it and aborts one side+-- with @40P01@; the interpreter retries the append once, and a repeated+-- conflict reaches the caller as+-- 'Kiroku.Store.Error.TransientTransactionFailure', which is documented+-- retryable. Eliminating the cycle would mean establishing every source+-- lock before any @$all@ lock, including for streams that do not exist yet+-- — tracked as IR-7, deliberately not done here because it is a throughput+-- question that needs benchmarking, not a correctness gap.+lockStreamsForMultiStmt :: Statement (Vector Text) ()+lockStreamsForMultiStmt =+ preparable+ """+ SELECT 1 FROM streams+ WHERE stream_name = ANY($1::text[])+ ORDER BY stream_id+ FOR UPDATE+ """+ (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+ D.noResult++-- ---------------------------------------------------------------------------+-- Lifecycle SQL Templates+-- ---------------------------------------------------------------------------++softDeleteStreamSQL :: Text+softDeleteStreamSQL =+ """+ UPDATE streams+ SET deleted_at = now()+ WHERE stream_name = $1+ AND deleted_at IS NULL+ RETURNING stream_id+ """++undeleteStreamSQL :: Text+undeleteStreamSQL =+ """+ UPDATE streams+ 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+ """++-- ---------------------------------------------------------------------------+-- Checkpoint Statements+-- ---------------------------------------------------------------------------++-- | Read the last-seen global position for a subscription.+getCheckpointStmt :: Statement Text (Maybe Int64)+getCheckpointStmt =+ preparable+ getCheckpointSQL+ (E.param (E.nonNullable E.text))+ (D.rowMaybe (D.column (D.nonNullable D.int8)))++-- | Upsert a checkpoint: insert or update the last-seen position.+saveCheckpointStmt :: Statement (Text, Int64) ()+saveCheckpointStmt =+ preparable+ saveCheckpointSQL+ ( contrazip2+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int8))+ )+ D.noResult++getCheckpointSQL :: Text+getCheckpointSQL =+ """+ SELECT last_seen+ FROM subscriptions+ WHERE subscription_name = $1+ """++saveCheckpointSQL :: Text+saveCheckpointSQL =+ """+ INSERT INTO subscriptions (subscription_name, last_seen, updated_at)+ VALUES ($1, $2, now())+ ON CONFLICT (subscription_name, consumer_group_member)+ DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now()+ """++-- | Read the last-seen position for one consumer-group member of a subscription.+getCheckpointMemberStmt :: Statement (Text, Int32) (Maybe Int64)+getCheckpointMemberStmt =+ preparable+ getCheckpointMemberSQL+ ( contrazip2+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ )+ (D.rowMaybe (D.column (D.nonNullable D.int8)))++-- | Upsert the last-seen position for one consumer-group member, keyed on the+-- composite @(subscription_name, consumer_group_member)@ unique index. Uses the+-- same @GREATEST(...)@ monotonicity as 'saveCheckpointStmt' so a save never moves+-- a member's checkpoint backward.+saveCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text, Maybe Text) ()+saveCheckpointMemberStmt =+ preparable+ saveCheckpointMemberSQL+ ( contrazip6+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.text))+ (E.param (E.nullable E.text))+ )+ D.noResult++-- | Bound targets use a fixed kind rather than encoding a constant per save.+saveAllCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32) ()+saveAllCheckpointMemberStmt =+ preparable+ (saveCheckpointMemberSQLWith "'all', NULL")+ (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))+ D.noResult++saveCategoryCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()+saveCategoryCheckpointMemberStmt =+ preparable+ (saveCheckpointMemberSQLWith "'category', $5")+ (contrazip5 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.text)))+ D.noResult++-- | Namespace saves keep the existing all/category hot statements unchanged.+saveNamespaceCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()+saveNamespaceCheckpointMemberStmt =+ preparable+ (saveCheckpointMemberSQLWith "'category_namespace', $5")+ (contrazip5 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.text)))+ D.noResult++getCheckpointMemberSQL :: Text+getCheckpointMemberSQL =+ """+ SELECT last_seen+ FROM subscriptions+ WHERE subscription_name = $1+ AND consumer_group_member = $2+ """++saveCheckpointMemberSQL :: Text+saveCheckpointMemberSQL = saveCheckpointMemberSQLWith "$5, $6"++saveCheckpointMemberSQLWith :: Text -> Text+saveCheckpointMemberSQLWith binding =+ "INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category) VALUES ($1, $2, $3, now(), $4, "+ <> binding+ <> ") "+ <> """+ ON CONFLICT (subscription_name, consumer_group_member)+ DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category+ """++-- ---------------------------------------------------------------------------+-- Dead-letter Statements+-- ---------------------------------------------------------------------------++-- | Parameters for 'insertDeadLetterAndCheckpointStmt'.+data DeadLetterParams = DeadLetterParams+ { dlSubscriptionName :: !Text,+ dlMember :: !Int32,+ dlTargetKind :: !Text,+ dlTargetCategory :: !(Maybe Text),+ dlGroupSize :: !Int32,+ dlGlobalPosition :: !Int64,+ dlEventId :: !UUID,+ -- | structured JSONB reason (@reason@ column)+ dlReason :: !Value,+ -- | operator-facing summary (@reason_summary@ column)+ dlReasonSummary :: !Text,+ dlAttemptCount :: !Int32+ }+ deriving stock (Show, Generic)++-- | A row of @kiroku.dead_letters@, returned by 'readDeadLettersStmt'.+data DeadLetterRecord = DeadLetterRecord+ { deadLetterGlobalPosition :: !Int64,+ deadLetterEventId :: !UUID,+ deadLetterReason :: !Value,+ deadLetterReasonSummary :: !Text,+ deadLetterAttemptCount :: !Int32,+ deadLetterCreatedAt :: !UTCTime+ }+ deriving stock (Show, Generic)++deadLetterParamsEncoder :: E.Params DeadLetterParams+deadLetterParamsEncoder =+ ((^. #dlSubscriptionName) >$< E.param (E.nonNullable E.text))+ <> ((^. #dlMember) >$< E.param (E.nonNullable E.int4))+ <> ((^. #dlGlobalPosition) >$< E.param (E.nonNullable E.int8))+ <> ((^. #dlEventId) >$< E.param (E.nonNullable E.uuid))+ <> ((^. #dlReason) >$< E.param (E.nonNullable E.jsonb))+ <> ((^. #dlReasonSummary) >$< E.param (E.nonNullable E.text))+ <> ((^. #dlAttemptCount) >$< E.param (E.nonNullable E.int4))+ <> ((^. #dlGroupSize) >$< E.param (E.nonNullable E.int4))+ <> ((^. #dlTargetKind) >$< E.param (E.nonNullable E.text))+ <> ((^. #dlTargetCategory) >$< E.param (E.nullable E.text))++-- | Atomically record an event in @kiroku.dead_letters@ and advance the+-- subscription's checkpoint past it, in a single statement.+--+-- The dead-letter insert and the checkpoint upsert run in one round trip: a+-- data-modifying CTE performs the insert (idempotent via @ON CONFLICT DO NOTHING@+-- on the natural key), and the final statement upserts the checkpoint with the+-- same @GREATEST(...)@ monotonicity as 'saveCheckpointMemberStmt'. If the insert+-- fails the whole statement aborts and the checkpoint does not advance, satisfying+-- the dead-letter atomicity criterion. The checkpoint reuses @$1@/@$2@/@$3@+-- (name / member / global_position) from the dead-letter params.+insertDeadLetterAndCheckpointStmt :: Statement DeadLetterParams ()+insertDeadLetterAndCheckpointStmt =+ preparable+ insertDeadLetterAndCheckpointSQL+ deadLetterParamsEncoder+ D.noResult++insertDeadLetterAndCheckpointSQL :: Text+insertDeadLetterAndCheckpointSQL =+ """+ WITH dl AS (+ INSERT INTO dead_letters+ (subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count)+ VALUES ($1, $2, $3, $4, $5, $6, $7)+ ON CONFLICT (subscription_name, consumer_group_member, global_position, event_id) DO NOTHING+ )+ INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)+ VALUES ($1, $2, $3, now(), $8, $9, $10)+ ON CONFLICT (subscription_name, consumer_group_member)+ DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category+ """++-- | Read the dead letters recorded for one subscription member, newest first.+readDeadLettersStmt :: Statement (Text, Int32) (Vector DeadLetterRecord)+readDeadLettersStmt =+ preparable+ readDeadLettersSQL+ ( contrazip2+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ )+ (D.rowVector deadLetterRecordRow)++deadLetterRecordRow :: D.Row DeadLetterRecord+deadLetterRecordRow =+ DeadLetterRecord+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.uuid)+ <*> D.column (D.nonNullable D.jsonb)+ <*> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.int4)+ <*> D.column (D.nonNullable D.timestamptz)++readDeadLettersSQL :: Text+readDeadLettersSQL =+ """+ SELECT global_position, event_id, reason, reason_summary, attempt_count, created_at+ FROM dead_letters+ WHERE subscription_name = $1+ AND consumer_group_member = $2+ ORDER BY global_position DESC, dead_letter_id DESC+ """++-- Catalog statements have a finite family of prepared shapes. Upper bounds+-- are deliberately checked after the ordered LIMIT, keeping generic plans+-- from bitmap-scanning and sorting an entire matching prefix.+data StreamRangeBound = InclusiveLower | ExclusiveLower | ExactName+ deriving stock (Eq, Show)++streamColumns :: Text+streamColumns = "stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before"++streamRangeSQL :: StreamRangeBound -> Text -> Text -> Text -> Text+streamRangeSQL bound lower upper limit =+ "SELECT * FROM (SELECT "+ <> streamColumns+ <> " FROM streams WHERE stream_id <> 0 AND stream_name COLLATE \"C\" "+ <> operator+ <> " "+ <> lower+ <> " ORDER BY stream_name COLLATE \"C\" LIMIT "+ <> limit+ <> ") bounded WHERE ("+ <> upper+ <> "::text IS NULL OR stream_name COLLATE \"C\" < "+ <> upper+ <> ")"+ where+ operator = case bound of InclusiveLower -> ">="; ExclusiveLower -> ">"; ExactName -> "="++listStreamsRangeStmt :: StreamRangeBound -> Statement (Text, Maybe Text, Int32) (Vector StreamInfo)+listStreamsRangeStmt bound =+ preparable+ (streamRangeSQL bound "$1" "$2" "$3" <> " ORDER BY stream_name COLLATE \"C\"")+ (contrazip3 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))+ (D.rowVector streamInfoRow)++listStreamsPairStmt :: StreamRangeBound -> StreamRangeBound -> Statement (Text, Maybe Text, Text, Maybe Text, Int32) (Vector StreamInfo)+listStreamsPairStmt first second =+ preparable+ ( "SELECT * FROM (("+ <> streamRangeSQL first "$1" "$2" "$5"+ <> ") UNION ALL ("+ <> streamRangeSQL second "$3" "$4" "$5"+ <> ")) matching ORDER BY stream_name COLLATE \"C\" LIMIT $5"+ )+ ( contrazip5+ (E.param (E.nonNullable E.text))+ (E.param (E.nullable E.text))+ (E.param (E.nonNullable E.text))+ (E.param (E.nullable E.text))+ (E.param (E.nonNullable E.int4))+ )+ (D.rowVector streamInfoRow)++-- | One statement and one checkout, including the bare-category/descendant union.+-- All text is encoded as parameters; only closed operator choices build SQL.+listStreamsSession :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Session.Session (Vector StreamInfo)+listStreamsSession category prefix after page = case ranges of+ [] -> pure mempty+ [(kind, lower, upper)] -> Session.statement (lower, upper, limit) (listStreamsRangeStmt kind)+ [(kind, lower, upper), (kind2, lower2, upper2)] ->+ Session.statement (lower, upper, lower2, upper2, limit) (listStreamsPairStmt kind kind2)+ _ -> error "catalog range invariant: at most two disjoint ranges"+ where+ limit = browsePageSizeValue page+ cursor = fmap (\(StreamName value) -> value) after+ ranges = case category of+ Nothing -> maybe [] pure (interval "" Nothing)+ Just (CategoryName value)+ | T.any (== '-') value -> []+ | otherwise -> bare value <> maybe [] pure (interval (value <> "-") (prefixEnd (value <> "-")))+ bare value+ | maybe True (`T.isPrefixOf` value) prefix && maybe True (< value) cursor = [(ExactName, value, Nothing)]+ | otherwise = []+ interval start end =+ let lower = max start (maybe "" (\value -> value) prefix)+ upper = minimumEnd end (prefix >>= prefixEnd)+ (kind, seek) = case cursor of+ Just value | value >= lower -> (ExclusiveLower, value)+ _ -> (InclusiveLower, lower)+ in if maybe False (<= seek) upper then Nothing else Just (kind, seek, upper)+ minimumEnd Nothing b = b+ minimumEnd a Nothing = a+ minimumEnd (Just a) (Just b) = Just (min a b)++-- Valid UTF-8 byte order follows Unicode scalar order. The all-maximum and+-- empty prefixes have no finite upper bound; PostgreSQL text has no NUL.+prefixEnd :: Text -> Maybe Text+prefixEnd value = case T.unsnoc value of+ Nothing -> Nothing+ Just (initial, lastChar)+ | ord lastChar == 0x10ffff -> prefixEnd initial+ | otherwise -> Just (T.snoc initial (chr (if ord lastChar + 1 == 0xd800 then 0xe000 else ord lastChar + 1)))++-- First and later category pages have separate cursor shapes. Category order+-- retains the deployment collation and its existing category index.+listCategoriesStmt :: Bool -> Statement (Maybe Text, Int32) (Vector Text)+listCategoriesStmt after =+ preparable+ query+ (contrazip2 (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))+ (D.rowVector (D.column (D.nonNullable D.text)))+ where+ first = if after then "AND s.category > $1" else "AND $1::text IS NULL"+ query =+ "WITH RECURSIVE next_category AS (SELECT (SELECT s.category FROM streams s WHERE s.stream_id <> 0 "+ <> first+ <> " ORDER BY s.category LIMIT 1) AS category UNION ALL SELECT (SELECT s.category FROM streams s "+ <> "WHERE s.stream_id <> 0 AND s.category > n.category ORDER BY s.category LIMIT 1) "+ <> "FROM next_category n WHERE n.category IS NOT NULL) SELECT category FROM next_category "+ <> "WHERE category IS NOT NULL LIMIT $2"++getEventStmt :: Statement UUID (Maybe RecordedEvent)+getEventStmt =+ preparable+ "SELECT e.event_id,e.event_type,se.stream_version,se.stream_version AS global_position, se.original_stream_id,se.original_stream_version,e.data,e.metadata,e.causation_id,e.correlation_id,e.created_at FROM events e JOIN stream_events se ON se.event_id=e.event_id AND se.stream_id=0 WHERE e.event_id=$1"+ (E.param (E.nonNullable E.uuid))+ (D.rowMaybe recordedEventRow)
src/Kiroku/Store/Settings.hs view
@@ -1,44 +1,43 @@-{- | Interpreter-level hooks applied to 'EventData' before encoding on-the append path and to 'RecordedEvent' after decoding on the read and-subscription paths.--The hook seam lives inside 'Kiroku.Store.Effect.runStorePool' (and the-subscription publisher\/worker) rather than at the SQL encoder\/decoder-layer so the hook sees the typed value — payload and metadata as-'Data.Aeson.Value', the event type, ids — and can branch on event type-or mutate structured JSON. Plumbing it at the encoder layer would force-hooks to operate on opaque bytes.--Both fields default to 'Nothing'. With the defaults, the helpers below-retain the original event list/vector without traversal. Subscription-decoding adds one batch constructor, with no per-event wrappers.--A typical use case is enriching every appended event with an-OpenTelemetry trace context drawn from the calling thread:--@-storeSettings = 'defaultStoreSettings'- { 'enrichEvent' = Just $ \\ed -> do- ctx <- captureCurrentSpan -- OpenTelemetry, OTLP, whatever- pure (ed & #metadata %~ injectTraceContext ctx)- , 'decodeHook' = Just $ \\re ->- pure (Right (re & #metadata %~ Just . redactPII))- }-@--Wire the resulting 'StoreSettings' into-'Kiroku.Store.Connection.ConnectionSettings' via its @storeSettings@-field; 'Kiroku.Store.Connection.withStore' copies it onto the-'Kiroku.Store.Connection.KirokuStore' handle for the interpreter to-reach.--Direct callers of 'Kiroku.Store.Transaction.appendToStreamTx' bypass-'runStorePool' and therefore the 'enrichEvent' hook. Use-'Kiroku.Store.Transaction.enrichEventsIO' to opt in to enrichment-manually before constructing the prepared event list.--}-module Kiroku.Store.Settings (- StoreSettings (..),+-- | Interpreter-level hooks applied to 'EventData' before encoding on+-- the append path and to 'RecordedEvent' after decoding on the read and+-- subscription paths.+--+-- The hook seam lives inside 'Kiroku.Store.Effect.runStorePool' (and the+-- subscription publisher\/worker) rather than at the SQL encoder\/decoder+-- layer so the hook sees the typed value — payload and metadata as+-- 'Data.Aeson.Value', the event type, ids — and can branch on event type+-- or mutate structured JSON. Plumbing it at the encoder layer would force+-- hooks to operate on opaque bytes.+--+-- Both fields default to 'Nothing'. With the defaults, the helpers below+-- retain the original event list/vector without traversal. Subscription+-- decoding adds one batch constructor, with no per-event wrappers.+--+-- A typical use case is enriching every appended event with an+-- OpenTelemetry trace context drawn from the calling thread:+--+-- @+-- storeSettings = 'defaultStoreSettings'+-- { 'enrichEvent' = Just $ \\ed -> do+-- ctx <- captureCurrentSpan -- OpenTelemetry, OTLP, whatever+-- pure (ed & #metadata %~ injectTraceContext ctx)+-- , 'decodeHook' = Just $ \\re ->+-- pure (Right (re & #metadata %~ Just . redactPII))+-- }+-- @+--+-- Wire the resulting 'StoreSettings' into+-- 'Kiroku.Store.Connection.ConnectionSettings' via its @storeSettings@+-- field; 'Kiroku.Store.Connection.withStore' copies it onto the+-- 'Kiroku.Store.Connection.KirokuStore' handle for the interpreter to+-- reach.+--+-- Direct callers of 'Kiroku.Store.Transaction.appendToStreamTx' bypass+-- 'runStorePool' and therefore the 'enrichEvent' hook. Use+-- 'Kiroku.Store.Transaction.enrichEventsIO' to opt in to enrichment+-- manually before constructing the prepared event list.+module Kiroku.Store.Settings+ ( StoreSettings (..), defaultStoreSettings, enrichEvents, DecodeFailure (..),@@ -50,7 +49,8 @@ decodedBatchLength, decodedBatchLastEvent, filterDecodedBatch,-) where+ )+where import Data.Text (Text) import Data.Vector (Vector)@@ -58,108 +58,102 @@ import GHC.Generics (Generic) import Kiroku.Store.Types (EventData, EventId, RecordedEvent) -{- | A hook could not decode this event. Return it through 'Left'; throwing-from the hook remains a programming error. Default retry exhaustion is surfaced-by SubscriptionUndecodable, carrying this failure.--}+-- | A hook could not decode this event. Return it through 'Left'; throwing+-- from the hook remains a programming error. Default retry exhaustion is surfaced+-- by SubscriptionUndecodable, carrying this failure. data DecodeFailure = DecodeFailure- { decodeFailureEventId :: !EventId- , decodeFailureReason :: !Text- }- deriving stock (Eq, Show, Generic)+ { decodeFailureEventId :: !EventId,+ decodeFailureReason :: !Text+ }+ deriving stock (Eq, Show, Generic) -- | A transformed event, or its raw value retained for retry and disposition. data DecodedEvent- = Decoded !RecordedEvent- | Undecodable !RecordedEvent !DecodeFailure- deriving stock (Eq, Show)+ = Decoded !RecordedEvent+ | Undecodable !RecordedEvent !DecodeFailure+ deriving stock (Eq, Show) -{- | No hook means no traversal or per-event wrappers. Hook results are shared-by all live publisher subscribers, whose retry/disposition is independent.--}+-- | No hook means no traversal or per-event wrappers. Hook results are shared+-- by all live publisher subscribers, whose retry/disposition is independent. data DecodedBatch- = UnchangedBatch !(Vector RecordedEvent)- | TransformedBatch !(Vector DecodedEvent)- deriving stock (Eq, Show)+ = UnchangedBatch !(Vector RecordedEvent)+ | TransformedBatch !(Vector DecodedEvent)+ deriving stock (Eq, Show) decodedEventRecorded :: DecodedEvent -> RecordedEvent decodedEventRecorded = \case- Decoded event -> event- Undecodable event _ -> event+ Decoded event -> event+ Undecodable event _ -> event decodedBatchLength :: DecodedBatch -> Int decodedBatchLength = \case- UnchangedBatch events -> V.length events- TransformedBatch events -> V.length events+ UnchangedBatch events -> V.length events+ TransformedBatch events -> V.length events decodedBatchLastEvent :: DecodedBatch -> RecordedEvent decodedBatchLastEvent = \case- UnchangedBatch events -> V.last events- TransformedBatch events -> decodedEventRecorded (V.last events)+ UnchangedBatch events -> V.last events+ TransformedBatch events -> decodedEventRecorded (V.last events) filterDecodedBatch :: (RecordedEvent -> Bool) -> DecodedBatch -> DecodedBatch filterDecodedBatch predicate = \case- UnchangedBatch events -> UnchangedBatch (V.filter predicate events)- TransformedBatch events -> TransformedBatch (V.filter (predicate . decodedEventRecorded) events)--{- | Interpreter-level hooks for cross-cutting concerns at the-event-data boundary. All fields default to 'Nothing' (no-op).--* 'enrichEvent' fires on the append path before the SQL encoder runs,- on the typed 'EventData' the caller supplied. Used to inject trace- contexts, attach tenant ids, or encrypt payloads.--* 'decodeHook' fires on the read and subscription paths after the SQL- decoder runs, on the typed 'RecordedEvent' about to be surfaced to- the caller. Used to decrypt payloads, redact PII, or attach derived- metadata.+ UnchangedBatch events -> UnchangedBatch (V.filter predicate events)+ TransformedBatch events -> TransformedBatch (V.filter (predicate . decodedEventRecorded) events) -When a field is 'Nothing', reads and enrichment return their input directly.-Subscription decoding retains the vector in one batch constructor without-traversal or per-event wrappers.--}+-- | Interpreter-level hooks for cross-cutting concerns at the+-- event-data boundary. All fields default to 'Nothing' (no-op).+--+-- * 'enrichEvent' fires on the append path before the SQL encoder runs,+-- on the typed 'EventData' the caller supplied. Used to inject trace+-- contexts, attach tenant ids, or encrypt payloads.+--+-- * 'decodeHook' fires on the read and subscription paths after the SQL+-- decoder runs, on the typed 'RecordedEvent' about to be surfaced to+-- the caller. Used to decrypt payloads, redact PII, or attach derived+-- metadata.+--+-- When a field is 'Nothing', reads and enrichment return their input directly.+-- Subscription decoding retains the vector in one batch constructor without+-- traversal or per-event wrappers. data StoreSettings = StoreSettings- { enrichEvent :: !(Maybe (EventData -> IO EventData))- -- ^ Append-path hook. Runs once per appended event before encoding.- , decodeHook :: !(Maybe (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)))- {- ^ Read- and subscription-path hook. Return 'Left' for an undecodable- event: reads fail with a typed store error, subscriptions use their- optional undecodable handler or retry and stop by default. Exceptions- remain programming failures. Runs once per surfaced event; an undecodable- event's retry re-applies the hook to its original value.- -}- }- deriving stock (Generic)+ { -- | Append-path hook. Runs once per appended event before encoding.+ enrichEvent :: !(Maybe (EventData -> IO EventData)),+ -- | Read- and subscription-path hook. Return 'Left' for an undecodable+ -- event: reads fail with a typed store error, subscriptions use their+ -- optional undecodable handler or retry and stop by default. Exceptions+ -- remain programming failures. Runs once per surfaced event; an undecodable+ -- event's retry re-applies the hook to its original value.+ decodeHook :: !(Maybe (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)))+ }+ deriving stock (Generic) -- | Defaults to both hooks being 'Nothing' — semantically a no-op. defaultStoreSettings :: StoreSettings defaultStoreSettings =- StoreSettings- { enrichEvent = Nothing- , decodeHook = Nothing- }+ StoreSettings+ { enrichEvent = Nothing,+ decodeHook = Nothing+ } -{- | Apply 'enrichEvent' to a list of events. When the hook is-'Nothing', returns the list unchanged with no traversal.--}+-- | Apply 'enrichEvent' to a list of events. When the hook is+-- 'Nothing', returns the list unchanged with no traversal. enrichEvents :: StoreSettings -> [EventData] -> IO [EventData] enrichEvents ss xs = case enrichEvent ss of- Nothing -> pure xs- Just f -> traverse f xs+ Nothing -> pure xs+ Just f -> traverse f xs -{- | Apply 'decodeHook' to a vector of events. When the hook is-'Nothing', retains the vector unchanged without traversal or per-event wrappers.--}+-- | Apply 'decodeHook' to a vector of events. When the hook is+-- 'Nothing', retains the vector unchanged without traversal or per-event wrappers. decodeEvents :: StoreSettings -> Vector RecordedEvent -> IO DecodedBatch decodeEvents ss xs = case decodeHook ss of- Nothing -> pure (UnchangedBatch xs)- Just f -> TransformedBatch <$> V.mapM (applyDecode f) xs+ Nothing -> pure (UnchangedBatch xs)+ Just f -> TransformedBatch <$> V.mapM (applyDecode f) xs -- | Re-apply the hook to one raw undecodable event on a subscriber retry. decodeEvent :: StoreSettings -> RecordedEvent -> IO DecodedEvent decodeEvent ss event = case decodeHook ss of- Nothing -> pure (Decoded event)- Just f -> applyDecode f event+ Nothing -> pure (Decoded event)+ Just f -> applyDecode f event applyDecode :: (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)) -> RecordedEvent -> IO DecodedEvent applyDecode f event = either (Undecodable event) Decoded <$> f event
src/Kiroku/Store/Subscription.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Subscription (- -- * Subscribe+module Kiroku.Store.Subscription+ ( -- * Subscribe subscribe, withSubscription, @@ -12,7 +12,8 @@ -- * Types module Kiroku.Store.Subscription.Types,-) where+ )+where import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO)@@ -39,313 +40,314 @@ 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. Resolves the exact @(subscription name, consumer-group member)@ checkpoint.- An existing row always wins. If absent, 'missingCheckpointPolicy' durably- seeds zero, atomically seeds the current @$all@ head, or fails before the- handler runs. A database error 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 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'.-Forgetting to cancel leaks the thread. Prefer 'withSubscription' for any-non-trivial code path; use the bare 'subscribe' only when the caller already-has a structured lifecycle (e.g., the Streamly 'subscriptionStream' bridge).--=== Delivery semantics--Events are delivered __at least once__. The store's checkpoint is advanced-__per batch__, not per event: when the handler returns 'Continue' for every-event in a batch the checkpoint is saved at the batch tail; when the handler-returns 'Stop' for some event the checkpoint is saved at that event. The-following events on the boundary therefore __replay__ on the next-subscription with the same 'Kiroku.Store.Subscription.Types.SubscriptionName':--* The handler returned 'Continue' but the worker was cancelled or the- process crashed before 'saveCheckpoint' completed. The events processed- by the handler are re-delivered.-* The handler was interrupted between events (cancellation, crash) inside- one batch. The events already processed are re-delivered along with the- not-yet-processed ones.-* The publisher could not reach the database for one cycle (transient- pool error). The next cycle re-fetches and re-broadcasts; subscribers- that had already exited catch-up may see those events delivered late- with no checkpoint advance in between, so a subsequent restart replays- them.--Handlers must therefore be idempotent — process a duplicate event without-producing a wrong-on-replay result — or be tolerant of duplicates by some-domain-specific check (e.g., a unique key on the projection table).--=== Failure modes--The 'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait' on the-returned handle resolves with one of:--* @Right ()@ — the handler returned 'Stop' for some event and the worker- exited cleanly. The checkpoint is saved at that event.-* @Left e@ where @e@ is 'Control.Concurrent.Async.AsyncCancelled' — the- caller invoked 'cancel'. No checkpoint advance is guaranteed; events- in flight at cancellation time will replay.-* @Left e@ where @e@ is- 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' — the publisher- 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- 'Kiroku.Store.Subscription.Types.SubscriptionUndecodable' — the default- decode retry policy exhausted. The exception carries 'DecodeFailure';- the checkpoint remains before the failed event. Fix the hook and restart- the same subscription to replay it.-* @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@ where @e@ is- 'Kiroku.Store.Subscription.Types.SubscriptionCheckpointMissing' —- 'Kiroku.Store.Subscription.Types.FailIfMissing' refused an absent exact key.- No checkpoint row was inserted and the handler did not run.-* @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- throws is signalling that the subscription cannot proceed safely.--}+-- | Start a subscription. Returns a handle for cancellation and waiting.+--+-- The subscription spawns a worker thread that:+--+-- 1. Resolves the exact @(subscription name, consumer-group member)@ checkpoint.+-- An existing row always wins. If absent, 'missingCheckpointPolicy' durably+-- seeds zero, atomically seeds the current @$all@ head, or fails before the+-- handler runs. A database error 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. Namespace+-- targets instead capture the committed database head and drain bounded+-- global windows through it, including windows without matching events.+-- 3. Switches to live mode. For 'Kiroku.Store.Subscription.Types.AllStreams'+-- 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'.+-- Forgetting to cancel leaks the thread. Prefer 'withSubscription' for any+-- non-trivial code path; use the bare 'subscribe' only when the caller already+-- has a structured lifecycle (e.g., the Streamly 'subscriptionStream' bridge).+--+-- === Delivery semantics+--+-- Events are delivered __at least once__. The store's checkpoint is advanced+-- __per batch__, not per event: when the handler returns 'Continue' for every+-- event in a batch the checkpoint is saved at the batch tail (or the completed+-- scan frontier for a namespace window); when the handler+-- returns 'Stop' for some event the checkpoint is saved at that event. The+-- following events on the boundary therefore __replay__ on the next+-- subscription with the same 'Kiroku.Store.Subscription.Types.SubscriptionName':+--+-- * The handler returned 'Continue' but the worker was cancelled or the+-- process crashed before 'saveCheckpoint' completed. The events processed+-- by the handler are re-delivered.+-- * The handler was interrupted between events (cancellation, crash) inside+-- one batch. The events already processed are re-delivered along with the+-- not-yet-processed ones.+-- * The publisher could not reach the database for one cycle (transient+-- pool error). The next cycle re-fetches and re-broadcasts; subscribers+-- that had already exited catch-up may see those events delivered late+-- with no checkpoint advance in between, so a subsequent restart replays+-- them.+--+-- Handlers must therefore be idempotent — process a duplicate event without+-- producing a wrong-on-replay result — or be tolerant of duplicates by some+-- domain-specific check (e.g., a unique key on the projection table).+--+-- === Failure modes+--+-- The 'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait' on the+-- returned handle resolves with one of:+--+-- * @Right ()@ — the handler returned 'Stop' for some event and the worker+-- exited cleanly. The checkpoint is saved at that event.+-- * @Left e@ where @e@ is 'Control.Concurrent.Async.AsyncCancelled' — the+-- caller invoked 'cancel'. No checkpoint advance is guaranteed; events+-- in flight at cancellation time will replay.+-- * @Left e@ where @e@ is+-- 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' — the publisher+-- 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+-- 'Kiroku.Store.Subscription.Types.SubscriptionUndecodable' — the default+-- decode retry policy exhausted. The exception carries 'DecodeFailure';+-- the checkpoint remains before the failed event. Fix the hook and restart+-- the same subscription to replay it.+-- * @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@ where @e@ is+-- 'Kiroku.Store.Subscription.Types.SubscriptionCheckpointMissing' —+-- 'Kiroku.Store.Subscription.Types.FailIfMissing' refused an absent exact key.+-- No checkpoint row was inserted and the handler did not run.+-- * @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+-- throws is signalling that the subscription cannot proceed safely. subscribe :: (MonadIO m) => KirokuStore -> SubscriptionConfig -> m SubscriptionHandle subscribe store config = liftIO $ do- mask $ \_restore ->+ 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)+ -- Plain categories and consumer-group category members both wake+ -- on the category's NOTIFY generation; a member's fetch applies+ -- its partition predicate in SQL.+ (_, Category (CategoryName cat)) ->+ pure (LiveFromCategoryNotify cat, pure ())+ (_, CategoryNamespace namespace) -> do+ (generation, release) <-+ atomically $+ Notifier.registerNamespace (Notifier.namespaceRegistry (store ^. #notifier)) namespace+ pure (LiveFromNamespaceNotify generation, atomically release)+ (Just _, AllStreams) ->+ pure (LiveFromGroupPolling, pure ())+ )+ (\(_, 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- ( 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)- -- Plain categories and consumer-group category members both wake- -- on the category's NOTIFY generation; a member's fetch applies- -- its partition predicate in SQL.- (_, Category (CategoryName cat)) ->- pure (LiveFromCategoryNotify cat, pure ())- (Just _, AllStreams) ->- pure (LiveFromGroupPolling, pure ())- )- (\(_, 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.--Starts a subscription, runs the body, and ensures the underlying worker-thread is cancelled on either normal exit or an exception thrown inside-the body. This is the recommended way to use subscriptions: forgetting-to call 'cancel' on a 'SubscriptionHandle' leaks the worker thread for-the lifetime of the process.--Equivalent to:-- bracket (subscribe store config) cancel action+ (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+ } -but lifted to any 'MonadUnliftIO' so it composes with effectful stacks-that already have an unlift in scope.--}+-- | Bracket-style subscription lifecycle.+--+-- Starts a subscription, runs the body, and ensures the underlying worker+-- thread is cancelled on either normal exit or an exception thrown inside+-- the body. This is the recommended way to use subscriptions: forgetting+-- to call 'cancel' on a 'SubscriptionHandle' leaks the worker thread for+-- the lifetime of the process.+--+-- Equivalent to:+--+-- bracket (subscribe store config) cancel action+--+-- but lifted to any 'MonadUnliftIO' so it composes with effectful stacks+-- that already have an unlift in scope. withSubscription ::- (MonadUnliftIO m) =>- KirokuStore ->- SubscriptionConfig ->- (SubscriptionHandle -> m a) ->- m a+ (MonadUnliftIO m) =>+ KirokuStore ->+ SubscriptionConfig ->+ (SubscriptionHandle -> m a) ->+ m a withSubscription store config action = withRunInIO $ \runInIO ->- bracket (subscribe store config) cancel (runInIO . action)--{- | Read the durable checkpoint inventory and global store position captured by-one PostgreSQL statement snapshot.--An empty 'checkpoints' vector means that no subscription checkpoint has yet-been written. Unlike 'subscriptionStates', stopped subscriptions remain in this-inventory because their committed rows are durable. Rows are sorted by-subscription name and consumer-group member, and a new call is required to-observe commits made after this snapshot.+ bracket (subscribe store config) cancel (runInIO . action) -A live worker cursor may be ahead of its durable checkpoint while work is in-flight. Member zero alone does not reveal whether a subscription is ungrouped-or is member zero of a group. 'checkpointUpdatedAt' records the last successful-checkpoint write, not proof that its position advanced. Finally, subtracting a-'checkpointPosition' from 'storePosition' yields a global position distance,-not an exact count of relevant events for filtered, category, or sharded-consumers.--}+-- | Read the durable checkpoint inventory and global store position captured by+-- one PostgreSQL statement snapshot.+--+-- An empty 'checkpoints' vector means that no subscription checkpoint has yet+-- been written. Unlike 'subscriptionStates', stopped subscriptions remain in this+-- inventory because their committed rows are durable. Rows are sorted by+-- subscription name and consumer-group member, and a new call is required to+-- observe commits made after this snapshot.+--+-- A live worker cursor may be ahead of its durable checkpoint while work is in+-- flight. Member zero alone does not reveal whether a subscription is ungrouped+-- or is member zero of a group. 'checkpointUpdatedAt' records the last successful+-- checkpoint write, not proof that its position advanced. Finally, subtracting a+-- 'checkpointPosition' from 'storePosition' yields a global position distance,+-- not an exact count of relevant events for filtered, category, or sharded+-- consumers. subscriptionCheckpointInventory ::- (HasCallStack, Store :> es) =>- Eff es SubscriptionCheckpointInventory+ (HasCallStack, Store :> es) =>+ Eff es SubscriptionCheckpointInventory subscriptionCheckpointInventory = send GetSubscriptionCheckpointInventory -{- | Resolve the durable checkpoint for one exact @(subscription name,-consumer-group member)@ key.--An existing row is returned unchanged for every policy. When no row exists,-'FromBeginning' atomically inserts position zero, 'FromCurrentHead' atomically-inserts the current @$all@ store position, and 'FailIfMissing' returns-'SubscriptionCheckpointMissing' without inserting. Concurrent initializers-converge on the first committed row.--}+-- | Resolve the durable checkpoint for one exact @(subscription name,+-- consumer-group member)@ key.+--+-- An existing row is returned unchanged for every policy. When no row exists,+-- 'FromBeginning' atomically inserts position zero, 'FromCurrentHead' atomically+-- inserts the current @$all@ store position, and 'FailIfMissing' returns+-- 'SubscriptionCheckpointMissing' without inserting. Concurrent initializers+-- converge on the first committed row. initializeSubscriptionCheckpoint ::- (HasCallStack, Store :> es) =>- SubscriptionName ->- Int32 ->- MissingCheckpointPolicy ->- Eff es (Either SubscriptionCheckpointMissing CheckpointInitialization)+ (HasCallStack, Store :> es) =>+ SubscriptionName ->+ Int32 ->+ MissingCheckpointPolicy ->+ Eff es (Either SubscriptionCheckpointMissing CheckpointInitialization) initializeSubscriptionCheckpoint subscriptionName member policy =- send (InitializeSubscriptionCheckpoint subscriptionName member policy)--{- | A public, point-in-time view of one live subscription's state, as returned-by 'subscriptionStates'. This is the committed observability surface external-consumers (a future Prometheus exporter, an admin tool) read directly; it is-intentionally a flat view, not the internal 'SubscriptionState' cell type.--* 'subscriptionName' / 'member' identify the subscription (member 0 for a- non-group subscription), matching the registry/checkpoint key.-* 'state' is the live 'SubscriptionState' read from the worker's registered cell.-* 'statePhase' is a stable, low-cardinality label (via- 'Kiroku.Store.Subscription.Fsm.stateName') suitable as a metric label value or- admin column; it does not drift if a constructor's fields change.-* 'cursor' is the worker FSM cursor (via 'Kiroku.Store.Subscription.Fsm.stateCursor').- It is the cheap live progress signal for observability, not a guaranteed- durable checkpoint row.+ send (InitializeSubscriptionCheckpoint subscriptionName member policy) -Read fields via the codebase's @generic-lens@ convention, e.g. @view ^. #state@.--}+-- | A public, point-in-time view of one live subscription's state, as returned+-- by 'subscriptionStates'. This is the committed observability surface external+-- consumers (a future Prometheus exporter, an admin tool) read directly; it is+-- intentionally a flat view, not the internal 'SubscriptionState' cell type.+--+-- * 'subscriptionName' / 'member' identify the subscription (member 0 for a+-- non-group subscription), matching the registry/checkpoint key.+-- * 'state' is the live 'SubscriptionState' read from the worker's registered cell.+-- * 'statePhase' is a stable, low-cardinality label (via+-- 'Kiroku.Store.Subscription.Fsm.stateName') suitable as a metric label value or+-- admin column; it does not drift if a constructor's fields change.+-- * 'cursor' is the worker FSM cursor (via 'Kiroku.Store.Subscription.Fsm.stateCursor').+-- It is the cheap live progress signal for observability, not a guaranteed+-- durable checkpoint row.+--+-- Read fields via the codebase's @generic-lens@ convention, e.g. @view ^. #state@. data SubscriptionStateView = SubscriptionStateView- { subscriptionName :: !SubscriptionName- , member :: !Int32- , state :: !SubscriptionState- , statePhase :: !Text- , cursor :: !GlobalPosition- }- deriving stock (Show, Generic)--{- | A near-instant snapshot of every live subscription as a public-'SubscriptionStateView', keyed by (subscription name, consumer-group member;-0 for non-group).--Snapshots the registry's outer map with 'readTVarIO', then reads each registered-state cell with 'readTVarIO' __outside__ STM. This is deliberately not one STM-transaction over all cells: a single transaction would put every subscription's-state cell in the reader's STM read set, and since each worker writes its cell-~once per batch, any such write would force the whole scan to re-run — a-reader-side cost that scales with subscription count. The named consumers (a-Prometheus scrape, an admin listing) read independent values and do not need a-globally atomic snapshot, so each entry is read as its own freshest value.+ { subscriptionName :: !SubscriptionName,+ member :: !Int32,+ state :: !SubscriptionState,+ statePhase :: !Text,+ cursor :: !GlobalPosition+ }+ deriving stock (Show, Generic) -A subscription appears here from the moment 'subscribe' returns its handle until-its worker exits (stop, cancel, or crash), at which point its key is removed; a-stopped/cancelled/crashed subscription is represented by __absence__, never by a-@"stopped"@ phase (the FSM never writes 'Stopped' into the cell). This map of-view records is the committed surface the future Prometheus exporter and admin-tool consume.--}+-- | A near-instant snapshot of every live subscription as a public+-- 'SubscriptionStateView', keyed by (subscription name, consumer-group member;+-- 0 for non-group).+--+-- Snapshots the registry's outer map with 'readTVarIO', then reads each registered+-- state cell with 'readTVarIO' __outside__ STM. This is deliberately not one STM+-- transaction over all cells: a single transaction would put every subscription's+-- state cell in the reader's STM read set, and since each worker writes its cell+-- ~once per batch, any such write would force the whole scan to re-run — a+-- reader-side cost that scales with subscription count. The named consumers (a+-- Prometheus scrape, an admin listing) read independent values and do not need a+-- globally atomic snapshot, so each entry is read as its own freshest value.+--+-- A subscription appears here from the moment 'subscribe' returns its handle until+-- its worker exits (stop, cancel, or crash), at which point its key is removed; a+-- stopped/cancelled/crashed subscription is represented by __absence__, never by a+-- @"stopped"@ phase (the FSM never writes 'Stopped' into the cell). This map of+-- view records is the committed surface the future Prometheus exporter and admin+-- tool consume. subscriptionStates :: KirokuStore -> IO (Map (SubscriptionName, Int32) SubscriptionStateView) subscriptionStates store = do- cells <- readTVarIO (store ^. #subscriptionRegistry)- Map.traverseWithKey- ( \(nm, mbr) (_tok, cell) -> do- st <- readTVarIO cell- pure- SubscriptionStateView- { subscriptionName = nm- , member = mbr- , state = st- , statePhase = stateName st- , cursor = stateCursor st- }- )- cells--{- | Read one subscription's dead letters, newest first by global position and-then dead-letter id. Echo the page's 'nextCursor' as 'after' for an exclusive-next page. 'consumerGroupMember' Nothing includes all historical members; Just-selects one (ungrouped subscriptions use 0). All-member work is bounded by the-number of historical members times the page size; use member-scoped polling for-large groups. One statement and one pool checkout serve a page.+ cells <- readTVarIO (store ^. #subscriptionRegistry)+ Map.traverseWithKey+ ( \(nm, mbr) (_tok, cell) -> do+ st <- readTVarIO cell+ pure+ SubscriptionStateView+ { subscriptionName = nm,+ member = mbr,+ state = st,+ statePhase = stateName st,+ cursor = stateCursor st+ }+ )+ cells -The reason JSON is returned unchanged, without an event decoding hook. An empty-page means no dead letters were recorded for this query. Hard-deleting source-streams can remove rows, but their cursors remain valid. This operation neither-writes a checkpoint nor changes the worker or cleanup paths.--}+-- | Read one subscription's dead letters, newest first by global position and+-- then dead-letter id. Echo the page's 'nextCursor' as 'after' for an exclusive+-- next page. 'consumerGroupMember' Nothing includes all historical members; Just+-- selects one (ungrouped subscriptions use 0). All-member work is bounded by the+-- number of historical members times the page size; use member-scoped polling for+-- large groups. One statement and one pool checkout serve a page.+--+-- The reason JSON is returned unchanged, without an event decoding hook. An empty+-- page means no dead letters were recorded for this query. Hard-deleting source+-- streams can remove rows, but their cursors remain valid. This operation neither+-- writes a checkpoint nor changes the worker or cleanup paths. subscriptionDeadLetters :: (HasCallStack, Store :> es) => SubscriptionDeadLetterQuery -> Eff es SubscriptionDeadLetterPage subscriptionDeadLetters = send . ListSubscriptionDeadLetters
src/Kiroku/Store/Subscription/Checkpoint.hs view
@@ -1,17 +1,17 @@-{- | Explicit mutation operations for durable subscription checkpoints.--Ordinary subscription checkpoint saves are monotonic. This module owns the-separate reset, resize and rebind operations for callers that need to move-persisted progress backward or forward as part of a larger transaction.--}-module Kiroku.Store.Subscription.Checkpoint (- SubscriptionCheckpointResetReport (..),+-- | Explicit mutation operations for durable subscription checkpoints.+--+-- Ordinary subscription checkpoint saves are monotonic. This module owns the+-- separate reset, resize and rebind operations for callers that need to move+-- persisted progress backward or forward as part of a larger transaction.+module Kiroku.Store.Subscription.Checkpoint+ ( SubscriptionCheckpointResetReport (..), resetSubscriptionCheckpointsTx, ConsumerGroupResizeReport (..), resizeConsumerGroupTx, SubscriptionTargetRebindReport (..), rebindSubscriptionTargetTx,-) where+ )+where import Data.Int (Int32) import Data.List (nub, sort)@@ -22,122 +22,117 @@ import GHC.Generics (Generic) import Hasql.Transaction qualified as Tx import Kiroku.Store.Subscription.Checkpoint.SQL qualified as SQL-import Kiroku.Store.Subscription.Types (- ConsumerGroupSize,+import Kiroku.Store.Subscription.Types+ ( ConsumerGroupSize, SubscriptionCheckpointKey (..), SubscriptionName (..), SubscriptionTarget, consumerGroupSizeValue,- )+ ) import Kiroku.Store.Types (GlobalPosition (..)) -{- | Exact result of resetting a non-empty set of subscription names.--'resetCheckpointKeys' contains every persisted @(name, member)@ row that was-updated. 'missingSubscriptionNames' contains requested names for which no row-existed. Both vectors are sorted by subscription name (and then member for-keys); duplicate requested names appear only once in the report.--}+-- | Exact result of resetting a non-empty set of subscription names.+--+-- 'resetCheckpointKeys' contains every persisted @(name, member)@ row that was+-- updated. 'missingSubscriptionNames' contains requested names for which no row+-- existed. Both vectors are sorted by subscription name (and then member for+-- keys); duplicate requested names appear only once in the report. data SubscriptionCheckpointResetReport = SubscriptionCheckpointResetReport- { resetCheckpointKeys :: !(Vector SubscriptionCheckpointKey)- , missingSubscriptionNames :: !(Vector SubscriptionName)- }- deriving stock (Eq, Show, Generic)--{- | Set every existing checkpoint member for the requested subscription names-to the exact target position and return complete deterministic evidence.+ { resetCheckpointKeys :: !(Vector SubscriptionCheckpointKey),+ missingSubscriptionNames :: !(Vector SubscriptionName)+ }+ deriving stock (Eq, Show, Generic) -The operation treats duplicate requested names as one name, updates all-persisted members for each name, and never creates checkpoint rows for missing-names. Unlike ordinary worker saves, this operation can move a checkpoint-backward. It is a 'Tx.Transaction' combinator so a caller can atomically compose-the reset with its own projection fence and target preparation; condemning that-surrounding transaction rolls back all of those writes together.--}+-- | Set every existing checkpoint member for the requested subscription names+-- to the exact target position and return complete deterministic evidence.+--+-- The operation treats duplicate requested names as one name, updates all+-- persisted members for each name, and never creates checkpoint rows for missing+-- names. Unlike ordinary worker saves, this operation can move a checkpoint+-- backward. It is a 'Tx.Transaction' combinator so a caller can atomically compose+-- the reset with its own projection fence and target preparation; condemning that+-- surrounding transaction rolls back all of those writes together. resetSubscriptionCheckpointsTx ::- NonEmpty SubscriptionName ->- GlobalPosition ->- Tx.Transaction SubscriptionCheckpointResetReport+ NonEmpty SubscriptionName ->+ GlobalPosition ->+ Tx.Transaction SubscriptionCheckpointResetReport resetSubscriptionCheckpointsTx names (GlobalPosition position) = do- rows <-- Tx.statement- ( Vector.fromList- [name | SubscriptionName name <- NonEmpty.toList names]- , position- )- SQL.resetSubscriptionCheckpointsStmt- pure- SubscriptionCheckpointResetReport- { resetCheckpointKeys = Vector.mapMaybe resetKey rows- , missingSubscriptionNames = Vector.mapMaybe missingName rows- }+ rows <-+ Tx.statement+ ( Vector.fromList+ [name | SubscriptionName name <- NonEmpty.toList names],+ position+ )+ SQL.resetSubscriptionCheckpointsStmt+ pure+ SubscriptionCheckpointResetReport+ { resetCheckpointKeys = Vector.mapMaybe resetKey rows,+ missingSubscriptionNames = Vector.mapMaybe missingName rows+ } where resetKey (name, Just member) =- Just (SubscriptionCheckpointKey (SubscriptionName name) member)+ Just (SubscriptionCheckpointKey (SubscriptionName name) member) resetKey (_, Nothing) = Nothing missingName (name, Nothing) = Just (SubscriptionName name) missingName (_, Just _) = Nothing -{- | Evidence of explicit topology equalization. Previous sizes are sorted and-distinct; all new members resume from 'resumePosition'. A missing group starts-at zero. A repeat reports the now-equalized topology without moving progress.--}+-- | Evidence of explicit topology equalization. Previous sizes are sorted and+-- distinct; all new members resume from 'resumePosition'. A missing group starts+-- at zero. A repeat reports the now-equalized topology without moving progress. data ConsumerGroupResizeReport = ConsumerGroupResizeReport- { previousSizes :: !(Vector Int32)- , previousMemberCount :: !Int- , newSize :: !ConsumerGroupSize- , resumePosition :: !GlobalPosition- }- deriving stock (Eq, Show, Generic)+ { previousSizes :: !(Vector Int32),+ previousMemberCount :: !Int,+ newSize :: !ConsumerGroupSize,+ resumePosition :: !GlobalPosition+ }+ deriving stock (Eq, Show, Generic) -{- | Stop every worker for the name before calling this operation. Lock its-checkpoint set, rewind every new member to the old minimum, and remove obsolete-members atomically. Existing member identities are retained. A hash-assignment-change also requires this equalization even when the group size is unchanged.-The caller can compose or roll back the resize with application-owned SQL.--}+-- | Stop every worker for the name before calling this operation. Lock its+-- checkpoint set, rewind every new member to the old minimum, and remove obsolete+-- members atomically. Existing member identities are retained. A hash-assignment+-- change also requires this equalization even when the group size is unchanged.+-- The caller can compose or roll back the resize with application-owned SQL. resizeConsumerGroupTx ::- SubscriptionName -> ConsumerGroupSize -> Tx.Transaction ConsumerGroupResizeReport+ SubscriptionName -> ConsumerGroupSize -> Tx.Transaction ConsumerGroupResizeReport resizeConsumerGroupTx (SubscriptionName name) newSize = do- Tx.statement name SQL.lockCheckpointNameStmt- rows <- Tx.statement name SQL.lockCheckpointRowsStmt- let position = if Vector.null rows then 0 else Vector.minimum (Vector.map (\(_, _, p, _) -> p) rows)- previousSizes = Vector.fromList . sort . nub $ [n | (_, n, _, _) <- Vector.toList rows]- let binding = if Vector.null rows then Nothing else let (_, _, _, b) = Vector.head rows in b- (kind, category) = SQL.targetColumns binding- Tx.statement (name, consumerGroupSizeValue newSize, position, kind, category) SQL.resizeCheckpointMembersStmt- pure- ConsumerGroupResizeReport- { previousSizes = previousSizes- , previousMemberCount = Vector.length rows- , newSize = newSize- , resumePosition = GlobalPosition position- }+ Tx.statement name SQL.lockCheckpointNameStmt+ rows <- Tx.statement name SQL.lockCheckpointRowsStmt+ let position = if Vector.null rows then 0 else Vector.minimum (Vector.map (\(_, _, p, _) -> p) rows)+ previousSizes = Vector.fromList . sort . nub $ [n | (_, n, _, _) <- Vector.toList rows]+ let binding = if Vector.null rows then Nothing else let (_, _, _, b) = Vector.head rows in b+ (kind, category) = SQL.targetColumns binding+ Tx.statement (name, consumerGroupSizeValue newSize, position, kind, category) SQL.resizeCheckpointMembersStmt+ pure+ ConsumerGroupResizeReport+ { previousSizes = previousSizes,+ previousMemberCount = Vector.length rows,+ newSize = newSize,+ resumePosition = GlobalPosition position+ } -- | Rebind evidence includes every distinct prior binding; Nothing denotes legacy rows. data SubscriptionTargetRebindReport = SubscriptionTargetRebindReport- { reboundMemberCount :: !Int- , previousBindings :: !(Vector (Maybe SubscriptionTarget))- , reboundTarget :: !SubscriptionTarget- , reboundPosition :: !GlobalPosition- }- deriving stock (Eq, Show)+ { reboundMemberCount :: !Int,+ previousBindings :: !(Vector (Maybe SubscriptionTarget)),+ reboundTarget :: !SubscriptionTarget,+ reboundPosition :: !GlobalPosition+ }+ deriving stock (Eq, Show) -{- | Stop all workers first. Bind every existing member and explicitly reset its-position in the caller's transaction. A missing name fails the transaction;-repeating the operation leaves target and progress unchanged.--}+-- | Stop all workers first. Bind every existing member and explicitly reset its+-- position in the caller's transaction. A missing name fails the transaction;+-- repeating the operation leaves target and progress unchanged. rebindSubscriptionTargetTx ::- SubscriptionName -> SubscriptionTarget -> GlobalPosition -> Tx.Transaction SubscriptionTargetRebindReport+ SubscriptionName -> SubscriptionTarget -> GlobalPosition -> Tx.Transaction SubscriptionTargetRebindReport rebindSubscriptionTargetTx (SubscriptionName name) target position@(GlobalPosition pos) = do- Tx.statement name SQL.lockCheckpointNameStmt- rows <- Tx.statement name SQL.lockCheckpointRowsStmt- let (kind, category) = SQL.targetColumns (Just target)- count <- Tx.statement (name, kind, category, pos) SQL.rebindCheckpointTargetStmt- pure- SubscriptionTargetRebindReport- { reboundMemberCount = fromIntegral count- , previousBindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]- , reboundTarget = target- , reboundPosition = position- }+ Tx.statement name SQL.lockCheckpointNameStmt+ rows <- Tx.statement name SQL.lockCheckpointRowsStmt+ let (kind, category) = SQL.targetColumns (Just target)+ count <- Tx.statement (name, kind, category, pos) SQL.rebindCheckpointTargetStmt+ pure+ SubscriptionTargetRebindReport+ { reboundMemberCount = fromIntegral count,+ previousBindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows],+ reboundTarget = target,+ reboundPosition = position+ }
src/Kiroku/Store/Subscription/Checkpoint/SQL.hs view
@@ -2,8 +2,8 @@ {-# LANGUAGE RankNTypes #-} -- | Package-internal SQL for subscription checkpoint lifecycle operations.-module Kiroku.Store.Subscription.Checkpoint.SQL (- initializeSubscriptionCheckpointSession,+module Kiroku.Store.Subscription.Checkpoint.SQL+ ( initializeSubscriptionCheckpointSession, initializeWorkerCheckpointSession, lockCheckpointNameStmt, lockCheckpointRowsStmt,@@ -12,7 +12,8 @@ targetColumns, saveBoundCheckpointSession, rebindCheckpointTargetStmt,-) where+ )+where import Contravariant.Extras (contrazip2, contrazip4, contrazip5, contrazip6) import Data.Int (Int32, Int64)@@ -27,8 +28,8 @@ import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store.SQL qualified as SQL-import Kiroku.Store.Subscription.Types (- CheckpointInitialization (..),+import Kiroku.Store.Subscription.Types+ ( CheckpointInitialization (..), ConsumerGroupSizeMismatch (..), MissingCheckpointPolicy (..), SomeSubscriptionStartupFailure (..),@@ -38,202 +39,211 @@ SubscriptionTarget (..), SubscriptionTargetMismatch (..), TargetBindingPolicy (..),- )+ ) import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..)) -{- | Resolve one checkpoint key in a Hasql session.--The first statement inserts the policy-selected position with @ON CONFLICT DO-NOTHING@ and then reads the winning row. PostgreSQL can report no row to that-final read when another transaction committed the conflicting insert after the-statement snapshot was taken. For an initializing policy, a second statement-therefore reads the now-committed winner on a fresh snapshot. 'FailIfMissing'-does not retry because it never attempts an insert.--}+-- | Resolve one checkpoint key in a Hasql session.+--+-- The name lock serializes topology adoption with worker startup, resize and+-- rebind. A newly inserted member inherits uniform sibling size and binding;+-- empty or inconsistent sets retain the size-1/unbound defaults. Existing rows+-- always win and sibling rows are never repaired here.+--+-- The insert statement inserts the policy-selected position with @ON CONFLICT DO+-- NOTHING@ and then reads the winning row. PostgreSQL can report no row to that+-- final read when another transaction committed the conflicting insert after the+-- statement snapshot was taken. For an initializing policy, a second statement+-- therefore reads the now-committed winner on a fresh snapshot. 'FailIfMissing'+-- does not retry because it never attempts an insert. initializeSubscriptionCheckpointSession ::- SubscriptionName ->- Int32 ->- MissingCheckpointPolicy ->- Session.Session (Either SubscriptionCheckpointMissing CheckpointInitialization)-initializeSubscriptionCheckpointSession name member policy =- initializeCheckpointWith Session.statement name member 1 Nothing policy+ SubscriptionName ->+ Int32 ->+ MissingCheckpointPolicy ->+ Session.Session (Either SubscriptionCheckpointMissing CheckpointInitialization)+initializeSubscriptionCheckpointSession subscriptionName@(SubscriptionName name) member policy =+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ Tx.statement name lockCheckpointNameStmt+ rows <- Tx.statement name readCheckpointRowsStmt+ let topologies = nub [(size, siblingBinding) | (_, size, _, siblingBinding) <- Vector.toList rows]+ (groupSize, binding) = case topologies of+ [uniform] -> uniform+ _ -> (1, Nothing)+ initializeCheckpointWith Tx.statement subscriptionName member groupSize binding policy -{- | Startup-only validation and insertion share one checkout and one transaction.-The name lock serializes competing topologies, including an initially absent-row set. It is never acquired by ordinary checkpoint saves or event appends.--}+-- | Startup-only validation and insertion share one checkout and one transaction.+-- The name lock serializes competing topologies, including an initially absent+-- row set. It is never acquired by ordinary checkpoint saves or event appends. initializeWorkerCheckpointSession ::- SubscriptionName ->- Int32 ->- Int32 ->- SubscriptionTarget ->- TargetBindingPolicy ->- MissingCheckpointPolicy ->- Session.Session (Either SomeSubscriptionStartupFailure (CheckpointInitialization, Bool))+ SubscriptionName ->+ Int32 ->+ Int32 ->+ SubscriptionTarget ->+ TargetBindingPolicy ->+ MissingCheckpointPolicy ->+ Session.Session (Either SomeSubscriptionStartupFailure (CheckpointInitialization, Bool)) initializeWorkerCheckpointSession subscriptionName@(SubscriptionName name) member configured target bindingPolicy policy =- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do- Tx.statement name lockCheckpointNameStmt- rows <- Tx.statement name readCheckpointRowsStmt- let sizes = Vector.fromList . sort . nub . fmap (\(_, n, _, _) -> n) $ Vector.toList rows- bindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]- unbound = not (Vector.null rows) && bindings == Vector.singleton Nothing- matches = Vector.all (== Just target) bindings- if Vector.any (/= configured) sizes- then pure (Left (SomeSubscriptionStartupFailure (ConsumerGroupSizeMismatch subscriptionName configured sizes)))- else- if not matches && not (unbound && bindingPolicy == AdoptUnbound)- then pure (Left (SomeSubscriptionStartupFailure (SubscriptionTargetMismatch subscriptionName target bindings)))- else do- -- Check exact-key absence before adoption so a refused startup never- -- mutates sibling bindings. Both operations share the name lock.- resolution <- initializeCheckpointWith Tx.statement subscriptionName member configured (Just target) policy- case resolution of- Left missing -> pure (Left (SomeSubscriptionStartupFailure missing))- Right initialized -> do- if unbound then Tx.statement (name, targetColumns (Just target)) adoptCheckpointTargetStmt else pure ()- pure (Right (initialized, unbound))+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ Tx.statement name lockCheckpointNameStmt+ rows <- Tx.statement name readCheckpointRowsStmt+ let sizes = Vector.fromList . sort . nub . fmap (\(_, n, _, _) -> n) $ Vector.toList rows+ bindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]+ unbound = not (Vector.null rows) && bindings == Vector.singleton Nothing+ matches = Vector.all (== Just target) bindings+ if Vector.any (/= configured) sizes+ then pure (Left (SomeSubscriptionStartupFailure (ConsumerGroupSizeMismatch subscriptionName configured sizes)))+ else+ if not matches && not (unbound && bindingPolicy == AdoptUnbound)+ then pure (Left (SomeSubscriptionStartupFailure (SubscriptionTargetMismatch subscriptionName target bindings)))+ else do+ -- Check exact-key absence before adoption so a refused startup never+ -- mutates sibling bindings. Both operations share the name lock.+ resolution <- initializeCheckpointWith Tx.statement subscriptionName member configured (Just target) policy+ case resolution of+ Left missing -> pure (Left (SomeSubscriptionStartupFailure missing))+ Right initialized -> do+ if unbound then Tx.statement (name, targetColumns (Just target)) adoptCheckpointTargetStmt else pure ()+ pure (Right (initialized, unbound)) initializeCheckpointWith ::- (Monad m) =>- (forall a b. a -> Statement a b -> m b) ->- SubscriptionName ->- Int32 ->- Int32 ->- Maybe SubscriptionTarget ->- MissingCheckpointPolicy ->- m (Either SubscriptionCheckpointMissing CheckpointInitialization)+ (Monad m) =>+ (forall a b. a -> Statement a b -> m b) ->+ SubscriptionName ->+ Int32 ->+ Int32 ->+ Maybe SubscriptionTarget ->+ MissingCheckpointPolicy ->+ m (Either SubscriptionCheckpointMissing CheckpointInitialization) initializeCheckpointWith statement subscriptionName@(SubscriptionName name) member groupSize binding policy = do- first <- statement (name, member, policyCode policy, groupSize, fst (targetColumns binding), snd (targetColumns binding)) initializeSubscriptionCheckpointStmt- case first of- Just result -> pure (Right (decodeResult result))- Nothing -> case policy of- FailIfMissing -> pure (Left missing)- _ -> do- -- The insert lost a concurrent unique-key race after this- -- statement's snapshot. A fresh statement snapshot observes- -- the committed winner; singleRow turns a violated invariant- -- into a structured Hasql session error.- position <- statement (name, member) readInitializedCheckpointStmt- pure (Right (ExistingCheckpoint key (GlobalPosition position)))+ first <- statement (name, member, policyCode policy, groupSize, fst (targetColumns binding), snd (targetColumns binding)) initializeSubscriptionCheckpointStmt+ case first of+ Just result -> pure (Right (decodeResult result))+ Nothing -> case policy of+ FailIfMissing -> pure (Left missing)+ _ -> do+ -- The insert lost a concurrent unique-key race after this+ -- statement's snapshot. A fresh statement snapshot observes+ -- the committed winner; singleRow turns a violated invariant+ -- into a structured Hasql session error.+ position <- statement (name, member) readInitializedCheckpointStmt+ pure (Right (ExistingCheckpoint key (GlobalPosition position))) where key = SubscriptionCheckpointKey subscriptionName member missing = SubscriptionCheckpointMissing key decodeResult (position, inserted)- | inserted = InitializedCheckpoint policy key (GlobalPosition position)- | otherwise = ExistingCheckpoint key (GlobalPosition position)+ | inserted = InitializedCheckpoint policy key (GlobalPosition position)+ | otherwise = ExistingCheckpoint key (GlobalPosition position) policyCode :: MissingCheckpointPolicy -> Text policyCode = \case- FromBeginning -> "from_beginning"- FromCurrentHead -> "from_current_head"- FailIfMissing -> "fail_if_missing"+ FromBeginning -> "from_beginning"+ FromCurrentHead -> "from_current_head"+ FailIfMissing -> "fail_if_missing" initializeSubscriptionCheckpointStmt :: Statement (Text, Int32, Text, Int32, Text, Maybe Text) (Maybe (Int64, Bool)) initializeSubscriptionCheckpointStmt =- preparable- """- WITH desired AS (- SELECT CASE $3- WHEN 'from_beginning' THEN 0::bigint- WHEN 'from_current_head' THEN (- SELECT stream_version FROM streams WHERE stream_id = 0- )- ELSE NULL::bigint- END AS last_seen- ),- inserted AS (- INSERT INTO subscriptions- (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)- SELECT $1, $2, desired.last_seen, now(), $4, $5, $6- FROM desired- WHERE desired.last_seen IS NOT NULL- ON CONFLICT (subscription_name, consumer_group_member) DO NOTHING- RETURNING last_seen- )- SELECT inserted.last_seen, TRUE AS initialized- FROM inserted- UNION ALL- SELECT subscriptions.last_seen, FALSE AS initialized- FROM subscriptions- WHERE subscription_name = $1- AND consumer_group_member = $2- LIMIT 1- """- ( contrazip6- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.text))- (E.param (E.nullable E.text))- )- ( D.rowMaybe $- (,)- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.bool)- )+ preparable+ """+ WITH desired AS (+ SELECT CASE $3+ WHEN 'from_beginning' THEN 0::bigint+ WHEN 'from_current_head' THEN (+ SELECT stream_version FROM streams WHERE stream_id = 0+ )+ ELSE NULL::bigint+ END AS last_seen+ ),+ inserted AS (+ INSERT INTO subscriptions+ (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)+ SELECT $1, $2, desired.last_seen, now(), $4, $5, $6+ FROM desired+ WHERE desired.last_seen IS NOT NULL+ ON CONFLICT (subscription_name, consumer_group_member) DO NOTHING+ RETURNING last_seen+ )+ SELECT inserted.last_seen, TRUE AS initialized+ FROM inserted+ UNION ALL+ SELECT subscriptions.last_seen, FALSE AS initialized+ FROM subscriptions+ WHERE subscription_name = $1+ AND consumer_group_member = $2+ LIMIT 1+ """+ ( contrazip6+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.text))+ (E.param (E.nullable E.text))+ )+ ( D.rowMaybe $+ (,)+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.bool)+ ) readInitializedCheckpointStmt :: Statement (Text, Int32) Int64 readInitializedCheckpointStmt =- preparable- """- SELECT last_seen- FROM subscriptions- WHERE subscription_name = $1- AND consumer_group_member = $2- """- ( contrazip2- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- )- (D.singleRow (D.column (D.nonNullable D.int8)))--{- | Reset every persisted member belonging to the requested subscription-names. The input is treated as a set by PostgreSQL. Each returned row contains-either one reset member or a requested name with no persisted rows, and the-result is deterministically ordered by name and member.+ preparable+ """+ SELECT last_seen+ FROM subscriptions+ WHERE subscription_name = $1+ AND consumer_group_member = $2+ """+ ( contrazip2+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ )+ (D.singleRow (D.column (D.nonNullable D.int8))) -This statement deliberately assigns @last_seen@ directly. Ordinary worker-saves retain their separate @GREATEST(...)@ monotonicity contract.--}+-- | Reset every persisted member belonging to the requested subscription+-- names. The input is treated as a set by PostgreSQL. Each returned row contains+-- either one reset member or a requested name with no persisted rows, and the+-- result is deterministically ordered by name and member.+--+-- This statement deliberately assigns @last_seen@ directly. Ordinary worker+-- saves retain their separate @GREATEST(...)@ monotonicity contract. resetSubscriptionCheckpointsStmt ::- Statement (Vector Text, Int64) (Vector (Text, Maybe Int32))+ Statement (Vector Text, Int64) (Vector (Text, Maybe Int32)) resetSubscriptionCheckpointsStmt =- preparable- """- WITH requested AS (- SELECT DISTINCT requested_name AS subscription_name- FROM unnest($1::text[]) AS requested_name- ),- updated AS (- UPDATE subscriptions AS checkpoint- SET last_seen = $2, updated_at = now()- FROM requested- WHERE checkpoint.subscription_name = requested.subscription_name- RETURNING checkpoint.subscription_name, checkpoint.consumer_group_member- )- SELECT requested.subscription_name, updated.consumer_group_member- FROM requested- LEFT JOIN updated USING (subscription_name)- ORDER BY requested.subscription_name, updated.consumer_group_member- """- ( contrazip2- (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))- (E.param (E.nonNullable E.int8))- )- ( D.rowVector $- (,)- <$> D.column (D.nonNullable D.text)- <*> D.column (D.nullable D.int4)- )+ preparable+ """+ WITH requested AS (+ SELECT DISTINCT requested_name AS subscription_name+ FROM unnest($1::text[]) AS requested_name+ ),+ updated AS (+ UPDATE subscriptions AS checkpoint+ SET last_seen = $2, updated_at = now()+ FROM requested+ WHERE checkpoint.subscription_name = requested.subscription_name+ RETURNING checkpoint.subscription_name, checkpoint.consumer_group_member+ )+ SELECT requested.subscription_name, updated.consumer_group_member+ FROM requested+ LEFT JOIN updated USING (subscription_name)+ ORDER BY requested.subscription_name, updated.consumer_group_member+ """+ ( contrazip2+ (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+ (E.param (E.nonNullable E.int8))+ )+ ( D.rowVector $+ (,)+ <$> D.column (D.nonNullable D.text)+ <*> D.column (D.nullable D.int4)+ ) -- | A separate key domain from the optional member guard. lockCheckpointNameStmt :: Statement Text () lockCheckpointNameStmt =- preparable- "SELECT pg_advisory_xact_lock(hashtextextended('kiroku:checkpoint-topology:' || $1, 0))"- (E.param (E.nonNullable E.text))- D.noResult+ preparable+ "SELECT pg_advisory_xact_lock(hashtextextended('kiroku:checkpoint-topology:' || $1, 0))"+ (E.param (E.nonNullable E.text))+ D.noResult readCheckpointRowsStmt :: Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget)) readCheckpointRowsStmt = checkpointRowsStmt ""@@ -243,52 +253,53 @@ checkpointRowsStmt :: Text -> Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget)) checkpointRowsStmt suffix =- preparable- ("SELECT consumer_group_member, consumer_group_size, last_seen, target_kind, target_category FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member" <> suffix)- (E.param (E.nonNullable E.text))- (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8) <*> targetBindingRow))+ preparable+ ("SELECT consumer_group_member, consumer_group_size, last_seen, target_kind, target_category FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member" <> suffix)+ (E.param (E.nonNullable E.text))+ (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8) <*> targetBindingRow)) -{- | Keep existing row identities, equalize every new member, remove obsolete-members. Caller already holds the name and row locks; workers must be stopped.--}+-- | Keep existing row identities, equalize every new member, remove obsolete+-- members. Caller already holds the name and row locks; workers must be stopped. resizeCheckpointMembersStmt :: Statement (Text, Int32, Int64, Text, Maybe Text) () resizeCheckpointMembersStmt =- preparable- """- WITH removed AS (- DELETE FROM subscriptions- WHERE subscription_name = $1 AND consumer_group_member >= $2- )- INSERT INTO subscriptions- (subscription_name, consumer_group_member, consumer_group_size, last_seen, updated_at, target_kind, target_category)- SELECT $1, member, $2, $3, now(), $4, $5 FROM generate_series(0, $2 - 1) AS member- ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE- SET consumer_group_size = EXCLUDED.consumer_group_size,- last_seen = EXCLUDED.last_seen, updated_at = now()- """- ( contrazip5- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.text))- (E.param (E.nullable E.text))- )- D.noResult+ preparable+ """+ WITH removed AS (+ DELETE FROM subscriptions+ WHERE subscription_name = $1 AND consumer_group_member >= $2+ )+ INSERT INTO subscriptions+ (subscription_name, consumer_group_member, consumer_group_size, last_seen, updated_at, target_kind, target_category)+ SELECT $1, member, $2, $3, now(), $4, $5 FROM generate_series(0, $2 - 1) AS member+ ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE+ SET consumer_group_size = EXCLUDED.consumer_group_size,+ last_seen = EXCLUDED.last_seen, updated_at = now()+ """+ ( contrazip5+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.text))+ (E.param (E.nullable E.text))+ )+ D.noResult -{- | Select a fixed-kind statement once for the configured target. Both forms-write the full binding and retain the same unconditional monotonic upsert.--}+-- | Select a fixed-kind statement once for the configured target. Both forms+-- write the full binding and retain the same unconditional monotonic upsert. saveBoundCheckpointSession :: SubscriptionTarget -> Text -> Int32 -> Int64 -> Int32 -> Session.Session () saveBoundCheckpointSession AllStreams name member position groupSize =- Session.statement (name, member, position, groupSize) SQL.saveAllCheckpointMemberStmt+ Session.statement (name, member, position, groupSize) SQL.saveAllCheckpointMemberStmt saveBoundCheckpointSession (Category (CategoryName category)) name member position groupSize =- Session.statement (name, member, position, groupSize, category) SQL.saveCategoryCheckpointMemberStmt+ Session.statement (name, member, position, groupSize, category) SQL.saveCategoryCheckpointMemberStmt+saveBoundCheckpointSession (CategoryNamespace namespace) name member position groupSize =+ Session.statement (name, member, position, groupSize, namespace) SQL.saveNamespaceCheckpointMemberStmt -- | One encoder for every checkpoint target write, including legacy provisioning. targetColumns :: Maybe SubscriptionTarget -> (Text, Maybe Text) targetColumns Nothing = ("unbound", Nothing) targetColumns (Just AllStreams) = ("all", Nothing) targetColumns (Just (Category (CategoryName category))) = ("category", Just category)+targetColumns (Just (CategoryNamespace namespace)) = ("category_namespace", Just namespace) -- The CHECK constraints make this decoder total over valid database rows. targetBindingRow :: D.Row (Maybe SubscriptionTarget)@@ -296,27 +307,28 @@ where decode "all" Nothing = Just AllStreams decode "category" (Just category) = Just (Category (CategoryName category))+ decode "category_namespace" (Just namespace) = Just (CategoryNamespace namespace) decode _ _ = Nothing adoptCheckpointTargetStmt :: Statement (Text, (Text, Maybe Text)) () adoptCheckpointTargetStmt =- preparable- "UPDATE subscriptions SET target_kind = $2, target_category = $3 WHERE subscription_name = $1"- (contrazip2 (E.param (E.nonNullable E.text)) (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text))))- D.noResult+ preparable+ "UPDATE subscriptions SET target_kind = $2, target_category = $3 WHERE subscription_name = $1"+ (contrazip2 (E.param (E.nonNullable E.text)) (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text))))+ D.noResult -- A singleRow decoder refuses an absent name and aborts the surrounding transaction. rebindCheckpointTargetStmt :: Statement (Text, Text, Maybe Text, Int64) Int64 rebindCheckpointTargetStmt =- preparable- """- WITH rebound AS (- UPDATE subscriptions- SET target_kind = $2, target_category = $3, last_seen = $4, updated_at = now()- WHERE subscription_name = $1- RETURNING subscription_id- )- SELECT count(*) FROM rebound HAVING count(*) > 0- """- (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int8)))- (D.singleRow (D.column (D.nonNullable D.int8)))+ preparable+ """+ WITH rebound AS (+ UPDATE subscriptions+ SET target_kind = $2, target_category = $3, last_seen = $4, updated_at = now()+ WHERE subscription_name = $1+ RETURNING subscription_id+ )+ SELECT count(*) FROM rebound HAVING count(*) > 0+ """+ (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int8)))+ (D.singleRow (D.column (D.nonNullable D.int8)))
src/Kiroku/Store/Subscription/CheckpointInventory/SQL.hs view
@@ -1,8 +1,9 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.Subscription.CheckpointInventory.SQL (- getSubscriptionCheckpointInventoryStmt,-) where+module Kiroku.Store.Subscription.CheckpointInventory.SQL+ ( getSubscriptionCheckpointInventoryStmt,+ )+where import Data.Int (Int32, Int64) import Data.Text (Text)@@ -13,102 +14,102 @@ import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Hasql.Statement qualified as Statement-import Kiroku.Store.Subscription.Types (- SubscriptionCheckpoint (..),+import Kiroku.Store.Subscription.Types+ ( SubscriptionCheckpoint (..), SubscriptionCheckpointInventory (..), SubscriptionName (..),- )+ ) import Kiroku.Store.Types (GlobalPosition (..)) data InventoryRow = InventoryRow- { rowStorePosition :: !Int64- , rowSubscriptionName :: !(Maybe Text)- , rowConsumerGroupMember :: !(Maybe Int32)- , rowCheckpointPosition :: !(Maybe Int64)- , rowCheckpointUpdatedAt :: !(Maybe UTCTime)- }+ { rowStorePosition :: !Int64,+ rowSubscriptionName :: !(Maybe Text),+ rowConsumerGroupMember :: !(Maybe Int32),+ rowCheckpointPosition :: !(Maybe Int64),+ rowCheckpointUpdatedAt :: !(Maybe UTCTime)+ } getSubscriptionCheckpointInventoryStmt :: Statement () SubscriptionCheckpointInventory getSubscriptionCheckpointInventoryStmt =- Statement.refineResult finalizeInventory $- preparable- """- SELECT store_head.stream_version,- checkpoint.subscription_name,- checkpoint.consumer_group_member,- checkpoint.last_seen,- checkpoint.updated_at- FROM streams AS store_head- LEFT JOIN subscriptions AS checkpoint ON TRUE- WHERE store_head.stream_id = 0- ORDER BY checkpoint.subscription_name ASC,- checkpoint.consumer_group_member ASC- """- E.noParams- (D.rowVector inventoryRow)+ Statement.refineResult finalizeInventory $+ preparable+ """+ SELECT store_head.stream_version,+ checkpoint.subscription_name,+ checkpoint.consumer_group_member,+ checkpoint.last_seen,+ checkpoint.updated_at+ FROM streams AS store_head+ LEFT JOIN subscriptions AS checkpoint ON TRUE+ WHERE store_head.stream_id = 0+ ORDER BY checkpoint.subscription_name ASC,+ checkpoint.consumer_group_member ASC+ """+ E.noParams+ (D.rowVector inventoryRow) inventoryRow :: D.Row InventoryRow inventoryRow =- InventoryRow- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nullable D.text)- <*> D.column (D.nullable D.int4)- <*> D.column (D.nullable D.int8)- <*> D.column (D.nullable D.timestamptz)+ InventoryRow+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nullable D.text)+ <*> D.column (D.nullable D.int4)+ <*> D.column (D.nullable D.int8)+ <*> D.column (D.nullable D.timestamptz) finalizeInventory :: Vector InventoryRow -> Either Text SubscriptionCheckpointInventory finalizeInventory rows = case V.uncons rows of- Nothing -> Left "subscription checkpoint inventory: missing $all stream row"- Just (firstRow, remainingRows) ->- let capturedPosition = rowStorePosition firstRow- in case checkpointColumns firstRow of- EmptyCheckpoint- | V.null remainingRows ->- Right $- SubscriptionCheckpointInventory- (GlobalPosition capturedPosition)- V.empty- | otherwise ->- Left "subscription checkpoint inventory: empty checkpoint row was not the only result"- PartialCheckpoint ->- Left "subscription checkpoint inventory: partially null checkpoint row"- CompleteCheckpoint ->- SubscriptionCheckpointInventory (GlobalPosition capturedPosition)- <$> V.mapM (decodeCheckpoint capturedPosition) rows+ Nothing -> Left "subscription checkpoint inventory: missing $all stream row"+ Just (firstRow, remainingRows) ->+ let capturedPosition = rowStorePosition firstRow+ in case checkpointColumns firstRow of+ EmptyCheckpoint+ | V.null remainingRows ->+ Right $+ SubscriptionCheckpointInventory+ (GlobalPosition capturedPosition)+ V.empty+ | otherwise ->+ Left "subscription checkpoint inventory: empty checkpoint row was not the only result"+ PartialCheckpoint ->+ Left "subscription checkpoint inventory: partially null checkpoint row"+ CompleteCheckpoint ->+ SubscriptionCheckpointInventory (GlobalPosition capturedPosition)+ <$> V.mapM (decodeCheckpoint capturedPosition) rows data CheckpointColumns- = EmptyCheckpoint- | PartialCheckpoint- | CompleteCheckpoint+ = EmptyCheckpoint+ | PartialCheckpoint+ | CompleteCheckpoint checkpointColumns :: InventoryRow -> CheckpointColumns checkpointColumns row =- case ( rowSubscriptionName row- , rowConsumerGroupMember row- , rowCheckpointPosition row- , rowCheckpointUpdatedAt row- ) of- (Nothing, Nothing, Nothing, Nothing) -> EmptyCheckpoint- (Just _, Just _, Just _, Just _) -> CompleteCheckpoint- _ -> PartialCheckpoint+ case ( rowSubscriptionName row,+ rowConsumerGroupMember row,+ rowCheckpointPosition row,+ rowCheckpointUpdatedAt row+ ) of+ (Nothing, Nothing, Nothing, Nothing) -> EmptyCheckpoint+ (Just _, Just _, Just _, Just _) -> CompleteCheckpoint+ _ -> PartialCheckpoint decodeCheckpoint :: Int64 -> InventoryRow -> Either Text SubscriptionCheckpoint decodeCheckpoint capturedPosition row- | rowStorePosition row /= capturedPosition =- Left "subscription checkpoint inventory: inconsistent repeated store position"- | otherwise =- case ( rowSubscriptionName row- , rowConsumerGroupMember row- , rowCheckpointPosition row- , rowCheckpointUpdatedAt row- ) of- (Just name, Just member, Just position, Just updatedAt) ->- Right $- SubscriptionCheckpoint- (SubscriptionName name)- member- (GlobalPosition position)- updatedAt- (Nothing, Nothing, Nothing, Nothing) ->- Left "subscription checkpoint inventory: unexpected empty checkpoint row"- _ -> Left "subscription checkpoint inventory: partially null checkpoint row"+ | rowStorePosition row /= capturedPosition =+ Left "subscription checkpoint inventory: inconsistent repeated store position"+ | otherwise =+ case ( rowSubscriptionName row,+ rowConsumerGroupMember row,+ rowCheckpointPosition row,+ rowCheckpointUpdatedAt row+ ) of+ (Just name, Just member, Just position, Just updatedAt) ->+ Right $+ SubscriptionCheckpoint+ (SubscriptionName name)+ member+ (GlobalPosition position)+ updatedAt+ (Nothing, Nothing, Nothing, Nothing) ->+ Left "subscription checkpoint inventory: unexpected empty checkpoint row"+ _ -> Left "subscription checkpoint inventory: partially null checkpoint row"
src/Kiroku/Store/Subscription/DeadLetter/SQL.hs view
@@ -1,12 +1,13 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.Subscription.DeadLetter.SQL (- listSubscriptionDeadLettersSession,+module Kiroku.Store.Subscription.DeadLetter.SQL+ ( listSubscriptionDeadLettersSession, listSubscriptionDeadLettersStmt, listSubscriptionDeadLettersFromStartStmt, listSubscriptionMemberDeadLettersStmt, listSubscriptionMemberDeadLettersFromStartStmt,-) where+ )+where import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5) import Control.Lens ((^.))@@ -25,118 +26,120 @@ listSubscriptionDeadLettersFromStartStmt :: Statement (Text, Int32) (Vector SubscriptionDeadLetter) listSubscriptionDeadLettersFromStartStmt =- preparable- """- WITH RECURSIVE members(member) AS (- SELECT (SELECT consumer_group_member FROM dead_letters- WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)- UNION ALL- SELECT (SELECT consumer_group_member FROM dead_letters- WHERE subscription_name = $1 AND consumer_group_member > m.member- ORDER BY consumer_group_member LIMIT 1)- FROM members m WHERE m.member IS NOT NULL- )- SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at- FROM members m- CROSS JOIN LATERAL (- SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at- FROM dead_letters- WHERE subscription_name = $1 AND consumer_group_member = m.member+ preparable+ """+ WITH RECURSIVE members(member) AS (+ SELECT (SELECT consumer_group_member FROM dead_letters+ WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)+ UNION ALL+ SELECT (SELECT consumer_group_member FROM dead_letters+ WHERE subscription_name = $1 AND consumer_group_member > m.member+ ORDER BY consumer_group_member LIMIT 1)+ FROM members m WHERE m.member IS NOT NULL+ )+ SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at+ FROM members m+ CROSS JOIN LATERAL (+ SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+ FROM dead_letters+ WHERE subscription_name = $1 AND consumer_group_member = m.member - ORDER BY global_position DESC, dead_letter_id DESC LIMIT $2- ) d- WHERE m.member IS NOT NULL- ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $2- """- (contrazip2 text int4)- (D.rowVector deadLetterRow)+ ORDER BY global_position DESC, dead_letter_id DESC LIMIT $2+ ) d+ WHERE m.member IS NOT NULL+ ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $2+ """+ (contrazip2 text int4)+ (D.rowVector deadLetterRow) listSubscriptionDeadLettersStmt :: Statement (Text, Int64, Int64, Int32) (Vector SubscriptionDeadLetter) listSubscriptionDeadLettersStmt =- preparable- """- WITH RECURSIVE members(member) AS (- SELECT (SELECT consumer_group_member FROM dead_letters- WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)- UNION ALL- SELECT (SELECT consumer_group_member FROM dead_letters- WHERE subscription_name = $1 AND consumer_group_member > m.member- ORDER BY consumer_group_member LIMIT 1)- FROM members m WHERE m.member IS NOT NULL- )- SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at- FROM members m- CROSS JOIN LATERAL (- SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at- FROM dead_letters- WHERE subscription_name = $1 AND consumer_group_member = m.member- AND (global_position, dead_letter_id) < ($2, $3)- ORDER BY global_position DESC, dead_letter_id DESC LIMIT $4- ) d- WHERE m.member IS NOT NULL- ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $4- """- (contrazip4 text int8 int8 int4)- (D.rowVector deadLetterRow)+ preparable+ """+ WITH RECURSIVE members(member) AS (+ SELECT (SELECT consumer_group_member FROM dead_letters+ WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)+ UNION ALL+ SELECT (SELECT consumer_group_member FROM dead_letters+ WHERE subscription_name = $1 AND consumer_group_member > m.member+ ORDER BY consumer_group_member LIMIT 1)+ FROM members m WHERE m.member IS NOT NULL+ )+ SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at+ FROM members m+ CROSS JOIN LATERAL (+ SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+ FROM dead_letters+ WHERE subscription_name = $1 AND consumer_group_member = m.member+ AND (global_position, dead_letter_id) < ($2, $3)+ ORDER BY global_position DESC, dead_letter_id DESC LIMIT $4+ ) d+ WHERE m.member IS NOT NULL+ ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $4+ """+ (contrazip4 text int8 int8 int4)+ (D.rowVector deadLetterRow) listSubscriptionMemberDeadLettersFromStartStmt :: Statement (Text, Int32, Int32) (Vector SubscriptionDeadLetter) listSubscriptionMemberDeadLettersFromStartStmt =- preparable- """- SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at- FROM dead_letters- WHERE subscription_name = $1 AND consumer_group_member = $2+ preparable+ """+ SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+ FROM dead_letters+ WHERE subscription_name = $1 AND consumer_group_member = $2 - ORDER BY global_position DESC, dead_letter_id DESC LIMIT $3- """- (contrazip3 text int4 int4)- (D.rowVector deadLetterRow)+ ORDER BY global_position DESC, dead_letter_id DESC LIMIT $3+ """+ (contrazip3 text int4 int4)+ (D.rowVector deadLetterRow) listSubscriptionMemberDeadLettersStmt :: Statement (Text, Int32, Int64, Int64, Int32) (Vector SubscriptionDeadLetter) listSubscriptionMemberDeadLettersStmt =- preparable- """- SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at- FROM dead_letters- WHERE subscription_name = $1 AND consumer_group_member = $2- AND (global_position, dead_letter_id) < ($3, $4)- ORDER BY global_position DESC, dead_letter_id DESC LIMIT $5- """- (contrazip5 text int4 int8 int8 int4)- (D.rowVector deadLetterRow)+ preparable+ """+ SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+ FROM dead_letters+ WHERE subscription_name = $1 AND consumer_group_member = $2+ AND (global_position, dead_letter_id) < ($3, $4)+ ORDER BY global_position DESC, dead_letter_id DESC LIMIT $5+ """+ (contrazip5 text int4 int8 int8 int4)+ (D.rowVector deadLetterRow) text :: E.Params Text text = E.param (E.nonNullable E.text)+ int4 :: E.Params Int32 int4 = E.param (E.nonNullable E.int4)+ int8 :: E.Params Int64 int8 = E.param (E.nonNullable E.int8) deadLetterRow :: D.Row SubscriptionDeadLetter deadLetterRow =- SubscriptionDeadLetter- <$> D.column (D.nonNullable D.int8)- <*> (SubscriptionName <$> D.column (D.nonNullable D.text))- <*> D.column (D.nonNullable D.int4)- <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))- <*> (EventId <$> D.column (D.nonNullable D.uuid))- <*> D.column (D.nonNullable D.jsonb)- <*> D.column (D.nonNullable D.text)- <*> D.column (D.nonNullable D.int4)- <*> D.column (D.nonNullable D.timestamptz)+ SubscriptionDeadLetter+ <$> D.column (D.nonNullable D.int8)+ <*> (SubscriptionName <$> D.column (D.nonNullable D.text))+ <*> D.column (D.nonNullable D.int4)+ <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+ <*> (EventId <$> D.column (D.nonNullable D.uuid))+ <*> D.column (D.nonNullable D.jsonb)+ <*> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.int4)+ <*> D.column (D.nonNullable D.timestamptz) listSubscriptionDeadLettersSession :: SubscriptionDeadLetterQuery -> Session SubscriptionDeadLetterPage listSubscriptionDeadLettersSession query = do- let SubscriptionName name = query ^. #subscriptionName- pageSize = subscriptionDeadLetterLimitValue (query ^. #limit)- fetch = pageSize + 1- rows <- case (query ^. #consumerGroupMember, query ^. #after) of- (Nothing, Nothing) -> Session.statement (name, fetch) listSubscriptionDeadLettersFromStartStmt- (Just selectedMember, Nothing) -> Session.statement (name, selectedMember, fetch) listSubscriptionMemberDeadLettersFromStartStmt- (Nothing, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->- Session.statement (name, position, ident, fetch) listSubscriptionDeadLettersStmt- (Just selectedMember, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->- Session.statement (name, selectedMember, position, ident, fetch) listSubscriptionMemberDeadLettersStmt- let page = V.take (fromIntegral pageSize) rows- cursor = if V.length rows > fromIntegral pageSize then Just (subscriptionDeadLetterCursor (V.last page)) else Nothing- pure (SubscriptionDeadLetterPage page cursor)+ let SubscriptionName name = query ^. #subscriptionName+ pageSize = subscriptionDeadLetterLimitValue (query ^. #limit)+ fetch = pageSize + 1+ rows <- case (query ^. #consumerGroupMember, query ^. #after) of+ (Nothing, Nothing) -> Session.statement (name, fetch) listSubscriptionDeadLettersFromStartStmt+ (Just selectedMember, Nothing) -> Session.statement (name, selectedMember, fetch) listSubscriptionMemberDeadLettersFromStartStmt+ (Nothing, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->+ Session.statement (name, position, ident, fetch) listSubscriptionDeadLettersStmt+ (Just selectedMember, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->+ Session.statement (name, selectedMember, position, ident, fetch) listSubscriptionMemberDeadLettersStmt+ let page = V.take (fromIntegral pageSize) rows+ cursor = if V.length rows > fromIntegral pageSize then Just (subscriptionDeadLetterCursor (V.last page)) else Nothing+ pure (SubscriptionDeadLetterPage page cursor)
src/Kiroku/Store/Subscription/Effect.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE TypeFamilies #-} -module Kiroku.Store.Subscription.Effect (- -- * The Subscription effect+module Kiroku.Store.Subscription.Effect+ ( -- * The Subscription effect Subscription (..), -- * Convenience wrappers@@ -11,7 +11,8 @@ -- * Interpreters runSubscription, runSubscriptionResource,-) where+ )+where import Control.Monad.IO.Class (liftIO) import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, Limit (..), Persistence (..), UnliftStrategy (..), (:>))@@ -26,13 +27,12 @@ -- Subscription effect -- --------------------------------------------------------------------------- -{- | The Subscription effect — subscribe to event streams.--This is a higher-order effect: the handler inside the config runs in the-caller's effect monad @m@, which is @Eff localEs@ at the call site.--}+-- | The Subscription effect — subscribe to event streams.+--+-- This is a higher-order effect: the handler inside the config runs in the+-- caller's effect monad @m@, which is @Eff localEs@ at the call site. data Subscription :: Effect where- Subscribe :: SubscriptionConfigM m -> Subscription m SubscriptionHandle+ Subscribe :: SubscriptionConfigM m -> Subscription m SubscriptionHandle type instance DispatchOf Subscription = Dynamic @@ -40,103 +40,100 @@ -- Convenience wrapper -- --------------------------------------------------------------------------- -{- | Subscribe to an event stream (effectful API).--The handler inside the config runs in @Eff es@, so it can use any effects-available in the caller's stack.--__Lifecycle note:__ The returned handle must be canceled before the effect-scope exits. The subscription thread captures the effect environment; if the-@Eff@ computation finishes while the thread is still running, the environment-becomes invalid. Prefer 'withSubscription' (also exported from this module)-which wraps subscribe/cancel in a @bracket@ and is exception-safe.--=== Delivery semantics--Identical to 'Kiroku.Store.Subscription.subscribe': events are delivered-__at least once__, the checkpoint is advanced __per batch__, and handlers-must be idempotent. See the Haddock on-'Kiroku.Store.Subscription.subscribe' for the full enumeration of replay-boundaries (cancel-after-Continue, mid-batch cancellation, transient-publisher pool errors) and the failure-mode table for-'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait'.--The effectful interpreter wraps the user's handler with the-@ConcUnlift Persistent (Limited 1)@ unlift strategy (see 'runSubscription').-This preserves the worker's single-threaded contract: only one handler-invocation is in flight at a time, and the effect environment outlives-all handler calls so 'Effectful.State.Static.Local.State' /-'Effectful.Reader.Static.Reader' contents remain consistent across the-subscription.--}+-- | Subscribe to an event stream (effectful API).+--+-- The handler inside the config runs in @Eff es@, so it can use any effects+-- available in the caller's stack.+--+-- __Lifecycle note:__ The returned handle must be canceled before the effect+-- scope exits. The subscription thread captures the effect environment; if the+-- @Eff@ computation finishes while the thread is still running, the environment+-- becomes invalid. Prefer 'withSubscription' (also exported from this module)+-- which wraps subscribe/cancel in a @bracket@ and is exception-safe.+--+-- === Delivery semantics+--+-- Identical to 'Kiroku.Store.Subscription.subscribe': events are delivered+-- __at least once__, the checkpoint is advanced __per batch__, and handlers+-- must be idempotent. See the Haddock on+-- 'Kiroku.Store.Subscription.subscribe' for the full enumeration of replay+-- boundaries (cancel-after-Continue, mid-batch cancellation, transient+-- publisher pool errors) and the failure-mode table for+-- 'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait'.+--+-- The effectful interpreter wraps the user's handler with the+-- @ConcUnlift Persistent (Limited 1)@ unlift strategy (see 'runSubscription').+-- This preserves the worker's single-threaded contract: only one handler+-- invocation is in flight at a time, and the effect environment outlives+-- all handler calls so 'Effectful.State.Static.Local.State' /+-- 'Effectful.Reader.Static.Reader' contents remain consistent across the+-- subscription. subscribe :: (HasCallStack, Subscription :> es) => SubscriptionConfigM (Eff es) -> Eff es SubscriptionHandle subscribe config = send (Subscribe config) -{- | Bracket-style subscription lifecycle for the effectful API.--Starts a subscription, runs the body, and cancels the worker on either-normal exit or an exception thrown inside the body. This is the-exception-safe form of 'subscribe' — the `Eff`-based 'subscribe' captures-the caller's effect environment in the worker thread, so a leaking thread-can refer to an environment that has already been torn down.--The body runs in @Eff es@; the underlying 'SubscriptionHandle' is IO-based,-so 'cancel' is invoked via 'liftIO'. 'Effectful.Exception.bracket' guarantees-the cancel runs even on async exceptions.--}+-- | Bracket-style subscription lifecycle for the effectful API.+--+-- Starts a subscription, runs the body, and cancels the worker on either+-- normal exit or an exception thrown inside the body. This is the+-- exception-safe form of 'subscribe' — the `Eff`-based 'subscribe' captures+-- the caller's effect environment in the worker thread, so a leaking thread+-- can refer to an environment that has already been torn down.+--+-- The body runs in @Eff es@; the underlying 'SubscriptionHandle' is IO-based,+-- so 'cancel' is invoked via 'liftIO'. 'Effectful.Exception.bracket' guarantees+-- the cancel runs even on async exceptions. withSubscription ::- (HasCallStack, Subscription :> es, IOE :> es) =>- SubscriptionConfigM (Eff es) ->- (SubscriptionHandle -> Eff es a) ->- Eff es a+ (HasCallStack, Subscription :> es, IOE :> es) =>+ SubscriptionConfigM (Eff es) ->+ (SubscriptionHandle -> Eff es a) ->+ Eff es a withSubscription config = bracket (subscribe config) (liftIO . cancel) -- --------------------------------------------------------------------------- -- Interpreters -- --------------------------------------------------------------------------- -{- | Interpret 'Subscription' by delegating to the @MonadIO@-based-'Kiroku.Store.Subscription.subscribe'.--The unlift strategy is @ConcUnlift Persistent (Limited 1)@:--* @Persistent@ — the effect environment must outlive each individual- handler invocation. The worker thread calls the handler many times- over its lifetime; an @Ephemeral@ unlift would reset the environment- between calls and lose any 'Effectful.State.Static.Local.State' /- 'Effectful.Reader.Static.Reader' contents.-* @Limited 1@ — only one concurrent unlift is in flight at any time.- The worker thread is single-threaded by construction (events are- processed sequentially within a subscription), so a higher bound- would be wasteful. A @Limited 0@ would prevent any unlift and break- the handler entirely.--Do not relax these bounds without restructuring the worker.--}+-- | Interpret 'Subscription' by delegating to the @MonadIO@-based+-- 'Kiroku.Store.Subscription.subscribe'.+--+-- The unlift strategy is @ConcUnlift Persistent (Limited 1)@:+--+-- * @Persistent@ — the effect environment must outlive each individual+-- handler invocation. The worker thread calls the handler many times+-- over its lifetime; an @Ephemeral@ unlift would reset the environment+-- between calls and lose any 'Effectful.State.Static.Local.State' /+-- 'Effectful.Reader.Static.Reader' contents.+-- * @Limited 1@ — only one concurrent unlift is in flight at any time.+-- The worker thread is single-threaded by construction (events are+-- processed sequentially within a subscription), so a higher bound+-- would be wasteful. A @Limited 0@ would prevent any unlift and break+-- the handler entirely.+--+-- Do not relax these bounds without restructuring the worker. runSubscription ::- (IOE :> es) =>- KirokuStore ->- Eff (Subscription : es) a ->- Eff es a+ (IOE :> es) =>+ KirokuStore ->+ Eff (Subscription : es) a ->+ Eff es a runSubscription store = interpret $ \env -> \case- Subscribe config ->- localUnliftIO env (ConcUnlift Persistent (Limited 1)) $ \unlift -> do- -- Record update intentionally: policy fields are preserved; both callbacks are unlifted,- -- including 'consumerGroup' and 'consumerGroupGuard' (added in EP-2). Do not- -- switch to a full record literal here — that would silently reset new fields- -- and drop a caller's consumer-group membership.- let ioConfig =- config- { handler = \evt -> unlift (handler config evt)- , undecodableHandler = fmap (\callback event failure -> unlift (callback event failure)) (undecodableHandler config)- }- Sub.subscribe store ioConfig+ Subscribe config ->+ localUnliftIO env (ConcUnlift Persistent (Limited 1)) $ \unlift -> do+ -- Record update intentionally: policy fields are preserved; both callbacks are unlifted,+ -- including 'consumerGroup' and 'consumerGroupGuard' (added in EP-2). Do not+ -- switch to a full record literal here — that would silently reset new fields+ -- and drop a caller's consumer-group membership.+ let ioConfig =+ config+ { handler = \evt -> unlift (handler config evt),+ undecodableHandler = fmap (\callback event failure -> unlift (callback event failure)) (undecodableHandler config)+ }+ Sub.subscribe store ioConfig -- | Interpret Subscription by reading the store handle from 'KirokuStoreResource'. runSubscriptionResource ::- (IOE :> es, KirokuStoreResource :> es) =>- Eff (Subscription : es) a ->- Eff es a+ (IOE :> es, KirokuStoreResource :> es) =>+ Eff (Subscription : es) a ->+ Eff es a runSubscriptionResource action = do- store <- getKirokuStore- runSubscription store action+ store <- getKirokuStore+ runSubscription store action
src/Kiroku/Store/Subscription/EventPublisher.hs view
@@ -1,24 +1,23 @@-{- | The centralized @$all@ broadcaster shared by queue-consuming live clients.--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-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-'SubscriberStatus': under the default @PauseAndResume@ it marks the subscriber-'Paused' and stops pushing (the worker drains and re-catches-up losslessly),-under @DropSubscription@ it marks it 'Overflowed', and under @DropOldest@ it-evicts the oldest batch and counts the drop in 'subDropped'. The publisher itself never blocks on a slow consumer.--}-module Kiroku.Store.Subscription.EventPublisher (- EventPublisher (..),+-- | The centralized @$all@ broadcaster shared by queue-consuming live clients.+--+-- 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+-- 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+-- 'SubscriberStatus': under the default @PauseAndResume@ it marks the subscriber+-- 'Paused' and stops pushing (the worker drains and re-catches-up losslessly),+-- under @DropSubscription@ it marks it 'Overflowed', and under @DropOldest@ it+-- evicts the oldest batch and counts the drop in 'subDropped'. The publisher itself never blocks on a slow consumer.+module Kiroku.Store.Subscription.EventPublisher+ ( EventPublisher (..), Subscriber (..), SubscriberStatus (..), startPublisher,@@ -27,12 +26,13 @@ PublisherSubscription (..), subscribePublisherWith, publisherPosition,-) where+ )+where import Control.Concurrent.Async (Async) import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (- STM,+import Control.Concurrent.STM+ ( STM, TBQueue, TChan, TVar,@@ -51,7 +51,7 @@ tryReadTChan, writeTBQueue, writeTVar,- )+ ) import Control.Concurrent.STM qualified as STM import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO) import Control.Monad (when)@@ -72,62 +72,57 @@ import Kiroku.Store.Types (GlobalPosition (..), RecordedEvent (..)) import Numeric.Natural (Natural) -{- | 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.--}+-- | 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))- -- ^ Active subscriber registry, keyed by an internal id.- , nextSubscriberId :: !(TVar Int)- , publisherThread :: !(Async ())- , lastPublished :: !(TVar GlobalPosition)- -- ^ Last-published position; workers read this to know when catch-up is done- }+ { -- | Active subscriber registry, keyed by an internal id.+ subscribers :: !(TVar (IntMap Subscriber)),+ nextSubscriberId :: !(TVar Int),+ publisherThread :: !(Async ()),+ -- | Last-published position; workers read this to know when catch-up is done+ lastPublished :: !(TVar GlobalPosition)+ } -{- | A registered subscriber. The publisher owns one of these per-'Kiroku.Store.Subscription.subscribe' call. The worker reads from-'subQueue' under normal operation; on overflow under 'DropSubscription'-the publisher flips 'subStatus' to 'Overflowed' and the worker-terminates the subscription with 'SubscriptionOverflowed'.--}+-- | A registered subscriber. The publisher owns one of these per+-- 'Kiroku.Store.Subscription.subscribe' call. The worker reads from+-- 'subQueue' under normal operation; on overflow under 'DropSubscription'+-- the publisher flips 'subStatus' to 'Overflowed' and the worker+-- terminates the subscription with 'SubscriptionOverflowed'. data Subscriber = Subscriber- { subQueue :: !(TBQueue DecodedBatch)- , subStatus :: !(TVar SubscriberStatus)- , subPolicy :: !OverflowPolicy- , subDropped :: !(TVar Word64)- -- ^ Dropped batches under DropOldest; compare successive readings (modulo Word64).- }+ { subQueue :: !(TBQueue DecodedBatch),+ subStatus :: !(TVar SubscriberStatus),+ subPolicy :: !OverflowPolicy,+ -- | Dropped batches under DropOldest; compare successive readings (modulo Word64).+ subDropped :: !(TVar Word64)+ } -- | A bounded publisher subscription with its exact dropped-batch counter. data PublisherSubscription = PublisherSubscription- { subscriptionQueue :: !(TBQueue DecodedBatch)- , subscriptionStatus :: !(TVar SubscriberStatus)- , subscriptionDropped :: !(TVar Word64)- , unsubscribe :: !(IO ())- -- ^ Idempotent deregistration; invoke on every exit.- }+ { subscriptionQueue :: !(TBQueue DecodedBatch),+ subscriptionStatus :: !(TVar SubscriberStatus),+ subscriptionDropped :: !(TVar Word64),+ -- | Idempotent deregistration; invoke on every exit.+ unsubscribe :: !(IO ())+ } -- | Subscriber lifecycle status as observed by its worker. data SubscriberStatus- = -- | Healthy; worker reads from the queue normally.- Active- | {- | Recoverable backpressure under 'PauseAndResume': the queue was full- so the publisher stopped pushing and set this flag. The worker observes- it, drains the stale queue, clears the flag back to 'Active', and- re-catches-up from its checkpoint to recover any skipped events.- -}- Paused- | {- | Publisher signalled overflow under 'DropSubscription'; worker should- surface a structured error.- -}- Overflowed- deriving stock (Eq, Show)+ = -- | Healthy; worker reads from the queue normally.+ Active+ | -- | Recoverable backpressure under 'PauseAndResume': the queue was full+ -- so the publisher stopped pushing and set this flag. The worker observes+ -- it, drains the stale queue, clears the flag back to 'Active', and+ -- re-catches-up from its checkpoint to recover any skipped events.+ Paused+ | -- | Publisher signalled overflow under 'DropSubscription'; worker should+ -- surface a structured error.+ Overflowed+ deriving stock (Eq, Show) -{- | Publisher batch size — larger than subscriber batch size because the-Publisher serves all subscribers.--}+-- | Publisher batch size — larger than subscriber batch size because the+-- Publisher serves all subscribers. publisherBatchSize :: Int32 publisherBatchSize = 1000 @@ -135,86 +130,83 @@ safetyPollMicros :: Int safetyPollMicros = 30_000_000 -{- | Start the EventPublisher. Spawns a thread that waits for ticks from the-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-query returns a 'Pool.UsageError'. The publisher continues to run on the-30-second safety poll regardless of the event firing; the event is the-operator's only structured signal that pool exhaustion or a server-error stalled the broadcast.--}+-- | Start the EventPublisher. Spawns a thread that waits for ticks from the+-- 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+-- query returns a 'Pool.UsageError'. The publisher continues to run on the+-- 30-second safety poll regardless of the event firing; the event is the+-- operator's only structured signal that pool exhaustion or a server+-- error stalled the broadcast. startPublisher ::- (MonadIO m) =>- Pool ->- TChan () ->- -- | optional event handler for publisher-side observability- Maybe (KirokuEvent -> IO ()) ->- {- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'- runs once per fetched batch before fan-out to subscribers.- -}- StoreSettings ->- m EventPublisher+ (MonadIO m) =>+ Pool ->+ TChan () ->+ -- | optional event handler for publisher-side observability+ Maybe (KirokuEvent -> IO ()) ->+ -- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'+ -- runs once per fetched batch before fan-out to subscribers.+ StoreSettings ->+ m EventPublisher 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)- thread <- Async.async (publisherLoop pool tickChan subsVar pos mHandler stSettings)- pure- EventPublisher- { subscribers = subsVar- , nextSubscriberId = nextIdVar- , publisherThread = thread- , lastPublished = pos- }+ 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)+ thread <- Async.async (publisherLoop pool tickChan subsVar pos mHandler stSettings)+ pure+ EventPublisher+ { subscribers = subsVar,+ nextSubscriberId = nextIdVar,+ publisherThread = thread,+ lastPublished = pos+ } -- | Stop the EventPublisher thread. stopPublisher :: (MonadIO m) => EventPublisher -> m () stopPublisher pub = liftIO $ do- Async.cancel (publisherThread pub)- () <$ Async.waitCatch (publisherThread pub)+ Async.cancel (publisherThread pub)+ () <$ Async.waitCatch (publisherThread pub) -{- | Register a new subscriber with the given queue capacity and overflow-policy. Returns the bounded queue, the subscriber's status TVar, and an-@unsubscribe@ action that the caller must invoke when the subscription-ends (worker exits, cancellation). Failing to unsubscribe causes the-publisher to keep delivering to a queue with no reader, which will fill-and trigger the configured policy unnecessarily.--}+-- | Register a new subscriber with the given queue capacity and overflow+-- policy. Returns the bounded queue, the subscriber's status TVar, and an+-- @unsubscribe@ action that the caller must invoke when the subscription+-- ends (worker exits, cancellation). Failing to unsubscribe causes the+-- publisher to keep delivering to a queue with no reader, which will fill+-- and trigger the configured policy unnecessarily. subscribePublisher ::- EventPublisher ->- -- | Queue capacity (number of batches)- Natural ->- OverflowPolicy ->- STM (TBQueue DecodedBatch, TVar SubscriberStatus, IO ())+ EventPublisher ->+ -- | Queue capacity (number of batches)+ Natural ->+ OverflowPolicy ->+ STM (TBQueue DecodedBatch, TVar SubscriberStatus, IO ()) subscribePublisher pub cap policy = do- sub <- subscribePublisherWith pub cap policy- pure (subscriptionQueue sub, subscriptionStatus sub, unsubscribe sub)+ sub <- subscribePublisherWith pub cap policy+ pure (subscriptionQueue sub, subscriptionStatus sub, unsubscribe sub) -- | Register a subscriber, also exposing the DropOldest counter (initially zero). subscribePublisherWith :: EventPublisher -> Natural -> OverflowPolicy -> STM PublisherSubscription subscribePublisherWith pub cap policy = do- queue <- newTBQueue cap- status <- newTVar Active- dropped <- newTVar 0- sid <- readTVar (nextSubscriberId pub)- writeTVar (nextSubscriberId pub) (sid + 1)- let sub = Subscriber{subQueue = queue, subStatus = status, subPolicy = policy, subDropped = dropped}- modifyTVar' (subscribers pub) (IntMap.insert sid sub)- let deregister =- atomically $- modifyTVar' (subscribers pub) (IntMap.delete sid)- pure (PublisherSubscription queue status dropped deregister)+ queue <- newTBQueue cap+ status <- newTVar Active+ dropped <- newTVar 0+ sid <- readTVar (nextSubscriberId pub)+ writeTVar (nextSubscriberId pub) (sid + 1)+ let sub = Subscriber {subQueue = queue, subStatus = status, subPolicy = policy, subDropped = dropped}+ modifyTVar' (subscribers pub) (IntMap.insert sid sub)+ let deregister =+ atomically $+ modifyTVar' (subscribers pub) (IntMap.delete sid)+ pure (PublisherSubscription queue status dropped deregister) -- | Read the last-published global position. publisherPosition :: EventPublisher -> STM GlobalPosition@@ -222,144 +214,144 @@ -- Internal: the publisher loop. publisherLoop ::- Pool ->- TChan () ->- TVar (IntMap Subscriber) ->- TVar GlobalPosition ->- Maybe (KirokuEvent -> IO ()) ->- StoreSettings ->- IO ()+ Pool ->+ TChan () ->+ TVar (IntMap Subscriber) ->+ TVar GlobalPosition ->+ Maybe (KirokuEvent -> IO ()) ->+ StoreSettings ->+ IO () publisherLoop pool tickChan subsVar posVar mHandler stSettings = loop where loop = do- -- Wait for a tick OR safety poll timeout- waitForWakeup tickChan- -- Drain all pending ticks (debouncing)- drainTicks tickChan- -- 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+ -- Wait for a tick OR safety poll timeout+ waitForWakeup tickChan+ -- Drain all pending ticks (debouncing)+ drainTicks tickChan+ -- 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+ 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- let nextPos = max cur tailPos- -- Do not store a chain of lazy max expressions that- -- retains earlier tail-query results.- nextPos `seq` writeTVar posVar (GlobalPosition nextPos)- pure False- else pure True- when raced fullFetch+ 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+ let nextPos = max cur tailPos+ -- Do not store a chain of lazy max expressions that+ -- retains earlier tail-query results.+ nextPos `seq` writeTVar posVar (GlobalPosition nextPos)+ 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.- emitOrDrop mHandler (KirokuEventPublisherPoolError err)- Right rawEvents- | V.null rawEvents -> pure ()- | otherwise -> do- -- Apply the decodeHook once per batch — every subscriber- -- observes the same transformed view, and the cost is- -- paid in one place instead of per-subscriber.- events <- decodeEvents stSettings rawEvents- case events of- UnchangedBatch _ -> pure ()- TransformedBatch decoded -> for_ decoded $ \case- Decoded _ -> pure ()- Undecodable event failure -> emitOrDrop mHandler (KirokuEventPublisherDecodeFailed (globalPosition event) (eventId event) failure)- let lastEvent = V.last rawEvents- newPos = globalPosition lastEvent- -- Snapshot the current subscriber set, then deliver outside- -- the snapshot's STM transaction. Each delivery is its own- -- atomic step so a slow subscriber under DropSubscription- -- cannot rollback another's enqueue.- subs <- readTVarIO subsVar- for_ (IntMap.elems subs) (deliverBatch events)- -- 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 rawEvents >= fromIntegral publisherBatchSize- then fullFetch- else pure ()+ 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.+ emitOrDrop mHandler (KirokuEventPublisherPoolError err)+ Right rawEvents+ | V.null rawEvents -> pure ()+ | otherwise -> do+ -- Apply the decodeHook once per batch — every subscriber+ -- observes the same transformed view, and the cost is+ -- paid in one place instead of per-subscriber.+ events <- decodeEvents stSettings rawEvents+ case events of+ UnchangedBatch _ -> pure ()+ TransformedBatch decoded -> for_ decoded $ \case+ Decoded _ -> pure ()+ Undecodable event failure -> emitOrDrop mHandler (KirokuEventPublisherDecodeFailed (globalPosition event) (eventId event) failure)+ let lastEvent = V.last rawEvents+ newPos = globalPosition lastEvent+ -- Snapshot the current subscriber set, then deliver outside+ -- the snapshot's STM transaction. Each delivery is its own+ -- atomic step so a slow subscriber under DropSubscription+ -- cannot rollback another's enqueue.+ subs <- readTVarIO subsVar+ for_ (IntMap.elems subs) (deliverBatch events)+ -- 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 rawEvents >= fromIntegral publisherBatchSize+ then fullFetch+ else pure () deliverBatch events sub = atomically $ do- deliverBatchSTM events sub+ deliverBatchSTM events sub deliverBatchSTM events sub = do- full <- isFullTBQueue (subQueue sub)- if not full- then do- -- There is space again: clear a prior 'PauseAndResume' pause so- -- the worker (which also clears it on resume) is not left waiting.- -- Never clear a terminal 'DropSubscription' overflow.- status <- readTVar (subStatus sub)- case status of- Paused -> writeTVar (subStatus sub) Active- Active -> pure ()- Overflowed -> pure ()- writeTBQueue (subQueue sub) events- else case subPolicy sub of- -- Recoverable: signal the pause and stop pushing. Do not drop;- -- the worker re-reads the skipped events from its checkpoint.- PauseAndResume -> writeTVar (subStatus sub) Paused- DropSubscription -> writeTVar (subStatus sub) Overflowed- DropOldest -> do- _ <- tryReadTBQueue (subQueue sub)- modifyTVar' (subDropped sub) (+ 1)- writeTBQueue (subQueue sub) events+ full <- isFullTBQueue (subQueue sub)+ if not full+ then do+ -- There is space again: clear a prior 'PauseAndResume' pause so+ -- the worker (which also clears it on resume) is not left waiting.+ -- Never clear a terminal 'DropSubscription' overflow.+ status <- readTVar (subStatus sub)+ case status of+ Paused -> writeTVar (subStatus sub) Active+ Active -> pure ()+ Overflowed -> pure ()+ writeTBQueue (subQueue sub) events+ else case subPolicy sub of+ -- Recoverable: signal the pause and stop pushing. Do not drop;+ -- the worker re-reads the skipped events from its checkpoint.+ PauseAndResume -> writeTVar (subStatus sub) Paused+ DropSubscription -> writeTVar (subStatus sub) Overflowed+ DropOldest -> do+ _ <- tryReadTBQueue (subQueue sub)+ modifyTVar' (subDropped sub) (+ 1)+ writeTBQueue (subQueue sub) events -- Wait for either a tick or a 30-second timeout (safety poll). waitForWakeup :: TChan () -> IO () waitForWakeup tickChan = do- timerVar <- registerDelay safetyPollMicros- atomically $- (readTChan tickChan)- `STM.orElse` (readTVar timerVar >>= STM.check)+ timerVar <- registerDelay safetyPollMicros+ atomically $+ (readTChan tickChan)+ `STM.orElse` (readTVar timerVar >>= STM.check) -- Drain all pending ticks from the channel (debouncing). drainTicks :: TChan () -> IO () drainTicks tickChan = atomically go where go = do- mTick <- tryReadTChan tickChan- case mTick of- Nothing -> pure ()- Just () -> go+ mTick <- tryReadTChan tickChan+ case mTick of+ Nothing -> pure ()+ Just () -> go
src/Kiroku/Store/Subscription/Fsm.hs view
@@ -1,37 +1,36 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | The explicit finite state machine driving a subscription worker.--A subscription worker is, at every instant, in exactly one named-'SubscriptionState'. A single, exhaustively pattern-matched transition-function 'step' names every legal move: given the current state and an-'Input' (a thing that just happened — a batch arrived, a fetch failed, the-queue overflowed, …) it returns the next state and a list of 'Effect's the-/driver/ ('Kiroku.Store.Subscription.Worker.runWorker') must perform.--Keeping the transition table in one pure function has two payoffs:-- * It is the single, documented /extension seam/ for the sibling plan- @docs\/plans\/40-...@ (per-event retry \/ dead-letter). When that plan adds- a 'Kiroku.Store.Subscription.Types.SubscriptionResult' constructor it will- also add a matching 'Input' constructor; because 'step' is exhaustively- pattern-matched on 'Input' within each driving state and this module is- compiled with @-Werror=incomplete-patterns@, that addition fails to- compile until every relevant clause handles it, rather than falling- through silently.- * There is exactly one place to read the current state for observability- (the worker mirrors it into a @TVar@ — see M4).--This module is deliberately a near-leaf in the dependency graph: it depends-only on 'Kiroku.Store.Types' and @hasql-pool@, never on-'Kiroku.Store.Subscription.Types' or 'Kiroku.Store.Observability'. That keeps-the graph acyclic once 'Kiroku.Store.Subscription.Types' grows a handle field-of type 'SubscriptionState'. The terminal-reason enumeration-'SubscriptionStopReason' lives here for the same reason and is re-exported by-'Kiroku.Store.Observability' so the public event API is unchanged.--}-module Kiroku.Store.Subscription.Fsm (- -- * State+-- | The explicit finite state machine driving a subscription worker.+--+-- A subscription worker is, at every instant, in exactly one named+-- 'SubscriptionState'. A single, exhaustively pattern-matched transition+-- function 'step' names every legal move: given the current state and an+-- 'Input' (a thing that just happened — a batch arrived, a fetch failed, the+-- queue overflowed, …) it returns the next state and a list of 'Effect's the+-- /driver/ ('Kiroku.Store.Subscription.Worker.runWorker') must perform.+--+-- Keeping the transition table in one pure function has two payoffs:+--+-- * It is the single, documented /extension seam/ for the sibling plan+-- @docs\/plans\/40-...@ (per-event retry \/ dead-letter). When that plan adds+-- a 'Kiroku.Store.Subscription.Types.SubscriptionResult' constructor it will+-- also add a matching 'Input' constructor; because 'step' is exhaustively+-- pattern-matched on 'Input' within each driving state and this module is+-- compiled with @-Werror=incomplete-patterns@, that addition fails to+-- compile until every relevant clause handles it, rather than falling+-- through silently.+-- * There is exactly one place to read the current state for observability+-- (the worker mirrors it into a @TVar@ — see M4).+--+-- This module is deliberately a near-leaf in the dependency graph: it depends+-- only on 'Kiroku.Store.Types' and @hasql-pool@, never on+-- 'Kiroku.Store.Subscription.Types' or 'Kiroku.Store.Observability'. That keeps+-- the graph acyclic once 'Kiroku.Store.Subscription.Types' grows a handle field+-- of type 'SubscriptionState'. The terminal-reason enumeration+-- 'SubscriptionStopReason' lives here for the same reason and is re-exported by+-- 'Kiroku.Store.Observability' so the public event API is unchanged.+module Kiroku.Store.Subscription.Fsm+ ( -- * State SubscriptionState (..), ResumeCondition (..), stateCursor,@@ -53,7 +52,8 @@ -- * Transition function step,-) where+ )+where import Control.Exception (SomeException) import Data.Aeson (Value, object, (.=))@@ -64,300 +64,294 @@ import Kiroku.Store.Settings (DecodeFailure (..), DecodedBatch, decodedBatchLastEvent, decodedBatchLength) import Kiroku.Store.Types (EventId (..), GlobalPosition (..), RecordedEvent (..)) -{- | Why a subscription's worker thread stopped.--Defined here (rather than in 'Kiroku.Store.Observability') so the FSM's-'Stopped' state can carry it without 'Kiroku.Store.Subscription.Fsm' having to-import the observability module — which would close a dependency cycle once-'Kiroku.Store.Subscription.Types' references 'SubscriptionState'.-'Kiroku.Store.Observability' re-exports this type, so the @KirokuEvent@ API is-unchanged.--}+-- | Why a subscription's worker thread stopped.+--+-- Defined here (rather than in 'Kiroku.Store.Observability') so the FSM's+-- 'Stopped' state can carry it without 'Kiroku.Store.Subscription.Fsm' having to+-- import the observability module — which would close a dependency cycle once+-- 'Kiroku.Store.Subscription.Types' references 'SubscriptionState'.+-- 'Kiroku.Store.Observability' re-exports this type, so the @KirokuEvent@ API is+-- unchanged. data SubscriptionStopReason- = {- | The handler returned 'Kiroku.Store.Subscription.Types.Stop' for some- event. The normal completion path; the checkpoint is saved at that event.- -}- StopHandlerRequested- | {- | The caller invoked @cancel@. No checkpoint advance is guaranteed;- in-flight events replay on the next restart.- -}- StopCancelled- | {- | The publisher marked the subscription overflowed under- 'Kiroku.Store.Subscription.Types.DropSubscription' and the worker- surfaced 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed'.- -}- StopOverflowed- | {- | The worker thread died from an uncaught exception (typically a handler- exception). The 'SomeException' carries the cause.- -}- StopWorkerCrashed !SomeException- | -- | Default undecodable-event retries exhausted; checkpoint remains before it.- StopUndecodable !DecodeFailure- deriving stock (Show)--{- | How long to wait before redelivering a retried event.+ = -- | The handler returned 'Kiroku.Store.Subscription.Types.Stop' for some+ -- event. The normal completion path; the checkpoint is saved at that event.+ StopHandlerRequested+ | -- | The caller invoked @cancel@. No checkpoint advance is guaranteed;+ -- in-flight events replay on the next restart.+ StopCancelled+ | -- | The publisher marked the subscription overflowed under+ -- 'Kiroku.Store.Subscription.Types.DropSubscription' and the worker+ -- surfaced 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed'.+ StopOverflowed+ | -- | The worker thread died from an uncaught exception (typically a handler+ -- exception). The 'SomeException' carries the cause.+ StopWorkerCrashed !SomeException+ | -- | Default undecodable-event retries exhausted; checkpoint remains before it.+ StopUndecodable !DecodeFailure+ deriving stock (Show) -The handler's @Kiroku.Store.Subscription.Types.Retry@ disposition carries one of-these; the worker sleeps for it before the next redelivery. Mirrors Shibuya's-@Shibuya.Core.Ack.RetryDelay@ shape (a 'NominalDiffTime') so the-@shibuya-kiroku-adapter@ can translate @AckRetry@ without loss, but is a-Kiroku-owned type so @kiroku-store@ does not depend on @shibuya-core@.--}+-- | How long to wait before redelivering a retried event.+--+-- The handler's @Kiroku.Store.Subscription.Types.Retry@ disposition carries one of+-- these; the worker sleeps for it before the next redelivery. Mirrors Shibuya's+-- @Shibuya.Core.Ack.RetryDelay@ shape (a 'NominalDiffTime') so the+-- @shibuya-kiroku-adapter@ can translate @AckRetry@ without loss, but is a+-- Kiroku-owned type so @kiroku-store@ does not depend on @shibuya-core@. newtype RetryDelay = RetryDelay NominalDiffTime- deriving stock (Eq, Show)+ deriving stock (Eq, Show) -{- | The retry delay in microseconds, clamped at @0@, suitable for-'Control.Concurrent.threadDelay'. Fractional seconds are rounded.--}+-- | The retry delay in microseconds, clamped at @0@, suitable for+-- 'Control.Concurrent.threadDelay'. Fractional seconds are rounded. retryDelayMicros :: RetryDelay -> Int retryDelayMicros (RetryDelay d) = max 0 (round (realToFrac d * 1_000_000 :: Double)) -{- | Why an event was dead-lettered (recorded in @kiroku.dead_letters@ while the-subscription advances past it). Kiroku-owned so @kiroku-store@ stays independent-of @shibuya-core@; the adapter converts Shibuya's @DeadLetterReason@ into one of-these. 'deadLetterSummary' produces the @reason_summary@ text column and-'deadLetterReasonJson' the structured @reason@ JSONB column.--}+-- | Why an event was dead-lettered (recorded in @kiroku.dead_letters@ while the+-- subscription advances past it). Kiroku-owned so @kiroku-store@ stays independent+-- of @shibuya-core@; the adapter converts Shibuya's @DeadLetterReason@ into one of+-- these. 'deadLetterSummary' produces the @reason_summary@ text column and+-- 'deadLetterReasonJson' the structured @reason@ JSONB column. data DeadLetterReason- = -- | Permanently unprocessable; the 'Text' is operator-facing detail.- 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 total deliveries.- DeadLetterMaxAttempts !Int- | -- | A custom reason: a summary plus structured JSON detail.- DeadLetterOther !Text !Value- | -- | A consumer explicitly chose to skip an undecodable event.- DeadLetterDecodeFailure !DecodeFailure- deriving stock (Eq, Show)+ = -- | Permanently unprocessable; the 'Text' is operator-facing detail.+ 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 total deliveries.+ DeadLetterMaxAttempts !Int+ | -- | A custom reason: a summary plus structured JSON detail.+ DeadLetterOther !Text !Value+ | -- | A consumer explicitly chose to skip an undecodable event.+ DeadLetterDecodeFailure !DecodeFailure+ deriving stock (Eq, Show) -- | The short operator-facing summary stored in @dead_letters.reason_summary@. deadLetterSummary :: DeadLetterReason -> Text deadLetterSummary = \case- DeadLetterPoison detail -> "poison: " <> detail- DeadLetterInvalid detail -> "invalid payload: " <> detail- DeadLetterMaxAttempts n -> "max retry attempts exceeded (" <> T.pack (show n) <> ")"- DeadLetterOther summary _ -> summary- DeadLetterDecodeFailure failure -> "decode failure: " <> decodeFailureReason failure+ DeadLetterPoison detail -> "poison: " <> detail+ DeadLetterInvalid detail -> "invalid payload: " <> detail+ DeadLetterMaxAttempts n -> "max retry attempts exceeded (" <> T.pack (show n) <> ")"+ DeadLetterOther summary _ -> summary+ DeadLetterDecodeFailure failure -> "decode failure: " <> decodeFailureReason failure -- | The structured JSON detail stored in @dead_letters.reason@ (JSONB). deadLetterReasonJson :: DeadLetterReason -> Value deadLetterReasonJson = \case- DeadLetterPoison detail -> object ["kind" .= ("poison" :: Text), "detail" .= detail]- DeadLetterInvalid detail -> object ["kind" .= ("invalid_payload" :: Text), "detail" .= detail]- DeadLetterMaxAttempts n -> object ["kind" .= ("max_attempts_exceeded" :: Text), "attempts" .= n]- DeadLetterOther summary detail -> object ["kind" .= ("other" :: Text), "summary" .= summary, "detail" .= detail]- DeadLetterDecodeFailure failure -> object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= decodeFailureReason failure]--{- | What unblocks a 'Paused' worker.+ DeadLetterPoison detail -> object ["kind" .= ("poison" :: Text), "detail" .= detail]+ DeadLetterInvalid detail -> object ["kind" .= ("invalid_payload" :: Text), "detail" .= detail]+ DeadLetterMaxAttempts n -> object ["kind" .= ("max_attempts_exceeded" :: Text), "attempts" .= n]+ DeadLetterOther summary detail -> object ["kind" .= ("other" :: Text), "summary" .= summary, "detail" .= detail]+ DeadLetterDecodeFailure failure -> object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= decodeFailureReason failure] -In M1 there is one resume condition: the bounded queue has drained enough that-the publisher cleared the subscriber's @Paused@ flag. The constructor is named-rather than a bare unit so a future backpressure source (e.g. an explicit-resume signal) can be added without changing the 'SubscriptionState' shape.--}+-- | What unblocks a 'Paused' worker.+--+-- In M1 there is one resume condition: the bounded queue has drained enough that+-- the publisher cleared the subscriber's @Paused@ flag. The constructor is named+-- rather than a bare unit so a future backpressure source (e.g. an explicit+-- resume signal) can be added without changing the 'SubscriptionState' shape. data ResumeCondition- = -- | Resume once the consumer drains its bounded queue.- ResumeOnDrain- deriving stock (Eq, Show)--{- | The named state a subscription worker is in.+ = -- | Resume once the consumer drains its bounded queue.+ ResumeOnDrain+ deriving stock (Eq, Show) - * 'CatchingUp' — reading history directly from PostgreSQL in batches from- @cursor@ until it reaches the publisher's last-published position.- @attempt@ counts consecutive failed catch-up fetches and drives the- capped exponential backoff; it resets to 0 on any successful fetch.- * 'Live' — caught up; receiving new events (from the publisher's bounded- queue for AllStreams, or by re-querying for Category / consumer-group- members).- * 'Paused' — recoverable backpressure: a slow consumer filled its bounded- queue, so the worker stopped pulling and waits for 'resumeWhen' (added in- M2; in M1 this state is defined but never entered).- * 'Reconnecting' — recovering from a lost database connection while live;- backs off and re-enters 'CatchingUp' from @cursor@ (added in M3; in M1- defined but never entered).- * 'Retrying' — a handler returned- 'Kiroku.Store.Subscription.Types.Retry' for the event at @cursor@; the- worker is redelivering it (@attempt@ counts redeliveries so far) and has not- advanced the checkpoint past it. Added by the sibling retry\/dead-letter plan- (@docs\/plans\/40-...@). It is a /surfaced observability state/: the worker's- delivery primitive writes it into the observable state @TVar@ while a- redelivery is pending and restores the prior driving state afterward, so it is- visible through @currentState@ but is never itself a driving state fed back to- 'step' (the @Retrying@ clause in 'step' is therefore defensive).- * 'Stopped' — terminal, carrying the 'SubscriptionStopReason'.--}+-- | The named state a subscription worker is in.+--+-- * 'CatchingUp' — reading history directly from PostgreSQL in batches from+-- @cursor@ until it reaches the publisher's last-published position.+-- @attempt@ counts consecutive failed catch-up fetches and drives the+-- capped exponential backoff; it resets to 0 on any successful fetch.+-- * 'Live' — caught up; receiving new events (from the publisher's bounded+-- queue for AllStreams, or by re-querying for Category / consumer-group+-- members).+-- * 'Paused' — recoverable backpressure: a slow consumer filled its bounded+-- queue, so the worker stopped pulling and waits for 'resumeWhen' (added in+-- M2; in M1 this state is defined but never entered).+-- * 'Reconnecting' — recovering from a lost database connection while live;+-- backs off and re-enters 'CatchingUp' from @cursor@ (added in M3; in M1+-- defined but never entered).+-- * 'Retrying' — a handler returned+-- 'Kiroku.Store.Subscription.Types.Retry' for the event at @cursor@; the+-- worker is redelivering it (@attempt@ counts redeliveries so far) and has not+-- advanced the checkpoint past it. Added by the sibling retry\/dead-letter plan+-- (@docs\/plans\/40-...@). It is a /surfaced observability state/: the worker's+-- delivery primitive writes it into the observable state @TVar@ while a+-- redelivery is pending and restores the prior driving state afterward, so it is+-- visible through @currentState@ but is never itself a driving state fed back to+-- 'step' (the @Retrying@ clause in 'step' is therefore defensive).+-- * 'Stopped' — terminal, carrying the 'SubscriptionStopReason'. data SubscriptionState- = CatchingUp {cursor :: !GlobalPosition, attempt :: !Int}- | Live {cursor :: !GlobalPosition}- | Paused {cursor :: !GlobalPosition, resumeWhen :: !ResumeCondition}- | Reconnecting {cursor :: !GlobalPosition, attempt :: !Int}- | Retrying {cursor :: !GlobalPosition, attempt :: !Int}- | Stopped {reason :: !SubscriptionStopReason}- deriving stock (Show)+ = CatchingUp {cursor :: !GlobalPosition, attempt :: !Int}+ | Live {cursor :: !GlobalPosition}+ | Paused {cursor :: !GlobalPosition, resumeWhen :: !ResumeCondition}+ | Reconnecting {cursor :: !GlobalPosition, attempt :: !Int}+ | Retrying {cursor :: !GlobalPosition, attempt :: !Int}+ | Stopped {reason :: !SubscriptionStopReason}+ deriving stock (Show) -{- | The cursor of a non-terminal state, or 'GlobalPosition' @0@ for 'Stopped'.-Used by the driver to attach the current position to emitted events.--}+-- | The cursor of a non-terminal state, or 'GlobalPosition' @0@ for 'Stopped'.+-- Used by the driver to attach the current position to emitted events. stateCursor :: SubscriptionState -> GlobalPosition stateCursor = \case- CatchingUp c _ -> c- Live c -> c- Paused c _ -> c- Reconnecting c _ -> c- Retrying c _ -> c- Stopped _ -> GlobalPosition 0+ CatchingUp c _ -> c+ Live c -> c+ Paused c _ -> c+ Reconnecting c _ -> c+ Retrying c _ -> c+ Stopped _ -> GlobalPosition 0 -{- | A stable, low-cardinality label for the state's /name/, independent of its-payload (cursor, attempt, reason). Suitable as a metric label value or an admin-column. The strings are fixed identifiers, not the derived 'Show' output, so-they will not drift if a constructor's fields change.--}+-- | A stable, low-cardinality label for the state's /name/, independent of its+-- payload (cursor, attempt, reason). Suitable as a metric label value or an admin+-- column. The strings are fixed identifiers, not the derived 'Show' output, so+-- they will not drift if a constructor's fields change. stateName :: SubscriptionState -> Text stateName = \case- CatchingUp{} -> "catching_up"- Live{} -> "live"- Paused{} -> "paused"- Reconnecting{} -> "reconnecting"- Retrying{} -> "retrying"- Stopped{} -> "stopped"--{- | A thing that just happened, fed to 'step' to compute the next state.+ CatchingUp {} -> "catching_up"+ Live {} -> "live"+ Paused {} -> "paused"+ Reconnecting {} -> "reconnecting"+ Retrying {} -> "retrying"+ Stopped {} -> "stopped" -The set is closed in M1; the sibling retry\/dead-letter plan extends it. Adding-a constructor here forces a compile error in every driving-state clause of-'step' that does not yet handle it (this module is @-Werror=incomplete-patterns@).--}+-- | A thing that just happened, fed to 'step' to compute the next state.+--+-- The set is closed in M1; the sibling retry\/dead-letter plan extends it. Adding+-- a constructor here forces a compile error in every driving-state clause of+-- 'step' that does not yet handle it (this module is @-Werror=incomplete-patterns@). data Input- = -- | A non-empty history\/live batch arrived.- BatchFetched !DecodedBatch- | -- | A fetch returned no rows (catch-up is complete).- FetchEmpty- | -- | A fetch hit a database error.- FetchFailed !Pool.UsageError- | -- | The cursor reached the publisher position; switch to live.- CaughtUp- | -- | The handler returned 'Stop' at this position.- HandlerStopped !GlobalPosition- | {- | The bounded queue filled under the fail-fast 'DropSubscription' policy- (terminal).- -}- QueueOverflowed- | {- | The bounded queue filled under the recoverable 'PauseAndResume' policy:- pause rather than terminate.- -}- QueueBackpressured- | -- | The worker drained the stale queue and is ready to recover (re-catch-up).- QueueDrained- | -- | The worker lost its database pool while live.- ConnectionLost !GlobalPosition !Pool.UsageError- | -- | The caller cancelled the worker.- Cancelled- deriving stock (Show)--{- | A side effect the driver must perform after a transition.+ = -- | A non-empty history\/live batch arrived.+ BatchFetched !DecodedBatch+ | -- | A scanned namespace window; progress remains speculative until delivery.+ NamespaceWindowFetched !DecodedBatch !GlobalPosition !Bool+ | -- | Every applicable disposition in the window has completed.+ NamespaceWindowCompleted !GlobalPosition !Bool+ | -- | A fetch returned no rows (catch-up is complete).+ FetchEmpty+ | -- | A fetch hit a database error.+ FetchFailed !Pool.UsageError+ | -- | The cursor reached the publisher position; switch to live.+ CaughtUp+ | -- | The handler returned 'Stop' at this position.+ HandlerStopped !GlobalPosition+ | -- | The bounded queue filled under the fail-fast 'DropSubscription' policy+ -- (terminal).+ QueueOverflowed+ | -- | The bounded queue filled under the recoverable 'PauseAndResume' policy:+ -- pause rather than terminate.+ QueueBackpressured+ | -- | The worker drained the stale queue and is ready to recover (re-catch-up).+ QueueDrained+ | -- | The worker lost its database pool while live.+ ConnectionLost !GlobalPosition !Pool.UsageError+ | -- | The caller cancelled the worker.+ Cancelled+ deriving stock (Show) -Some constructors (e.g. 'FetchHistory', 'RunLive') describe work the driver-already performs by inspecting the next state; they exist so the effect list is-a complete description of the transition for future drivers and for readers.--}+-- | A side effect the driver must perform after a transition.+--+-- Some constructors (e.g. 'FetchHistory', 'RunLive') describe work the driver+-- already performs by inspecting the next state; they exist so the effect list is+-- a complete description of the transition for future drivers and for readers. data Effect- = -- | Read a catch-up batch from the given cursor.- FetchHistory !GlobalPosition- | -- | Obtain the next live batch via the active live strategy.- RunLive- | -- | Call the handler per event, checkpointing at the batch tail.- DeliverBatch !DecodedBatch- | {- | Persist the checkpoint at this position. (Named 'Checkpoint' rather- than @SaveCheckpoint@ to avoid clashing with the- 'Kiroku.Store.Observability.SubscriptionDbPhase' constructor of that name.)- -}- Checkpoint !GlobalPosition- | -- | Block until the consumer drains the queue.- WaitForDrain- | -- | Sleep for the backoff schedule of this attempt number.- Backoff !Int- | -- | Emit @KirokuEventSubscriptionCaughtUp@.- EmitCaughtUp- | -- | Emit @KirokuEventSubscriptionPaused@ (M4).- EmitPaused- | -- | Emit @KirokuEventSubscriptionResumed@ (M4).- EmitResumed- | -- | Emit @KirokuEventSubscriptionReconnecting@ with this attempt (M4).- EmitReconnecting !Int- | -- | Terminal: stop the driver with this reason (return or rethrow).- Halt !SubscriptionStopReason- deriving stock (Show)+ = -- | Read a catch-up batch from the given cursor.+ FetchHistory !GlobalPosition+ | -- | Obtain the next live batch via the active live strategy.+ RunLive+ | -- | Call the handler per event, checkpointing at the batch tail.+ DeliverBatch !DecodedBatch+ | -- | Deliver before publishing the window frontier as completed state.+ DeliverNamespaceWindow !DecodedBatch !GlobalPosition !Bool+ | -- | Persist the checkpoint at this position. (Named 'Checkpoint' rather+ -- than @SaveCheckpoint@ to avoid clashing with the+ -- 'Kiroku.Store.Observability.SubscriptionDbPhase' constructor of that name.)+ Checkpoint !GlobalPosition+ | -- | Block until the consumer drains the queue.+ WaitForDrain+ | -- | Sleep for the backoff schedule of this attempt number.+ Backoff !Int+ | -- | Emit @KirokuEventSubscriptionCaughtUp@.+ EmitCaughtUp+ | -- | Emit @KirokuEventSubscriptionPaused@ (M4).+ EmitPaused+ | -- | Emit @KirokuEventSubscriptionResumed@ (M4).+ EmitResumed+ | -- | Emit @KirokuEventSubscriptionReconnecting@ with this attempt (M4).+ EmitReconnecting !Int+ | -- | Terminal: stop the driver with this reason (return or rethrow).+ Halt !SubscriptionStopReason+ deriving stock (Show) -- The last event's position in a batch, falling back to the given cursor when -- the batch is empty (the driver never feeds 'step' an empty 'BatchFetched', -- so the fallback is defensive). lastPos :: GlobalPosition -> DecodedBatch -> GlobalPosition lastPos fallback evs- | decodedBatchLength evs == 0 = fallback- | otherwise = globalPosition (decodedBatchLastEvent evs)--{- | The single transition function. Given the current state and an input,-return the next state and the effects to perform.+ | decodedBatchLength evs == 0 = fallback+ | otherwise = globalPosition (decodedBatchLastEvent evs) -Exhaustively pattern-matched on 'Input' within the two /driving/ states-('CatchingUp', 'Live') so that extending 'Input' (the retry\/dead-letter-sibling plan) forces a compile error here. The recoverable states 'Paused' and-'Reconnecting' carry their M2\/M3 transitions plus defensive self-loops; the-terminal 'Stopped' state ignores further input.--}+-- | The single transition function. Given the current state and an input,+-- return the next state and the effects to perform.+--+-- Exhaustively pattern-matched on 'Input' within the two /driving/ states+-- ('CatchingUp', 'Live') so that extending 'Input' (the retry\/dead-letter+-- sibling plan) forces a compile error here. The recoverable states 'Paused' and+-- 'Reconnecting' carry their M2\/M3 transitions plus defensive self-loops; the+-- terminal 'Stopped' state ignores further input. step :: SubscriptionState -> Input -> (SubscriptionState, [Effect]) step st input = case st of- CatchingUp c n -> case input of- BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])- FetchEmpty -> (Live c, [EmitCaughtUp])- FetchFailed _ -> (CatchingUp c (n + 1), [Backoff n])- CaughtUp -> (Live c, [EmitCaughtUp])- HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])- QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])- QueueBackpressured -> (CatchingUp c n, []) -- defensive: catch-up reads the DB, not the queue- QueueDrained -> (CatchingUp c n, [])- ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])- Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])- Live c -> case input of- BatchFetched evs -> (Live (lastPos c evs), [DeliverBatch evs])- FetchEmpty -> (Live c, [RunLive])- FetchFailed _ -> (Reconnecting c 1, [EmitReconnecting 1, Backoff 1])- CaughtUp -> (Live c, [])- HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])- QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])- QueueBackpressured -> (Paused c ResumeOnDrain, [EmitPaused])- QueueDrained -> (Live c, [RunLive])- ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])- Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])- Paused c rc -> case input of- -- The worker drained the stale queue and cleared the pause flag; recover- -- by re-catching-up from the checkpoint so any events the publisher- -- skipped while full are re-read from the database. No loss; the live- -- queue's stale filter drops the now-superseded queued entries on return.- QueueDrained -> (CatchingUp c 0, [EmitResumed])- Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])- HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])- _ -> (Paused c rc, [])- Reconnecting c n -> case input of- BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])- FetchEmpty -> (Live c, [])- FetchFailed _ -> (Reconnecting c (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])- ConnectionLost observed _ -> (Reconnecting (max c observed) (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])- CaughtUp -> (CatchingUp c 0, [])- HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])- Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])- QueueOverflowed -> (Reconnecting c n, [])- QueueBackpressured -> (Reconnecting c n, [])- QueueDrained -> (Reconnecting c n, [])- -- 'Retrying' is a surfaced observability state managed inside the worker's- -- delivery primitive (it sets the observable @TVar@ to 'Retrying' while a- -- redelivery is pending and restores the prior driving state afterward); it- -- is never a driving state fed back to 'step'. This clause is therefore- -- defensive — only cancellation / a handler stop could plausibly race in, and- -- both terminate; anything else returns to 'Live' at the same cursor.- Retrying c _ -> case input of- Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])- HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])- _ -> (Live c, [])- Stopped r -> (Stopped r, [])+ CatchingUp c n -> case input of+ NamespaceWindowFetched evs frontier done -> (CatchingUp c n, [DeliverNamespaceWindow evs frontier done])+ NamespaceWindowCompleted frontier done -> if done then (Live frontier, [EmitCaughtUp]) else (CatchingUp frontier 0, [])+ BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])+ FetchEmpty -> (Live c, [EmitCaughtUp])+ FetchFailed _ -> (CatchingUp c (n + 1), [Backoff n])+ CaughtUp -> (Live c, [EmitCaughtUp])+ HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+ QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])+ QueueBackpressured -> (CatchingUp c n, []) -- defensive: catch-up reads the DB, not the queue+ QueueDrained -> (CatchingUp c n, [])+ ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])+ Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+ Live c -> case input of+ NamespaceWindowFetched evs frontier done -> (Live c, [DeliverNamespaceWindow evs frontier done])+ NamespaceWindowCompleted frontier _ -> (Live frontier, [])+ BatchFetched evs -> (Live (lastPos c evs), [DeliverBatch evs])+ FetchEmpty -> (Live c, [RunLive])+ FetchFailed _ -> (Reconnecting c 1, [EmitReconnecting 1, Backoff 1])+ CaughtUp -> (Live c, [])+ HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+ QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])+ QueueBackpressured -> (Paused c ResumeOnDrain, [EmitPaused])+ QueueDrained -> (Live c, [RunLive])+ ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])+ Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+ Paused c rc -> case input of+ -- The worker drained the stale queue and cleared the pause flag; recover+ -- by re-catching-up from the checkpoint so any events the publisher+ -- skipped while full are re-read from the database. No loss; the live+ -- queue's stale filter drops the now-superseded queued entries on return.+ QueueDrained -> (CatchingUp c 0, [EmitResumed])+ Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+ HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+ _ -> (Paused c rc, [])+ Reconnecting c n -> case input of+ NamespaceWindowFetched evs frontier done -> (CatchingUp c 0, [DeliverNamespaceWindow evs frontier done])+ NamespaceWindowCompleted frontier done -> if done then (Live frontier, [EmitCaughtUp]) else (CatchingUp frontier 0, [])+ BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])+ FetchEmpty -> (Live c, [])+ FetchFailed _ -> (Reconnecting c (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])+ ConnectionLost observed _ -> (Reconnecting (max c observed) (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])+ CaughtUp -> (CatchingUp c 0, [])+ HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+ Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+ QueueOverflowed -> (Reconnecting c n, [])+ QueueBackpressured -> (Reconnecting c n, [])+ QueueDrained -> (Reconnecting c n, [])+ -- 'Retrying' is a surfaced observability state managed inside the worker's+ -- delivery primitive (it sets the observable @TVar@ to 'Retrying' while a+ -- redelivery is pending and restores the prior driving state afterward); it+ -- is never a driving state fed back to 'step'. This clause is therefore+ -- defensive — only cancellation / a handler stop could plausibly race in, and+ -- both terminate; anything else returns to 'Live' at the same cursor.+ Retrying c _ -> case input of+ Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+ HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+ _ -> (Live c, [])+ Stopped r -> (Stopped r, [])
src/Kiroku/Store/Subscription/Stream.hs view
@@ -1,32 +1,31 @@-{- | Streamly bridges that turn a push-based subscription into a pull-based-@Stream IO@, with a bounded 'Control.Concurrent.STM.TBQueue' providing-backpressure between the Kiroku worker and a downstream stream consumer.--Two bridges are provided:-- * 'subscriptionStream' — the plain bridge. The bridge handler enqueues each- event and immediately returns 'Continue', so the worker checkpoints at the- batch tail independently of whether the downstream consumer has processed- the event. Use it when the stream is an in-process handoff and per-event- acknowledgement is not required.- * 'subscriptionAckStream' — the __ack-coupled__ bridge. Each emitted 'AckItem'- carries a one-shot reply variable; the bridge handler blocks until the- consumer replies with a 'SubscriptionResult', and only then does the worker- checkpoint \/ retry \/ dead-letter. This is the path the- @shibuya-kiroku-adapter@ builds on so a Shibuya @AckRetry@\/@AckDeadLetter@- drives a real Kiroku disposition before the checkpoint advances.--Both honour the subscription's worker-side filters-('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+-- | Streamly bridges that turn a push-based subscription into a pull-based+-- @Stream IO@, with a bounded 'Control.Concurrent.STM.TBQueue' providing+-- backpressure between the Kiroku worker and a downstream stream consumer.+--+-- Two bridges are provided:+--+-- * 'subscriptionStream' — the plain bridge. The bridge handler enqueues each+-- event and immediately returns 'Continue', so the worker checkpoints at the+-- batch tail independently of whether the downstream consumer has processed+-- the event. Use it when the stream is an in-process handoff and per-event+-- acknowledgement is not required.+-- * 'subscriptionAckStream' — the __ack-coupled__ bridge. Each emitted 'AckItem'+-- carries a one-shot reply variable; the bridge handler blocks until the+-- consumer replies with a 'SubscriptionResult', and only then does the worker+-- checkpoint \/ retry \/ dead-letter. This is the path the+-- @shibuya-kiroku-adapter@ builds on so a Shibuya @AckRetry@\/@AckDeadLetter@+-- drives a real Kiroku disposition before the checkpoint advances.+--+-- Both honour the subscription's worker-side filters+-- ('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 subscriptionStream, -- * Ack-coupled pull stream@@ -37,12 +36,12 @@ streamBufferSizeValue, defaultStreamBufferSize, subscriptionAckStream,-)+ ) where import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (- STM,+import Control.Concurrent.STM+ ( STM, TMVar, TVar, atomically,@@ -57,59 +56,56 @@ takeTMVar, writeTBQueue, writeTVar,- )+ ) import Control.Exception (SomeException, fromException, mask, onException, throwIO) import Data.IORef (atomicModifyIORef', newIORef) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Subscription (subscribe)-import Kiroku.Store.Subscription.Types (- SubscriptionConfig,+import Kiroku.Store.Subscription.Types+ ( SubscriptionConfig, SubscriptionConfigM (..), SubscriptionHandleM (..), SubscriptionResult (..),- )+ ) import Kiroku.Store.Types (EventId, RecordedEvent (..)) import Numeric.Natural (Natural) import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream -{- | One ack-coupled item emitted by 'subscriptionAckStream'.--The Kiroku subscription worker enqueues an 'AckItem' for each delivered event and-then __blocks__ until the consumer writes a 'SubscriptionResult' into 'ackReply'.-Only then does the worker act on it (checkpoint on 'Continue'\/'Stop', redeliver-on 'Retry', record-and-advance on 'DeadLetter'). This is what lets a downstream-consumer (the @shibuya-kiroku-adapter@) drive Kiroku's per-event disposition from-its own acknowledgement decision.--}+-- | One ack-coupled item emitted by 'subscriptionAckStream'.+--+-- The Kiroku subscription worker enqueues an 'AckItem' for each delivered event and+-- then __blocks__ until the consumer writes a 'SubscriptionResult' into 'ackReply'.+-- Only then does the worker act on it (checkpoint on 'Continue'\/'Stop', redeliver+-- on 'Retry', record-and-advance on 'DeadLetter'). This is what lets a downstream+-- consumer (the @shibuya-kiroku-adapter@) drive Kiroku's per-event disposition from+-- its own acknowledgement decision. data AckItem = AckItem- { ackEvent :: !RecordedEvent- -- ^ the delivered event- , ackAttempt :: !Word- {- ^ zero-based redelivery count for this event: @0@ on first delivery, @1@- on the first redelivery after a 'Retry', and so on. Tracked by the bridge- (the worker redelivers the same event consecutively while retrying).- -}- , ackReply :: !(TMVar SubscriptionResult)- -- ^ one-shot reply the consumer must fill exactly once- }--{- | Construction error from 'mkStreamBufferSize' for a zero capacity.+ { -- | the delivered event+ ackEvent :: !RecordedEvent,+ -- | zero-based redelivery count for this event: @0@ on first delivery, @1@+ -- on the first redelivery after a 'Retry', and so on. Tracked by the bridge+ -- (the worker redelivers the same event consecutively while retrying).+ ackAttempt :: !Word,+ -- | one-shot reply the consumer must fill exactly once+ ackReply :: !(TMVar SubscriptionResult)+ } -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.--}+-- | Construction error from 'mkStreamBufferSize' for a zero capacity.+--+-- 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 stock (Eq, Show) -- | Positive capacity, validated before allocating a bridge or starting a worker. newtype StreamBufferSize = StreamBufferSize Natural- deriving stock (Eq, Show)+ deriving stock (Eq, Show) mkStreamBufferSize :: Natural -> Either InvalidStreamBufferSize StreamBufferSize mkStreamBufferSize n- | n >= 1 = Right (StreamBufferSize n)- | otherwise = Left (InvalidStreamBufferSize n)+ | n >= 1 = Right (StreamBufferSize n)+ | otherwise = Left (InvalidStreamBufferSize n) streamBufferSizeValue :: StreamBufferSize -> Natural streamBufferSizeValue (StreamBufferSize n) = n@@ -118,128 +114,126 @@ defaultStreamBufferSize = StreamBufferSize 256 data BridgeTermination- = BridgeClosedCleanly- | BridgeCrashed !SomeException+ = 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 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.--This is the plain, non-acknowledging bridge: every event is replied 'Continue'-as soon as the consumer pulls it, preserving the original semantics. It is-implemented in terms of 'subscriptionAckStream'; consumers that need to drive-retry\/dead-letter dispositions should use 'subscriptionAckStream' directly.--The config's-'Kiroku.Store.Subscription.Types.eventTypeFilter' is honored: the worker-applies it before the bridge handler runs, so the stream yields only matching-events while the checkpoint still advances past filtered-out ones. The stream's-element type is unchanged ('RecordedEvent') — filtering only removes elements,-it does not reshape them.+ readTVar closedVar >>= \case+ Nothing -> writeTVar closedVar (Just termination)+ Just _ -> pure () -The returned cancel action cancels the underlying subscription and wakes any-blocked reader without writing to the bounded queue.--}+-- | 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 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.+--+-- This is the plain, non-acknowledging bridge: every event is replied 'Continue'+-- as soon as the consumer pulls it, preserving the original semantics. It is+-- implemented in terms of 'subscriptionAckStream'; consumers that need to drive+-- retry\/dead-letter dispositions should use 'subscriptionAckStream' directly.+--+-- The config's+-- 'Kiroku.Store.Subscription.Types.eventTypeFilter' is honored: the worker+-- applies it before the bridge handler runs, so the stream yields only matching+-- events while the checkpoint still advances past filtered-out ones. The stream's+-- element type is unchanged ('RecordedEvent') — filtering only removes elements,+-- it does not reshape them.+--+-- The returned cancel action cancels the underlying subscription and wakes any+-- blocked reader without writing to the bounded queue. subscriptionStream ::- KirokuStore ->- SubscriptionConfig ->- -- | TBQueue capacity for the bridge; must be at least 1.- StreamBufferSize ->- IO (Stream IO RecordedEvent, IO ())+ KirokuStore ->+ SubscriptionConfig ->+ -- | TBQueue capacity for the bridge; must be at least 1.+ StreamBufferSize ->+ IO (Stream IO RecordedEvent, IO ()) subscriptionStream store config bufferSize = do- (ackStream, cancelAction) <- subscriptionAckStream store config bufferSize- let stream =- Stream.mapM- ( \item -> do- atomically (putTMVar (ackReply item) Continue)- pure (ackEvent item)- )- ackStream- pure (stream, cancelAction)--{- | Create an ack-coupled pull 'Stream' from a kiroku subscription.--Each emitted 'AckItem' carries a one-shot reply variable; the underlying Kiroku-worker blocks in its handler until the consumer fills 'ackReply' with a-'SubscriptionResult'. The worker then checkpoints, redelivers, dead-letters, or-stops accordingly — so the consumer controls Kiroku checkpointing per event. The-@handler@ field in the provided 'SubscriptionConfig' is ignored; the bridge-installs its own.+ (ackStream, cancelAction) <- subscriptionAckStream store config bufferSize+ let stream =+ Stream.mapM+ ( \item -> do+ atomically (putTMVar (ackReply item) Continue)+ pure (ackEvent item)+ )+ ackStream+ pure (stream, cancelAction) -The returned cancel action cancels the underlying subscription (interrupting any-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.--}+-- | Create an ack-coupled pull 'Stream' from a kiroku subscription.+--+-- Each emitted 'AckItem' carries a one-shot reply variable; the underlying Kiroku+-- worker blocks in its handler until the consumer fills 'ackReply' with a+-- 'SubscriptionResult'. The worker then checkpoints, redelivers, dead-letters, or+-- stops accordingly — so the consumer controls Kiroku checkpointing per event. The+-- @handler@ field in the provided 'SubscriptionConfig' is ignored; the bridge+-- installs its own.+--+-- The returned cancel action cancels the underlying subscription (interrupting any+-- 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 for the bridge; must be at least 1.- StreamBufferSize ->- IO (Stream IO AckItem, IO ())+ KirokuStore ->+ SubscriptionConfig ->+ -- | TBQueue capacity for the bridge; must be at least 1.+ StreamBufferSize ->+ IO (Stream IO AckItem, IO ()) subscriptionAckStream store config bufferSize = mask $ \restore -> do- queue <- newTBQueueIO (streamBufferSizeValue 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,- -- so there is no concurrent access to this ref.- attemptRef <- newIORef (Nothing :: Maybe (EventId, Word))+ queue <- newTBQueueIO (streamBufferSizeValue 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,+ -- so there is no concurrent access to this ref.+ attemptRef <- newIORef (Nothing :: Maybe (EventId, Word)) - let bridgeHandler :: RecordedEvent -> IO SubscriptionResult- bridgeHandler event = do- attempt <- atomicModifyIORef' attemptRef $ \mPrev ->- case mPrev of- Just (eid, n)- | eid == eventId event -> (Just (eid, n + 1), n + 1)- _ -> (Just (eventId event, 0), 0)- reply <- newEmptyTMVarIO- atomically (writeTBQueue queue (AckItem event attempt reply))- atomically (takeTMVar reply)+ let bridgeHandler :: RecordedEvent -> IO SubscriptionResult+ bridgeHandler event = do+ attempt <- atomicModifyIORef' attemptRef $ \mPrev ->+ case mPrev of+ Just (eid, n)+ | eid == eventId event -> (Just (eid, n + 1), n + 1)+ _ -> (Just (eventId event, 0), 0)+ reply <- newEmptyTMVarIO+ atomically (writeTBQueue queue (AckItem event attempt reply))+ atomically (takeTMVar reply) - let bridgeConfig = config{handler = bridgeHandler}+ let bridgeConfig = config {handler = bridgeHandler} - subHandle <- restore (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- )- `onException` cancel subHandle+ subHandle <- restore (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+ )+ `onException` cancel subHandle - let cancelAction = do- cancel subHandle- Async.wait monitor- atomically (closeBridge closedVar BridgeClosedCleanly)+ let cancelAction = do+ cancel subHandle+ Async.wait monitor+ atomically (closeBridge closedVar BridgeClosedCleanly) - let step :: () -> IO (Maybe (AckItem, ()))- step () = do- 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+ let step :: () -> IO (Maybe (AckItem, ()))+ step () = do+ 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)+ pure (Stream.unfoldrM step (), cancelAction)
src/Kiroku/Store/Subscription/Types.hs view
@@ -1,25 +1,24 @@ {-# LANGUAGE ExistentialQuantification #-} -{- | Core configuration and result types for subscriptions.--This module defines the user-facing vocabulary for starting a subscription and-controlling it per event:-- * 'SubscriptionConfigM' (built with 'defaultSubscriptionConfig') — the- declarative config: target, handler, batch size, overflow policy- ('OverflowPolicy'), consumer-group membership, retry policy, and the two- delivery filters ('EventTypeFilter' plus the opaque 'selector').- * 'SubscriptionResult' — what the handler returns per event: 'Continue',- 'Stop', or the per-event dispositions 'Retry' and 'DeadLetter'.- * 'SubscriptionHandleM' — the lifecycle handle ('cancel', 'wait', and the- observable 'currentState').--The worker's state machine lives in "Kiroku.Store.Subscription.Fsm" (from which-'SubscriptionState', 'RetryDelay', and 'DeadLetterReason' are re-exported here);-this module is the configuration/result surface that drives it.--}-module Kiroku.Store.Subscription.Types (- SubscriptionName (..),+-- | Core configuration and result types for subscriptions.+--+-- This module defines the user-facing vocabulary for starting a subscription and+-- controlling it per event:+--+-- * 'SubscriptionConfigM' (built with 'defaultSubscriptionConfig') — the+-- declarative config: target, handler, batch size, overflow policy+-- ('OverflowPolicy'), consumer-group membership, retry policy, and the two+-- delivery filters ('EventTypeFilter' plus the opaque 'selector').+-- * 'SubscriptionResult' — what the handler returns per event: 'Continue',+-- 'Stop', or the per-event dispositions 'Retry' and 'DeadLetter'.+-- * 'SubscriptionHandleM' — the lifecycle handle ('cancel', 'wait', and the+-- observable 'currentState').+--+-- The worker's state machine lives in "Kiroku.Store.Subscription.Fsm" (from which+-- 'SubscriptionState', 'RetryDelay', and 'DeadLetterReason' are re-exported here);+-- this module is the configuration/result surface that drives it.+module Kiroku.Store.Subscription.Types+ ( SubscriptionName (..), MissingCheckpointPolicy (..), SubscriptionCheckpointKey (..), CheckpointInitialization (..),@@ -87,7 +86,8 @@ ConsumerGroupSizeMismatch (..), InvalidConsumerGroup (..), ConsumerGroupGuardConflict (..),-) where+ )+where import Control.Exception (Exception (..), SomeException) import Data.Aeson (Value)@@ -101,325 +101,313 @@ import Data.Vector (Vector) import GHC.Generics (Generic) import Kiroku.Store.Settings (DecodeFailure)-import Kiroku.Store.Subscription.Fsm (- DeadLetterReason (..),+import Kiroku.Store.Subscription.Fsm+ ( DeadLetterReason (..), RetryDelay (..), SubscriptionState, deadLetterReasonJson, deadLetterSummary, retryDelayMicros,- )+ ) import Kiroku.Store.Types (CategoryName, EventId, EventType, GlobalPosition, RecordedEvent (..)) import Numeric.Natural (Natural) -{- | A declarative, closed filter over event types for a subscription.--'AllEventTypes' (the default) delivers every event. @'OnlyEventTypes' s@-delivers only events whose 'eventType' is in @s@; all other events are-skipped (the handler is not called) but the subscription checkpoint still-advances past them, so a highly selective subscription never stalls on a long-run of filtered-out events.--=== Why this is a closed sum and not @RecordedEvent -> Bool@--EventStore's subscription @selector@ is an opaque @RecordedEvent -> Bool@.-Kiroku deliberately models the /common/ case — \"deliver only these event-types\" — as a closed, first-order value instead, and keeps the opaque-predicate as a separate, optional escape hatch ('selector' on-'SubscriptionConfigM'). The closed sum is preferred for the steady-state filter-because it:-- * stays __introspectable__ — an operator or a metrics endpoint can read which- types a subscription delivers (you cannot inspect a closure);- * is __'Eq'\/'Show'-able__ for tests and diagnostics; and- * leaves the door open to __SQL pushdown__ on fetching subscriptions- (@Category@ / consumer-group), where @event_type = ANY(...)@ can be lowered- into the catch-up read so the worker stops fetching-and-discarding. A- @RecordedEvent -> Bool@ can never be pushed down.--Reach for 'selector' only when you need a predicate the type set cannot express-(e.g. filtering on metadata during a one-off catch-up reprocess while-investigating a problem). The two compose: an event is delivered only when it-passes /both/ (see 'shouldDeliver').--This is unrelated to 'Kiroku.Store.Types.EventFilter', which filters-correlation\/causation /queries/.--}+-- | A declarative, closed filter over event types for a subscription.+--+-- 'AllEventTypes' (the default) delivers every event. @'OnlyEventTypes' s@+-- delivers only events whose 'eventType' is in @s@; all other events are+-- skipped (the handler is not called) but the subscription checkpoint still+-- advances past them, so a highly selective subscription never stalls on a long+-- run of filtered-out events.+--+-- === Why this is a closed sum and not @RecordedEvent -> Bool@+--+-- EventStore's subscription @selector@ is an opaque @RecordedEvent -> Bool@.+-- Kiroku deliberately models the /common/ case — \"deliver only these event+-- types\" — as a closed, first-order value instead, and keeps the opaque+-- predicate as a separate, optional escape hatch ('selector' on+-- 'SubscriptionConfigM'). The closed sum is preferred for the steady-state filter+-- because it:+--+-- * stays __introspectable__ — an operator or a metrics endpoint can read which+-- types a subscription delivers (you cannot inspect a closure);+-- * is __'Eq'\/'Show'-able__ for tests and diagnostics; and+-- * leaves the door open to __SQL pushdown__ on fetching subscriptions+-- (@Category@ / consumer-group), where @event_type = ANY(...)@ can be lowered+-- into the catch-up read so the worker stops fetching-and-discarding. A+-- @RecordedEvent -> Bool@ can never be pushed down.+--+-- Reach for 'selector' only when you need a predicate the type set cannot express+-- (e.g. filtering on metadata during a one-off catch-up reprocess while+-- investigating a problem). The two compose: an event is delivered only when it+-- passes /both/ (see 'shouldDeliver').+--+-- This is unrelated to 'Kiroku.Store.Types.EventFilter', which filters+-- correlation\/causation /queries/. data EventTypeFilter- = -- | Deliver every event (the no-op default).- AllEventTypes- | -- | Deliver only events whose 'eventType' is in this set.- OnlyEventTypes !(Set EventType)- deriving stock (Eq, Show)+ = -- | Deliver every event (the no-op default).+ AllEventTypes+ | -- | Deliver only events whose 'eventType' is in this set.+ OnlyEventTypes !(Set EventType)+ deriving stock (Eq, Show) -{- | 'True' when an event passes the declarative type filter.-The worker applies this /before/ calling the handler, so a non-matching event-never reaches the handler (or the ack-coupled bridge) and is never retried or-dead-lettered.--}+-- | 'True' when an event passes the declarative type filter.+-- The worker applies this /before/ calling the handler, so a non-matching event+-- never reaches the handler (or the ack-coupled bridge) and is never retried or+-- dead-lettered. eventTypeMatches :: EventTypeFilter -> RecordedEvent -> Bool eventTypeMatches AllEventTypes _ = True-eventTypeMatches (OnlyEventTypes s) RecordedEvent{eventType = t} = Set.member t s--{- | The complete per-event delivery predicate for a subscription: an event is-delivered to the handler only when it passes __both__ the declarative-'eventTypeFilter' and the optional opaque 'selector' (an absent selector always-passes). The two compose as a logical AND — the type filter narrows by-'eventType', the selector narrows further by any predicate over the whole-'RecordedEvent'.+eventTypeMatches (OnlyEventTypes s) RecordedEvent {eventType = t} = Set.member t s -The worker applies this /before/ calling the handler, so an event rejected by-either filter never reaches the handler or the ack-coupled bridge (it is never-retried or dead-lettered), yet the checkpoint still advances past it — the-no-stall invariant holds for the selector exactly as it does for the type-filter.--}+-- | The complete per-event delivery predicate for a subscription: an event is+-- delivered to the handler only when it passes __both__ the declarative+-- 'eventTypeFilter' and the optional opaque 'selector' (an absent selector always+-- passes). The two compose as a logical AND — the type filter narrows by+-- 'eventType', the selector narrows further by any predicate over the whole+-- 'RecordedEvent'.+--+-- The worker applies this /before/ calling the handler, so an event rejected by+-- either filter never reaches the handler or the ack-coupled bridge (it is never+-- retried or dead-lettered), yet the checkpoint still advances past it — the+-- no-stall invariant holds for the selector exactly as it does for the type+-- filter. shouldDeliver :: EventTypeFilter -> Maybe (RecordedEvent -> Bool) -> RecordedEvent -> Bool shouldDeliver etf sel event =- eventTypeMatches etf event && maybe True ($ event) sel+ eventTypeMatches etf event && maybe True ($ event) sel -- | Unique name for a subscription (e.g., @"inventory-projection"@). newtype SubscriptionName = SubscriptionName Text- deriving newtype (Eq, Ord, Show)+ deriving newtype (Eq, Ord, Show) -{- | What a subscription does when its exact-@('SubscriptionName', consumer-group member)@ checkpoint key has no durable-row. The policy is consulted only for absence; an existing row always wins and-is never moved by initialization.--}+-- | What a subscription does when its exact+-- @('SubscriptionName', consumer-group member)@ checkpoint key has no durable+-- row. The policy is consulted only for absence; an existing row always wins and+-- is never moved by initialization. data MissingCheckpointPolicy- = -- | Materialize position zero and replay retained history.- FromBeginning- | -- | Atomically materialize the current @$all@ store head.- FromCurrentHead- | -- | Refuse startup without inserting a row.- FailIfMissing- deriving stock (Eq, Show, Generic)+ = -- | Materialize position zero and replay retained history.+ FromBeginning+ | -- | Atomically materialize the current @$all@ store head.+ FromCurrentHead+ | -- | Refuse startup without inserting a row.+ FailIfMissing+ deriving stock (Eq, Show, Generic) -- | The durable identity of one subscription checkpoint row. data SubscriptionCheckpointKey = SubscriptionCheckpointKey- { subscriptionName :: !SubscriptionName- , consumerGroupMember :: !Int32- }- deriving stock (Eq, Ord, Show, Generic)+ { subscriptionName :: !SubscriptionName,+ consumerGroupMember :: !Int32+ }+ deriving stock (Eq, Ord, Show, Generic) -{- | The successful result of resolving one subscription checkpoint at-startup. 'ExistingCheckpoint' means a durable row already existed;-'InitializedCheckpoint' records the policy that created the row. In both-cases the position is the durable position returned by PostgreSQL.--}+-- | The successful result of resolving one subscription checkpoint at+-- startup. 'ExistingCheckpoint' means a durable row already existed;+-- 'InitializedCheckpoint' records the policy that created the row. In both+-- cases the position is the durable position returned by PostgreSQL. data CheckpointInitialization- = ExistingCheckpoint !SubscriptionCheckpointKey !GlobalPosition- | InitializedCheckpoint !MissingCheckpointPolicy !SubscriptionCheckpointKey !GlobalPosition- deriving stock (Eq, Show, Generic)+ = ExistingCheckpoint !SubscriptionCheckpointKey !GlobalPosition+ | InitializedCheckpoint !MissingCheckpointPolicy !SubscriptionCheckpointKey !GlobalPosition+ deriving stock (Eq, Show, Generic) -- | Extract the durable position from either successful initialization result. checkpointInitializationPosition :: CheckpointInitialization -> GlobalPosition checkpointInitializationPosition = \case- ExistingCheckpoint _ position -> position- InitializedCheckpoint _ _ position -> position+ ExistingCheckpoint _ position -> position+ InitializedCheckpoint _ _ position -> position -{- | A typed startup refusal produced by 'FailIfMissing'. No checkpoint row is-inserted and a worker throws this exception before invoking its handler.--}+-- | A typed startup refusal produced by 'FailIfMissing'. No checkpoint row is+-- inserted and a worker throws this exception before invoking its handler. newtype SubscriptionCheckpointMissing = SubscriptionCheckpointMissing- { checkpointKey :: SubscriptionCheckpointKey- }- deriving stock (Eq, Show, Generic)+ { checkpointKey :: SubscriptionCheckpointKey+ }+ deriving stock (Eq, Show, Generic) instance Exception SubscriptionCheckpointMissing where- toException = toException . SomeSubscriptionStartupFailure- fromException exception = do- SomeSubscriptionStartupFailure refusal <- fromException exception- cast refusal+ toException = toException . SomeSubscriptionStartupFailure+ fromException exception = do+ SomeSubscriptionStartupFailure refusal <- fromException exception+ cast refusal -- | One checkpoint row that has been durably persisted by a subscription. data SubscriptionCheckpoint = SubscriptionCheckpoint- { subscriptionName :: !SubscriptionName- , consumerGroupMember :: !Int32- , checkpointPosition :: !GlobalPosition- , checkpointUpdatedAt :: !UTCTime- }- deriving stock (Eq, Show, Generic)+ { subscriptionName :: !SubscriptionName,+ consumerGroupMember :: !Int32,+ checkpointPosition :: !GlobalPosition,+ checkpointUpdatedAt :: !UTCTime+ }+ deriving stock (Eq, Show, Generic) -{- | A point-in-time view of the global store position and every durable-subscription checkpoint. The rows are ordered by 'subscriptionName' and then-'consumerGroupMember'.--}+-- | A point-in-time view of the global store position and every durable+-- subscription checkpoint. The rows are ordered by 'subscriptionName' and then+-- 'consumerGroupMember'. data SubscriptionCheckpointInventory = SubscriptionCheckpointInventory- { storePosition :: !GlobalPosition- , checkpoints :: !(Vector SubscriptionCheckpoint)- }- deriving stock (Eq, Show, Generic)+ { storePosition :: !GlobalPosition,+ checkpoints :: !(Vector SubscriptionCheckpoint)+ }+ deriving stock (Eq, Show, Generic) -- | A durably parked event. The worker's structured reason JSON is unchanged. data SubscriptionDeadLetter = SubscriptionDeadLetter- { deadLetterId :: !Int64- , subscriptionName :: !SubscriptionName- , consumerGroupMember :: !Int32- , globalPosition :: !GlobalPosition- , eventId :: !EventId- , reason :: !Value- , reasonSummary :: !Text- , attemptCount :: !Int32- , createdAt :: !UTCTime- }- deriving stock (Eq, Show, Generic)+ { deadLetterId :: !Int64,+ subscriptionName :: !SubscriptionName,+ consumerGroupMember :: !Int32,+ globalPosition :: !GlobalPosition,+ eventId :: !EventId,+ reason :: !Value,+ reasonSummary :: !Text,+ attemptCount :: !Int32,+ createdAt :: !UTCTime+ }+ deriving stock (Eq, Show, Generic) -{- | Exclusive cursor in descending (global position, dead-letter id) order.-It remains usable after the originating row is deleted.--}+-- | Exclusive cursor in descending (global position, dead-letter id) order.+-- It remains usable after the originating row is deleted. data SubscriptionDeadLetterCursor = SubscriptionDeadLetterCursor- { cursorGlobalPosition :: !GlobalPosition- , cursorDeadLetterId :: !Int64- }- deriving stock (Eq, Ord, Show, Generic)+ { cursorGlobalPosition :: !GlobalPosition,+ cursorDeadLetterId :: !Int64+ }+ deriving stock (Eq, Ord, Show, Generic) subscriptionDeadLetterCursor :: SubscriptionDeadLetter -> SubscriptionDeadLetterCursor-subscriptionDeadLetterCursor SubscriptionDeadLetter{globalPosition = position, deadLetterId = ident} =- SubscriptionDeadLetterCursor position ident+subscriptionDeadLetterCursor SubscriptionDeadLetter {globalPosition = position, deadLetterId = ident} =+ SubscriptionDeadLetterCursor position ident -{- | Validated page size, 1 through 1,000. Use the smart constructor.-No Generic or numeric instance exposes a construction bypass.--}+-- | Validated page size, 1 through 1,000. Use the smart constructor.+-- No Generic or numeric instance exposes a construction bypass. newtype SubscriptionDeadLetterLimit = SubscriptionDeadLetterLimit Int32- deriving stock (Eq, Ord, Show)+ deriving stock (Eq, Ord, Show) newtype SubscriptionDeadLetterLimitOutOfRange = SubscriptionDeadLetterLimitOutOfRange Int32- deriving stock (Eq, Show)+ deriving stock (Eq, Show)+ mkSubscriptionDeadLetterLimit :: Int32 -> Either SubscriptionDeadLetterLimitOutOfRange SubscriptionDeadLetterLimit mkSubscriptionDeadLetterLimit value- | value < 1 || value > 1000 = Left (SubscriptionDeadLetterLimitOutOfRange value)- | otherwise = Right (SubscriptionDeadLetterLimit value)+ | value < 1 || value > 1000 = Left (SubscriptionDeadLetterLimitOutOfRange value)+ | otherwise = Right (SubscriptionDeadLetterLimit value)+ subscriptionDeadLetterLimitValue :: SubscriptionDeadLetterLimit -> Int32 subscriptionDeadLetterLimitValue (SubscriptionDeadLetterLimit value) = value+ defaultSubscriptionDeadLetterLimit :: SubscriptionDeadLetterLimit defaultSubscriptionDeadLetterLimit = SubscriptionDeadLetterLimit 100 -- | Nothing selects all historical members, or starts at the newest row. data SubscriptionDeadLetterQuery = SubscriptionDeadLetterQuery- { subscriptionName :: !SubscriptionName- , consumerGroupMember :: !(Maybe Int32)- , after :: !(Maybe SubscriptionDeadLetterCursor)- , limit :: !SubscriptionDeadLetterLimit- }- deriving stock (Eq, Show, Generic)+ { subscriptionName :: !SubscriptionName,+ consumerGroupMember :: !(Maybe Int32),+ after :: !(Maybe SubscriptionDeadLetterCursor),+ limit :: !SubscriptionDeadLetterLimit+ }+ deriving stock (Eq, Show, Generic) defaultSubscriptionDeadLetterQuery :: SubscriptionName -> SubscriptionDeadLetterQuery defaultSubscriptionDeadLetterQuery name = SubscriptionDeadLetterQuery name Nothing Nothing defaultSubscriptionDeadLetterLimit -- | Trimmed page; a cursor is present exactly when another row exists. data SubscriptionDeadLetterPage = SubscriptionDeadLetterPage- { deadLetters :: !(Vector SubscriptionDeadLetter)- , nextCursor :: !(Maybe SubscriptionDeadLetterCursor)- }- deriving stock (Eq, Show, Generic)+ { deadLetters :: !(Vector SubscriptionDeadLetter),+ nextCursor :: !(Maybe SubscriptionDeadLetterCursor)+ }+ deriving stock (Eq, Show, Generic) --- | Which stream to subscribe to.+-- | Which events to subscribe to. data SubscriptionTarget- = -- | Subscribe to all events in global position order.- AllStreams- | -- | Subscribe to events from streams matching a category prefix.- Category !CategoryName- deriving stock (Eq, Show)+ = -- | Subscribe to all events in global position order.+ AllStreams+ | -- | Subscribe to one exact category.+ Category !CategoryName+ | -- | A literal category namespace: @"pm"@ includes @pm@ and @pm:*@,+ -- but not @pmm@. No trimming, case folding or wildcard interpretation.+ -- Empty and trailing-colon values are literal too. Each worker scans+ -- bounded global windows; its checkpoint records completed scan progress.+ CategoryNamespace !Text+ deriving stock (Eq, Show) -- | What the handler returns to control the subscription lifecycle. data SubscriptionResult- = -- | Continue processing events.- Continue- | -- | Stop the subscription gracefully.- Stop- | {- | Redeliver /this same event/ after the given delay before the checkpoint- advances past it. Redelivery is bounded by the subscription's- '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- atomically advance the checkpoint past it, then continue with the next- event. The adapter maps Shibuya's @AckDeadLetter@ to this.- -}- DeadLetter !DeadLetterReason- deriving stock (Eq, Show)+ = -- | Continue processing events.+ Continue+ | -- | Stop the subscription gracefully.+ Stop+ | -- | Redeliver /this same event/ after the given delay before the checkpoint+ -- advances past it. Redelivery is bounded by the subscription's+ -- '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+ -- atomically advance the checkpoint past it, then continue with the next+ -- event. The adapter maps Shibuya's @AckDeadLetter@ to this.+ DeadLetter !DeadLetterReason+ deriving stock (Eq, Show) -{- | 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.--}+-- | 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 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)+ { -- | 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).+ retryMaxAttempts :: Int+ }+ deriving stock (Eq, Show) -{- | 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.--}+-- | 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}--{- | What the publisher does when a subscriber's bounded queue is full.+defaultRetryPolicy = RetryPolicy {retryMaxAttempts = 5} -The default 'PauseAndResume' is recoverable and lossless: the publisher-stops pushing to the full subscriber (and signals it), and the worker —-once its slow handler catches up — drains the stale queue and re-reads the-events it missed directly from the database from its checkpoint, so no-event is lost and the checkpoint still advances monotonically. The-fail-fast 'DropSubscription' (terminate with 'SubscriptionOverflowed') and-the lossy 'DropOldest' remain available for consumers that prefer a hard-error or best-effort delivery respectively.--}+-- | What the publisher does when a subscriber's bounded queue is full.+--+-- The default 'PauseAndResume' is recoverable and lossless: the publisher+-- stops pushing to the full subscriber (and signals it), and the worker —+-- once its slow handler catches up — drains the stale queue and re-reads the+-- events it missed directly from the database from its checkpoint, so no+-- event is lost and the checkpoint still advances monotonically. The+-- fail-fast 'DropSubscription' (terminate with 'SubscriptionOverflowed') and+-- the lossy 'DropOldest' remain available for consumers that prefer a hard+-- error or best-effort delivery respectively. data OverflowPolicy- = {- | Recoverable backpressure (the default). When the queue is full the- publisher marks the subscriber 'Kiroku.Store.Subscription.EventPublisher.Paused'- and stops pushing (it does not drop). The worker observes the pause,- drains the stale queue, clears the flag, and re-catches-up from its- checkpoint so every skipped event is delivered. No loss; monotonic- checkpoint; other subscribers unaffected.- -}- PauseAndResume- | {- | Mark the subscription as overflowed; the worker observes this on- its next iteration and surfaces 'SubscriptionOverflowed' through- 'wait'. The slow subscriber is terminated; other subscribers are- unaffected.- -}- DropSubscription- | {- | Drop the oldest queued batch and enqueue the new one. The- subscription continues but loses events. Choose only when at-least-once- semantics are not required for this consumer.- -}- DropOldest- deriving stock (Eq, Show)+ = -- | Recoverable backpressure (the default). When the queue is full the+ -- publisher marks the subscriber 'Kiroku.Store.Subscription.EventPublisher.Paused'+ -- and stops pushing (it does not drop). The worker observes the pause,+ -- drains the stale queue, clears the flag, and re-catches-up from its+ -- checkpoint so every skipped event is delivered. No loss; monotonic+ -- checkpoint; other subscribers unaffected.+ PauseAndResume+ | -- | Mark the subscription as overflowed; the worker observes this on+ -- its next iteration and surfaces 'SubscriptionOverflowed' through+ -- 'wait'. The slow subscriber is terminated; other subscribers are+ -- unaffected.+ DropSubscription+ | -- | Drop the oldest queued batch and enqueue the new one. The+ -- subscription continues but loses events. Choose only when at-least-once+ -- semantics are not required for this consumer.+ DropOldest+ deriving stock (Eq, Show) -{- | Raised on a 'SubscriptionHandleM' wait when the publisher dropped the-subscription because its bounded queue overflowed (overflow policy-'DropSubscription'). The 'subscriptionName' identifies the subscription-that overflowed; the consumer is expected to investigate the slow handler-and either fix the slowness or switch to 'DropOldest'.--}+-- | Raised on a 'SubscriptionHandleM' wait when the publisher dropped the+-- subscription because its bounded queue overflowed (overflow policy+-- 'DropSubscription'). The 'subscriptionName' identifies the subscription+-- that overflowed; the consumer is expected to investigate the slow handler+-- and either fix the slowness or switch to 'DropOldest'. newtype SubscriptionOverflowed = SubscriptionOverflowed- { subscriptionName :: SubscriptionName- }- deriving stock (Show)- deriving anyclass (Exception)+ { subscriptionName :: SubscriptionName+ }+ deriving stock (Show)+ deriving anyclass (Exception) -{- | Default decode retries exhausted. Wait returns this exception and the-stopped event carries StopUndecodable; the durable checkpoint is still before-the failed event. Fix the hook and restart to replay it.--}+-- | Default decode retries exhausted. Wait returns this exception and the+-- stopped event carries StopUndecodable; the durable checkpoint is still before+-- the failed event. Fix the hook and restart to replay it. newtype SubscriptionUndecodable = SubscriptionUndecodable- { subscriptionDecodeFailure :: DecodeFailure- }- deriving stock (Eq, Show)- deriving anyclass (Exception)+ { subscriptionDecodeFailure :: DecodeFailure+ }+ deriving stock (Eq, Show)+ deriving anyclass (Exception) -- | Handler callback invoked for each event, parameterized by monad. type EventHandlerM m = RecordedEvent -> m SubscriptionResult@@ -429,191 +417,179 @@ -- | Configuration for starting a subscription, parameterized by monad. data SubscriptionConfigM m = SubscriptionConfig- { name :: !SubscriptionName- , target :: !SubscriptionTarget- , handler :: !(EventHandlerM m)- , undecodableHandler :: !(Maybe (RecordedEvent -> DecodeFailure -> m SubscriptionResult))- {- ^ Optional disposition of a raw undecodable event. Default Nothing:- retry at one-second spacing and then stop without skipping the event.- A callback uses the ordinary Continue/Stop/Retry/DeadLetter vocabulary.- -}- , handlerStallWarnAfter :: !(Maybe NominalDiffTime)- {- ^ Optional positive warning interval for a handler holding one event.- Default Nothing allocates no diagnostic cell, watchdog or timer and does- no diagnostic work per delivery. Warnings are advisory and never finalize- an acknowledgement or advance a checkpoint. Nonpositive values fail startup.- -}- , batchSize :: !BatchSize- -- ^ 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'. 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).- -}- , overflowPolicy :: !OverflowPolicy- {- ^ What the publisher does when this subscriber's queue is full.- 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.- 'Just cg' = this worker is member 'member cg' of a group of size- 'size cg'. The invariant @size >= 1@ and @0 <= member < size@ is- enforced at construction by 'mkConsumerGroupSize' and 'mkConsumerGroup'.- -}- , consumerGroupGuard :: !Bool- {- ^ When 'True' (default 'False'), the worker performs a one-shot- PostgreSQL advisory-lock conflict check at startup so two processes- cannot both run the same @(name, member)@ at once. See the worker's- @guardMember@ for the exact semantics and its documented limitation- (a startup detection probe, not a lifetime-held lock). Ignored when- 'consumerGroup' is 'Nothing'.- -}- , missingCheckpointPolicy :: !MissingCheckpointPolicy- {- ^ What startup does only when the exact @(name, member)@ checkpoint row- is absent. 'FromBeginning' is the compatibility default and materializes- position zero. 'FromCurrentHead' atomically seeds the current store head,- and 'FailIfMissing' refuses startup before the handler runs. Existing rows- always take precedence, so changing this field never rewinds or advances- durable progress.- -}- , targetBindingPolicy :: !TargetBindingPolicy- -- ^ Adopt legacy unbound checkpoints (default) or require an existing binding.- , retryPolicy :: !RetryPolicy- {- ^ Bounds redelivery of an event for which the handler returned- 'Retry' before the worker dead-letters it. Default: 'defaultRetryPolicy'- (five attempts). Handlers that only return 'Continue' / 'Stop' are- unaffected.- -}- , eventTypeFilter :: !EventTypeFilter- {- ^ Which event types this subscription delivers. Default- 'AllEventTypes' (deliver everything). When 'OnlyEventTypes', the worker- skips the handler for non-matching events but still advances the- checkpoint past them, so the subscription never stalls on a long run of- filtered-out events. Applied worker-side before the handler/bridge, so a- filtered-out event is never retried or dead-lettered. Composed with- 'selector' (an event must pass both — see 'shouldDeliver').- -}- , selector :: !(Maybe (RecordedEvent -> Bool))- {- ^ Optional opaque per-event predicate, the escape hatch for filtering- that 'eventTypeFilter' cannot express (e.g. on metadata, stream name, or- correlation\/causation ids). Default 'Nothing' (no extra filtering).-- This is EventStore's @RecordedEvent -> Bool@ @selector@. It is intentionally- /separate/ from 'eventTypeFilter' rather than replacing it: because a- closure is not introspectable, not 'Eq'\/'Show'-able, and not SQL-pushable,- 'eventTypeFilter' remains the preferred steady-state type filter (see its- note), and 'selector' is reserved for ad-hoc\/operational filtering — most- often a one-off catch-up reprocess that narrows by metadata while- investigating a problem.-- Composes with 'eventTypeFilter' as a logical AND ('shouldDeliver'): an event- is delivered only when it passes both. Applied worker-side before the- handler\/bridge, so a rejected event is never retried or dead-lettered, yet- the checkpoint still advances past it (no stall).- -}- }+ { name :: !SubscriptionName,+ target :: !SubscriptionTarget,+ handler :: !(EventHandlerM m),+ -- | Optional disposition of a raw undecodable event. Default Nothing:+ -- retry at one-second spacing and then stop without skipping the event.+ -- A callback uses the ordinary Continue/Stop/Retry/DeadLetter vocabulary.+ undecodableHandler :: !(Maybe (RecordedEvent -> DecodeFailure -> m SubscriptionResult)),+ -- | Optional positive warning interval for a handler holding one event.+ -- Default Nothing allocates no diagnostic cell, watchdog or timer and does+ -- no diagnostic work per delivery. Warnings are advisory and never finalize+ -- an acknowledgement or advance a checkpoint. Nonpositive values fail startup.+ handlerStallWarnAfter :: !(Maybe NominalDiffTime),+ -- | Number of events to fetch per batch during catch-up (default: 100)+ batchSize :: !BatchSize,+ -- | Maximum number of /batches/ the publisher may enqueue for this+ -- 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).+ queueCapacity :: !Natural,+ -- | What the publisher does when this subscriber's queue is full.+ -- 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.+ overflowPolicy :: !OverflowPolicy,+ -- | 'Nothing' (the default) = ordinary single-consumer subscription.+ -- 'Just cg' = this worker is member 'member cg' of a group of size+ -- 'size cg'. The invariant @size >= 1@ and @0 <= member < size@ is+ -- enforced at construction by 'mkConsumerGroupSize' and 'mkConsumerGroup'.+ consumerGroup :: !(Maybe ConsumerGroup),+ -- | When 'True' (default 'False'), the worker performs a one-shot+ -- PostgreSQL advisory-lock conflict check at startup so two processes+ -- cannot both run the same @(name, member)@ at once. See the worker's+ -- @guardMember@ for the exact semantics and its documented limitation+ -- (a startup detection probe, not a lifetime-held lock). Ignored when+ -- 'consumerGroup' is 'Nothing'.+ consumerGroupGuard :: !Bool,+ -- | What startup does only when the exact @(name, member)@ checkpoint row+ -- is absent. 'FromBeginning' is the compatibility default and materializes+ -- position zero. 'FromCurrentHead' atomically seeds the current store head,+ -- and 'FailIfMissing' refuses startup before the handler runs. Existing rows+ -- always take precedence, so changing this field never rewinds or advances+ -- durable progress.+ missingCheckpointPolicy :: !MissingCheckpointPolicy,+ -- | Adopt legacy unbound checkpoints (default) or require an existing binding.+ targetBindingPolicy :: !TargetBindingPolicy,+ -- | Bounds redelivery of an event for which the handler returned+ -- 'Retry' before the worker dead-letters it. Default: 'defaultRetryPolicy'+ -- (five attempts). Handlers that only return 'Continue' / 'Stop' are+ -- unaffected.+ retryPolicy :: !RetryPolicy,+ -- | Which event types this subscription delivers. Default+ -- 'AllEventTypes' (deliver everything). When 'OnlyEventTypes', the worker+ -- skips the handler for non-matching events but still advances the+ -- checkpoint past them, so the subscription never stalls on a long run of+ -- filtered-out events. Applied worker-side before the handler/bridge, so a+ -- filtered-out event is never retried or dead-lettered. Composed with+ -- 'selector' (an event must pass both — see 'shouldDeliver').+ eventTypeFilter :: !EventTypeFilter,+ -- | Optional opaque per-event predicate, the escape hatch for filtering+ -- that 'eventTypeFilter' cannot express (e.g. on metadata, stream name, or+ -- correlation\/causation ids). Default 'Nothing' (no extra filtering).+ --+ -- This is EventStore's @RecordedEvent -> Bool@ @selector@. It is intentionally+ -- /separate/ from 'eventTypeFilter' rather than replacing it: because a+ -- closure is not introspectable, not 'Eq'\/'Show'-able, and not SQL-pushable,+ -- 'eventTypeFilter' remains the preferred steady-state type filter (see its+ -- note), and 'selector' is reserved for ad-hoc\/operational filtering — most+ -- often a one-off catch-up reprocess that narrows by metadata while+ -- investigating a problem.+ --+ -- Composes with 'eventTypeFilter' as a logical AND ('shouldDeliver'): an event+ -- is delivered only when it passes both. Applied worker-side before the+ -- handler\/bridge, so a rejected event is never retried or dead-lettered, yet+ -- the checkpoint still advances past it (no stall).+ selector :: !(Maybe (RecordedEvent -> Bool))+ } -- | Configuration defaulting to 'IO'. type SubscriptionConfig = SubscriptionConfigM IO -{- | Build a 'SubscriptionConfig' with the recommended defaults.--The catch-up batch size defaults to 100 events per database fetch — large-enough to amortise round-trip overhead on typical projection workloads,-small enough that a single slow handler call does not stall the worker-for long. Override the 'batchSize' field on the returned record if a-different value suits the workload.--The missing-checkpoint policy defaults to 'FromBeginning' for source-compatibility. New call sites should override 'missingCheckpointPolicy'-deliberately when a future-only or pre-provisioned worker is intended.--@-let cfg = (defaultSubscriptionConfig "my-projection" AllStreams handler)- { missingCheckpointPolicy = FromBeginning }-withSubscription store cfg $ \\h -> wait h-@--}+-- | Build a 'SubscriptionConfig' with the recommended defaults.+--+-- The catch-up batch size defaults to 100 events per database fetch — large+-- enough to amortise round-trip overhead on typical projection workloads,+-- small enough that a single slow handler call does not stall the worker+-- for long. Override the 'batchSize' field on the returned record if a+-- different value suits the workload.+--+-- The missing-checkpoint policy defaults to 'FromBeginning' for source+-- compatibility. New call sites should override 'missingCheckpointPolicy'+-- deliberately when a future-only or pre-provisioned worker is intended.+--+-- @+-- let cfg = (defaultSubscriptionConfig "my-projection" AllStreams handler)+-- { missingCheckpointPolicy = FromBeginning }+-- withSubscription store cfg $ \\h -> wait h+-- @ defaultSubscriptionConfig ::- SubscriptionName ->- SubscriptionTarget ->- EventHandlerM m ->- SubscriptionConfigM m+ SubscriptionName ->+ SubscriptionTarget ->+ EventHandlerM m ->+ SubscriptionConfigM m defaultSubscriptionConfig name' target' handler' =- SubscriptionConfig- { name = name'- , target = target'- , handler = handler'- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = PauseAndResume- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }+ SubscriptionConfig+ { name = name',+ target = target',+ handler = handler',+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = PauseAndResume,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ } -- | Handle returned to the caller for lifecycle management, parameterized by monad. data SubscriptionHandleM m = SubscriptionHandle- { cancel :: !(m ())- -- ^ Cancel the subscription gracefully- , wait :: !(m (Either SomeException ()))- -- ^ Block until the subscription completes or fails- , currentState :: !(m (Maybe SubscriptionState))- {- ^ Read the worker's current FSM state- ('Kiroku.Store.Subscription.Fsm.SubscriptionState') as of this instant,- resolved through the store's central subscription-state registry.-- @Just s@ means the worker is live and still owns its registry entry, in state- @s@ — one of @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),- @Reconnecting@, or @Retrying@. @Nothing@ means the subscription is __not- currently live__: it has stopped, been cancelled, crashed (its registry key- was removed in the worker's @finally@ cleanup), was never started, or has- been superseded by a newer worker registered under the same @(name, member)@- key.-- Note the invariant that a @Stopped@ state never appears here: the FSM never- writes @Stopped@ into the cell, so a not-live subscription is represented by- @Nothing@ — never by a @Just (Stopped …)@. This unifies one rule, "stopped =- absent", across 'currentState' and- 'Kiroku.Store.Subscription.subscriptionStates'. For the stream of past- transitions (including the terminal stop reason) use the @KirokuEvent@- lifecycle events instead.- -}- }+ { -- | Cancel the subscription gracefully+ cancel :: !(m ()),+ -- | Block until the subscription completes or fails+ wait :: !(m (Either SomeException ())),+ -- | Read the worker's current FSM state+ -- ('Kiroku.Store.Subscription.Fsm.SubscriptionState') as of this instant,+ -- resolved through the store's central subscription-state registry.+ --+ -- @Just s@ means the worker is live and still owns its registry entry, in state+ -- @s@ — one of @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),+ -- @Reconnecting@, or @Retrying@. @Nothing@ means the subscription is __not+ -- currently live__: it has stopped, been cancelled, crashed (its registry key+ -- was removed in the worker's @finally@ cleanup), was never started, or has+ -- been superseded by a newer worker registered under the same @(name, member)@+ -- key.+ --+ -- Note the invariant that a @Stopped@ state never appears here: the FSM never+ -- writes @Stopped@ into the cell, so a not-live subscription is represented by+ -- @Nothing@ — never by a @Just (Stopped …)@. This unifies one rule, "stopped =+ -- absent", across 'currentState' and+ -- 'Kiroku.Store.Subscription.subscriptionStates'. For the stream of past+ -- transitions (including the terminal stop reason) use the @KirokuEvent@+ -- lifecycle events instead.+ currentState :: !(m (Maybe SubscriptionState))+ } -- | Handle defaulting to 'IO'. type SubscriptionHandle = SubscriptionHandleM IO -- | A positive group size, validated once before starting any workers. newtype ConsumerGroupSize = ConsumerGroupSize Int32- deriving stock (Eq, Show)+ deriving stock (Eq, Show) consumerGroupSizeValue :: ConsumerGroupSize -> Int32 consumerGroupSizeValue (ConsumerGroupSize n) = n mkConsumerGroupSize :: Int32 -> Either InvalidConsumerGroup ConsumerGroupSize mkConsumerGroupSize n- | n >= 1 = Right (ConsumerGroupSize n)- | otherwise = Left (InvalidConsumerGroup 0 n)+ | n >= 1 = Right (ConsumerGroupSize n)+ | otherwise = Left (InvalidConsumerGroup 0 n) -- | Static membership. Use 'mkConsumerGroup'; accessors cannot update the pair. data ConsumerGroup = ConsumerGroup !Int32 !ConsumerGroupSize- deriving stock (Eq, Show)+ deriving stock (Eq, Show) member :: ConsumerGroup -> Int32 member (ConsumerGroup m _) = m@@ -623,97 +599,95 @@ mkConsumerGroup :: Int32 -> ConsumerGroupSize -> Either InvalidConsumerGroup ConsumerGroup mkConsumerGroup m n- | m >= 0 && m < consumerGroupSizeValue n = Right (ConsumerGroup m n)- | otherwise = Left (InvalidConsumerGroup m (consumerGroupSizeValue n))+ | m >= 0 && m < consumerGroupSizeValue n = Right (ConsumerGroup m n)+ | otherwise = Left (InvalidConsumerGroup m (consumerGroupSizeValue n)) -- | Construction error; invalid configuration never reaches a worker. data InvalidConsumerGroup = InvalidConsumerGroup- { invalidMember :: !Int32- , invalidSize :: !Int32- }- deriving stock (Eq, Show)+ { invalidMember :: !Int32,+ invalidSize :: !Int32+ }+ deriving stock (Eq, Show) -{- | Stored topology disagrees with the configured size. Stop all members and-use 'Kiroku.Store.Subscription.Checkpoint.resizeConsumerGroupTx' before restart.--}+-- | Stored topology disagrees with the configured size. Stop all members and+-- use 'Kiroku.Store.Subscription.Checkpoint.resizeConsumerGroupTx' before restart. data ConsumerGroupSizeMismatch = ConsumerGroupSizeMismatch- { mismatchName :: !SubscriptionName- , configuredSize :: !Int32- , observedSizes :: !(Vector Int32)- }- deriving stock (Eq, Show)+ { mismatchName :: !SubscriptionName,+ configuredSize :: !Int32,+ observedSizes :: !(Vector Int32)+ }+ deriving stock (Eq, Show) instance Exception ConsumerGroupSizeMismatch where- toException = toException . SomeSubscriptionStartupFailure- fromException exception = do- SomeSubscriptionStartupFailure refusal <- fromException exception- cast refusal+ toException = toException . SomeSubscriptionStartupFailure+ fromException exception = do+ SomeSubscriptionStartupFailure refusal <- fromException exception+ cast refusal -{- | Thrown at subscription startup when 'consumerGroupGuard' is 'True' and-another holder currently holds the advisory lock for this @(name, member)@.-Indicates two processes are configured as the same group member. This is a-startup /detection/ probe, not a lifetime-held lock — see the worker's-@guardMember@.--}+-- | Thrown at subscription startup when 'consumerGroupGuard' is 'True' and+-- another holder currently holds the advisory lock for this @(name, member)@.+-- Indicates two processes are configured as the same group member. This is a+-- startup /detection/ probe, not a lifetime-held lock — see the worker's+-- @guardMember@. data ConsumerGroupGuardConflict = ConsumerGroupGuardConflict- { conflictName :: !SubscriptionName- , conflictMember :: !Int32- }- deriving stock (Eq, Show)+ { conflictName :: !SubscriptionName,+ conflictMember :: !Int32+ }+ deriving stock (Eq, Show) instance Exception ConsumerGroupGuardConflict where- toException = toException . SomeSubscriptionStartupFailure- fromException exception = do- SomeSubscriptionStartupFailure refusal <- fromException exception- cast refusal+ toException = toException . SomeSubscriptionStartupFailure+ fromException exception = do+ SomeSubscriptionStartupFailure refusal <- fromException exception+ cast refusal -- | A nonpositive handler-stall warning interval was configured. newtype InvalidHandlerStallWarnAfter = InvalidHandlerStallWarnAfter NominalDiffTime- deriving stock (Eq, Show)+ deriving stock (Eq, Show) instance Exception InvalidHandlerStallWarnAfter where- toException = toException . SomeSubscriptionStartupFailure- fromException exception = do- SomeSubscriptionStartupFailure refusal <- fromException exception- cast refusal+ toException = toException . SomeSubscriptionStartupFailure+ fromException exception = do+ SomeSubscriptionStartupFailure refusal <- fromException exception+ cast refusal -- | A catchable family of subscription startup refusals. Concrete catches work too. data SomeSubscriptionStartupFailure = forall e. (Exception e) => SomeSubscriptionStartupFailure e instance Show SomeSubscriptionStartupFailure where- show (SomeSubscriptionStartupFailure e) = show e+ show (SomeSubscriptionStartupFailure e) = show e instance Exception SomeSubscriptionStartupFailure -- | Policy for checkpoint rows created before target identity was persisted. data TargetBindingPolicy = AdoptUnbound | RequireBound- deriving stock (Eq, Show)+ deriving stock (Eq, Show) -- | Stored bindings differ from the requested target, or strict startup found unbound rows. data SubscriptionTargetMismatch = SubscriptionTargetMismatch- { targetMismatchName :: !SubscriptionName- , configuredTarget :: !SubscriptionTarget- , observedTargets :: !(Vector (Maybe SubscriptionTarget))- }- deriving stock (Eq, Show)+ { targetMismatchName :: !SubscriptionName,+ configuredTarget :: !SubscriptionTarget,+ observedTargets :: !(Vector (Maybe SubscriptionTarget))+ }+ deriving stock (Eq, Show) instance Exception SubscriptionTargetMismatch where- toException = toException . SomeSubscriptionStartupFailure- fromException exception = do- SomeSubscriptionStartupFailure refusal <- fromException exception- cast refusal+ toException = toException . SomeSubscriptionStartupFailure+ fromException exception = do+ SomeSubscriptionStartupFailure refusal <- fromException exception+ cast refusal -- | A positive catch-up fetch size. The constructor is deliberately hidden. newtype BatchSize = BatchSize Int32- deriving stock (Eq, Show)+ deriving stock (Eq, Show) newtype InvalidBatchSize = InvalidBatchSize Int32- deriving stock (Eq, Show)+ deriving stock (Eq, Show) mkBatchSize :: Int32 -> Either InvalidBatchSize BatchSize mkBatchSize n- | n >= 1 = Right (BatchSize n)- | otherwise = Left (InvalidBatchSize n)+ | n >= 1 = Right (BatchSize n)+ | otherwise = Left (InvalidBatchSize n) batchSizeValue :: BatchSize -> Int32 batchSizeValue (BatchSize n) = n
src/Kiroku/Store/Subscription/Worker.hs view
@@ -1,944 +1,1083 @@-{- | The subscription worker: the impure driver behind a running subscription.--'runWorker' is the long-lived loop spawned by-'Kiroku.Store.Subscription.subscribe'. It is the interpreter for the pure state-machine in "Kiroku.Store.Subscription.Fsm": it supplies inputs (a batch was-fetched, the queue overflowed, the pool errored, the handler returned a-disposition) and carries out the resulting effects (deliver a batch, save the-checkpoint, back off, emit a @KirokuEvent@, halt). The worker walks the named-states — @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),-@Reconnecting@ (re-catch-up after a live fetch loses the pool), @Retrying@, and-@Stopped@ — and writes each transition to the @TVar@ exposed through-'Kiroku.Store.Subscription.Types.currentState'.--Per-event delivery (including the worker-side-'Kiroku.Store.Subscription.Types.eventTypeFilter' \/-'Kiroku.Store.Subscription.Types.selector' check applied /before/ the handler,-and the bounded-retry \/ dead-letter disposition mechanics) is concentrated in-the single delivery primitive shared by every live path, so behaviour is-identical for @AllStreams@, @Category@, and consumer-group subscriptions.--'withFetchBatchHookForTest', 'withLoadCheckpointHookForTest', and-'withSaveCheckpointHookForTest' are test-only seams for controlling fetch,-checkpoint-load, and checkpoint-save boundaries.--}-module Kiroku.Store.Subscription.Worker (- LiveSource (..),- runWorker,- configMember,- withFetchBatchHookForTest,- withLoadCheckpointHookForTest,- withSaveCheckpointHookForTest,-) where--import Contravariant.Extras (contrazip2)-import Control.Concurrent (threadDelay)-import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (TBQueue, TVar, atomically, check, newTVarIO, orElse, readTBQueue, readTVar, registerDelay, tryReadTBQueue, writeTVar)-import Control.Concurrent.STM qualified as STM-import Control.Exception (SomeException, bracket, finally, fromException, mask, throwIO, toException, try)-import Control.Monad (when)-import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.IORef (IORef, newIORef, readIORef, writeIORef)-import Data.Int (Int32)-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Text (Text)-import Data.Time (NominalDiffTime)-import Data.Vector (Vector)-import Data.Vector qualified as V-import Data.Word (Word64)-import GHC.Clock (getMonotonicTimeNSec)-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Pool (Pool)-import Hasql.Pool qualified as Pool-import Hasql.Session qualified as Session-import Hasql.Statement (Statement, preparable)-import Kiroku.Store.Observability (- KirokuEvent (..),- SubscriptionDbPhase (..),- SubscriptionDeliveryPhase (..),- SubscriptionGroupContext (..),- SubscriptionStopReason (..),- emitOrDrop,- )-import Kiroku.Store.SQL qualified as SQL-import Kiroku.Store.Settings (DecodedBatch (..), DecodedEvent (..), StoreSettings, decodeEvent, decodeEvents, decodedBatchLastEvent, decodedBatchLength, decodedEventRecorded, filterDecodedBatch)-import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL-import Kiroku.Store.Subscription.EventPublisher (SubscriberStatus)-import Kiroku.Store.Subscription.EventPublisher qualified as Pub-import Kiroku.Store.Subscription.Fsm (- Effect (..),- Input (..),- SubscriptionState (..),- stateCursor,- step,- )-import Kiroku.Store.Subscription.Types hiding (eventId, globalPosition)-import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent (..))-import System.IO.Unsafe (unsafePerformIO)---- Mirror 'Kiroku.Store.Subscription.EventPublisher.safetyPollMicros': an idle--- category re-checks at most this often, reconciling NOTIFYs lost while the--- listener connection was reconnecting. An idle category therefore costs at most--- one empty fetch per safety interval, not per global publisher tick.-categorySafetyPollMicros :: Int-categorySafetyPollMicros = 30_000_000--type FetchBatchHook =- SubscriptionConfig ->- GlobalPosition ->- IO (Maybe (Either Pool.UsageError (Vector RecordedEvent)))--type LoadCheckpointHook =- SubscriptionConfig ->- IO- ( Maybe- ( Either- Pool.UsageError- (Either SubscriptionCheckpointMissing CheckpointInitialization)- )- )--type SaveCheckpointHook =- SubscriptionConfig ->- GlobalPosition ->- IO ()--{-# NOINLINE fetchBatchHookRef #-}-fetchBatchHookRef :: IORef (Maybe FetchBatchHook)-fetchBatchHookRef = unsafePerformIO (newIORef Nothing)--{-# NOINLINE loadCheckpointHookRef #-}-loadCheckpointHookRef :: IORef (Maybe LoadCheckpointHook)-loadCheckpointHookRef = unsafePerformIO (newIORef Nothing)--{-# NOINLINE saveCheckpointHookRef #-}-saveCheckpointHookRef :: IORef (Maybe SaveCheckpointHook)-saveCheckpointHookRef = unsafePerformIO (newIORef Nothing)--{- | Install a process-local fetch hook for tests that need deterministic-subscription-worker fault injection. Production code leaves the hook unset.--}-withFetchBatchHookForTest :: FetchBatchHook -> IO a -> IO a-withFetchBatchHookForTest hook action =- bracket- ( do- previous <- readIORef fetchBatchHookRef- writeIORef fetchBatchHookRef (Just hook)- pure previous- )- (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)--{- | Install a process-local checkpoint-save boundary hook for lifecycle tests.--The hook runs immediately before the database statement. Production code leaves-it unset.--}-withSaveCheckpointHookForTest :: SaveCheckpointHook -> IO a -> IO a-withSaveCheckpointHookForTest hook action =- bracket- ( do- previous <- readIORef saveCheckpointHookRef- writeIORef saveCheckpointHookRef (Just hook)- pure previous- )- (writeIORef saveCheckpointHookRef)- (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 DecodedBatch) !(TVar SubscriberStatus)- | {- | Category, plain or consumer-group member: wake on the named- category's NOTIFY generation counter and re-query the database (with the- partition predicate, for a member).- -}- LiveFromCategoryNotify !Text- | {- | Consumer-group member of AllStreams: 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 '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-case 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' is thrown-and surfaces through 'Async.waitCatch').--If an 'eventHandler' callback is supplied, the worker emits:--* 'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointResolved' once- when startup resumes or initializes a checkpoint, followed by- 'Kiroku.Store.Observability.KirokuEventSubscriptionStarted'. A refused- missing checkpoint emits- 'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointMissing'- instead and fails before delivery.-* 'Kiroku.Store.Observability.KirokuEventSubscriptionCaughtUp' when- catch-up completes and the worker switches to live mode.-* 'Kiroku.Store.Observability.KirokuEventSubscriptionDbError' in the- subscription database phases: 'loadCheckpoint', 'fetchBatch', and- 'saveCheckpoint'. 'fetchBatch' errors are retried at the same cursor;- the event is the operator's structured signal.-* 'Kiroku.Store.Observability.KirokuEventSubscriptionStopped' when the- worker exits, with a reason discriminating handler-stop, cancel,- overflow, and worker-crash.--}-runWorker ::- (MonadIO m) =>- Pool ->- LiveSource ->- {- | the worker's current FSM state, written on every transition so callers- can read it through 'Kiroku.Store.Subscription.Types.currentState'.- -}- TVar SubscriptionState ->- TVar GlobalPosition ->- {- | per-category wake counter from the Notifier; the @Category@ live loop- blocks on this category's entry rather than busy-polling the global position.- -}- TVar (Map Text Word64) ->- SubscriptionConfig ->- -- | optional event handler for subscription observability- Maybe (KirokuEvent -> IO ()) ->- {- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'- runs on the catch-up fetch path, mirroring the publisher's- application in live mode.- -}- StoreSettings ->- m ()-runWorker pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $- withHandlerStallDiagnostics config (emitOrDrop mHandler) $ \deliveryConfig ->- runWorkerBody pool liveSource stateVar pubPosVar catGenVar deliveryConfig mHandler stSettings---- Select the delivery handler once per worker. The disabled arm returns the--- original config directly, with no per-delivery diagnostic branch or work.-withHandlerStallDiagnostics :: SubscriptionConfig -> (KirokuEvent -> IO ()) -> (SubscriptionConfig -> IO a) -> IO a-withHandlerStallDiagnostics config emit action = case handlerStallWarnAfter config of- Nothing -> action config- Just threshold- | threshold <= 0 -> throwIO (InvalidHandlerStallWarnAfter threshold)- | otherwise -> do- pending <- newTVarIO Nothing- let tracked event = mask $ \restore -> do- started <- getMonotonicTimeNSec- let delivery = StalledDelivery (globalPosition event) (eventId event) started- atomically (writeTVar pending (Just delivery))- restore (handler config event) `finally` atomically (writeTVar pending Nothing)- Async.withAsync (watchHandler pending threshold) $ \watchdog -> do- Async.link watchdog- action config{handler = tracked}- where- -- Park while idle. Reuse a pending interval across completed/replaced- -- invocations, rather than abandoning a registerDelay timer per event.- watchHandler pending threshold = awaitDelivery- where- awaitDelivery = do- _ <- atomically $ readTVar pending >>= maybe STM.retry pure- waitInterval threshold- waitInterval delay = do- timer <- registerDelay (durationMicros delay)- atomically (readTVar timer >>= check)- current <- atomically (readTVar pending)- case current of- Nothing -> awaitDelivery- Just delivery@(StalledDelivery pos eid started) -> do- now <- getMonotonicTimeNSec- let elapsed = fromRational (fromIntegral (now - started) / 1_000_000_000)- if elapsed < threshold- then waitInterval (threshold - elapsed)- else do- stillPending <- atomically ((== Just delivery) <$> readTVar pending)- when stillPending $- emit (KirokuEventSubscriptionHandlerStalled (name config) pos eid elapsed (groupCtxOf config))- waitInterval threshold---- One cell per enabled worker, not one timer/thread per handler invocation.-data StalledDelivery = StalledDelivery !GlobalPosition !EventId !Word64- deriving stock (Eq)---- registerDelay accepts an Int. Cap huge intervals safely and round positive--- sub-microsecond intervals up instead of creating a zero-delay polling loop.-durationMicros :: NominalDiffTime -> Int-durationMicros duration = fromInteger (max 1 (min (toInteger (maxBound :: Int)) (ceiling (duration * 1_000_000))))--runWorkerBody :: Pool -> LiveSource -> TVar SubscriptionState -> TVar GlobalPosition -> TVar (Map Text Word64) -> SubscriptionConfig -> Maybe (KirokuEvent -> IO ()) -> StoreSettings -> IO ()-runWorkerBody pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = do- let emit = emitOrDrop mHandler- subName = name config- groupCtx = groupCtxOf config- posRef <- newIORef (GlobalPosition 0)-- let body = do- -- Optional startup guardrail: when consumerGroupGuard is on, fail fast- -- if another holder currently holds this (name, member)'s advisory lock.- case (consumerGroupGuard config, consumerGroup config) of- (True, Just cg) -> guardMember pool subName (member cg)- _ -> pure ()- resolution <- loadCheckpoint pool config emit- checkpoint <- case resolution of- Left missing -> do- emit (KirokuEventSubscriptionCheckpointMissing missing groupCtx)- throwIO missing- Right initialization -> do- emit (KirokuEventSubscriptionCheckpointResolved initialization groupCtx)- pure (checkpointInitializationPosition initialization)- writeIORef posRef checkpoint- emit (KirokuEventSubscriptionStarted subName checkpoint groupCtx)- -- Drive the explicit FSM from the catch-up state. The pure 'step'- -- (Kiroku.Store.Subscription.Fsm) decides every lifecycle transition;- -- this driver supplies the inputs (what just happened) and interprets- -- the effects (deliver a batch, emit an event, back off, halt). The- -- three live strategies remain the *mechanism* for obtaining the next- -- batch within the 'Live' state; the FSM governs the lifecycle.- loop (CatchingUp checkpoint 0)-- -- One driver iteration: publish the current state for observability- -- ('currentState'), discover the next 'Input' by performing the state's- -- natural (possibly blocking) action, then hand it to 'feed'. Recording- -- the state at loop entry means the value read while the worker blocks in- -- 'nextInput' (e.g. waiting on the live queue) is the state it is blocked in.- loop :: SubscriptionState -> IO ()- loop st = do- atomically (writeTVar stateVar st)- nextInput st >>= feed st-- -- Apply 'step' to the (state, input) pair, interpret the resulting- -- effects, and continue. An effect (delivery, a gate) may itself produce- -- a follow-up 'Input' (e.g. the handler returned 'Stop'); that is fed back- -- to 'step' immediately. Otherwise: stop when the new state is terminal,- -- else loop on the new state.- feed :: SubscriptionState -> Input -> IO ()- feed st inp = do- let (st', effs) = step st inp- follow <- runEffects st' effs- case follow of- Just fInp -> feed st' fInp- Nothing -> case st' of- Stopped _ -> pure ()- _ -> loop st'-- -- Produce the next 'Input' for a state by performing its blocking action.- -- * CatchingUp: if caught up, 'CaughtUp'; else fetch one history batch,- -- mapping the result to 'BatchFetched' / 'FetchEmpty' (caught up) /- -- 'FetchFailed' (the catch-up retry, escalated by the state's attempt).- -- * Live (AllStreams, non-group): read the publisher's bounded queue;- -- 'QueueOverflowed' when the publisher signalled overflow, else the- -- stale-filtered batch ('FetchEmpty' if all stale).- -- * Live (Category / consumer-group): run the existing live loop to its- -- natural termination (handler 'Stop'), then report 'HandlerStopped'.- -- These loops retain their own NOTIFY-generation / global-position- -- gates and per-fetch retry, exactly as before.- nextInput :: SubscriptionState -> IO Input- nextInput = \case- CatchingUp c _ -> do- writeIORef posRef c- pubPos <- atomically (readTVar pubPosVar)- if c >= pubPos- then pure CaughtUp- else do- fetchResult <- fetchBatch pool config c emit stSettings- case fetchResult of- Left err -> pure (FetchFailed err)- Right events- | decodedBatchLength events == 0 -> pure CaughtUp- | otherwise -> pure (BatchFetched events)- Live c -> case liveSource of- LiveFromPublisherQueue liveQueue statusVar -> do- writeIORef posRef c- atomically $ do- status <- readTVar statusVar- case status of- Pub.Overflowed -> pure QueueOverflowed- Pub.Paused -> pure QueueBackpressured- Pub.Active -> do- -- A subscription registers its live queue before- -- catch-up begins, so events appended during- -- catch-up may be both fetched from SQL and waiting- -- in the queue. Drop those stale entries so live- -- mode cannot replay them or rewind the checkpoint.- events <- readTBQueue liveQueue- let fresh = filterDecodedBatch ((> c) . globalPosition) events- pure (if decodedBatchLength fresh == 0 then FetchEmpty else BatchFetched fresh)- LiveFromCategoryNotify cat ->- liveExitToInput =<< liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef c stSettings- 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- -- events are re-read from the database by the re-catch-up that- -- 'QueueDrained' triggers) and clear the flag back to 'Active' so the- -- publisher resumes pushing and the worker is not left waiting. The- -- AllStreams live path's @> cursor@ filter drops any superseded queued- -- entries once the worker is live again.- Paused{} -> do- 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- -- empty result means there is nothing new, so return to live.- Reconnecting c _ -> do- writeIORef posRef c- fetchResult <- fetchBatch pool config c emit stSettings- case fetchResult of- Left err -> pure (FetchFailed err)- Right events- | decodedBatchLength events == 0 -> pure FetchEmpty- | otherwise -> pure (BatchFetched events)- -- Defensive totality: 'Retrying' is a surfaced observability state- -- that the delivery primitive writes into the state TVar and then- -- restores, so it is never a driving state reaching this function.- -- Mirror 'step''s defensive 'Retrying' clause, which returns to- -- 'Live' at the same cursor.- Retrying c _ -> nextInput (Live c)- Stopped{} -> pure Cancelled-- -- Interpret a transition's effects against the *new* state. Returns a- -- follow-up 'Input' when an effect produces one (only 'DeliverBatch', when- -- the handler returns 'Stop'); 'Nothing' otherwise. 'Halt' terminates the- -- driver: a handler-requested stop returns cleanly (the outer handler- -- emits the Stopped event), overflow/crash rethrow so the outer 'try'- -- classifies and re-emits — preserving today's exact event sequence.- runEffects :: SubscriptionState -> [Effect] -> IO (Maybe Input)- runEffects st' = go- where- go [] = pure Nothing- go (e : es) = case e of- EmitCaughtUp -> do- emit (KirokuEventSubscriptionCaughtUp subName (stateCursor st') groupCtx)- go es- EmitPaused -> do- emit (KirokuEventSubscriptionPaused subName (stateCursor st') groupCtx)- go es- EmitResumed -> do- emit (KirokuEventSubscriptionResumed subName (stateCursor st') groupCtx)- go es- EmitReconnecting n -> do- emit (KirokuEventSubscriptionReconnecting subName n groupCtx)- go es- Backoff n -> threadDelay (fetchRetryDelayMicros n) >> go es- WaitForDrain -> go es- Checkpoint p -> saveCheckpoint pool config p emit >> go es- FetchHistory _ -> go es- RunLive -> go es- DeliverBatch events -> do- result <- processEvents pool config stateVar events emit posRef stSettings- case result of- Nothing -> pure (Just (HandlerStopped (lastPosOf events)))- Just _ -> go es- Halt reason -> case reason of- StopHandlerRequested -> pure Nothing- StopOverflowed -> throwIO (SubscriptionOverflowed subName)- StopCancelled -> throwIO Async.AsyncCancelled- StopWorkerCrashed ex -> throwIO ex- StopUndecodable failure -> throwIO (SubscriptionUndecodable failure)-- lastPosOf events = globalPosition (decodedBatchLastEvent events)-- -- Map a DB-driven live loop's exit onto the next FSM input: a clean stop- -- becomes 'HandlerStopped' (at the last processed position); a fetch error- -- becomes 'ConnectionLost', driving the FSM into 'Reconnecting'.- liveExitToInput = \case- LiveHandlerStopped -> HandlerStopped <$> readIORef posRef- LiveFetchError err -> do- position <- readIORef posRef- pure (ConnectionLost position err)-- -- Read and discard every batch currently in the live queue (non-blocking).- -- Used when resuming from 'Paused': the discarded events are re-read from- -- the database by the subsequent re-catch-up, so nothing is lost.- drainQueue q = do- m <- tryReadTBQueue q- case m of- Nothing -> pure ()- Just _ -> drainQueue q-- result <- try body- pos <- readIORef posRef- case result of- Right () -> emit (KirokuEventSubscriptionStopped subName pos StopHandlerRequested groupCtx)- Left (e :: SomeException) -> do- emit (KirokuEventSubscriptionStopped subName pos (classifyStopReason e) groupCtx)- throwIO e---- The consumer-group context for this config's lifecycle events: 'NonGroup' for--- an ordinary subscription, @GroupMember member size@ for a group member.-groupCtxOf :: SubscriptionConfig -> SubscriptionGroupContext-groupCtxOf config = maybe NonGroup (\cg -> GroupMember (member cg) (size cg)) (consumerGroup config)--{- Startup-only conflict probe for the consumer-group guardrail. Uses a-transaction-scoped advisory lock ('pg_try_advisory_xact_lock') which auto-releases-at transaction end, so it only detects a /concurrent/ holder at this instant. The-key is a stable bigint hash of the @name:member@ pair computed in SQL so all-processes agree. NOTE: this does NOT hold the lock for the worker's lifetime; full-mutual exclusion would need a session-level lock on a dedicated connection (the-'Kiroku.Store.Notification.Notifier' pattern), recorded as follow-up in EP-2's-Decision Log. On a database error the probe degrades open (treats it as "no-conflict") so a transient pool error cannot wedge startup. -}-guardMember :: Pool -> SubscriptionName -> Int32 -> IO ()-guardMember pool subName@(SubscriptionName n) mem = do- let probe :: Statement (Text, Int32) Bool- probe =- preparable- "SELECT pg_try_advisory_xact_lock(hashtextextended($1 || ':' || $2::text, 0))"- ( contrazip2- (E.param (E.nonNullable E.text))- (E.param (E.nonNullable E.int4))- )- (D.singleRow (D.column (D.nonNullable D.bool)))- result <- Pool.use pool (Session.statement (n, mem) probe)- case result of- Right True -> pure () -- got the lock; no concurrent holder right now- Right False -> throwIO (ConsumerGroupGuardConflict subName mem)- Left _ -> pure () -- DB error: degrade open (do not block startup)---- Map an exception to the 'SubscriptionStopReason' the operator should see.-classifyStopReason :: SomeException -> SubscriptionStopReason-classifyStopReason e- | Just (_ :: SubscriptionOverflowed) <- fromException e = StopOverflowed- | Just (_ :: Async.AsyncCancelled) <- fromException e = StopCancelled- | Just (SubscriptionUndecodable failure) <- fromException e = StopUndecodable failure- | otherwise = StopWorkerCrashed e---- The consumer-group member index for this config, or 0 for a non-group--- subscription. We always route checkpoints through the member-aware--- statements with member 0 for the non-group case, so there is a single code--- path: EP-1's schema guarantees pre-existing rows are consumer_group_member = 0,--- so a non-group worker reads and writes the same (name, 0) row it always did.-configMember :: SubscriptionConfig -> Int32-configMember config = maybe 0 member (consumerGroup config)--configSize :: SubscriptionConfig -> Int32-configSize config = maybe 1 size (consumerGroup config)---- Resolve the exact checkpoint key through the shared initializer. A database--- error is emitted and rethrown so startup fails loudly. A semantic--- 'FailIfMissing' result remains typed so the caller can emit the distinct--- refusal event before throwing it. Each group member resolves its own key.-loadCheckpoint ::- Pool ->- SubscriptionConfig ->- (KirokuEvent -> IO ()) ->- IO (Either SubscriptionCheckpointMissing CheckpointInitialization)-loadCheckpoint pool config emit = do- let subName = name config- mem = configMember config- mHook <- readIORef loadCheckpointHookRef- injected <- maybe (pure Nothing) (\hook -> hook config) mHook- result <- case injected of- Just hooked -> pure (fmap (either (Left . SomeSubscriptionStartupFailure) (Right . (,False))) hooked)- Nothing ->- Pool.use pool $- CheckpointSQL.initializeWorkerCheckpointSession- subName- mem- (configSize config)- (target config)- (targetBindingPolicy config)- (missingCheckpointPolicy config)- case result of- Left err -> do- emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))- throwIO err- Right (Left refusal@(SomeSubscriptionStartupFailure concrete)) ->- case fromException (toException concrete) of- Just missing -> pure (Left missing)- Nothing -> do- case fromException (toException concrete) of- Just mismatch -> emit (KirokuEventSubscriptionGroupSizeMismatch mismatch (groupCtxOf config))- Nothing -> pure ()- throwIO refusal- Right (Right (resolution, adopted)) -> do- when adopted $ emit (KirokuEventSubscriptionTargetBound subName (target config) (groupCtxOf config))- pure (Right resolution)---- How a DB-driven live loop ('liveLoopCategoryNotify' / 'liveLoopDbDriven')--- exited. The driver maps these onto FSM inputs: a clean handler stop becomes--- 'HandlerStopped'; a fetch error becomes 'ConnectionLost', which drives the FSM--- into 'Reconnecting' (backoff + re-catch-up from the checkpoint) rather than the--- old in-loop retry. AllStreams live has no entry here because it reads the--- publisher's queue and never fetches — its reconnect is the publisher's concern.-data LiveExit- = -- | The handler returned 'Stop'; the loop exited cleanly.- LiveHandlerStopped- | -- | A live-mode database fetch failed; the worker should reconnect.- LiveFetchError !Pool.UsageError---- Phase 2: live (Category, NOTIFY-driven). Blocks on this category's generation--- counter, which the Notifier bumps on every NOTIFY for a stream in the category,--- so an idle category does ZERO DB work while other categories receive traffic.--- The generation is snapshotted BEFORE an unconditional drain so a notification--- that arrives during the drain is never missed (it leaves gen > gen0 and the loop--- drains again on the next iteration). A safety timeout (matching the publisher's--- 30s safety poll) reconciles notifications lost while the listener connection is--- reconnecting, preserving at-least-once delivery with bounded latency.------ This loop serves every `Category` subscription, plain or consumer-group. A--- member cannot tell from a NOTIFY payload whether the stream is in its slice--- (that is `hashtextextended(stream_id) % size = member`, a Postgres hash), but--- it can gate on the category: the category generation advances on every append--- to a stream of the category, a superset of the member's own streams, and--- `fetchBatch` applies the partition predicate in SQL. So a member of an idle--- category does no live database work while other categories are busy, and a--- member whose sibling received the append does one empty fetch.-liveLoopCategoryNotify ::- Pool ->- SubscriptionConfig ->- TVar SubscriptionState ->- TVar (Map Text Word64) ->- -- | this subscription's category- Text ->- (KirokuEvent -> IO ()) ->- IORef GlobalPosition ->- GlobalPosition ->- StoreSettings ->- IO LiveExit-liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef startPos stSettings = go startPos- where- readGen = Map.findWithDefault 0 cat <$> readTVar catGenVar- go cursor = do- writeIORef posRef cursor- -- Snapshot the generation BEFORE draining so a NOTIFY landing mid-drain- -- is not lost: it leaves gen > gen0 and the gate below re-opens at once.- gen0 <- atomically readGen- drainResult <- drainTo cursor- case drainResult of- Left err -> pure (LiveFetchError err) -- reconnect: driver re-catches-up- Right Nothing -> pure LiveHandlerStopped -- handler said Stop- Right (Just c) -> do- -- Block until this category is notified again OR the safety timer- -- fires (reconciling NOTIFYs lost across a listener reconnect).- timer <- registerDelay categorySafetyPollMicros- atomically $- (readGen >>= \g -> check (g > gen0))- `orElse` (readTVar timer >>= check)- go c- where- -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'- -- state owns the backoff and re-catch-up. On success drain to empty.- drainTo c = do- fetchResult <- fetchBatch pool config c emit stSettings- case fetchResult of- Left err -> pure (Left err)- Right events -> do- emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))- if decodedBatchLength events == 0- then pure (Right (Just c))- else do- result <- processEvents pool config stateVar events emit posRef stSettings- case result of- Nothing -> pure (Right Nothing) -- handler said Stop- Just newPos -> drainTo newPos---- Phase 2: live (DB-driven, consumer-group members of AllStreams). Bypasses the broadcast--- and re-queries the database when the publisher's GLOBAL position advances,--- letting `fetchBatch` apply the partition predicate baked into the consumer-group--- SQL. A partitioned member cannot read the broadcast `liveQueue` because it--- carries unfiltered $all events and there is no in-process stream-id -> member map--- to filter them with. See EP-3 F18 / EP-2 Decision Log for the rationale versus--- extending RecordedEvent or maintaining an in-process cache.------ The gate waits for the publisher to advance past the LAST OBSERVED global--- position (not past `cursor`). A member's partition cursor only moves on events in--- its slice, but `pubPosVar` moves on every append; gating on the cursor busy-loops--- whenever another partition is ahead (the original defect). Gating on the last--- observed `pubPos` blocks until genuinely new global work exists. After the gate--- opens we drain the partition to empty (not stopping at `pubPos`), which--- guarantees no lost wakeup: the $all position is strictly monotonic, so any later--- partition event lands at a position strictly greater than the observed `pubPos`--- and re-opens the gate.-liveLoopDbDriven ::- Pool ->- SubscriptionConfig ->- TVar SubscriptionState ->- TVar GlobalPosition ->- (KirokuEvent -> IO ()) ->- IORef GlobalPosition ->- GlobalPosition ->- StoreSettings ->- IO LiveExit-liveLoopDbDriven pool config stateVar pubPosVar emit posRef startPos stSettings =- go startPos (GlobalPosition 0)- where- go cursor waitFrom = do- writeIORef posRef cursor- pubPos <- atomically $ do- p <- readTVar pubPosVar- check (p > waitFrom)- pure p- -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'- -- state owns the backoff and re-catch-up. On success drain to empty.- let drainTo c = do- fetchResult <- fetchBatch pool config c emit stSettings- case fetchResult of- Left err -> pure (Left err)- Right events -> do- emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))- if decodedBatchLength events == 0- then pure (Right (Just c))- else do- result <- processEvents pool config stateVar events emit posRef stSettings- case result of- Nothing -> pure (Right Nothing) -- handler said Stop- Just newPos -> drainTo newPos- drainResult <- drainTo cursor- case drainResult of- Left err -> pure (LiveFetchError err)- Right Nothing -> pure LiveHandlerStopped- Right (Just c) -> go c pubPos---- Fetch a batch of events from the database based on subscription target.--- Surfaces a database error through the event handler and returns the error to--- the caller so catch-up and DB-driven live loops can retry the same cursor.-fetchBatch ::- Pool ->- SubscriptionConfig ->- GlobalPosition ->- (KirokuEvent -> IO ()) ->- StoreSettings ->- IO (Either Pool.UsageError DecodedBatch)-fetchBatch pool config cursor@(GlobalPosition pos) emit stSettings = do- mHook <- readIORef fetchBatchHookRef- injected <- maybe (pure Nothing) (\hook -> hook config cursor) mHook- case injected of- Just result -> handle result- Nothing ->- case (consumerGroup config, target config) of- (Nothing, AllStreams) -> do- result <- Pool.use pool (Session.statement (pos, batchSizeValue (batchSize config)) SQL.readAllForwardStmt)- handle result- (Nothing, Category (CategoryName cat)) -> do- result <- Pool.use pool (Session.statement (pos, cat, batchSizeValue (batchSize config)) SQL.readCategoryForwardStmt)- handle result- (Just cg, AllStreams) -> do- let m = member cg; n = size cg- result <- Pool.use pool (Session.statement (pos, m, n, batchSizeValue (batchSize config)) SQL.readAllForwardConsumerGroupStmt)- handle result- (Just cg, Category (CategoryName cat)) -> do- let m = member cg; n = size cg- result <- Pool.use pool (Session.statement (pos, cat, m, n, batchSizeValue (batchSize config)) SQL.readCategoryForwardConsumerGroupStmt)- handle result- where- handle = \case- Left err -> do- emit (KirokuEventSubscriptionDbError (name config) FetchBatch err (groupCtxOf config))- pure (Left err)- -- Apply decodeHook on the catch-up path so catch-up batches are- -- transformed identically to live batches (which the publisher- -- transforms once before fan-out).- Right events -> Right <$> decodeEvents stSettings events---- Process a batch of events through the handler, resolving each event's--- disposition. Returns the new cursor position if the batch was fully consumed--- (every event resolved to 'Continue', 'DeadLetter', or an exhausted 'Retry'),--- or 'Nothing' if the handler returned 'Stop'.------ This is the single delivery primitive shared by the FSM 'DeliverBatch' effect--- (catch-up for every target; AllStreams live) and the two DB-driven live loops,--- so the four dispositions behave identically on every path (EP-2 / MasterPlan 6--- Decision Log). Checkpointing keeps the existing per-batch model: 'Continue'--- events advance the checkpoint only at the batch tail; 'Stop' checkpoints at the--- stopping event; 'DeadLetter' (and exhausted 'Retry') atomically record the--- event and advance the checkpoint past it via--- 'SQL.insertDeadLetterAndCheckpointStmt'.------ 'Retry' redelivers the same event after its 'RetryDelay', bounded by the--- config's 'retryMaxAttempts'; while a redelivery is pending the worker's--- observable state @TVar@ shows 'Retrying' (restored to the driving state — the--- value the driver wrote before this batch — once the event resolves).-processEvents ::- Pool ->- SubscriptionConfig ->- TVar SubscriptionState ->- DecodedBatch ->- (KirokuEvent -> IO ()) ->- IORef GlobalPosition ->- StoreSettings ->- IO (Maybe GlobalPosition)-processEvents pool config stateVar batch emit posRef stSettings = do- driving <- atomically (readTVar stateVar)- let phase = case driving of- CatchingUp{} -> DeliveredCatchUp- _ -> DeliveredLive- emit (KirokuEventSubscriptionDelivered subName (decodedBatchLength batch) phase groupCtx)- -- Choose the vector representation once per batch. The unchanged arm walks- -- RecordedEvent directly, with no per-event Decoded/Undecodable allocation.- case batch of- UnchangedBatch events -> walk events id (\event pos -> deliver driving event pos 1)- TransformedBatch events -> walk events decodedEventRecorded (\event pos -> dispatch driving event pos 1)- where- subName = name config- groupCtx = groupCtxOf config- maxAttempts = retryMaxAttempts (retryPolicy config)-- walk :: Vector a -> (a -> RecordedEvent) -> (a -> GlobalPosition -> IO Bool) -> IO (Maybe GlobalPosition)- walk events rawOf consume = go 0- where- go i- | i >= V.length events = do- let newPos = globalPosition (rawOf (V.last events))- writeIORef posRef newPos- saveCheckpoint pool config newPos emit- pure (Just newPos)- | otherwise = do- let item = events V.! i- event = rawOf item- evtPos = globalPosition event- keepGoing <-- if shouldDeliver (eventTypeFilter config) (selector config) event- then consume item evtPos- else pure True- if keepGoing- then writeIORef posRef evtPos >> go (i + 1)- else pure Nothing-- deliver driving event evtPos attempt = do- writeIORef posRef evtPos- result <- handler config event- resolve driving event evtPos attempt result (deliver driving event evtPos (attempt + 1))-- dispatch driving outcome evtPos attempt = case outcome of- Decoded event -> deliver driving event evtPos attempt- Undecodable raw failure -> case undecodableHandler config of- Nothing- | attempt >= maxAttempts -> throwIO (SubscriptionUndecodable failure)- | otherwise -> do- pause driving evtPos attempt (RetryDelay 1)- retryDecode driving raw evtPos (attempt + 1)- Just callback -> do- result <- callback raw failure- resolve driving raw evtPos attempt result (retryDecode driving raw evtPos (attempt + 1))-- retryDecode driving raw evtPos attempt = do- outcome <- decodeEvent stSettings raw- case outcome of- Decoded event | not (shouldDeliver (eventTypeFilter config) (selector config) event) -> pure True- _ -> dispatch driving outcome evtPos attempt-- -- Ordinary and explicitly chosen undecodable dispositions share exactly- -- one checkpoint/dead-letter/retry resolver. The absent callback never- -- enters its exhausted-Retry dead-letter branch.- resolve driving event evtPos attempt result retry = case result of- Continue -> pure True- Stop -> do- writeIORef posRef evtPos- saveCheckpoint pool config evtPos emit- pure False- DeadLetter reason -> do- writeDeadLetter pool config evtPos event reason attempt emit- pure True- Retry delay- | attempt >= maxAttempts -> do- writeDeadLetter pool config evtPos event (DeadLetterMaxAttempts attempt) attempt emit- pure True- | otherwise -> pause driving evtPos attempt delay >> retry-- pause driving evtPos attempt delay = do- atomically (writeTVar stateVar (Retrying evtPos attempt))- emit (KirokuEventSubscriptionRetrying subName evtPos attempt groupCtx)- threadDelay (retryDelayMicros delay)- atomically (writeTVar stateVar driving)---- Atomically record an event in @kiroku.dead_letters@ and advance the--- subscription's checkpoint past it (one statement; the checkpoint does not--- advance if the insert fails). On a database error the worker surfaces a--- 'KirokuEventSubscriptionDbError' and rethrows, so the event is neither lost--- nor silently skipped — it replays from the unadvanced checkpoint on restart.-writeDeadLetter ::- Pool ->- SubscriptionConfig ->- GlobalPosition ->- RecordedEvent ->- DeadLetterReason ->- -- | attempt count to record- Int ->- (KirokuEvent -> IO ()) ->- IO ()-writeDeadLetter pool config gp@(GlobalPosition pos) event reason attempt emit = do- let subName@(SubscriptionName name') = name config- mem = configMember config- (kind, category) = CheckpointSQL.targetColumns (Just (target config))- EventId uuid = eventId event- params =- SQL.DeadLetterParams- { SQL.dlSubscriptionName = name'- , SQL.dlMember = mem- , SQL.dlTargetKind = kind- , SQL.dlTargetCategory = category- , SQL.dlGroupSize = configSize config- , SQL.dlGlobalPosition = pos- , SQL.dlEventId = uuid- , SQL.dlReason = deadLetterReasonJson reason- , SQL.dlReasonSummary = deadLetterSummary reason- , SQL.dlAttemptCount = fromIntegral attempt- }- result <- Pool.use pool (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)- case result of- Left err -> do- emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))- throwIO err- Right () -> emit (KirokuEventSubscriptionDeadLettered subName gp reason (groupCtxOf config))---- Save a checkpoint to the database. Surfaces a database error through--- the event handler; the worker continues running but the next restart--- with the same name re-processes events the handler has already seen.--- Keyed by (subscription_name, member) so each group member persists its own--- position; non-group subscriptions use member 0 (see 'configMember').-saveCheckpoint ::- Pool ->- SubscriptionConfig ->- GlobalPosition ->- (KirokuEvent -> IO ()) ->- IO ()-saveCheckpoint pool config position@(GlobalPosition pos) emit = do- let subName@(SubscriptionName name') = name config- mem = configMember config- mHook <- readIORef saveCheckpointHookRef- mapM_ (\hook -> hook config position) mHook- result <- Pool.use pool (CheckpointSQL.saveBoundCheckpointSession (target config) name' mem pos (configSize config))- case result of- Left err -> emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))- Right () -> pure ()+-- | The subscription worker: the impure driver behind a running subscription.+--+-- 'runWorker' is the long-lived loop spawned by+-- 'Kiroku.Store.Subscription.subscribe'. It is the interpreter for the pure state+-- machine in "Kiroku.Store.Subscription.Fsm": it supplies inputs (a batch was+-- fetched, the queue overflowed, the pool errored, the handler returned a+-- disposition) and carries out the resulting effects (deliver a batch, save the+-- checkpoint, back off, emit a @KirokuEvent@, halt). The worker walks the named+-- states — @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),+-- @Reconnecting@ (re-catch-up after a live fetch loses the pool), @Retrying@, and+-- @Stopped@ — and writes each transition to the @TVar@ exposed through+-- 'Kiroku.Store.Subscription.Types.currentState'.+--+-- Per-event delivery (including the worker-side+-- 'Kiroku.Store.Subscription.Types.eventTypeFilter' \/+-- 'Kiroku.Store.Subscription.Types.selector' check applied /before/ the handler,+-- and the bounded-retry \/ dead-letter disposition mechanics) is concentrated in+-- the single delivery primitive shared by every live path, so behaviour is+-- identical for @AllStreams@, @Category@, and consumer-group subscriptions.+--+-- 'withFetchBatchHookForTest', 'withLoadCheckpointHookForTest', and+-- 'withSaveCheckpointHookForTest' are test-only seams for controlling fetch,+-- checkpoint-load, and checkpoint-save boundaries.+module Kiroku.Store.Subscription.Worker+ ( LiveSource (..),+ runWorker,+ configMember,+ withFetchBatchHookForTest,+ withLoadCheckpointHookForTest,+ withSaveCheckpointHookForTest,+ withNamespaceSafetyPollForTest,+ withNamespaceWindowHookForTest,+ )+where++import Contravariant.Extras (contrazip2)+import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.STM (TBQueue, TVar, atomically, check, newTVarIO, orElse, readTBQueue, readTVar, registerDelay, tryReadTBQueue, writeTVar)+import Control.Concurrent.STM qualified as STM+import Control.Exception (SomeException, bracket, finally, fromException, mask, throwIO, toException, try)+import Control.Monad (when)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Time (NominalDiffTime)+import Data.Vector (Vector)+import Data.Vector qualified as V+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool (Pool)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Kiroku.Store.Observability+ ( KirokuEvent (..),+ SubscriptionDbPhase (..),+ SubscriptionDeliveryPhase (..),+ SubscriptionGroupContext (..),+ SubscriptionStopReason (..),+ emitOrDrop,+ )+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Settings (DecodedBatch (..), DecodedEvent (..), StoreSettings, decodeEvent, decodeEvents, decodedBatchLastEvent, decodedBatchLength, decodedEventRecorded, filterDecodedBatch)+import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL+import Kiroku.Store.Subscription.EventPublisher (SubscriberStatus)+import Kiroku.Store.Subscription.EventPublisher qualified as Pub+import Kiroku.Store.Subscription.Fsm+ ( Effect (..),+ Input (..),+ SubscriptionState (..),+ stateCursor,+ step,+ )+import Kiroku.Store.Subscription.Types hiding (eventId, globalPosition)+import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent (..))+import System.IO.Unsafe (unsafePerformIO)++-- Mirror 'Kiroku.Store.Subscription.EventPublisher.safetyPollMicros': an idle+-- category re-checks at most this often, reconciling NOTIFYs lost while the+-- listener connection was reconnecting. An idle category therefore costs at most+-- one empty fetch per safety interval, not per global publisher tick.+categorySafetyPollMicros :: Int+categorySafetyPollMicros = 30_000_000++type FetchBatchHook =+ SubscriptionConfig ->+ GlobalPosition ->+ IO (Maybe (Either Pool.UsageError (Vector RecordedEvent)))++type LoadCheckpointHook =+ SubscriptionConfig ->+ IO+ ( Maybe+ ( Either+ Pool.UsageError+ (Either SubscriptionCheckpointMissing CheckpointInitialization)+ )+ )++type SaveCheckpointHook =+ SubscriptionConfig ->+ GlobalPosition ->+ IO ()++{-# NOINLINE fetchBatchHookRef #-}+fetchBatchHookRef :: IORef (Maybe FetchBatchHook)+fetchBatchHookRef = unsafePerformIO (newIORef Nothing)++{-# NOINLINE loadCheckpointHookRef #-}+loadCheckpointHookRef :: IORef (Maybe LoadCheckpointHook)+loadCheckpointHookRef = unsafePerformIO (newIORef Nothing)++{-# NOINLINE saveCheckpointHookRef #-}+saveCheckpointHookRef :: IORef (Maybe SaveCheckpointHook)+saveCheckpointHookRef = unsafePerformIO (newIORef Nothing)++-- | Install a process-local fetch hook for tests that need deterministic+-- subscription-worker fault injection. Production code leaves the hook unset.+withFetchBatchHookForTest :: FetchBatchHook -> IO a -> IO a+withFetchBatchHookForTest hook action =+ bracket+ ( do+ previous <- readIORef fetchBatchHookRef+ writeIORef fetchBatchHookRef (Just hook)+ pure previous+ )+ (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)++-- | Install a process-local checkpoint-save boundary hook for lifecycle tests.+--+-- The hook runs immediately before the database statement. Production code leaves+-- it unset.+withSaveCheckpointHookForTest :: SaveCheckpointHook -> IO a -> IO a+withSaveCheckpointHookForTest hook action =+ bracket+ ( do+ previous <- readIORef saveCheckpointHookRef+ writeIORef saveCheckpointHookRef (Just hook)+ pure previous+ )+ (writeIORef saveCheckpointHookRef)+ (const action)++-- | Test timing and fetch injection; production uses the normal safety interval.+{-# NOINLINE namespaceSafetyPollRef #-}+namespaceSafetyPollRef :: IORef Int+namespaceSafetyPollRef = unsafePerformIO (newIORef categorySafetyPollMicros)++withNamespaceSafetyPollForTest :: Int -> IO a -> IO a+withNamespaceSafetyPollForTest micros action =+ bracket+ (readIORef namespaceSafetyPollRef <* writeIORef namespaceSafetyPollRef micros)+ (writeIORef namespaceSafetyPollRef)+ (const action)++type NamespaceWindowHook = SubscriptionConfig -> GlobalPosition -> GlobalPosition -> IO (Maybe (Either Pool.UsageError SQL.NamespaceWindow))++{-# NOINLINE namespaceWindowHookRef #-}+namespaceWindowHookRef :: IORef (Maybe NamespaceWindowHook)+namespaceWindowHookRef = unsafePerformIO (newIORef Nothing)++withNamespaceWindowHookForTest :: NamespaceWindowHook -> IO a -> IO a+withNamespaceWindowHookForTest hook action =+ bracket+ (readIORef namespaceWindowHookRef <* writeIORef namespaceWindowHookRef (Just hook))+ (writeIORef namespaceWindowHookRef)+ (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 DecodedBatch) !(TVar SubscriberStatus)+ | -- | Category, plain or consumer-group member: wake on the named+ -- category's NOTIFY generation counter and re-query the database (with the+ -- partition predicate, for a member).+ LiveFromCategoryNotify !Text+ | -- | Namespace workers share only their active namespace's wake cell.+ LiveFromNamespaceNotify !(TVar Word64)+ | -- | Consumer-group member of AllStreams: 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 '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+-- case 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' is thrown+-- and surfaces through 'Async.waitCatch').+--+-- If an 'eventHandler' callback is supplied, the worker emits:+--+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointResolved' once+-- when startup resumes or initializes a checkpoint, followed by+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionStarted'. A refused+-- missing checkpoint emits+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointMissing'+-- instead and fails before delivery.+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionCaughtUp' when+-- catch-up completes and the worker switches to live mode.+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionDbError' in the+-- subscription database phases: 'loadCheckpoint', 'fetchBatch', and+-- 'saveCheckpoint'. 'fetchBatch' errors are retried at the same cursor;+-- the event is the operator's structured signal.+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionStopped' when the+-- worker exits, with a reason discriminating handler-stop, cancel,+-- overflow, and worker-crash.+runWorker ::+ (MonadIO m) =>+ Pool ->+ LiveSource ->+ -- | the worker's current FSM state, written on every transition so callers+ -- can read it through 'Kiroku.Store.Subscription.Types.currentState'.+ TVar SubscriptionState ->+ TVar GlobalPosition ->+ -- | per-category wake counter from the Notifier; the @Category@ live loop+ -- blocks on this category's entry rather than busy-polling the global position.+ TVar (Map Text Word64) ->+ SubscriptionConfig ->+ -- | optional event handler for subscription observability+ Maybe (KirokuEvent -> IO ()) ->+ -- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'+ -- runs on the catch-up fetch path, mirroring the publisher's+ -- application in live mode.+ StoreSettings ->+ m ()+runWorker pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $+ withHandlerStallDiagnostics config (emitOrDrop mHandler) $ \deliveryConfig ->+ runWorkerBody pool liveSource stateVar pubPosVar catGenVar deliveryConfig mHandler stSettings++-- Select the delivery handler once per worker. The disabled arm returns the+-- original config directly, with no per-delivery diagnostic branch or work.+withHandlerStallDiagnostics :: SubscriptionConfig -> (KirokuEvent -> IO ()) -> (SubscriptionConfig -> IO a) -> IO a+withHandlerStallDiagnostics config emit action = case handlerStallWarnAfter config of+ Nothing -> action config+ Just threshold+ | threshold <= 0 -> do+ let refusal = InvalidHandlerStallWarnAfter threshold+ emit (KirokuEventSubscriptionStopped (name config) (GlobalPosition 0) (StopWorkerCrashed (toException refusal)) (groupCtxOf config))+ throwIO refusal+ | otherwise -> do+ pending <- newTVarIO Nothing+ let tracked event = mask $ \restore -> do+ started <- getMonotonicTimeNSec+ let delivery = StalledDelivery (globalPosition event) (eventId event) started+ atomically (writeTVar pending (Just delivery))+ restore (handler config event) `finally` atomically (writeTVar pending Nothing)+ Async.withAsync (watchHandler pending threshold) $ \watchdog -> do+ Async.link watchdog+ action config {handler = tracked}+ where+ -- Park while idle. Reuse a pending interval across completed/replaced+ -- invocations, rather than abandoning a registerDelay timer per event.+ watchHandler pending threshold = awaitDelivery+ where+ awaitDelivery = do+ _ <- atomically $ readTVar pending >>= maybe STM.retry pure+ waitInterval threshold+ waitInterval delay = do+ timer <- registerDelay (durationMicros delay)+ atomically (readTVar timer >>= check)+ current <- atomically (readTVar pending)+ case current of+ Nothing -> awaitDelivery+ Just delivery@(StalledDelivery pos eid started) -> do+ now <- getMonotonicTimeNSec+ let elapsed = fromRational (fromIntegral (now - started) / 1_000_000_000)+ if elapsed < threshold+ then waitInterval (threshold - elapsed)+ else do+ stillPending <- atomically ((== Just delivery) <$> readTVar pending)+ when stillPending $+ emit (KirokuEventSubscriptionHandlerStalled (name config) pos eid elapsed (groupCtxOf config))+ waitInterval threshold++-- One cell per enabled worker, not one timer/thread per handler invocation.+data StalledDelivery = StalledDelivery !GlobalPosition !EventId !Word64+ deriving stock (Eq)++-- registerDelay accepts an Int. Cap huge intervals safely and round positive+-- sub-microsecond intervals up instead of creating a zero-delay polling loop.+durationMicros :: NominalDiffTime -> Int+durationMicros duration = fromInteger (max 1 (min (toInteger (maxBound :: Int)) (ceiling (duration * 1_000_000))))++-- Allocated only for namespace workers; old targets keep their startup path.+data NamespaceScanState = NamespaceScanState+ { scanSweep :: !(IORef (Maybe GlobalPosition)),+ scanGeneration :: !(IORef (Maybe Word64)),+ scanEmptyWindows :: !(IORef Int),+ scanSaved :: !(IORef GlobalPosition)+ }++runWorkerBody :: Pool -> LiveSource -> TVar SubscriptionState -> TVar GlobalPosition -> TVar (Map Text Word64) -> SubscriptionConfig -> Maybe (KirokuEvent -> IO ()) -> StoreSettings -> IO ()+runWorkerBody pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = do+ let emit = emitOrDrop mHandler+ subName = name config+ groupCtx = groupCtxOf config+ posRef <- newIORef (GlobalPosition 0)+ -- One captured database head per sweep and a generation snapshotted before it.+ namespaceState <- case liveSource of+ LiveFromNamespaceNotify _ -> Just <$> (NamespaceScanState <$> newIORef Nothing <*> newIORef Nothing <*> newIORef 0 <*> newIORef (GlobalPosition 0))+ _ -> pure Nothing++ let namespaceScan = case namespaceState of+ Just state -> state+ Nothing -> error "namespace scan state requested for a legacy target"+ body = do+ -- Optional startup guardrail: when consumerGroupGuard is on, fail fast+ -- if another holder currently holds this (name, member)'s advisory lock.+ case (consumerGroupGuard config, consumerGroup config) of+ (True, Just cg) -> guardMember pool subName (member cg)+ _ -> pure ()+ resolution <- loadCheckpoint pool config emit+ checkpoint <- case resolution of+ Left missing -> do+ emit (KirokuEventSubscriptionCheckpointMissing missing groupCtx)+ throwIO missing+ Right initialization -> do+ emit (KirokuEventSubscriptionCheckpointResolved initialization groupCtx)+ pure (checkpointInitializationPosition initialization)+ mapM_ (\scan -> writeIORef (scanSaved scan) checkpoint) namespaceState+ writeIORef posRef checkpoint+ emit (KirokuEventSubscriptionStarted subName checkpoint groupCtx)+ -- Drive the explicit FSM from the catch-up state. The pure 'step'+ -- (Kiroku.Store.Subscription.Fsm) decides every lifecycle transition;+ -- this driver supplies the inputs (what just happened) and interprets+ -- the effects (deliver a batch, emit an event, back off, halt). The+ -- three live strategies remain the *mechanism* for obtaining the next+ -- batch within the 'Live' state; the FSM governs the lifecycle.+ loop (CatchingUp checkpoint 0)++ namespaceInput :: Bool -> GlobalPosition -> TVar Word64 -> IO Input+ namespaceInput live cursor generation = do+ sweep <- readIORef (scanSweep namespaceScan)+ captured <- case sweep of+ Just headPosition -> pure (Right headPosition)+ Nothing -> do+ previous <- readIORef (scanGeneration namespaceScan)+ when live $ case previous of+ Nothing -> pure ()+ Just before -> do+ micros <- readIORef namespaceSafetyPollRef+ timer <- registerDelay micros+ atomically $+ (readTVar generation >>= \g -> check (g /= before))+ `orElse` (readTVar timer >>= check)+ -- Snapshot BEFORE the head read, including on initial catch-up.+ before <- atomically (readTVar generation)+ result <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)+ case result of+ Left err -> pure (Left err)+ Right headPosition -> do+ writeIORef (scanGeneration namespaceScan) (Just before)+ writeIORef (scanSweep namespaceScan) (Just (GlobalPosition headPosition))+ pure (Right (GlobalPosition headPosition))+ result <- case captured of+ Left err -> pure (Left err)+ Right headPosition+ | cursor >= headPosition -> pure (Right (SQL.NamespaceWindow V.empty cursor 0 True))+ | otherwise -> fetchNamespaceWindow pool config cursor headPosition+ case result of+ Left err -> do+ emit (KirokuEventSubscriptionDbError subName FetchBatch err groupCtx)+ pure (if live then ConnectionLost cursor err else FetchFailed err)+ Right window -> do+ events <- decodeEvents stSettings (SQL.namespaceEvents window)+ emit (KirokuEventSubscriptionFetched subName (decodedBatchLength events) groupCtx)+ pure (NamespaceWindowFetched events (SQL.scannedThrough window) (SQL.exhausted window))++ -- One driver iteration: publish the current state for observability+ -- ('currentState'), discover the next 'Input' by performing the state's+ -- natural (possibly blocking) action, then hand it to 'feed'. Recording+ -- the state at loop entry means the value read while the worker blocks in+ -- 'nextInput' (e.g. waiting on the live queue) is the state it is blocked in.+ loop :: SubscriptionState -> IO ()+ loop st = do+ atomically (writeTVar stateVar st)+ nextInput st >>= feed st++ -- Apply 'step' to the (state, input) pair, interpret the resulting+ -- effects, and continue. An effect (delivery, a gate) may itself produce+ -- a follow-up 'Input' (e.g. the handler returned 'Stop'); that is fed back+ -- to 'step' immediately. Otherwise: stop when the new state is terminal,+ -- else loop on the new state.+ feed :: SubscriptionState -> Input -> IO ()+ feed st inp = do+ let (st', effs) = step st inp+ follow <- runEffects st' effs+ case follow of+ Just fInp -> feed st' fInp+ Nothing -> case st' of+ Stopped _ -> pure ()+ _ -> loop st'++ -- Produce the next 'Input' for a state by performing its blocking action.+ -- * CatchingUp: if caught up, 'CaughtUp'; else fetch one history batch,+ -- mapping the result to 'BatchFetched' / 'FetchEmpty' (caught up) /+ -- 'FetchFailed' (the catch-up retry, escalated by the state's attempt).+ -- * Live (AllStreams, non-group): read the publisher's bounded queue;+ -- 'QueueOverflowed' when the publisher signalled overflow, else the+ -- stale-filtered batch ('FetchEmpty' if all stale).+ -- * Live (Category / consumer-group): run the existing live loop to its+ -- natural termination (handler 'Stop'), then report 'HandlerStopped'.+ -- These loops retain their own NOTIFY-generation / global-position+ -- gates and per-fetch retry, exactly as before.+ nextInput :: SubscriptionState -> IO Input+ nextInput = \case+ CatchingUp c _+ | LiveFromNamespaceNotify generation <- liveSource ->+ namespaceInput False c generation+ CatchingUp c _ -> do+ writeIORef posRef c+ pubPos <- atomically (readTVar pubPosVar)+ if c >= pubPos+ then pure CaughtUp+ else do+ fetchResult <- fetchBatch pool config c emit stSettings+ case fetchResult of+ Left err -> pure (FetchFailed err)+ Right events+ | decodedBatchLength events == 0 -> pure CaughtUp+ | otherwise -> pure (BatchFetched events)+ Live c -> case liveSource of+ LiveFromPublisherQueue liveQueue statusVar -> do+ writeIORef posRef c+ atomically $ do+ status <- readTVar statusVar+ case status of+ Pub.Overflowed -> pure QueueOverflowed+ Pub.Paused -> pure QueueBackpressured+ Pub.Active -> do+ -- A subscription registers its live queue before+ -- catch-up begins, so events appended during+ -- catch-up may be both fetched from SQL and waiting+ -- in the queue. Drop those stale entries so live+ -- mode cannot replay them or rewind the checkpoint.+ events <- readTBQueue liveQueue+ let fresh = filterDecodedBatch ((> c) . globalPosition) events+ pure (if decodedBatchLength fresh == 0 then FetchEmpty else BatchFetched fresh)+ LiveFromCategoryNotify cat ->+ liveExitToInput =<< liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef c stSettings+ LiveFromNamespaceNotify generation -> namespaceInput True c generation+ 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+ -- events are re-read from the database by the re-catch-up that+ -- 'QueueDrained' triggers) and clear the flag back to 'Active' so the+ -- publisher resumes pushing and the worker is not left waiting. The+ -- AllStreams live path's @> cursor@ filter drops any superseded queued+ -- entries once the worker is live again.+ Paused {} -> do+ 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+ LiveFromNamespaceNotify {} -> 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+ -- empty result means there is nothing new, so return to live.+ Reconnecting c _+ | LiveFromNamespaceNotify generation <- liveSource ->+ namespaceInput False c generation+ Reconnecting c _ -> do+ writeIORef posRef c+ fetchResult <- fetchBatch pool config c emit stSettings+ case fetchResult of+ Left err -> pure (FetchFailed err)+ Right events+ | decodedBatchLength events == 0 -> pure FetchEmpty+ | otherwise -> pure (BatchFetched events)+ -- Defensive totality: 'Retrying' is a surfaced observability state+ -- that the delivery primitive writes into the state TVar and then+ -- restores, so it is never a driving state reaching this function.+ -- Mirror 'step''s defensive 'Retrying' clause, which returns to+ -- 'Live' at the same cursor.+ Retrying c _ -> nextInput (Live c)+ Stopped {} -> pure Cancelled++ -- Interpret a transition's effects against the *new* state. Returns a+ -- follow-up 'Input' when an effect produces one (only 'DeliverBatch', when+ -- the handler returns 'Stop'); 'Nothing' otherwise. 'Halt' terminates the+ -- driver: a handler-requested stop returns cleanly (the outer handler+ -- emits the Stopped event), overflow/crash rethrow so the outer 'try'+ -- classifies and re-emits — preserving today's exact event sequence.+ runEffects :: SubscriptionState -> [Effect] -> IO (Maybe Input)+ runEffects st' = go+ where+ go [] = pure Nothing+ go (e : es) = case e of+ EmitCaughtUp -> do+ emit (KirokuEventSubscriptionCaughtUp subName (stateCursor st') groupCtx)+ go es+ EmitPaused -> do+ emit (KirokuEventSubscriptionPaused subName (stateCursor st') groupCtx)+ go es+ EmitResumed -> do+ emit (KirokuEventSubscriptionResumed subName (stateCursor st') groupCtx)+ go es+ EmitReconnecting n -> do+ emit (KirokuEventSubscriptionReconnecting subName n groupCtx)+ go es+ Backoff n -> threadDelay (fetchRetryDelayMicros n) >> go es+ WaitForDrain -> go es+ Checkpoint p -> saveCheckpoint pool config p emit >> go es+ FetchHistory _ -> go es+ RunLive -> go es+ DeliverNamespaceWindow events frontier done -> do+ result <-+ if decodedBatchLength events == 0+ then do+ count <- readIORef (scanEmptyWindows namespaceScan)+ writeIORef (scanEmptyWindows namespaceScan) (count + 1)+ saved <- readIORef (scanSaved namespaceScan)+ when (frontier > saved && (done || count + 1 >= 32)) $ do+ success <- saveCheckpointResult pool config frontier emit+ when success $ do+ writeIORef (scanSaved namespaceScan) frontier+ writeIORef (scanEmptyWindows namespaceScan) 0+ writeIORef posRef frontier+ pure (Just frontier)+ else processEventsWithCompletion (Just (frontier, (scanSaved namespaceScan))) pool config stateVar events emit posRef stSettings+ case result of+ Nothing -> Just . HandlerStopped <$> readIORef posRef+ Just completed -> do+ -- A nonempty window already attempted one tail save; remember+ -- pending progress after failures for a later empty sweep.+ when (decodedBatchLength events > 0) (writeIORef (scanEmptyWindows namespaceScan) 0)+ when done (writeIORef (scanSweep namespaceScan) Nothing)+ pure (Just (NamespaceWindowCompleted completed done))+ DeliverBatch events -> do+ result <- processEvents pool config stateVar events emit posRef stSettings+ case result of+ Nothing -> pure (Just (HandlerStopped (lastPosOf events)))+ Just _ -> go es+ Halt reason -> case reason of+ StopHandlerRequested -> pure Nothing+ StopOverflowed -> throwIO (SubscriptionOverflowed subName)+ StopCancelled -> throwIO Async.AsyncCancelled+ StopWorkerCrashed ex -> throwIO ex+ StopUndecodable failure -> throwIO (SubscriptionUndecodable failure)++ lastPosOf events = globalPosition (decodedBatchLastEvent events)++ -- Map a DB-driven live loop's exit onto the next FSM input: a clean stop+ -- becomes 'HandlerStopped' (at the last processed position); a fetch error+ -- becomes 'ConnectionLost', driving the FSM into 'Reconnecting'.+ liveExitToInput = \case+ LiveHandlerStopped -> HandlerStopped <$> readIORef posRef+ LiveFetchError err -> do+ position <- readIORef posRef+ pure (ConnectionLost position err)++ -- Read and discard every batch currently in the live queue (non-blocking).+ -- Used when resuming from 'Paused': the discarded events are re-read from+ -- the database by the subsequent re-catch-up, so nothing is lost.+ drainQueue q = do+ m <- tryReadTBQueue q+ case m of+ Nothing -> pure ()+ Just _ -> drainQueue q++ result <- try body+ pos <- readIORef posRef+ case result of+ Right () -> emit (KirokuEventSubscriptionStopped subName pos StopHandlerRequested groupCtx)+ Left (e :: SomeException) -> do+ emit (KirokuEventSubscriptionStopped subName pos (classifyStopReason e) groupCtx)+ throwIO e++-- The consumer-group context for this config's lifecycle events: 'NonGroup' for+-- an ordinary subscription, @GroupMember member size@ for a group member.+groupCtxOf :: SubscriptionConfig -> SubscriptionGroupContext+groupCtxOf config = maybe NonGroup (\cg -> GroupMember (member cg) (size cg)) (consumerGroup config)++{- Startup-only conflict probe for the consumer-group guardrail. Uses a+transaction-scoped advisory lock ('pg_try_advisory_xact_lock') which auto-releases+at transaction end, so it only detects a /concurrent/ holder at this instant. The+key is a stable bigint hash of the @name:member@ pair computed in SQL so all+processes agree. NOTE: this does NOT hold the lock for the worker's lifetime; full+mutual exclusion would need a session-level lock on a dedicated connection (the+'Kiroku.Store.Notification.Notifier' pattern), recorded as follow-up in EP-2's+Decision Log. On a database error the probe degrades open (treats it as "no+conflict") so a transient pool error cannot wedge startup. -}+guardMember :: Pool -> SubscriptionName -> Int32 -> IO ()+guardMember pool subName@(SubscriptionName n) mem = do+ let probe :: Statement (Text, Int32) Bool+ probe =+ preparable+ "SELECT pg_try_advisory_xact_lock(hashtextextended($1 || ':' || $2::text, 0))"+ ( contrazip2+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.int4))+ )+ (D.singleRow (D.column (D.nonNullable D.bool)))+ result <- Pool.use pool (Session.statement (n, mem) probe)+ case result of+ Right True -> pure () -- got the lock; no concurrent holder right now+ Right False -> throwIO (ConsumerGroupGuardConflict subName mem)+ Left _ -> pure () -- DB error: degrade open (do not block startup)++-- Map an exception to the 'SubscriptionStopReason' the operator should see.+classifyStopReason :: SomeException -> SubscriptionStopReason+classifyStopReason e+ | Just (_ :: SubscriptionOverflowed) <- fromException e = StopOverflowed+ | Just (_ :: Async.AsyncCancelled) <- fromException e = StopCancelled+ | Just (SubscriptionUndecodable failure) <- fromException e = StopUndecodable failure+ | otherwise = StopWorkerCrashed e++-- The consumer-group member index for this config, or 0 for a non-group+-- subscription. We always route checkpoints through the member-aware+-- statements with member 0 for the non-group case, so there is a single code+-- path: EP-1's schema guarantees pre-existing rows are consumer_group_member = 0,+-- so a non-group worker reads and writes the same (name, 0) row it always did.+configMember :: SubscriptionConfig -> Int32+configMember config = maybe 0 member (consumerGroup config)++configSize :: SubscriptionConfig -> Int32+configSize config = maybe 1 size (consumerGroup config)++-- Resolve the exact checkpoint key through the shared initializer. A database+-- error is emitted and rethrown so startup fails loudly. A semantic+-- 'FailIfMissing' result remains typed so the caller can emit the distinct+-- refusal event before throwing it. Each group member resolves its own key.+loadCheckpoint ::+ Pool ->+ SubscriptionConfig ->+ (KirokuEvent -> IO ()) ->+ IO (Either SubscriptionCheckpointMissing CheckpointInitialization)+loadCheckpoint pool config emit = do+ let subName = name config+ mem = configMember config+ mHook <- readIORef loadCheckpointHookRef+ injected <- maybe (pure Nothing) (\hook -> hook config) mHook+ result <- case injected of+ Just hooked -> pure (fmap (either (Left . SomeSubscriptionStartupFailure) (Right . (,False))) hooked)+ Nothing ->+ Pool.use pool $+ CheckpointSQL.initializeWorkerCheckpointSession+ subName+ mem+ (configSize config)+ (target config)+ (targetBindingPolicy config)+ (missingCheckpointPolicy config)+ case result of+ Left err -> do+ emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))+ throwIO err+ Right (Left refusal@(SomeSubscriptionStartupFailure concrete)) ->+ case fromException (toException concrete) of+ Just missing -> pure (Left missing)+ Nothing -> do+ case fromException (toException concrete) of+ Just mismatch -> emit (KirokuEventSubscriptionGroupSizeMismatch mismatch (groupCtxOf config))+ Nothing -> pure ()+ case fromException (toException concrete) of+ Just mismatch -> emit (KirokuEventSubscriptionTargetMismatch mismatch (groupCtxOf config))+ Nothing -> pure ()+ throwIO refusal+ Right (Right (resolution, adopted)) -> do+ when adopted $ emit (KirokuEventSubscriptionTargetBound subName (target config) (groupCtxOf config))+ pure (Right resolution)++-- How a DB-driven live loop ('liveLoopCategoryNotify' / 'liveLoopDbDriven')+-- exited. The driver maps these onto FSM inputs: a clean handler stop becomes+-- 'HandlerStopped'; a fetch error becomes 'ConnectionLost', which drives the FSM+-- into 'Reconnecting' (backoff + re-catch-up from the checkpoint) rather than the+-- old in-loop retry. AllStreams live has no entry here because it reads the+-- publisher's queue and never fetches — its reconnect is the publisher's concern.+data LiveExit+ = -- | The handler returned 'Stop'; the loop exited cleanly.+ LiveHandlerStopped+ | -- | A live-mode database fetch failed; the worker should reconnect.+ LiveFetchError !Pool.UsageError++-- Phase 2: live (Category, NOTIFY-driven). Blocks on this category's generation+-- counter, which the Notifier bumps on every NOTIFY for a stream in the category,+-- so an idle category does ZERO DB work while other categories receive traffic.+-- The generation is snapshotted BEFORE an unconditional drain so a notification+-- that arrives during the drain is never missed (it leaves gen > gen0 and the loop+-- drains again on the next iteration). A safety timeout (matching the publisher's+-- 30s safety poll) reconciles notifications lost while the listener connection is+-- reconnecting, preserving at-least-once delivery with bounded latency.+--+-- This loop serves every `Category` subscription, plain or consumer-group. A+-- member cannot tell from a NOTIFY payload whether the stream is in its slice+-- (that is `hashtextextended(stream_id) % size = member`, a Postgres hash), but+-- it can gate on the category: the category generation advances on every append+-- to a stream of the category, a superset of the member's own streams, and+-- `fetchBatch` applies the partition predicate in SQL. So a member of an idle+-- category does no live database work while other categories are busy, and a+-- member whose sibling received the append does one empty fetch.+liveLoopCategoryNotify ::+ Pool ->+ SubscriptionConfig ->+ TVar SubscriptionState ->+ TVar (Map Text Word64) ->+ -- | this subscription's category+ Text ->+ (KirokuEvent -> IO ()) ->+ IORef GlobalPosition ->+ GlobalPosition ->+ StoreSettings ->+ IO LiveExit+liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef startPos stSettings = go startPos+ where+ readGen = Map.findWithDefault 0 cat <$> readTVar catGenVar+ go cursor = do+ writeIORef posRef cursor+ -- Snapshot the generation BEFORE draining so a NOTIFY landing mid-drain+ -- is not lost: it leaves gen > gen0 and the gate below re-opens at once.+ gen0 <- atomically readGen+ drainResult <- drainTo cursor+ case drainResult of+ Left err -> pure (LiveFetchError err) -- reconnect: driver re-catches-up+ Right Nothing -> pure LiveHandlerStopped -- handler said Stop+ Right (Just c) -> do+ -- Block until this category is notified again OR the safety timer+ -- fires (reconciling NOTIFYs lost across a listener reconnect).+ timer <- registerDelay categorySafetyPollMicros+ atomically $+ (readGen >>= \g -> check (g > gen0))+ `orElse` (readTVar timer >>= check)+ go c+ where+ -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'+ -- state owns the backoff and re-catch-up. On success drain to empty.+ drainTo c = do+ fetchResult <- fetchBatch pool config c emit stSettings+ case fetchResult of+ Left err -> pure (Left err)+ Right events -> do+ emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))+ if decodedBatchLength events == 0+ then pure (Right (Just c))+ else do+ result <- processEvents pool config stateVar events emit posRef stSettings+ case result of+ Nothing -> pure (Right Nothing) -- handler said Stop+ Just newPos -> drainTo newPos++-- Phase 2: live (DB-driven, consumer-group members of AllStreams). Bypasses the broadcast+-- and re-queries the database when the publisher's GLOBAL position advances,+-- letting `fetchBatch` apply the partition predicate baked into the consumer-group+-- SQL. A partitioned member cannot read the broadcast `liveQueue` because it+-- carries unfiltered $all events and there is no in-process stream-id -> member map+-- to filter them with. See EP-3 F18 / EP-2 Decision Log for the rationale versus+-- extending RecordedEvent or maintaining an in-process cache.+--+-- The gate waits for the publisher to advance past the LAST OBSERVED global+-- position (not past `cursor`). A member's partition cursor only moves on events in+-- its slice, but `pubPosVar` moves on every append; gating on the cursor busy-loops+-- whenever another partition is ahead (the original defect). Gating on the last+-- observed `pubPos` blocks until genuinely new global work exists. After the gate+-- opens we drain the partition to empty (not stopping at `pubPos`), which+-- guarantees no lost wakeup: the $all position is strictly monotonic, so any later+-- partition event lands at a position strictly greater than the observed `pubPos`+-- and re-opens the gate.+liveLoopDbDriven ::+ Pool ->+ SubscriptionConfig ->+ TVar SubscriptionState ->+ TVar GlobalPosition ->+ (KirokuEvent -> IO ()) ->+ IORef GlobalPosition ->+ GlobalPosition ->+ StoreSettings ->+ IO LiveExit+liveLoopDbDriven pool config stateVar pubPosVar emit posRef startPos stSettings =+ go startPos (GlobalPosition 0)+ where+ go cursor waitFrom = do+ writeIORef posRef cursor+ pubPos <- atomically $ do+ p <- readTVar pubPosVar+ check (p > waitFrom)+ pure p+ -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'+ -- state owns the backoff and re-catch-up. On success drain to empty.+ let drainTo c = do+ fetchResult <- fetchBatch pool config c emit stSettings+ case fetchResult of+ Left err -> pure (Left err)+ Right events -> do+ emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))+ if decodedBatchLength events == 0+ then pure (Right (Just c))+ else do+ result <- processEvents pool config stateVar events emit posRef stSettings+ case result of+ Nothing -> pure (Right Nothing) -- handler said Stop+ Just newPos -> drainTo newPos+ drainResult <- drainTo cursor+ case drainResult of+ Left err -> pure (LiveFetchError err)+ Right Nothing -> pure LiveHandlerStopped+ Right (Just c) -> go c pubPos++-- Fetch a batch of events from the database based on subscription target.+-- Surfaces a database error through the event handler and returns the error to+-- the caller so catch-up and DB-driven live loops can retry the same cursor.+fetchBatch ::+ Pool ->+ SubscriptionConfig ->+ GlobalPosition ->+ (KirokuEvent -> IO ()) ->+ StoreSettings ->+ IO (Either Pool.UsageError DecodedBatch)+fetchBatch pool config cursor@(GlobalPosition pos) emit stSettings = do+ mHook <- readIORef fetchBatchHookRef+ injected <- maybe (pure Nothing) (\hook -> hook config cursor) mHook+ case injected of+ Just result -> handle result+ Nothing ->+ case (consumerGroup config, target config) of+ (_, CategoryNamespace _) -> error "namespace workers fetch bounded windows"+ (Nothing, AllStreams) -> do+ result <- Pool.use pool (Session.statement (pos, batchSizeValue (batchSize config)) SQL.readAllForwardStmt)+ handle result+ (Nothing, Category (CategoryName cat)) -> do+ result <- Pool.use pool (Session.statement (pos, cat, batchSizeValue (batchSize config)) SQL.readCategoryForwardStmt)+ handle result+ (Just cg, AllStreams) -> do+ let m = member cg; n = size cg+ result <- Pool.use pool (Session.statement (pos, m, n, batchSizeValue (batchSize config)) SQL.readAllForwardConsumerGroupStmt)+ handle result+ (Just cg, Category (CategoryName cat)) -> do+ let m = member cg; n = size cg+ result <- Pool.use pool (Session.statement (pos, cat, m, n, batchSizeValue (batchSize config)) SQL.readCategoryForwardConsumerGroupStmt)+ handle result+ where+ handle = \case+ Left err -> do+ emit (KirokuEventSubscriptionDbError (name config) FetchBatch err (groupCtxOf config))+ pure (Left err)+ -- Apply decodeHook on the catch-up path so catch-up batches are+ -- transformed identically to live batches (which the publisher+ -- transforms once before fan-out).+ Right events -> Right <$> decodeEvents stSettings events++-- Fetch only one bounded window. No request is made at/beyond the captured head.+fetchNamespaceWindow :: Pool -> SubscriptionConfig -> GlobalPosition -> GlobalPosition -> IO (Either Pool.UsageError SQL.NamespaceWindow)+fetchNamespaceWindow pool config cursor@(GlobalPosition position) headPosition@(GlobalPosition upper) = do+ hook <- readIORef namespaceWindowHookRef+ injected <- maybe (pure Nothing) (\f -> f config cursor headPosition) hook+ case injected of+ Just result -> pure result+ Nothing -> case target config of+ CategoryNamespace namespace ->+ let width = min 1024 (batchSizeValue (batchSize config))+ in Pool.use pool $ case consumerGroup config of+ Nothing -> Session.statement (position, upper, namespace, width) SQL.readNamespaceWindowStmt+ Just cg -> Session.statement (position, upper, namespace, width, member cg, size cg) SQL.readNamespaceGroupWindowStmt+ _ -> error "fetchNamespaceWindow requires a namespace target"++-- Process a batch of events through the handler, resolving each event's+-- disposition. Returns the new cursor position if the batch was fully consumed+-- (every event resolved to 'Continue', 'DeadLetter', or an exhausted 'Retry'),+-- or 'Nothing' if the handler returned 'Stop'.+--+-- This is the single delivery primitive shared by the FSM 'DeliverBatch' effect+-- (catch-up for every target; AllStreams live) and the two DB-driven live loops,+-- so the four dispositions behave identically on every path (EP-2 / MasterPlan 6+-- Decision Log). Checkpointing keeps the existing per-batch model: 'Continue'+-- events advance the checkpoint only at the batch tail; 'Stop' checkpoints at the+-- stopping event; 'DeadLetter' (and exhausted 'Retry') atomically record the+-- event and advance the checkpoint past it via+-- 'SQL.insertDeadLetterAndCheckpointStmt'.+--+-- 'Retry' redelivers the same event after its 'RetryDelay', bounded by the+-- config's 'retryMaxAttempts'; while a redelivery is pending the worker's+-- observable state @TVar@ shows 'Retrying' (restored to the driving state — the+-- value the driver wrote before this batch — once the event resolves).+processEvents ::+ Pool ->+ SubscriptionConfig ->+ TVar SubscriptionState ->+ DecodedBatch ->+ (KirokuEvent -> IO ()) ->+ IORef GlobalPosition ->+ StoreSettings ->+ IO (Maybe GlobalPosition)+processEvents = processEventsWithCompletion Nothing++-- A fully resolved namespace window saves its completed scan frontier once.+-- Stop and atomic dead letters retain their per-event positions.+processEventsWithCompletion :: Maybe (GlobalPosition, IORef GlobalPosition) -> Pool -> SubscriptionConfig -> TVar SubscriptionState -> DecodedBatch -> (KirokuEvent -> IO ()) -> IORef GlobalPosition -> StoreSettings -> IO (Maybe GlobalPosition)+processEventsWithCompletion completion pool config stateVar batch emit posRef stSettings = do+ driving <- atomically (readTVar stateVar)+ let phase = case driving of+ CatchingUp {} -> DeliveredCatchUp+ _ -> DeliveredLive+ emit (KirokuEventSubscriptionDelivered subName (decodedBatchLength batch) phase groupCtx)+ -- Choose the vector representation once per batch. The unchanged arm walks+ -- RecordedEvent directly, with no per-event Decoded/Undecodable allocation.+ case batch of+ UnchangedBatch events -> walk events id (\event pos -> deliver driving event pos 1)+ TransformedBatch events -> walk events decodedEventRecorded (\event pos -> dispatch driving event pos 1)+ where+ subName = name config+ groupCtx = groupCtxOf config+ maxAttempts = retryMaxAttempts (retryPolicy config)++ walk :: Vector a -> (a -> RecordedEvent) -> (a -> GlobalPosition -> IO Bool) -> IO (Maybe GlobalPosition)+ walk events rawOf consume = go 0+ where+ go i+ | i >= V.length events = do+ let newPos = maybe (globalPosition (rawOf (V.last events))) fst completion+ writeIORef posRef newPos+ success <- saveCheckpointResult pool config newPos emit+ when success $ case completion of+ Nothing -> pure ()+ Just (_, saved) -> writeIORef saved newPos+ pure (Just newPos)+ | otherwise = do+ let item = events V.! i+ event = rawOf item+ evtPos = globalPosition event+ keepGoing <-+ if shouldDeliver (eventTypeFilter config) (selector config) event+ then consume item evtPos+ else pure True+ if keepGoing+ then writeIORef posRef evtPos >> go (i + 1)+ else pure Nothing++ deliver driving event evtPos attempt = do+ writeIORef posRef evtPos+ result <- handler config event+ resolve driving event evtPos attempt result (deliver driving event evtPos (attempt + 1))++ dispatch driving outcome evtPos attempt = case outcome of+ Decoded event -> deliver driving event evtPos attempt+ Undecodable raw failure -> case undecodableHandler config of+ Nothing+ | attempt >= maxAttempts -> throwIO (SubscriptionUndecodable failure)+ | otherwise -> do+ pause driving evtPos attempt (RetryDelay 1)+ retryDecode driving raw evtPos (attempt + 1)+ Just callback -> do+ result <- callback raw failure+ resolve driving raw evtPos attempt result (retryDecode driving raw evtPos (attempt + 1))++ retryDecode driving raw evtPos attempt = do+ outcome <- decodeEvent stSettings raw+ -- walk already accepted the initial outcome. Recovery may rewrite its+ -- type or payload; retain that decision throughout this delivery.+ dispatch driving outcome evtPos attempt++ -- Ordinary and explicitly chosen undecodable dispositions share exactly+ -- one checkpoint/dead-letter/retry resolver. The absent callback never+ -- enters its exhausted-Retry dead-letter branch.+ resolve driving event evtPos attempt result retry = case result of+ Continue -> pure True+ Stop -> do+ writeIORef posRef evtPos+ saveCheckpoint pool config evtPos emit+ pure False+ DeadLetter reason -> do+ writeDeadLetter pool config evtPos event reason attempt emit+ pure True+ Retry delay+ | attempt >= maxAttempts -> do+ writeDeadLetter pool config evtPos event (DeadLetterMaxAttempts attempt) attempt emit+ pure True+ | otherwise -> pause driving evtPos attempt delay >> retry++ pause driving evtPos attempt delay = do+ atomically (writeTVar stateVar (Retrying evtPos attempt))+ emit (KirokuEventSubscriptionRetrying subName evtPos attempt groupCtx)+ threadDelay (retryDelayMicros delay)+ atomically (writeTVar stateVar driving)++-- Atomically record an event in @kiroku.dead_letters@ and advance the+-- subscription's checkpoint past it (one statement; the checkpoint does not+-- advance if the insert fails). On a database error the worker surfaces a+-- 'KirokuEventSubscriptionDbError' and rethrows, so the event is neither lost+-- nor silently skipped — it replays from the unadvanced checkpoint on restart.+writeDeadLetter ::+ Pool ->+ SubscriptionConfig ->+ GlobalPosition ->+ RecordedEvent ->+ DeadLetterReason ->+ -- | attempt count to record+ Int ->+ (KirokuEvent -> IO ()) ->+ IO ()+writeDeadLetter pool config gp@(GlobalPosition pos) event reason attempt emit = do+ let subName@(SubscriptionName name') = name config+ mem = configMember config+ (kind, category) = CheckpointSQL.targetColumns (Just (target config))+ EventId uuid = eventId event+ params =+ SQL.DeadLetterParams+ { SQL.dlSubscriptionName = name',+ SQL.dlMember = mem,+ SQL.dlTargetKind = kind,+ SQL.dlTargetCategory = category,+ SQL.dlGroupSize = configSize config,+ SQL.dlGlobalPosition = pos,+ SQL.dlEventId = uuid,+ SQL.dlReason = deadLetterReasonJson reason,+ SQL.dlReasonSummary = deadLetterSummary reason,+ SQL.dlAttemptCount = fromIntegral attempt+ }+ result <- Pool.use pool (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)+ case result of+ Left err -> do+ emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))+ throwIO err+ Right () -> emit (KirokuEventSubscriptionDeadLettered subName gp reason (groupCtxOf config))++-- Save a checkpoint to the database. Surfaces a database error through+-- the event handler; the worker continues running but the next restart+-- with the same name re-processes events the handler has already seen.+-- Keyed by (subscription_name, member) so each group member persists its own+-- position; non-group subscriptions use member 0 (see 'configMember').+saveCheckpoint ::+ Pool ->+ SubscriptionConfig ->+ GlobalPosition ->+ (KirokuEvent -> IO ()) ->+ IO ()+saveCheckpoint pool config position emit = do+ _ <- saveCheckpointResult pool config position emit+ pure ()++-- Failed empty-window flushes must remain pending rather than looking durable.+saveCheckpointResult :: Pool -> SubscriptionConfig -> GlobalPosition -> (KirokuEvent -> IO ()) -> IO Bool+saveCheckpointResult pool config position@(GlobalPosition pos) emit = do+ let subName@(SubscriptionName name') = name config+ mem = configMember config+ mHook <- readIORef saveCheckpointHookRef+ mapM_ (\hook -> hook config position) mHook+ result <- Pool.use pool (CheckpointSQL.saveBoundCheckpointSession (target config) name' mem pos (configSize config))+ case result of+ Left err -> emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config)) >> pure False+ Right () -> pure True
src/Kiroku/Store/Transaction.hs view
@@ -1,46 +1,45 @@-{- | Transactional combinators that compose a Kiroku event-store-operation with arbitrary additional @hasql-transaction@ work in a-single ACID transaction.--This module is the entry point for callers — most prominently the-@keiro@ projection layer — that need to atomically write to their own-SQL tables in the same transaction as an event append. Three ergonomic-levels are provided:--* 'runTransaction' / 'runTransactionNoRetry' — bare escape hatch: run- an opaque 'Tx.Transaction' against the store's connection pool.--* 'appendToStreamTx' — a 'Tx.Transaction'-flavored single-stream- append. Useful inside a 'runTransaction' block when the caller- wants full control over conflict handling.--* 'runTransactionAppending' / 'runTransactionAppendingNoRetry' — the- recommended high-level wrapper that combines a single-stream append- with the caller's 'Tx.Transaction' continuation in one atomic- transaction. This is the primary API for @keiro@-style projection- consumers.--Replay-history leases and one-stream repair guards are exposed separately by-'Kiroku.Store.HistoryRetention' as @Tx.Transaction@ combinators. When composing-both in one body, acquire or renew the lease before taking the stream guard.-The transactional read deliberately bypasses the IO-only decode hook.--The @-NoRetry@ variants use-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood; the-default variants use 'Hasql.Transaction.Sessions.transaction', which-automatically retries the body on PostgreSQL serialization conflicts.-The retry runs the entire 'Tx.Transaction' value more than once. This-is safe for pure-SQL bodies (each retry's prior partial work rolls back-inside Postgres before the next attempt begins) but should be-considered when the caller reasons about external observability.--Mocking: a mock 'Kiroku.Store.Effect.Store' interpreter cannot-meaningfully execute an opaque 'Tx.Transaction' against in-memory state-and is expected to reject 'Kiroku.Store.Effect.RunTransaction' /-'Kiroku.Store.Effect.RunTransactionNoRetry' at runtime.--}-module Kiroku.Store.Transaction (- -- * Bare escape hatch+-- | Transactional combinators that compose a Kiroku event-store+-- operation with arbitrary additional @hasql-transaction@ work in a+-- single ACID transaction.+--+-- This module is the entry point for callers — most prominently the+-- @keiro@ projection layer — that need to atomically write to their own+-- SQL tables in the same transaction as an event append. Three ergonomic+-- levels are provided:+--+-- * 'runTransaction' / 'runTransactionNoRetry' — bare escape hatch: run+-- an opaque 'Tx.Transaction' against the store's connection pool.+--+-- * 'appendToStreamTx' — a 'Tx.Transaction'-flavored single-stream+-- append. Useful inside a 'runTransaction' block when the caller+-- wants full control over conflict handling.+--+-- * 'runTransactionAppending' / 'runTransactionAppendingNoRetry' — the+-- recommended high-level wrapper that combines a single-stream append+-- with the caller's 'Tx.Transaction' continuation in one atomic+-- transaction. This is the primary API for @keiro@-style projection+-- consumers.+--+-- Replay-history leases and one-stream repair guards are exposed separately by+-- 'Kiroku.Store.HistoryRetention' as @Tx.Transaction@ combinators. When composing+-- both in one body, acquire or renew the lease before taking the stream guard.+-- The transactional read deliberately bypasses the IO-only decode hook.+--+-- The @-NoRetry@ variants use+-- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood; the+-- default variants use 'Hasql.Transaction.Sessions.transaction', which+-- automatically retries the body on PostgreSQL serialization conflicts.+-- The retry runs the entire 'Tx.Transaction' value more than once. This+-- is safe for pure-SQL bodies (each retry's prior partial work rolls back+-- inside Postgres before the next attempt begins) but should be+-- considered when the caller reasons about external observability.+--+-- Mocking: a mock 'Kiroku.Store.Effect.Store' interpreter cannot+-- meaningfully execute an opaque 'Tx.Transaction' against in-memory state+-- and is expected to reject 'Kiroku.Store.Effect.RunTransaction' /+-- 'Kiroku.Store.Effect.RunTransactionNoRetry' at runtime.+module Kiroku.Store.Transaction+ ( -- * Bare escape hatch runTransaction, runTransactionNoRetry, @@ -61,7 +60,8 @@ -- * Append-precondition conflicts AppendConflict (..), appendConflictToStoreError,-) where+ )+where import Control.Lens ((^.)) import Control.Monad.IO.Class (MonadIO, liftIO)@@ -79,295 +79,285 @@ import Kiroku.Store.Settings qualified as Settings import Kiroku.Store.Types -{- | Run an arbitrary 'Tx.Transaction' against the store's connection-pool inside a 'BEGIN'/'COMMIT' block.--The body may run /more than once/ if PostgreSQL raises a serialization-conflict — automatic retry is the default behavior of-'Hasql.Transaction.Sessions.transaction'. Use 'runTransactionNoRetry'-when retry is unacceptable (for example, the caller has been promised-exactly-once semantics that an outside observer of intermediate state-could break).--The body executes at 'Hasql.Transaction.Sessions.ReadCommitted'-isolation in 'Hasql.Transaction.Sessions.Write' mode, mirroring the-existing transactional sites in 'Kiroku.Store.Append.appendMultiStream'-and the hard-delete path.--Errors from both 'Tx.sql' scripts and 'Tx.statement' preserve PostgreSQL-SQLSTATE and message through 'UnexpectedServerError' or-'TransientTransactionFailure'. Event-ID duplicates retain 'DuplicateEvent';-opaque bodies do not fabricate stream context for other constraints.-Connection failures use 'ConnectionLost', and pool acquisition timeouts use-'PoolAcquisitionTimeout'. Unsupported driver errors retain 'ConnectionError'.-Calling 'Tx.condemn' inside the-body marks the transaction for rollback at commit; the call returns the-final value the body produced, but no SQL writes from this transaction-will be visible. Mock interpreters of 'Store' reject this constructor.--}+-- | Run an arbitrary 'Tx.Transaction' against the store's connection+-- pool inside a 'BEGIN'/'COMMIT' block.+--+-- The body may run /more than once/ if PostgreSQL raises a serialization+-- conflict — automatic retry is the default behavior of+-- 'Hasql.Transaction.Sessions.transaction'. Use 'runTransactionNoRetry'+-- when retry is unacceptable (for example, the caller has been promised+-- exactly-once semantics that an outside observer of intermediate state+-- could break).+--+-- The body executes at 'Hasql.Transaction.Sessions.ReadCommitted'+-- isolation in 'Hasql.Transaction.Sessions.Write' mode, mirroring the+-- existing transactional sites in 'Kiroku.Store.Append.appendMultiStream'+-- and the hard-delete path.+--+-- Errors from both 'Tx.sql' scripts and 'Tx.statement' preserve PostgreSQL+-- SQLSTATE and message through 'UnexpectedServerError' or+-- 'TransientTransactionFailure'. Event-ID duplicates retain 'DuplicateEvent';+-- opaque bodies do not fabricate stream context for other constraints.+-- Connection failures use 'ConnectionLost', and pool acquisition timeouts use+-- 'PoolAcquisitionTimeout'. Unsupported driver errors retain 'ConnectionError'.+-- Calling 'Tx.condemn' inside the+-- body marks the transaction for rollback at commit; the call returns the+-- final value the body produced, but no SQL writes from this transaction+-- will be visible. Mock interpreters of 'Store' reject this constructor. runTransaction ::- (HasCallStack, Store :> es) =>- Tx.Transaction a ->- Eff es a+ (HasCallStack, Store :> es) =>+ Tx.Transaction a ->+ Eff es a runTransaction tx = send (RunTransaction tx) -{- | Like 'runTransaction' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' — the body is executed-exactly once even on PostgreSQL serialization conflicts. Serialization-failures (@40001@) and deadlocks (@40P01@) surface as-'TransientTransactionFailure' with the original SQLSTATE and server message.-Use this entry point for a body that persistently raises those errors; the-retrying runner can keep re-executing it.--}+-- | Like 'runTransaction' but uses+-- 'Hasql.Transaction.Sessions.transactionNoRetry' — the body is executed+-- exactly once even on PostgreSQL serialization conflicts. Serialization+-- failures (@40001@) and deadlocks (@40P01@) surface as+-- 'TransientTransactionFailure' with the original SQLSTATE and server message.+-- Use this entry point for a body that persistently raises those errors; the+-- retrying runner can keep re-executing it. runTransactionNoRetry ::- (HasCallStack, Store :> es) =>- Tx.Transaction a ->- Eff es a+ (HasCallStack, Store :> es) =>+ Tx.Transaction a ->+ Eff es a runTransactionNoRetry tx = send (RunTransactionNoRetry tx) -- --------------------------------------------------------------------------- -- Tx-flavored append -- --------------------------------------------------------------------------- -{- | Generate UUIDv7s for any 'EventData' lacking a caller-supplied-'eventId' and return the prepared list, ready to feed into-'appendToStreamTx' inside a 'Tx.Transaction'.--'Tx.Transaction' has no 'MonadIO' instance, so UUID generation must-happen /before/ the transaction body runs. This helper is the IO-side-preparation step paired with 'appendToStreamTx'.--}+-- | Generate UUIDv7s for any 'EventData' lacking a caller-supplied+-- 'eventId' and return the prepared list, ready to feed into+-- 'appendToStreamTx' inside a 'Tx.Transaction'.+--+-- 'Tx.Transaction' has no 'MonadIO' instance, so UUID generation must+-- happen /before/ the transaction body runs. This helper is the IO-side+-- preparation step paired with 'appendToStreamTx'. prepareEventsIO :: (MonadIO m) => [EventData] -> m [PreparedEvent] prepareEventsIO = prepareEvents -{- | Append a batch of events to a single stream as part of a larger-'Tx.Transaction'.--The 'Tx.Transaction'-flavored counterpart to-'Kiroku.Store.Append.appendToStream'. It does /not/ enforce the @$all@-reserved-stream check — callers should validate the stream name before-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-around the conflict, or commit (which would persist the rest of the-transaction body without the append).--The events list must be pre-prepared via 'prepareEventsIO' (which-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. This function returns-@'Left' ('EmptyAppendBatchConflict' …)@ without dispatching a statement;-the high-level @runTransactionAppending*@ wrappers return-@'Left' ('EmptyAppendBatch' …)@ before opening a transaction.--}+-- | Append a batch of events to a single stream as part of a larger+-- 'Tx.Transaction'.+--+-- The 'Tx.Transaction'-flavored counterpart to+-- 'Kiroku.Store.Append.appendToStream'. It does /not/ enforce the @$all@+-- reserved-stream check — callers should validate the stream name before+-- 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+-- around the conflict, or commit (which would persist the rest of the+-- transaction body without the append).+--+-- The events list must be pre-prepared via 'prepareEventsIO' (which+-- 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. This function returns+-- @'Left' ('EmptyAppendBatchConflict' …)@ without dispatching a statement;+-- the high-level @runTransactionAppending*@ wrappers return+-- @'Left' ('EmptyAppendBatch' …)@ before opening a transaction. appendToStreamTx ::- StreamName ->- ExpectedVersion ->- [PreparedEvent] ->- UTCTime ->- Tx.Transaction (Either AppendConflict AppendResult)+ StreamName ->+ ExpectedVersion ->+ [PreparedEvent] ->+ UTCTime ->+ Tx.Transaction (Either AppendConflict AppendResult) appendToStreamTx sn@(StreamName name) expected prepared now = do- 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)+ 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 -- --------------------------------------------------------------------------- -{- | Append events to a single stream and run an arbitrary-'Tx.Transaction' continuation in one ACID transaction.--This is the recommended API for callers who want to combine an event-append with their own SQL writes (canonical example: a projection row-that must land iff the append commits).--Behavior:--* 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- inside).-* Inside the transaction, 'appendToStreamTx' is called. On 'Left'- (version conflict, missing stream, etc.), the transaction is- 'Tx.condemn'-ed, the continuation is /not/ run, and the call returns- @'Left' storeErr@.-* On 'Right' the continuation runs with the resulting 'AppendResult'.- If the continuation calls 'Tx.condemn', the transaction rolls back- at commit time and no writes — including the event append — are- visible.-* The transaction body may execute more than once if PostgreSQL raises- 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-through the returned 'Either' — that 'Either' is reserved for semantic-append-precondition conflicts (and the up-front reserved-stream-rejection).--This wrapper does /not/ apply the-'Kiroku.Store.Settings.enrichEvent' hook, because resolving the-'StoreSettings' requires reaching the live 'KirokuStore' handle and the-constraint set here is intentionally minimal. Callers running on a-'Kiroku.Store.Effect.Resource.KirokuStoreResource'-flavored stack and-wanting the hook should use 'runTransactionAppendingResource'; callers-on a bare stack can call 'enrichEventsIO' explicitly before invoking-this wrapper.--}+-- | Append events to a single stream and run an arbitrary+-- 'Tx.Transaction' continuation in one ACID transaction.+--+-- This is the recommended API for callers who want to combine an event+-- append with their own SQL writes (canonical example: a projection row+-- that must land iff the append commits).+--+-- Behavior:+--+-- * 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+-- inside).+-- * Inside the transaction, 'appendToStreamTx' is called. On 'Left'+-- (version conflict, missing stream, etc.), the transaction is+-- 'Tx.condemn'-ed, the continuation is /not/ run, and the call returns+-- @'Left' storeErr@.+-- * On 'Right' the continuation runs with the resulting 'AppendResult'.+-- If the continuation calls 'Tx.condemn', the transaction rolls back+-- at commit time and no writes — including the event append — are+-- visible.+-- * The transaction body may execute more than once if PostgreSQL raises+-- 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+-- through the returned 'Either' — that 'Either' is reserved for semantic+-- append-precondition conflicts (and the up-front reserved-stream+-- rejection).+--+-- This wrapper does /not/ apply the+-- 'Kiroku.Store.Settings.enrichEvent' hook, because resolving the+-- 'StoreSettings' requires reaching the live 'KirokuStore' handle and the+-- constraint set here is intentionally minimal. Callers running on a+-- 'Kiroku.Store.Effect.Resource.KirokuStoreResource'-flavored stack and+-- wanting the hook should use 'runTransactionAppendingResource'; callers+-- on a bare stack can call 'enrichEventsIO' explicitly before invoking+-- this wrapper. runTransactionAppending ::- (HasCallStack, IOE :> es, Store :> es) =>- StreamName ->- ExpectedVersion ->- [EventData] ->- (AppendResult -> Tx.Transaction a) ->- Eff es (Either StoreError a)+ (HasCallStack, IOE :> es, Store :> es) =>+ StreamName ->+ ExpectedVersion ->+ [EventData] ->+ (AppendResult -> Tx.Transaction a) ->+ Eff es (Either StoreError a) runTransactionAppending = runTransactionAppendingWith RunTransaction -{- | Like 'runTransactionAppending' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood — the-transaction body runs exactly once even on PostgreSQL serialization-conflicts.--Like 'runTransactionAppending', this wrapper bypasses the-'Kiroku.Store.Settings.enrichEvent' hook; see that function's-Haddock for guidance on hooking under different stacks and for the-@$all@ lock-hold warning that applies equally here.--}+-- | Like 'runTransactionAppending' but uses+-- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood — the+-- transaction body runs exactly once even on PostgreSQL serialization+-- conflicts.+--+-- Like 'runTransactionAppending', this wrapper bypasses the+-- 'Kiroku.Store.Settings.enrichEvent' hook; see that function's+-- 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) =>- StreamName ->- ExpectedVersion ->- [EventData] ->- (AppendResult -> Tx.Transaction a) ->- Eff es (Either StoreError a)+ (HasCallStack, IOE :> es, Store :> es) =>+ StreamName ->+ ExpectedVersion ->+ [EventData] ->+ (AppendResult -> Tx.Transaction a) ->+ Eff es (Either StoreError a) runTransactionAppendingNoRetry = runTransactionAppendingWith RunTransactionNoRetry -{- | Hook-aware variant of 'runTransactionAppending' for callers running-under a 'Kiroku.Store.Effect.Resource.KirokuStoreResource' effect.--Behaves exactly like 'runTransactionAppending' except that the-'Kiroku.Store.Settings.enrichEvent' hook (if any) is applied to every-'EventData' before the transaction body opens. The hook fires in-'IO', /outside/ the transaction; subsequent retries of a serialization-conflict do not re-invoke it (the enriched events are already prepared).--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. The continuation still runs-after the append; see 'runTransactionAppending' for the @$all@-lock-hold warning and keep resource-aware continuations just as small.--}+-- | Hook-aware variant of 'runTransactionAppending' for callers running+-- under a 'Kiroku.Store.Effect.Resource.KirokuStoreResource' effect.+--+-- Behaves exactly like 'runTransactionAppending' except that the+-- 'Kiroku.Store.Settings.enrichEvent' hook (if any) is applied to every+-- 'EventData' before the transaction body opens. The hook fires in+-- 'IO', /outside/ the transaction; subsequent retries of a serialization+-- conflict do not re-invoke it (the enriched events are already prepared).+--+-- 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. 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) =>- StreamName ->- ExpectedVersion ->- [EventData] ->- (AppendResult -> Tx.Transaction a) ->- Eff es (Either StoreError a)+ (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>+ StreamName ->+ ExpectedVersion ->+ [EventData] ->+ (AppendResult -> Tx.Transaction a) ->+ Eff es (Either StoreError a) runTransactionAppendingResource sn expected events k = do- store <- getKirokuStore- events' <- liftIO $ enrichEventsIO store events- runTransactionAppendingWith RunTransaction sn expected events' k+ store <- getKirokuStore+ events' <- liftIO $ enrichEventsIO store events+ runTransactionAppendingWith RunTransaction sn expected events' k -{- | Like 'runTransactionAppendingResource' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood. The-@$all@ lock-hold guidance from 'runTransactionAppending' applies-unchanged.--}+-- | Like 'runTransactionAppendingResource' but uses+-- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood. The+-- @$all@ lock-hold guidance from 'runTransactionAppending' applies+-- unchanged. runTransactionAppendingResourceNoRetry ::- (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>- StreamName ->- ExpectedVersion ->- [EventData] ->- (AppendResult -> Tx.Transaction a) ->- Eff es (Either StoreError a)+ (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>+ StreamName ->+ ExpectedVersion ->+ [EventData] ->+ (AppendResult -> Tx.Transaction a) ->+ Eff es (Either StoreError a) runTransactionAppendingResourceNoRetry sn expected events k = do- store <- getKirokuStore- events' <- liftIO $ enrichEventsIO store events- runTransactionAppendingWith RunTransactionNoRetry sn expected events' k--{- | Apply the store's 'Kiroku.Store.Settings.enrichEvent' hook (if any)-to a list of 'EventData' values.--Use this when calling 'appendToStreamTx' directly — that lower-level-API does not see the interpreter and therefore does not run the hook-on its own. Calling 'enrichEventsIO' yourself before-'prepareEventsIO' restores hook coverage for that path.+ store <- getKirokuStore+ events' <- liftIO $ enrichEventsIO store events+ runTransactionAppendingWith RunTransactionNoRetry sn expected events' k -When 'Kiroku.Store.Settings.enrichEvent' is 'Nothing' the function-returns the input list unchanged with no traversal.--}+-- | Apply the store's 'Kiroku.Store.Settings.enrichEvent' hook (if any)+-- to a list of 'EventData' values.+--+-- Use this when calling 'appendToStreamTx' directly — that lower-level+-- API does not see the interpreter and therefore does not run the hook+-- on its own. Calling 'enrichEventsIO' yourself before+-- 'prepareEventsIO' restores hook coverage for that path.+--+-- When 'Kiroku.Store.Settings.enrichEvent' is 'Nothing' the function+-- returns the input list unchanged with no traversal. enrichEventsIO :: KirokuStore -> [EventData] -> IO [EventData] enrichEventsIO store = Settings.enrichEvents (store ^. #storeSettings) -{- | Shared implementation for 'runTransactionAppending' and-'runTransactionAppendingNoRetry'. The only difference between the-two wrappers is which 'Store' constructor is sent.--}+-- | Shared implementation for 'runTransactionAppending' and+-- 'runTransactionAppendingNoRetry'. The only difference between the+-- two wrappers is which 'Store' constructor is sent. runTransactionAppendingWith ::- (IOE :> es, Store :> es) =>- (forall x. Tx.Transaction x -> Store (Eff es) x) ->- StreamName ->- ExpectedVersion ->- [EventData] ->- (AppendResult -> Tx.Transaction a) ->- Eff es (Either StoreError a)+ (IOE :> es, Store :> es) =>+ (forall x. Tx.Transaction x -> Store (Eff es) x) ->+ StreamName ->+ ExpectedVersion ->+ [EventData] ->+ (AppendResult -> Tx.Transaction a) ->+ Eff es (Either StoreError a) runTransactionAppendingWith ctor sn@(StreamName _) expected events k- | Left err <- validateStreamName sn = pure (Left err)- | null events = pure (Left (EmptyAppendBatch sn))- | otherwise = do- prepared <- prepareEventsIO events- now <- liftIO getCurrentTime- let body = do- outcome <- appendToStreamTx sn expected prepared now- case outcome of- Left conflict ->- Tx.condemn $> Left (appendConflictToStoreError conflict)- Right ar ->- Right <$> k ar- send (ctor body)+ | Left err <- validateStreamName sn = pure (Left err)+ | null events = pure (Left (EmptyAppendBatch sn))+ | otherwise = do+ prepared <- prepareEventsIO events+ now <- liftIO getCurrentTime+ let body = do+ outcome <- appendToStreamTx sn expected prepared now+ case outcome of+ Left conflict ->+ Tx.condemn $> Left (appendConflictToStoreError conflict)+ Right ar ->+ Right <$> k ar+ send (ctor body)
src/Kiroku/Store/Types.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Types (- StreamName (..),+module Kiroku.Store.Types+ ( StreamName (..), StreamId (..), EventId (..), EventType (..),@@ -19,7 +19,8 @@ BrowsePageSizeError (..), mkBrowsePageSize, browsePageSizeValue,-) where+ )+where import Data.Aeson (Value) import Data.Int (Int32, Int64)@@ -31,330 +32,294 @@ -- Stream identification -{- | The human-readable name of a stream (e.g. @"orders-1"@). Stream names-are unique per store; the substring before the first @-@ is the-stream's category, used by 'CategoryName'-targeted reads and-subscriptions.--All names except @$all@ are ordinary application stream names. Names-such as @"invoice-payment"@, @"$invoice-payment"@, names containing-commas, and names without a dash are accepted by the store. The exact-name @$all@ is reserved for the global read stream and mutating APIs-reject it with 'Kiroku.Store.Error.ReservedStreamName'; use-'Kiroku.Store.Read.readAllForward' or-'Kiroku.Store.Read.readAllBackward' to read the global sequence.--}+-- | The human-readable name of a stream (e.g. @"orders-1"@). Stream names+-- are unique per store; the substring before the first @-@ is the+-- stream's category, used by 'CategoryName'-targeted reads and+-- subscriptions.+--+-- All names except @$all@ are ordinary application stream names. Names+-- such as @"invoice-payment"@, @"$invoice-payment"@, names containing+-- commas, and names without a dash are accepted by the store. The exact+-- name @$all@ is reserved for the global read stream and mutating APIs+-- reject it with 'Kiroku.Store.Error.ReservedStreamName'; use+-- 'Kiroku.Store.Read.readAllForward' or+-- 'Kiroku.Store.Read.readAllBackward' to read the global sequence. newtype StreamName = StreamName Text- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -{- | The database surrogate id of a stream. Stable for the lifetime of-the stream row; reused by 'hardDeleteStream' followed by re-creation-only at the database's discretion. Not generally used by application-code — prefer 'StreamName' for identification.--}+-- | The database surrogate id of a stream. Stable for the lifetime of+-- the stream row; reused by 'hardDeleteStream' followed by re-creation+-- only at the database's discretion. Not generally used by application+-- code — prefer 'StreamName' for identification. newtype StreamId = StreamId Int64- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -- Event identification -{- | The store's identifier for an event. Generated by the store as a-UUIDv7 unless the caller supplies one in 'EventData.eventId' (used for-idempotent retries — see 'appendToStream').--The 'Ord' instance compares by raw UUID byte order, /not/ by-generation time. UUIDv7 is timestamp-prefixed in its high bytes, so-byte order roughly matches generation order, but two ids generated in-the same millisecond can compare in either direction. For-time-ordering use 'RecordedEvent.globalPosition'.--}+-- | The store's identifier for an event. Generated by the store as a+-- UUIDv7 unless the caller supplies one in 'EventData.eventId' (used for+-- idempotent retries — see 'appendToStream').+--+-- The 'Ord' instance compares by raw UUID byte order, /not/ by+-- generation time. UUIDv7 is timestamp-prefixed in its high bytes, so+-- byte order roughly matches generation order, but two ids generated in+-- the same millisecond can compare in either direction. For+-- time-ordering use 'RecordedEvent.globalPosition'. newtype EventId = EventId UUID- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -{- | The application-level discriminator for an event payload (e.g.-@"OrderCreated"@). Free-form text; the store does not interpret it.--}+-- | The application-level discriminator for an event payload (e.g.+-- @"OrderCreated"@). Free-form text; the store does not interpret it. newtype EventType = EventType Text- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -- Positions -{- | Monotonic per-stream version counter. Starts at 0 for a freshly-created empty stream and increments by one per appended event. After-@N@ events, the stream's version is @N@; the @i@-th event has-@streamVersion = i@ (one-indexed within a stream).--}+-- | Monotonic per-stream version counter. Starts at 0 for a freshly+-- created empty stream and increments by one per appended event. After+-- @N@ events, the stream's version is @N@; the @i@-th event has+-- @streamVersion = i@ (one-indexed within a stream). newtype StreamVersion = StreamVersion Int64- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) -{- | 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.--}+-- | 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- deriving stock (Eq, Ord, Show, Generic)--{- | Version expectations for appends.+ deriving stock (Eq, Ord, Show, Generic) -The store enforces optimistic concurrency at the stream level: each-'appendToStream' call carries an 'ExpectedVersion' that the-@stream_version@ row must satisfy at commit time. A mismatch produces-'Kiroku.Store.Error.WrongExpectedVersion' or-'Kiroku.Store.Error.StreamAlreadyExists' depending on the constructor.--}+-- | Version expectations for appends.+--+-- The store enforces optimistic concurrency at the stream level: each+-- 'appendToStream' call carries an 'ExpectedVersion' that the+-- @stream_version@ row must satisfy at commit time. A mismatch produces+-- 'Kiroku.Store.Error.WrongExpectedVersion' or+-- 'Kiroku.Store.Error.StreamAlreadyExists' depending on the constructor. data ExpectedVersion- = {- | The stream must not exist yet. Creates the stream and appends- the events. Fails with- 'Kiroku.Store.Error.StreamAlreadyExists' if the stream is- already present, including soft-deleted streams. Use this for- aggregate creation.- -}- NoStream- | {- | The stream must already exist (and not be soft-deleted), but- its current version does not matter. Fails with- 'Kiroku.Store.Error.StreamNotFound' if the stream is missing- or soft-deleted.- -}- StreamExists- | {- | The stream's current version must match exactly. Use this- when the caller has read the stream and wants to append only- if no concurrent writer has advanced it. Fails with- 'Kiroku.Store.Error.WrongExpectedVersion' on mismatch.- -}- ExactVersion !StreamVersion- | {- | Upsert: create the stream if missing, append otherwise. Does- not check the existing version. Maps to a single- @INSERT ... ON CONFLICT DO UPDATE@ at the SQL layer. The name- is historical — \"create-or-append\" is the working- description.- -}- AnyVersion- deriving stock (Eq, Show, Generic)--{- | What the caller provides when appending events.+ = -- | The stream must not exist yet. Creates the stream and appends+ -- the events. Fails with+ -- 'Kiroku.Store.Error.StreamAlreadyExists' if the stream is+ -- already present, including soft-deleted streams. Use this for+ -- aggregate creation.+ NoStream+ | -- | The stream must already exist (and not be soft-deleted), but+ -- its current version does not matter. Fails with+ -- 'Kiroku.Store.Error.StreamNotFound' if the stream is missing+ -- or soft-deleted.+ StreamExists+ | -- | The stream's current version must match exactly. Use this+ -- when the caller has read the stream and wants to append only+ -- if no concurrent writer has advanced it. Fails with+ -- 'Kiroku.Store.Error.WrongExpectedVersion' on mismatch.+ ExactVersion !StreamVersion+ | -- | Upsert: create the stream if missing, append otherwise. Does+ -- not check the existing version. Maps to a single+ -- @INSERT ... ON CONFLICT DO UPDATE@ at the SQL layer. The name+ -- is historical — \"create-or-append\" is the working+ -- description.+ AnyVersion+ deriving stock (Eq, Show, Generic) -Field accessors are intended to be used through @generic-lens@ labels-(@event ^. #eventType@); the cabal common stanza enables-@DuplicateRecordFields@ and @OverloadedLabels@ so the same field names-can appear on both 'EventData' and 'RecordedEvent'.--}+-- | What the caller provides when appending events.+--+-- Field accessors are intended to be used through @generic-lens@ labels+-- (@event ^. #eventType@); the cabal common stanza enables+-- @DuplicateRecordFields@ and @OverloadedLabels@ so the same field names+-- can appear on both 'EventData' and 'RecordedEvent'. data EventData = EventData- { eventId :: !(Maybe EventId)- {- ^ 'Nothing' lets the store generate a UUIDv7 at append time. 'Just'- lets the caller supply an id, primarily to make appends idempotent- across transient failures: a retry with the same id either succeeds- (because the previous attempt did not commit) or surfaces as- 'Kiroku.Store.Error.DuplicateEvent' (because it did).- -}- , eventType :: !EventType- {- ^ Application-level discriminator. Free-form; the store does not- interpret it but it is indexed for queries.- -}- , payload :: !Value- -- ^ The event's JSONB payload.- , metadata :: !(Maybe Value)- {- ^ Optional JSONB metadata. Often used for tenant ids, trace- contexts, and correlation data.- -}- , causationId :: !(Maybe UUID)- {- ^ Optional id of the event that caused this one (the immediate- cause). Used to reconstruct chains.- -}- , correlationId :: !(Maybe UUID)- {- ^ Optional correlation id (the saga / transaction this event- belongs to). Distinct from @causationId@.- -}- }- deriving stock (Show, Generic)--{- | Stream metadata returned by 'getStream'.+ { -- | 'Nothing' lets the store generate a UUIDv7 at append time. 'Just'+ -- lets the caller supply an id, primarily to make appends idempotent+ -- across transient failures: a retry with the same id either succeeds+ -- (because the previous attempt did not commit) or surfaces as+ -- 'Kiroku.Store.Error.DuplicateEvent' (because it did).+ eventId :: !(Maybe EventId),+ -- | Application-level discriminator. Free-form; the store does not+ -- interpret it but it is indexed for queries.+ eventType :: !EventType,+ -- | The event's JSONB payload.+ payload :: !Value,+ -- | Optional JSONB metadata. Often used for tenant ids, trace+ -- contexts, and correlation data.+ metadata :: !(Maybe Value),+ -- | Optional id of the event that caused this one (the immediate+ -- cause). Used to reconstruct chains.+ causationId :: !(Maybe UUID),+ -- | Optional correlation id (the saga / transaction this event+ -- belongs to). Distinct from @causationId@.+ correlationId :: !(Maybe UUID)+ }+ deriving stock (Show, Generic) -The 'id' field shadows 'Prelude.id' under 'DuplicateRecordFields'; access-it via @info ^. #id@ to disambiguate. 'Prelude.id' remains available as-@Data.Function.id@ if needed.--}+-- | Stream metadata returned by 'getStream'.+--+-- The 'id' field shadows 'Prelude.id' under 'DuplicateRecordFields'; access+-- it via @info ^. #id@ to disambiguate. 'Prelude.id' remains available as+-- @Data.Function.id@ if needed. data StreamInfo = StreamInfo- { id :: !StreamId- {- ^ The stream's database surrogate id. Stable for the row's- lifetime.- -}- , name :: !StreamName- -- ^ The stream's human-readable name as supplied at creation.- , version :: !StreamVersion- -- ^ The stream's current version (count of appended events).- , createdAt :: !UTCTime- -- ^ When the stream row was first inserted.- , deletedAt :: !(Maybe UTCTime)- {- ^ 'Just' the soft-delete timestamp if the stream has been- 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)--{- | What comes back from reading events.+ { -- | The stream's database surrogate id. Stable for the row's+ -- lifetime.+ id :: !StreamId,+ -- | The stream's human-readable name as supplied at creation.+ name :: !StreamName,+ -- | The stream's current version (count of appended events).+ version :: !StreamVersion,+ -- | When the stream row was first inserted.+ createdAt :: !UTCTime,+ -- | 'Just' the soft-delete timestamp if the stream has been+ -- soft-deleted, 'Nothing' otherwise. Hard-deleted streams do not+ -- appear in 'getStream' at all.+ deletedAt :: !(Maybe UTCTime),+ -- | 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'.+ truncateBefore :: !StreamVersion+ }+ deriving stock (Eq, Show, Generic) -For events read from their /source/ stream, @streamVersion@ and-@originalVersion@ agree, and @originalStreamId@ matches the stream being-read. For events read from a /linked/ target stream (one populated by-'linkToStream'), @streamVersion@ is the target's version (the position-of the link in the target stream) while @originalVersion@ is the-source's version (where the event lived when first appended) and-@originalStreamId@ is the source's id. Subscriptions and category reads-return events at their source positions.--}+-- | What comes back from reading events.+--+-- For events read from their /source/ stream, @streamVersion@ and+-- @originalVersion@ agree, and @originalStreamId@ matches the stream being+-- read. For events read from a /linked/ target stream (one populated by+-- 'linkToStream'), @streamVersion@ is the target's version (the position+-- of the link in the target stream) while @originalVersion@ is the+-- source's version (where the event lived when first appended) and+-- @originalStreamId@ is the source's id. Subscriptions and category reads+-- return events at their source positions. data RecordedEvent = RecordedEvent- { eventId :: !EventId- -- ^ The event's stable id (UUIDv7 by default).- , eventType :: !EventType- -- ^ The application-level type discriminator.- , streamVersion :: !StreamVersion- {- ^ Position in the stream being read. For source-stream reads this- equals @originalVersion@; for linked-target reads it is the- link's position in the target.- -}- , globalPosition :: !GlobalPosition- {- ^ Position in the global @$all@ sequence at original-append time.- Stable across links; used as the subscription cursor.- -}- , originalStreamId :: !StreamId- {- ^ The surrogate id of the stream the event was first appended to. For- fan-in reads (@$all@, categories, causation\/correlation queries,- subscriptions) this is the only stream identifier on the event; resolve a- batch of these to human-readable 'StreamName's with- 'Kiroku.Store.Read.lookupStreamNames'.- -}- , originalVersion :: !StreamVersion- -- ^ The position the event was assigned in its source stream.- , payload :: !Value- , metadata :: !(Maybe Value)- , causationId :: !(Maybe UUID)- , correlationId :: !(Maybe UUID)- , createdAt :: !UTCTime- }- deriving stock (Eq, Show, Generic)+ { -- | The event's stable id (UUIDv7 by default).+ eventId :: !EventId,+ -- | The application-level type discriminator.+ eventType :: !EventType,+ -- | Position in the stream being read. For source-stream reads this+ -- equals @originalVersion@; for linked-target reads it is the+ -- link's position in the target.+ streamVersion :: !StreamVersion,+ -- | Position in the global @$all@ sequence at original-append time.+ -- Stable across links; used as the subscription cursor.+ globalPosition :: !GlobalPosition,+ -- | The surrogate id of the stream the event was first appended to. For+ -- fan-in reads (@$all@, categories, causation\/correlation queries,+ -- subscriptions) this is the only stream identifier on the event; resolve a+ -- batch of these to human-readable 'StreamName's with+ -- 'Kiroku.Store.Read.lookupStreamNames'.+ originalStreamId :: !StreamId,+ -- | The position the event was assigned in its source stream.+ originalVersion :: !StreamVersion,+ payload :: !Value,+ metadata :: !(Maybe Value),+ causationId :: !(Maybe UUID),+ correlationId :: !(Maybe UUID),+ createdAt :: !UTCTime+ }+ deriving stock (Eq, Show, Generic) -- | Result of a successful 'appendToStream' or 'appendMultiStream' call. data AppendResult = AppendResult- { streamId :: !StreamId- {- ^ The id of the stream that was appended to (created if it did- not exist).- -}- , streamVersion :: !StreamVersion- {- ^ The stream's version after the append (the position of the- /last/ event in the batch).- -}- , globalPosition :: !GlobalPosition- -- ^ The global position assigned to the /last/ event in the batch.- }- deriving stock (Eq, Show, Generic)--{- | Result of a successful 'linkToStream' call.+ { -- | The id of the stream that was appended to (created if it did+ -- not exist).+ streamId :: !StreamId,+ -- | The stream's version after the append (the position of the+ -- /last/ event in the batch).+ streamVersion :: !StreamVersion,+ -- | The global position assigned to the /last/ event in the batch.+ globalPosition :: !GlobalPosition+ }+ deriving stock (Eq, Show, Generic) -There is no @globalPosition@ field because linking does not advance the-global counter — the linked events keep their original-'globalPosition'. To enumerate the new positions of linked events, read-the target stream after the call.--}+-- | Result of a successful 'linkToStream' call.+--+-- There is no @globalPosition@ field because linking does not advance the+-- global counter — the linked events keep their original+-- 'globalPosition'. To enumerate the new positions of linked events, read+-- the target stream after the call. data LinkResult = LinkResult- { streamId :: !StreamId- -- ^ The id of the link target (created if it did not exist).- , streamVersion :: !StreamVersion- {- ^ The target's version after the link (position of the /last/- linked event in the target).- -}- }- deriving stock (Eq, Show, Generic)+ { -- | The id of the link target (created if it did not exist).+ streamId :: !StreamId,+ -- | The target's version after the link (position of the /last/+ -- linked event in the target).+ streamVersion :: !StreamVersion+ }+ deriving stock (Eq, Show, Generic) -{- | The category prefix of a stream name (the part before the first-@-@). E.g., @StreamName "orders-1"@ has @CategoryName "orders"@. Used by-'readCategory' and category-targeted subscriptions to fan-in events from-many streams that share a prefix.--}+-- | The category prefix of a stream name (the part before the first+-- @-@). E.g., @StreamName "orders-1"@ has @CategoryName "orders"@. Used by+-- 'readCategory' and category-targeted subscriptions to fan-in events from+-- many streams that share a prefix. 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:+ deriving stock (Eq, Ord, Show, Generic) ->>> categoryName (StreamName "singleton")-CategoryName "singleton"--}+-- | 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"--}+-- | 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-to one SQL statement in "Kiroku.Store.SQL".--The sum is closed: mock interpreters that implement 'Kiroku.Store.Effect.Store'-can pattern-match exhaustively. Adding a new filter is a breaking change for-downstream interpreters and is captured by GHC's exhaustiveness check.--}+-- | Filter passed to the 'Kiroku.Store.Effect.FindEvents' constructor and the+-- public smart constructors in "Kiroku.Store.Causation". Each constructor maps+-- to one SQL statement in "Kiroku.Store.SQL".+--+-- The sum is closed: mock interpreters that implement 'Kiroku.Store.Effect.Store'+-- can pattern-match exhaustively. Adding a new filter is a breaking change for+-- downstream interpreters and is captured by GHC's exhaustiveness check. data EventFilter- = -- | Match every event whose @correlation_id@ equals the supplied UUID.- FilterCorrelation !UUID- | {- | Match the seed event and every event whose @causation_id@ chain- walks back to it. The seed event itself is included as the depth-0- row when it exists.- -}- FilterCausationDescendants !EventId- | {- | Match the seed event and every event reachable by walking- @causation_id@ upward from the seed. The seed itself is included.- -}- FilterCausationAncestors !EventId- deriving stock (Eq, Show, Generic)+ = -- | Match every event whose @correlation_id@ equals the supplied UUID.+ FilterCorrelation !UUID+ | -- | Match the seed event and every event whose @causation_id@ chain+ -- walks back to it. The seed event itself is included as the depth-0+ -- row when it exists.+ FilterCausationDescendants !EventId+ | -- | Match the seed event and every event reachable by walking+ -- @causation_id@ upward from the seed. The seed itself is included.+ FilterCausationAncestors !EventId+ deriving stock (Eq, Show, Generic) -{- | Validated catalog page size: 1–1001, including one HTTP over-fetch row.-The constructor is private; arithmetic on untrusted limits cannot overflow.--}+-- | Validated catalog page size: 1–1001, including one HTTP over-fetch row.+-- The constructor is private; arithmetic on untrusted limits cannot overflow. newtype BrowsePageSize = BrowsePageSize Int32- deriving stock (Eq, Show)+ deriving stock (Eq, Show) data BrowsePageSizeError = InvalidBrowsePageSize !Int- deriving stock (Eq, Show)+ deriving stock (Eq, Show) mkBrowsePageSize :: Int -> Either BrowsePageSizeError BrowsePageSize mkBrowsePageSize n- | n >= 1 && n <= 1001 = Right (BrowsePageSize (fromIntegral n))- | otherwise = Left (InvalidBrowsePageSize n)+ | n >= 1 && n <= 1001 = Right (BrowsePageSize (fromIntegral n))+ | otherwise = Left (InvalidBrowsePageSize n) browsePageSizeValue :: BrowsePageSize -> Int32 browsePageSizeValue (BrowsePageSize n) = n
test/Main.hs view
@@ -45,2189 +45,2193 @@ import Test.HistoryRetentionMock qualified as HistoryRetentionMock import Test.Hspec import Test.InterpreterHooks qualified as InterpreterHooks-import Test.NotifyGuard qualified as NotifyGuard-import Test.PerformanceStructure qualified as PerformanceStructure-import Test.Properties qualified as Properties-import Test.PublisherCallbackResilience qualified as PublisherCallbackResilience-import Test.PublisherDropCounter qualified as PublisherDropCounter-import Test.PublisherIdleAdvance qualified as PublisherIdleAdvance-import Test.PublisherRestartNoRebroadcast qualified as PublisherRestartNoRebroadcast-import Test.ReadStream qualified as ReadStream-import Test.ScriptSessionErrors qualified as ScriptSessionErrors-import Test.StartupFailureSurfacing qualified as StartupFailureSurfacing-import Test.StreamBridgeTermination qualified as StreamBridgeTermination-import Test.StreamHead qualified as StreamHead-import Test.StreamHeadIsolation qualified as StreamHeadIsolation-import Test.StreamHeadMock qualified as StreamHeadMock-import Test.StreamHistoryGuard qualified as StreamHistoryGuard-import Test.StreamNameLookup qualified as StreamNameLookup-import Test.SubscriptionCheckpointInitialization qualified as SubscriptionCheckpointInitialization-import Test.SubscriptionCheckpointInitializationMock qualified as SubscriptionCheckpointInitializationMock-import Test.SubscriptionCheckpointInventory qualified as SubscriptionCheckpointInventory-import Test.SubscriptionCheckpointInventoryMock qualified as SubscriptionCheckpointInventoryMock-import Test.SubscriptionCheckpointReset qualified as SubscriptionCheckpointReset-import Test.SubscriptionCheckpointWorker qualified as SubscriptionCheckpointWorker-import Test.SubscriptionDeadLetters qualified as SubscriptionDeadLetters-import Test.SubscriptionDeadLettersMock qualified as SubscriptionDeadLettersMock-import Test.SubscriptionPauseResume qualified as SubscriptionPauseResume-import Test.SubscriptionReconnect qualified as SubscriptionReconnect-import Test.SubscriptionRegistry qualified as SubscriptionRegistry-import Test.SubscriptionRetryDeadLetter qualified as SubscriptionRetryDeadLetter-import Test.SubscriptionState qualified as SubscriptionState-import Test.SubscriptionTarget qualified as SubscriptionTarget-import Test.Transaction qualified as Transaction-import Test.TruncateBefore qualified as TruncateBefore-import Test.UniqueViolationMapping qualified as UniqueViolationMapping-import Test.VisibleGlobalHeadPosition qualified as VisibleGlobalHeadPosition-import Test.VisibleGlobalHeadPositionMock qualified as VisibleGlobalHeadPositionMock--main :: IO ()-main = withSharedMigratedPostgres $ hspec $ do- BrowseReads.spec- BrowseReadsMock.spec- SubscriptionDeadLetters.spec- SubscriptionDeadLettersMock.spec- UniqueViolationMapping.spec- SubscriptionTarget.spec- Category.spec- Properties.spec- Concurrency.spec- FailureInjection.spec- HistoryRetention.spec- HistoryRetentionMock.spec- Transaction.spec- ScriptSessionErrors.spec- TruncateBefore.spec- ReadStream.spec- StreamNameLookup.spec- StreamBridgeTermination.spec- StreamHistoryGuard.spec- InterpreterHooks.spec- Causation.spec- ConsumerGroupSql.spec- ConsumerGroupResize.spec- ConsumerGroup.spec- ConsumerGroupEffect.spec- describe "performance structure" $ do- BrowseQueryPlans.spec- StreamHeadIsolation.spec- PerformanceStructure.spec- NotifyGuard.spec- StreamNameLookup.noOpSpec- CategoryIdleNoSpin.spec- HandlerStall.spec- PublisherCallbackResilience.spec- PublisherDropCounter.spec- PublisherIdleAdvance.spec- PublisherRestartNoRebroadcast.spec- CatchupDbErrorNoPrematureSwitch.spec- SubscriptionPauseResume.spec- SubscriptionCheckpointInitialization.spec- SubscriptionCheckpointInitializationMock.spec- SubscriptionCheckpointWorker.spec- SubscriptionCheckpointInventory.spec- SubscriptionCheckpointInventoryMock.spec- SubscriptionCheckpointReset.spec- SubscriptionReconnect.spec- StartupFailureSurfacing.spec- SubscriptionState.spec- SubscriptionRegistry.spec- SubscriptionRetryDeadLetter.spec- EventTypeFilter.spec- StreamHead.spec- StreamHeadMock.spec- VisibleGlobalHeadPosition.spec- VisibleGlobalHeadPositionMock.spec- around withTestStore $ do- describe "schema migrations" $ do- it "installs every Kiroku table under the kiroku schema" $ \store -> do- let tables = ["streams", "events", "stream_events", "subscriptions"]- results <- mapM (\t -> tableExists store ("kiroku." <> t)) tables- results `shouldBe` replicate (length tables) True-- it "leaves the public schema free of Kiroku tables" $ \store -> do- let tables = ["streams", "events", "stream_events", "subscriptions"]- results <- mapM (\t -> tableExists store ("public." <> t)) tables- results `shouldBe` replicate (length tables) False-- it "provides a UUIDv7 database default for direct event inserts" $ \store -> do- eventIdText <- insertEventUsingDefaultId store- version <- serverVersionNum store- let isV7 = T.length eventIdText > 14 && T.index eventIdText 14 == '7'- unless isV7 $- expectationFailure- ( "expected database-generated event_id to be UUIDv7 on PostgreSQL "- <> T.unpack version- <> ", got "- <> T.unpack eventIdText- )-- describe "appendToStream" $ do- describe "NoStream" $ do- it "creates a new stream and appends events" $ \store -> do- let event = makeEvent "OrderCreated" (Aeson.object [("orderId", Aeson.String "123")])- result <- runStoreIO store $ appendToStream (StreamName "order-123") NoStream [event]- case result of- Left err -> expectationFailure ("Expected success, got: " <> show err)- Right r -> do- (r ^. #streamVersion) `shouldBe` StreamVersion 1- (r ^. #globalPosition) `shouldBe` GlobalPosition 1-- it "fails when stream already exists" $ \store -> do- _ <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderCreated" (Aeson.object [])]- result <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderUpdated" (Aeson.object [])]- case result of- 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 allBefore <- 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 allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 10- fmap (^. #globalPosition) (V.toList allAfter) `shouldBe` fmap (^. #globalPosition) (V.toList allBefore)-- 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 [])]- (r1 ^. #streamVersion) `shouldBe` StreamVersion 1-- result <- runStoreIO store $ appendToStream (StreamName "order-789") (ExactVersion (StreamVersion 1)) [makeEvent "OrderUpdated" (Aeson.object [])]- case result of- Left err -> expectationFailure ("Expected success, got: " <> show err)- Right r -> do- (r ^. #streamVersion) `shouldBe` StreamVersion 2- (r ^. #globalPosition) `shouldBe` GlobalPosition 2-- it "fails on version conflict" $ \store -> do- _ <- runStoreIO store $ appendToStream (StreamName "order-conflict") NoStream [makeEvent "OrderCreated" (Aeson.object [])]- result <- runStoreIO store $ appendToStream (StreamName "order-conflict") (ExactVersion (StreamVersion 0)) [makeEvent "OrderUpdated" (Aeson.object [])]- case result of- Left (WrongExpectedVersion _ _ _) -> pure ()- other -> expectationFailure ("Expected WrongExpectedVersion, got: " <> show other)-- describe "StreamExists" $ do- it "appends to an existing stream" $ \store -> do- _ <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") NoStream [makeEvent "Created" (Aeson.object [])]- result <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") StreamExists [makeEvent "Updated" (Aeson.object [])]- case result of- Left err -> expectationFailure ("Expected success, got: " <> show err)- Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2-- it "fails when stream does not exist" $ \store -> do- result <- runStoreIO store $ appendToStream (StreamName "nonexistent-stream") StreamExists [makeEvent "Created" (Aeson.object [])]- case result of- Left (StreamNotFound _) -> pure ()- other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)-- describe "AnyVersion" $ do- it "creates a new stream" $ \store -> do- result <- runStoreIO store $ appendToStream (StreamName "any-new") AnyVersion [makeEvent "Created" (Aeson.object [])]- case result of- Left err -> expectationFailure ("Expected success, got: " <> show err)- Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1-- it "appends to an existing stream" $ \store -> do- _ <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Created" (Aeson.object [])]- result <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Updated" (Aeson.object [])]- case result of- Left err -> expectationFailure ("Expected success, got: " <> show err)- Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2-- describe "batch append" $ do- it "appends multiple events with sequential versions" $ \store -> do- let events =- [ makeEvent "Event1" (Aeson.object [("n", Aeson.Number 1)])- , makeEvent "Event2" (Aeson.object [("n", Aeson.Number 2)])- , makeEvent "Event3" (Aeson.object [("n", Aeson.Number 3)])- ]- result <- runStoreIO store $ appendToStream (StreamName "batch-test") NoStream events- case result of- Left err -> expectationFailure ("Expected success, got: " <> show err)- Right r -> do- (r ^. #streamVersion) `shouldBe` StreamVersion 3- (r ^. #globalPosition) `shouldBe` GlobalPosition 3-- describe "global position contiguity" $ do- it "assigns contiguous global positions across streams" $ \store -> do- Right r1 <- runStoreIO store $ appendToStream (StreamName "stream-a") NoStream [makeEvent "A" (Aeson.object [])]- (r1 ^. #globalPosition) `shouldBe` GlobalPosition 1-- Right r2 <- runStoreIO store $ appendToStream (StreamName "stream-b") NoStream [makeEvent "B" (Aeson.object [])]- (r2 ^. #globalPosition) `shouldBe` GlobalPosition 2-- Right r3 <- runStoreIO store $ appendToStream (StreamName "stream-c") NoStream [makeEvent "C" (Aeson.object [])]- (r3 ^. #globalPosition) `shouldBe` GlobalPosition 3-- describe "duplicate event ID" $ do- it "returns the caller event id on same-stream retry and leaves one stored event" $ \store -> do- let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)- event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just eid- stream = StreamName "same-stream-duplicate"- Right _ <- runStoreIO store $ appendToStream stream NoStream [event]- forM_ [AnyVersion, StreamExists, ExactVersion (StreamVersion 1)] $ \expected -> do- result <- runStoreIO store $ appendToStream stream expected [event]- result `shouldBe` Left (DuplicateEvent (Just eid))- countEvents store `shouldReturn` 1- Right stored <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 100- Right global <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- map (\e -> e ^. #eventId) (V.toList stored) `shouldBe` [eid]- map (\e -> e ^. #eventId) (V.toList global) `shouldBe` [eid]- Right info <- runStoreIO store $ getStream stream- fmap (\s -> s ^. #version) info `shouldBe` Just (StreamVersion 1)-- it "rejects duplicate event IDs" $ \store -> do- let eid = EventId (case UUID.fromString "01234567-89ab-7def-8012-34567890abcd" of Just u -> u; Nothing -> error "bad uuid")- let event1 =- EventData- { eventId = Just eid- , eventType = EventType "Created"- , payload = Aeson.object []- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }- Right _ <- runStoreIO store $ appendToStream (StreamName "dup-test-1") NoStream [event1]-- let event2 =- EventData- { eventId = Just eid- , eventType = EventType "Created"- , payload = Aeson.object []- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }- result <- runStoreIO store $ appendToStream (StreamName "dup-test-2") NoStream [event2]- case result of- Left (DuplicateEvent _) -> pure ()- other -> expectationFailure ("Expected DuplicateEvent, got: " <> show other)-- describe "stream-name contract" $ do- it "treats system-looking names other than $all as ordinary streams" $ \store -> do- let names =- [ StreamName "invoice-payment"- , StreamName "$invoice-payment"- , StreamName "invoice,payment"- , StreamName "invoicepayment"- ]- mapM_- ( \name -> do- Right r <- runStoreIO store $ appendToStream name NoStream [makeEvent "StreamNameContract" (Aeson.object [])]- (r ^. #streamVersion) `shouldBe` StreamVersion 1- Right events <- runStoreIO store $ readStreamForward name (StreamVersion 0) 10- V.length events `shouldBe` 1- )- names-- it "rejects $all as an application append target" $ \store -> do- result <- runStoreIO store $ appendToStream (StreamName "$all") AnyVersion [makeEvent "BadAllAppend" (Aeson.object [])]- case result of- Left (ReservedStreamName (StreamName "$all")) -> pure ()- other -> expectationFailure ("Expected ReservedStreamName for $all append, got: " <> show other)- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10- V.length allEvents `shouldBe` 0-- it "rejects $all as a multi-stream append target without partial commit" $ \store -> do- result <-- runStoreIO store $- appendMultiStream- [ (StreamName "multi-reserved-ok", NoStream, [makeEvent "ShouldRollback" (Aeson.object [])])- , (StreamName "$all", AnyVersion, [makeEvent "BadAllMulti" (Aeson.object [])])- ]- case result of- Left (ReservedStreamName (StreamName "$all")) -> pure ()- other -> expectationFailure ("Expected ReservedStreamName for $all multi-stream append, got: " <> show other)- Right info <- runStoreIO store $ getStream (StreamName "multi-reserved-ok")- info `shouldBe` Nothing-- it "rejects $all as a link target" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "reserved-link-source") NoStream [makeEvent "Source" (Aeson.object [])]- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "reserved-link-source") (StreamVersion 0) 10- let eid = V.head srcEvents ^. #eventId- result <- runStoreIO store $ linkToStream (StreamName "$all") [eid]- case result of- Left (ReservedStreamName (StreamName "$all")) -> pure ()- other -> expectationFailure ("Expected ReservedStreamName for $all link, got: " <> show other)- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10- V.length allEvents `shouldBe` 1-- it "rejects lifecycle operations against $all" $ \store -> do- soft <- runStoreIO store $ softDeleteStream (StreamName "$all")- hard <- runStoreIO store $ hardDeleteStream (StreamName "$all")- undel <- runStoreIO store $ undeleteStream (StreamName "$all")- let shouldBeReserved label result =- case result of- Left (ReservedStreamName (StreamName "$all")) -> pure ()- other -> expectationFailure ("Expected ReservedStreamName for " <> label <> ", got: " <> show other)- shouldBeReserved "softDeleteStream $all" soft- shouldBeReserved "hardDeleteStream $all" hard- shouldBeReserved "undeleteStream $all" undel- Right info <- runStoreIO store $ getStream (StreamName "$all")- case info of- 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 =- [ makeEvent "A" (Aeson.object [("n", Aeson.Number 1)])- , makeEvent "B" (Aeson.object [("n", Aeson.Number 2)])- , makeEvent "C" (Aeson.object [("n", Aeson.Number 3)])- ]- Right _ <- runStoreIO store $ appendToStream (StreamName "read-fwd") NoStream events- Right result <- runStoreIO store $ readStreamForward (StreamName "read-fwd") (StreamVersion 0) 100- V.length result `shouldBe` 3- (V.head result ^. #eventType) `shouldBe` EventType "A"- (result V.! 1 ^. #eventType) `shouldBe` EventType "B"- (result V.! 2 ^. #eventType) `shouldBe` EventType "C"- (V.head result ^. #streamVersion) `shouldBe` StreamVersion 1- (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2- (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3-- describe "readStreamBackward" $ do- it "reads events in backward order" $ \store -> do- let events =- [ makeEvent "A" (Aeson.object [])- , makeEvent "B" (Aeson.object [])- , makeEvent "C" (Aeson.object [])- ]- Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd") NoStream events- Right result <- runStoreIO store $ readStreamBackward (StreamName "read-bwd") (StreamVersion 0) 100- V.length result `shouldBe` 3- (V.head result ^. #eventType) `shouldBe` EventType "C"- (result V.! 1 ^. #eventType) `shouldBe` EventType "B"- (result V.! 2 ^. #eventType) `shouldBe` EventType "A"- (V.head result ^. #streamVersion) `shouldBe` StreamVersion 3- (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]- Right _ <- runStoreIO store $ appendToStream (StreamName "read-page") NoStream events- -- Read first page of 2- Right page1 <- runStoreIO store $ readStreamForward (StreamName "read-page") (StreamVersion 0) 2- V.length page1 `shouldBe` 2- let cursor1 = page1 V.! 1 ^. #streamVersion- cursor1 `shouldBe` StreamVersion 2- -- Read second page from cursor- Right page2 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor1 2- V.length page2 `shouldBe` 2- (V.head page2 ^. #streamVersion) `shouldBe` StreamVersion 3- -- Read third page — only 1 event left- let cursor2 = page2 V.! 1 ^. #streamVersion- Right page3 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor2 2- V.length page3 `shouldBe` 1- -- Read past end — empty- let cursor3 = V.head page3 ^. #streamVersion- Right page4 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor3 2- V.length page4 `shouldBe` 0-- describe "readAllForward" $ do- it "reads events from $all in global order" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "all-s1") NoStream [makeEvent "X" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "all-s2") NoStream [makeEvent "Y" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "all-s3") NoStream [makeEvent "Z" (Aeson.object [])]- Right result <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- V.length result `shouldBe` 3- (V.head result ^. #eventType) `shouldBe` EventType "X"- (result V.! 1 ^. #eventType) `shouldBe` EventType "Y"- (result V.! 2 ^. #eventType) `shouldBe` EventType "Z"- -- Global positions should be contiguous- (V.head result ^. #globalPosition) `shouldBe` GlobalPosition 1- (result V.! 1 ^. #globalPosition) `shouldBe` GlobalPosition 2- (result V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3-- describe "readAllBackward" $ do- it "reads events from $all in reverse order" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s1") NoStream [makeEvent "X" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s2") NoStream [makeEvent "Y" (Aeson.object [])]- Right result <- runStoreIO store $ readAllBackward (GlobalPosition 0) 100- V.length result `shouldBe` 2- (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- V.length result `shouldBe` 0-- describe "getStream" $ do- it "returns metadata for existing stream" $ \store -> do- Right _ <-- runStoreIO store $- appendToStream- (StreamName "meta-test")- NoStream- [ makeEvent "A" (Aeson.object [])- , makeEvent "B" (Aeson.object [])- ]- Right info <- runStoreIO store $ getStream (StreamName "meta-test")- case info of- Just si -> do- (si ^. #name) `shouldBe` StreamName "meta-test"- (si ^. #version) `shouldBe` StreamVersion 2- Nothing -> expectationFailure "Expected Just StreamInfo, got Nothing"-- it "returns Nothing for nonexistent stream" $ \store -> do- Right info <- runStoreIO store $ getStream (StreamName "no-such-stream")- info `shouldBe` Nothing-- describe "lookupStreamId" $ do- it "returns the same id as getStream for a live stream" $ \store -> do- Right _ <-- runStoreIO store $- appendToStream- (StreamName "lookup-live")- NoStream- [makeEvent "A" (Aeson.object [])]- Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-live")- Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-live")- case (mInfo, mSid) of- (Just info, Just sid) ->- (info ^. #id) `shouldBe` sid- _ ->- expectationFailure "Expected both getStream and lookupStreamId to return Just"-- it "returns Nothing for a stream that has never been created" $ \store -> do- Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-missing")- mSid `shouldBe` Nothing-- it "returns Just the same id for a soft-deleted stream" $ \store -> do- Right _ <-- runStoreIO store $- appendToStream- (StreamName "lookup-soft")- NoStream- [makeEvent "A" (Aeson.object [])]- Right mSidBefore <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")- Right _ <- runStoreIO store $ softDeleteStream (StreamName "lookup-soft")- Right mSidAfter <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")- mSidAfter `shouldBe` mSidBefore- Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-soft")- case (mInfo, mSidAfter) of- (Just info, Just sid) -> (info ^. #id) `shouldBe` sid- _ -> expectationFailure "Expected Just on a soft-deleted stream"-- describe "integration: full lifecycle through withStore" $ do- it "append, read forward, read $all, getStream — all through public API" $ \store -> do- -- Append events to two streams- Right _ <-- runStoreIO store $- appendToStream- (StreamName "integ-orders")- NoStream- [ makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")])- , makeEvent "OrderShipped" (Aeson.object [("id", Aeson.String "1")])- ]- Right _ <-- runStoreIO store $- appendToStream- (StreamName "integ-users")- NoStream- [ makeEvent "UserRegistered" (Aeson.object [("name", Aeson.String "alice")])- ]-- -- Read from a named stream- Right orders <- runStoreIO store $ readStreamForward (StreamName "integ-orders") (StreamVersion 0) 100- V.length orders `shouldBe` 2- (V.head orders ^. #eventType) `shouldBe` EventType "OrderCreated"- (orders V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"-- -- Read from $all — should see all 3 events in global order- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- V.length allEvents `shouldBe` 3- (V.head allEvents ^. #globalPosition) `shouldBe` GlobalPosition 1- (allEvents V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3-- -- Query stream metadata- Right orderInfo <- runStoreIO store $ getStream (StreamName "integ-orders")- case orderInfo of- Just si -> (si ^. #version) `shouldBe` StreamVersion 2- Nothing -> expectationFailure "Expected stream info"-- Right noInfo <- runStoreIO store $ getStream (StreamName "nonexistent")- noInfo `shouldBe` Nothing-- -- =================================================================- -- Link tests (M5.8)- -- =================================================================- describe "linkToStream" $ do- it "links a single event to a new stream" $ \store -> do- let event = makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")])- Right appendR <- runStoreIO store $ appendToStream (StreamName "source-1") NoStream [event]- -- Read back to get the event ID- Right events <- runStoreIO store $ readStreamForward (StreamName "source-1") (StreamVersion 0) 100- let eid = V.head events ^. #eventId- -- Link it- Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-1") [eid]- (linkR ^. #streamVersion) `shouldBe` StreamVersion 1- -- Read the linked stream- Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-1") (StreamVersion 0) 100- V.length linked `shouldBe` 1- (V.head linked ^. #eventId) `shouldBe` eid- (V.head linked ^. #originalStreamId) `shouldBe` (appendR ^. #streamId)- (V.head linked ^. #originalVersion) `shouldBe` StreamVersion 1-- it "links multiple events with sequential versions" $ \store -> do- let events =- [ makeEvent "A" (Aeson.object [])- , makeEvent "B" (Aeson.object [])- , makeEvent "C" (Aeson.object [])- ]- Right _ <- runStoreIO store $ appendToStream (StreamName "source-multi") NoStream events- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "source-multi") (StreamVersion 0) 100- let eids = V.toList (V.map (^. #eventId) srcEvents)- Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-multi") eids- (linkR ^. #streamVersion) `shouldBe` StreamVersion 3- Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-multi") (StreamVersion 0) 100- V.length linked `shouldBe` 3- (V.head linked ^. #streamVersion) `shouldBe` StreamVersion 1- (linked V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2- (linked V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3-- it "links events to an existing stream and bumps version" $ \store -> do- let events1 = [makeEvent "A" (Aeson.object [])]- events2 = [makeEvent "B" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "src-a") NoStream events1- Right _ <- runStoreIO store $ appendToStream (StreamName "src-b") NoStream events2- Right evA <- runStoreIO store $ readStreamForward (StreamName "src-a") (StreamVersion 0) 100- Right evB <- runStoreIO store $ readStreamForward (StreamName "src-b") (StreamVersion 0) 100- let eidA = V.head evA ^. #eventId- eidB = V.head evB ^. #eventId- -- Link first event- Right r1 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidA]- (r1 ^. #streamVersion) `shouldBe` StreamVersion 1- -- Link second event to same stream- Right r2 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidB]- (r2 ^. #streamVersion) `shouldBe` StreamVersion 2- -- Read all linked events- Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-existing") (StreamVersion 0) 100- V.length linked `shouldBe` 2-- it "linked events still appear in $all at original global positions" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "orig-all") NoStream [makeEvent "X" (Aeson.object [])]- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "orig-all") (StreamVersion 0) 100- let eid = V.head srcEvents ^. #eventId- _ <- runStoreIO store $ linkToStream (StreamName "linked-all") [eid]- -- Read $all — should have exactly 1 event (not duplicated)- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- let matchingEvents = V.filter (\e -> (e ^. #eventId) == eid) allEvents- V.length matchingEvents `shouldBe` 1-- it "rejects linking the same event to the same stream twice" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "src-dup") NoStream [makeEvent "X" (Aeson.object [])]- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "src-dup") (StreamVersion 0) 100- let eid = V.head srcEvents ^. #eventId- Right _ <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]- result <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]- case result of- 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.- -- Before the fix, the CTE's `JOIN LATERAL` silently dropped link rows for- -- event_ids that had no `stream_events` row (e.g. never existed, or were- -- hard-deleted). The `stream_upsert` had already bumped `stream_version`,- -- so the stream advanced by N but only some link rows were inserted.- it "rejects link when the source event does not exist" $ \store -> do- 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 (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")- info `shouldBe` Nothing-- it "rejects link with a mix of valid and missing events (no partial commit)" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f3-mixed-src") NoStream [makeEvent "Real" (Aeson.object [])]- Right realEvents <- runStoreIO store $ readStreamForward (StreamName "f3-mixed-src") (StreamVersion 0) 100- let realId = V.head realEvents ^. #eventId- 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 (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-- -- F5 regression — linkToStream against a soft-deleted target stream.- -- Symmetric with the appendAnyVersion soft-delete check from F2: the- -- DO UPDATE clause's WHERE filters on deleted_at IS NULL, so the- -- upsert returns no row and the interpreter maps that to StreamNotFound.- it "rejects link to a soft-deleted target stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f5-src") NoStream [makeEvent "X" (Aeson.object [])]- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f5-src") (StreamVersion 0) 100- let eid = V.head srcEvents ^. #eventId- Right _ <- runStoreIO store $ appendToStream (StreamName "f5-target") NoStream [makeEvent "Init" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "f5-target")- result <- runStoreIO store $ linkToStream (StreamName "f5-target") [eid]- case result of- Left (StreamNotFound _) -> pure ()- other -> expectationFailure ("Expected StreamNotFound for soft-deleted target, got: " <> show other)- -- Soft-deleted target's version must not have advanced.- Right info <- runStoreIO store $ getStream (StreamName "f5-target")- case info of- Just si -> (si ^. #version) `shouldBe` StreamVersion 1- Nothing -> expectationFailure "soft-deleted stream row should still exist"-- -- =================================================================- -- Category read tests (M5.8)- -- =================================================================- describe "readCategory" $ do- it "reads events from matching category streams in global position order" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]- Right result <- runStoreIO store $ readCategory (CategoryName "order") (GlobalPosition 0) 100- V.length result `shouldBe` 2- (V.head result ^. #eventType) `shouldBe` EventType "OrderCreated"- (result V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"- -- Verify global positions are ascending- (V.head result ^. #globalPosition) `shouldSatisfy` (< (result V.! 1 ^. #globalPosition))-- it "supports pagination with start position and limit" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "cat-1") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "cat-2") NoStream [makeEvent "B" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "cat-3") NoStream [makeEvent "C" (Aeson.object [])]- -- Read first 2- Right page1 <- runStoreIO store $ readCategory (CategoryName "cat") (GlobalPosition 0) 2- V.length page1 `shouldBe` 2- -- Read from cursor- let cursor = page1 V.! 1 ^. #globalPosition- Right page2 <- runStoreIO store $ readCategory (CategoryName "cat") cursor 100- V.length page2 `shouldBe` 1-- it "returns empty for nonexistent category" $ \store -> do- Right result <- runStoreIO store $ readCategory (CategoryName "nope") (GlobalPosition 0) 100- V.length result `shouldBe` 0-- it "includes linked events that originate from category streams" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "evt-1") NoStream [makeEvent "EventA" (Aeson.object [])]- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "evt-1") (StreamVersion 0) 100- let eid = V.head srcEvents ^. #eventId- -- Link into a different-category stream- Right _ <- runStoreIO store $ linkToStream (StreamName "projection-1") [eid]- -- Category "evt" should still return the event (it originates from evt-1)- Right result <- runStoreIO store $ readCategory (CategoryName "evt") (GlobalPosition 0) 100- V.length result `shouldBe` 1- (V.head result ^. #eventType) `shouldBe` EventType "EventA"-- -- =================================================================- -- Multi-stream transaction tests (M5.8)- -- =================================================================- describe "appendMultiStream" $ do- it "atomically appends to two streams" $ \store -> do- let ops =- [ (StreamName "multi-a", NoStream, [makeEvent "A" (Aeson.object [])])- , (StreamName "multi-b", NoStream, [makeEvent "B" (Aeson.object [])])- ]- Right results <- runStoreIO store $ appendMultiStream ops- length results `shouldBe` 2- ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 1- ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1-- it "rolls back all streams on version conflict" $ \store -> do- -- Create stream multi-c- Right _ <- runStoreIO store $ appendToStream (StreamName "multi-c") NoStream [makeEvent "C" (Aeson.object [])]- let ops =- [ (StreamName "multi-d", NoStream, [makeEvent "D" (Aeson.object [])])- , (StreamName "multi-c", NoStream, [makeEvent "C2" (Aeson.object [])]) -- conflict: already exists- ]- result <- runStoreIO store $ appendMultiStream ops- case result of- Left _ -> do- -- Verify multi-d was NOT created (rollback)- Right info <- runStoreIO store $ getStream (StreamName "multi-d")- info `shouldBe` Nothing- Right _ -> expectationFailure "Expected error for version conflict"-- it "appends to three streams with different expected versions" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "tri-1") NoStream [makeEvent "X" (Aeson.object [])]- let ops =- [ (StreamName "tri-1", ExactVersion (StreamVersion 1), [makeEvent "X2" (Aeson.object [])])- , (StreamName "tri-2", NoStream, [makeEvent "Y" (Aeson.object [])])- , (StreamName "tri-3", AnyVersion, [makeEvent "Z" (Aeson.object [])])- ]- Right results <- runStoreIO store $ appendMultiStream ops- length results `shouldBe` 3- ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 2- ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1- ((results !! 2) ^. #streamVersion) `shouldBe` StreamVersion 1-- -- F4 regression — pre-lock pass over named streams in stream_id order.- -- The deterministic deadlock-prevention scenario (two concurrent calls- -- touching the same streams in opposite orders) is covered by EP-6's- -- planned concurrency-test harness; this test verifies that the- -- pre-lock does not change user-visible ordering of global positions- -- within a single multi-stream call.- it "preserves user-supplied ordering of global positions" $ \store -> do- let ops =- [ (StreamName "f4-zzz", NoStream, [makeEvent "Z" (Aeson.object [])])- , (StreamName "f4-mmm", NoStream, [makeEvent "M" (Aeson.object [])])- , (StreamName "f4-aaa", NoStream, [makeEvent "A" (Aeson.object [])])- ]- Right results <- runStoreIO store $ appendMultiStream ops- length results `shouldBe` 3- let p0 = (results !! 0) ^. #globalPosition- p1 = (results !! 1) ^. #globalPosition- p2 = (results !! 2) ^. #globalPosition- p0 `shouldSatisfy` (< p1)- p1 `shouldSatisfy` (< p2)- -- Read $all and confirm the events appear in user-supplied order.- Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- let names = V.toList (V.map (^. #eventType) allEvts)- names `shouldBe` [EventType "Z", EventType "M", EventType "A"]-- -- =================================================================- -- Soft delete tests (M6.8)- -- =================================================================- describe "softDeleteStream" $ do- it "soft-deletes a stream and getStream shows deletedAt" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "del-1") NoStream [makeEvent "A" (Aeson.object [])]- Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-1")- mId `shouldSatisfy` (/= Nothing)- Right info <- runStoreIO store $ getStream (StreamName "del-1")- case info of- Just si -> (si ^. #deletedAt) `shouldSatisfy` (/= Nothing)- Nothing -> expectationFailure "Expected stream info with deletedAt set"-- it "returns empty for reads from a soft-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "del-read") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-read")- Right fwd <- runStoreIO store $ readStreamForward (StreamName "del-read") (StreamVersion 0) 100- V.length fwd `shouldBe` 0- Right bwd <- runStoreIO store $ readStreamBackward (StreamName "del-read") (StreamVersion 0) 100- V.length bwd `shouldBe` 0-- it "rejects appends to a soft-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "del-append") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-append")- result <- runStoreIO store $ appendToStream (StreamName "del-append") StreamExists [makeEvent "B" (Aeson.object [])]- case result of- Left (StreamNotFound _) -> pure ()- other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)-- it "returns Nothing for nonexistent stream" $ \store -> do- Right mId <- runStoreIO store $ softDeleteStream (StreamName "no-such")- mId `shouldBe` Nothing-- it "returns Nothing for already-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "del-twice") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-twice")- Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-twice")- mId `shouldBe` Nothing-- it "events from soft-deleted stream still appear in $all" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "del-all") NoStream [makeEvent "KeepInAll" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-all")- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- let matching = V.filter (\e -> (e ^. #eventType) == EventType "KeepInAll") allEvents- V.length matching `shouldBe` 1-- -- F2 regression — the protection against writes to a soft-deleted stream is- -- enforced by a `deleted_at IS NULL` filter inside each append CTE, not by a- -- pre-check that races concurrent soft-deletes. These tests exercise every- -- append variant so a reader can see at a glance which constructor maps to- -- which error after a soft-delete.- it "ExactVersion append rejected against soft-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") NoStream [makeEvent "Init" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-exv")- result <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])]- case result of- Left (WrongExpectedVersion _ _ _) -> pure ()- Left (StreamNotFound _) -> pure ()- other -> expectationFailure ("Expected WrongExpectedVersion or StreamNotFound, got: " <> show other)-- it "AnyVersion append rejected against soft-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") NoStream [makeEvent "Init" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-any")- result <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") AnyVersion [makeEvent "X" (Aeson.object [])]- case result of- Left (StreamNotFound _) -> pure ()- other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)-- it "NoStream append rejected against soft-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "Init" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-no")- result <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "X" (Aeson.object [])]- case result of- Left (StreamAlreadyExists _) -> pure ()- other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other)-- -- =================================================================- -- Undelete tests (M6.8)- -- =================================================================- describe "undeleteStream" $ do- it "restores a soft-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "undel-1") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-1")- Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-1")- mId `shouldSatisfy` (/= Nothing)- Right info <- runStoreIO store $ getStream (StreamName "undel-1")- case info of- Just si -> (si ^. #deletedAt) `shouldBe` Nothing- Nothing -> expectationFailure "Expected stream info"-- it "reads work after undelete" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "undel-read") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-read")- Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-read")- Right events <- runStoreIO store $ readStreamForward (StreamName "undel-read") (StreamVersion 0) 100- V.length events `shouldBe` 1-- it "appends work after undelete" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "undel-append") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-append")- Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-append")- Right r <- runStoreIO store $ appendToStream (StreamName "undel-append") StreamExists [makeEvent "B" (Aeson.object [])]- (r ^. #streamVersion) `shouldBe` StreamVersion 2-- it "returns Nothing for non-deleted stream" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "undel-noop") NoStream [makeEvent "A" (Aeson.object [])]- Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-noop")- mId `shouldBe` Nothing-- -- =================================================================- -- Hard delete tests (M6.8)- -- =================================================================- describe "hardDeleteStream" $ do- it "hard-deletes a stream completely" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "hard-1") NoStream [makeEvent "A" (Aeson.object [])]- Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-1")- mId `shouldSatisfy` (/= Nothing)- Right info <- runStoreIO store $ getStream (StreamName "hard-1")- info `shouldBe` Nothing-- it "events from hard-deleted stream no longer appear in $all" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "hard-all") NoStream [makeEvent "HardDelEvent" (Aeson.object [])]- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-all")- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- let matching = V.filter (\e -> (e ^. #eventType) == EventType "HardDelEvent") allEvents- V.length matching `shouldBe` 0-- it "returns Nothing for nonexistent stream" $ \store -> do- Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-no-such")- mId `shouldBe` Nothing-- -- F6 regression — TRUNCATE on protected tables must be gated by the- -- same GUC as DELETE. Without the BEFORE TRUNCATE triggers added in- -- EP-1 F6, an operator could TRUNCATE events / stream_events / streams- -- without setting kiroku.enable_hard_deletes, bypassing the row-level- -- protect_deletion check.- it "TRUNCATE on events is rejected without the GUC" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate") NoStream [makeEvent "X" (Aeson.object [])]- rejected <- truncateRejected store "events"- rejected `shouldBe` True-- it "TRUNCATE on stream_events is rejected without the GUC" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-se") NoStream [makeEvent "X" (Aeson.object [])]- rejected <- truncateRejected store "stream_events"- rejected `shouldBe` True-- it "TRUNCATE on streams is rejected without the GUC" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-s") NoStream [makeEvent "X" (Aeson.object [])]- rejected <- truncateRejected store "streams"- rejected `shouldBe` True-- -- F1 regression — events orphaned in `events` table after hard-delete.- it "removes orphan event payloads from the events table" $ \store -> do- let evts = map (\i -> makeEvent ("F1Orphan" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "f1-orphan") NoStream evts- countBefore <- countEvents store- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-orphan")- countAfter <- countEvents store- (countBefore - countAfter) `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- Right _ <- runStoreIO store $ appendToStream (StreamName "f1-keep-src") NoStream [makeEvent "Keep" (Aeson.object [])]- Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f1-keep-src") (StreamVersion 0) 100- let eid = V.head srcEvents ^. #eventId- -- Append another stream that gets a different event so we can hard-delete keep-src- -- without touching the linked stream's source. Then link f1-keep-src's event to a- -- third stream we will leave alone.- Right _ <- runStoreIO store $ appendToStream (StreamName "f1-other") NoStream [makeEvent "Other" (Aeson.object [])]- -- Link via appendToStream + linkToStream to a different stream- _ <- runStoreIO store $ linkToStream (StreamName "f1-keep-link") [eid]- -- Hard-deleting f1-keep-src removes the source's events even though the link- -- referenced them: link rows have original_stream_id = f1-keep-src's id, so they- -- match the junction-delete WHERE clause. The contract is documented in F1's fix- -- commit: hard-delete cascades through link junctions of the deleted stream's- -- original events. (See SQL.hs hard-delete documentation.)- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-keep-src")- -- f1-other's event must still exist (not affected at all).- Right otherEvents <- runStoreIO store $ readStreamForward (StreamName "f1-other") (StreamVersion 0) 100- V.length otherEvents `shouldBe` 1-- -- =================================================================- -- Subscription tests (M7.7)- -- =================================================================- describe "subscribe" $ do- it "catches up from position 0 on a store with existing events" $ \store -> do- -- Append 10 events- let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-1") NoStream events- -- Wait until the EventPublisher has ingested all 10 events.- waitForPublisher store (GlobalPosition 10)- -- Subscribe from position 0- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n >= 10- then pure Stop- else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "catchup-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- readIORef ref- length collected `shouldBe` 10-- it "receives live events appended after subscription starts" $ \store -> do- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n >= 5- then pure Stop- else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "live-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- -- Give subscription time to start- threadDelay 100_000- -- Append 5 events- let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "live-1") NoStream events- -- Wait for subscription to complete- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- readIORef ref- length collected `shouldBe` 5-- it "persists checkpoint and resumes from saved position" $ \store -> do- -- Append 10 events- let events = map (\i -> makeEvent ("CP" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "ckpt-1") NoStream events- waitForPublisher store (GlobalPosition 10)- -- First subscription: process 5 events then stop- countRef1 <- newTVarIO (0 :: Int)- let handler1 _evt = do- n <- atomically $ do- c <- readTVar countRef1- let c' = c + 1- writeTVar countRef1 c'- pure c'- if n >= 5- then pure Stop- else pure Continue- let cfg1 =- SubscriptionConfig- { name = SubscriptionName "ckpt-test"- , target = AllStreams- , handler = handler1- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h1 <- subscribe store cfg1- _ <- waitWithTimeout 10_000_000 h1- -- Second subscription with same name: should resume from position 5- ref2 <- newIORef ([] :: [RecordedEvent])- countRef2 <- newTVarIO (0 :: Int)- let handler2 evt = do- modifyIORef' ref2 (evt :)- n <- atomically $ do- c <- readTVar countRef2- let c' = c + 1- writeTVar countRef2 c'- pure c'- if n >= 5- then pure Stop- else pure Continue- let cfg2 =- SubscriptionConfig- { name = SubscriptionName "ckpt-test"- , target = AllStreams- , handler = handler2- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h2 <- subscribe store cfg2- _ <- waitWithTimeout 10_000_000 h2- collected <- readIORef ref2- -- Should receive events 6–10 only- length collected `shouldBe` 5-- it "does not replay catch-up events when switching to all-stream live mode" $ \store -> do- let seedEvents = map (\i -> makeEvent ("TransitionSeed" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "transition-seed") NoStream seedEvents- waitForPublisher store (GlobalPosition 5)-- firstSeen <- newEmptyMVar- releaseFirst <- newEmptyMVar- countRef <- newTVarIO (0 :: Int)- ref <- newIORef ([] :: [RecordedEvent])- let stopType = EventType "TransitionStopAfterLive"- handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n == 1- then do- putMVar firstSeen ()- takeMVar releaseFirst- else pure ()- if evt ^. #eventType == stopType- then pure Stop- else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "transition-no-duplicates"- , target = AllStreams- , handler = handler'- , batchSize = either (error . show) Prelude.id (mkBatchSize 2)- , queueCapacity = 32- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- takeMVar firstSeen-- mapM_- ( \i -> do- let sn = StreamName ("transition-during-" <> T.pack (show (i :: Int)))- Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("TransitionDuring" <> T.pack (show i)) (Aeson.object [])]- pure ()- )- [6 .. 10]- waitForPublisher store (GlobalPosition 10)- putMVar releaseFirst ()- atomically $ do- c <- readTVar countRef- check (c >= 10)-- Right _ <- runStoreIO store $ appendToStream (StreamName "transition-stop") NoStream [makeEvent "TransitionStopAfterLive" (Aeson.object [])]- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()-- collected <- reverse <$> readIORef ref- map (^. #globalPosition) collected `shouldBe` map GlobalPosition [1 .. 11]-- it "does not skip an event when cancelled before checkpoint save" $ \store -> do- let events = map (\i -> makeEvent ("CancelReplay" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-replay") NoStream events- waitForPublisher store (GlobalPosition 3)-- firstSeen <- newEmptyMVar- block <- newEmptyMVar- firstRef <- newIORef ([] :: [RecordedEvent])- let handler1 evt = do- modifyIORef' firstRef (evt :)- putMVar firstSeen ()- _ <- takeMVar block- pure Continue- cfg1 =- SubscriptionConfig- { name = SubscriptionName "cancel-replay-test"- , target = AllStreams- , handler = handler1- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h1 <- subscribe store cfg1- takeMVar firstSeen- cancel h1- _ <- waitWithTimeout 5_000_000 h1-- ref2 <- newIORef ([] :: [RecordedEvent])- let handler2 evt = do- modifyIORef' ref2 (evt :)- pure Stop- cfg2 =- SubscriptionConfig- { name = SubscriptionName "cancel-replay-test"- , target = AllStreams- , handler = handler2- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h2 <- subscribe store cfg2- result <- waitWithTimeout 10_000_000 h2- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- replayed <- reverse <$> readIORef ref2- map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 1]-- it "saves checkpoints at Stop boundaries without skipping the next event" $ \store -> do- let events = map (\i -> makeEvent ("StopBoundary" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "stop-boundary") NoStream events- waitForPublisher store (GlobalPosition 5)-- countRef1 <- newTVarIO (0 :: Int)- let handler1 _evt = do- n <- atomically $ do- c <- readTVar countRef1- let c' = c + 1- writeTVar countRef1 c'- pure c'- if n >= 3 then pure Stop else pure Continue- cfg1 =- SubscriptionConfig- { name = SubscriptionName "stop-boundary-test"- , target = AllStreams- , handler = handler1- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h1 <- subscribe store cfg1- _ <- waitWithTimeout 10_000_000 h1-- ref2 <- newIORef ([] :: [RecordedEvent])- countRef2 <- newTVarIO (0 :: Int)- let handler2 evt = do- modifyIORef' ref2 (evt :)- n <- atomically $ do- c <- readTVar countRef2- let c' = c + 1- writeTVar countRef2 c'- pure c'- if n >= 2 then pure Stop else pure Continue- cfg2 =- SubscriptionConfig- { name = SubscriptionName "stop-boundary-test"- , target = AllStreams- , handler = handler2- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h2 <- subscribe store cfg2- result <- waitWithTimeout 10_000_000 h2- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- replayed <- reverse <$> readIORef ref2- map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 4, GlobalPosition 5]-- -- F18 regression — Category subscriptions in live mode previously- -- received unfiltered events from $all because `filterEvents` was- -- a no-op for Category. The fix routes Category live-mode through- -- a DB-driven loop that re-uses the SQL category filter. Before- -- the fix, the handler would observe the UserNoise event below.- it "delivers only category-matching events during live mode (F18)" $ \store -> do- seedSeen <- newEmptyMVar- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- -- After the catch-up seed event, signal that we are- -- entering live mode. Subsequent appends exercise- -- the live path under the new DB-driven loop.- if n == 1- then putMVar seedSeen ()- else pure ()- if n >= 3 then pure Stop else pure Continue- -- Pre-seed an order event so catch-up fires the handler once.- Right _ <-- runStoreIO store $- appendToStream- (StreamName "order-seed")- NoStream- [makeEvent "OrderSeed" (Aeson.object [])]- let cfg =- SubscriptionConfig- { name = SubscriptionName "f18-live-test"- , target = Category (CategoryName "order")- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- -- Block until catch-up has delivered the seed event — the- -- worker has finished catch-up and is about to enter live- -- mode.- takeMVar seedSeen- -- Append a non-matching user event followed by two matching- -- order events. Under the bug, UserNoise would slip through- -- the live broadcast unfiltered.- Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserNoise" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderTwo" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "order-3") NoStream [makeEvent "OrderThree" (Aeson.object [])]- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- readIORef ref- length collected `shouldBe` 3- -- No UserNoise event should have been delivered.- mapM_- (\e -> (e ^. #eventType) `shouldNotBe` EventType "UserNoise")- collected-- it "delivers only category-matching events during catch-up" $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]- waitForPublisher store (GlobalPosition 3)- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n >= 2- then pure Stop- else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "cat-sub-test"- , target = Category (CategoryName "order")- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- _ <- waitWithTimeout 10_000_000 handle- collected <- readIORef ref- length collected `shouldBe` 2- -- All events should be order events- mapM_ (\e -> (e ^. #eventType) `shouldSatisfy` (\(EventType t) -> T.isPrefixOf "Order" t)) collected-- it "preserves category subscription order under mixed invoice-payment writes" $ \store -> do- let appendOne sn typ =- runStoreIO store (appendToStream sn AnyVersion [makeEvent typ (Aeson.object [])]) >>= \result ->- case result of- Right _ -> pure ()- Left err -> expectationFailure ("append failed: " <> show err)- appendOne (StreamName "invoice-payment") "InvoicePaymentStarted"- appendOne (StreamName "user-1") "UserNoiseOne"- appendOne (StreamName "invoice-reminder") "InvoiceReminderSent"- appendOne (StreamName "order-1") "OrderNoiseOne"- appendOne (StreamName "invoice-payment") "InvoicePaymentFinished"- appendOne (StreamName "user-2") "UserNoiseTwo"- appendOne (StreamName "invoice-refund") "InvoiceRefundFinished"- waitForPublisher store (GlobalPosition 7)-- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n >= 4 then pure Stop else pure Continue- cfg =- SubscriptionConfig- { name = SubscriptionName "invoice-category-ordering"- , target = Category (CategoryName "invoice")- , handler = handler'- , batchSize = either (error . show) Prelude.id (mkBatchSize 2)- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- reverse <$> readIORef ref- map (^. #globalPosition) collected `shouldBe` [GlobalPosition 1, GlobalPosition 3, GlobalPosition 5, GlobalPosition 7]- map (^. #eventType) collected- `shouldBe` [ EventType "InvoicePaymentStarted"- , EventType "InvoiceReminderSent"- , EventType "InvoicePaymentFinished"- , EventType "InvoiceRefundFinished"- ]-- it "cancels a running subscription cleanly" $ \store -> do- let handler' _evt = pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "cancel-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- -- Give it time to start- threadDelay 100_000- cancel handle- result <- waitWithTimeout 5_000_000 handle- -- Should exit cleanly (AsyncCancelled or Right ())- case result of- Left _timeout -> expectationFailure "Cancel did not terminate in time"- Right (Left _) -> pure () -- AsyncCancelled is expected- Right (Right ()) -> pure ()-- it "receives events appended to an initially empty store" $ \store -> do- ref <- newIORef ([] :: [RecordedEvent])- let handler' evt = do- modifyIORef' ref (evt :)- pure Stop- let cfg =- SubscriptionConfig- { name = SubscriptionName "empty-store-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- -- Give subscription time to start in live mode- threadDelay 100_000- -- Append one event- Right _ <- runStoreIO store $ appendToStream (StreamName "empty-1") NoStream [makeEvent "First" (Aeson.object [])]- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- readIORef ref- length collected `shouldBe` 1-- it "handles rapid appends without losing events (debouncing)" $ \store -> do- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n >= 50- then pure Stop- else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "debounce-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , -- Sized to absorb 50 publisher batches without- -- backpressure overflow. The default 16 was- -- intermittently overrun under load (the- -- publisher emits one batch per append because- -- there is no inter-append debounce window in- -- this loop), surfacing as- -- SubscriptionOverflowed in flaky CI runs.- -- Coverage of the bounded-queue policy lives- -- in the F6 overflow test below, not here.- queueCapacity = 64- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- threadDelay 100_000- -- Append 50 events rapidly to different streams- mapM_- ( \i -> do- let sn = StreamName ("rapid-" <> T.pack (show (i :: Int)))- Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("R" <> T.pack (show i)) (Aeson.object [])]- pure ()- )- [1 .. 50]- result <- waitWithTimeout 30_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- readIORef ref- length collected `shouldBe` 50-- -- F6 regression — the publisher's broadcast was an unbounded TChan;- -- a slow subscriber's dupTChan grew without limit until the host- -- ran out of memory. The fix replaces the broadcast with a- -- per-subscriber bounded TBQueue plus an overflow policy. With- -- DropSubscription, the publisher signals overflow on the- -- subscriber's status TVar; the worker observes it on its next- -- STM read and surfaces SubscriptionOverflowed via wait/waitCatch.- it "surfaces SubscriptionOverflowed when a slow subscriber overruns its queue (F6)" $ \store -> do- firstSeen <- newEmptyMVar- release <- newEmptyMVar- seenCount <- newTVarIO (0 :: Int)- let handler' _evt = do- n <- atomically $ do- c <- readTVar seenCount- let c' = c + 1- writeTVar seenCount c'- pure c'- if n == 1- then do- putMVar firstSeen ()- takeMVar release- else pure ()- pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "f6-overflow-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 1- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- -- This fixture requires a live handler; catch-up could otherwise- -- consume the queued range before observing overflow.- let awaitLive = do- st <- currentState handle- case st of- Just Fsm.Live{} -> pure ()- _ -> threadDelay 1_000 >> awaitLive- Async.race (threadDelay 5_000_000) awaitLive >>= (`shouldBe` Right ())- -- First append: triggers handler, which blocks on the release MVar.- Right _ <- runStoreIO store $ appendToStream (StreamName "f6-1") NoStream [makeEvent "E1" (Aeson.object [])]- takeMVar firstSeen- -- While the worker is stuck inside the handler, append events one- -- at a time and wait for the publisher to fetch each individually.- -- Each append corresponds to a separate publisher batch written- -- to the subscriber's queue. Capacity is 1, so the second- -- write fills it and the third triggers DropSubscription.- let waitForPub n = atomically $ do- GlobalPosition p <- publisherPosition (store ^. #publisher)- check (p >= n)- let appendOne i = do- let sn = StreamName ("f6-" <> T.pack (show (i :: Int)))- Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]- waitForPub (fromIntegral i)- -- Append 4 more events with publisher synchronisation. Combined- -- with the first event, that is 5 publisher batches.- appendOne 2- appendOne 3- appendOne 4- appendOne 5- -- Release the handler. Worker exits processEvents and on the- -- next STM read observes Overflowed.- putMVar release ()- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got clean exit"- Right (Left e) ->- case Control.Exception.fromException e of- Just (SubscriptionOverflowed sn) ->- sn `shouldBe` SubscriptionName "f6-overflow-test"- Nothing ->- expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)-- ref2 <- newIORef ([] :: [RecordedEvent])- countRef2 <- newTVarIO (0 :: Int)- let handler2 evt = do- modifyIORef' ref2 (evt :)- n <- atomically $ do- c <- readTVar countRef2- let c' = c + 1- writeTVar countRef2 c'- pure c'- if n >= 4 then pure Stop else pure Continue- cfg2 =- SubscriptionConfig- { name = SubscriptionName "f6-overflow-test"- , target = AllStreams- , handler = handler2- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- h2 <- subscribe store cfg2- replayResult <- waitWithTimeout 10_000_000 h2- case replayResult of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed after overflow restart: " <> show err)- Right (Right ()) -> pure ()- replayed <- reverse <$> readIORef ref2- map (^. #globalPosition) replayed `shouldBe` map GlobalPosition [2 .. 5]-- it "catches up with an Eff-based handler via the effectful API" $ \store -> do- -- Append 10 events- let events = map (\i -> makeEvent ("Eff" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]- Right _ <- runStoreIO store $ appendToStream (StreamName "eff-sub-1") NoStream events- waitForPublisher store (GlobalPosition 10)- -- Subscribe using the effectful API with an Eff-based handler- ref <- newIORef ([] :: [RecordedEvent])- countRef <- newTVarIO (0 :: Int)- result <- runEff . runErrorNoCallStack @StoreError . runStorePool store . runSubscription store $ do- let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult- effHandler evt = do- liftIO $ modifyIORef' ref (evt :)- n <- liftIO $ atomically $ do- c <- readTVar countRef- let c' = c + 1- writeTVar countRef c'- pure c'- if n >= 10- then pure Stop- else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "eff-catchup-test"- , target = AllStreams- , handler = effHandler- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- SubEff.subscribe cfg- liftIO $ do- r <- waitWithTimeout 10_000_000 handle- case r of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- case result of- Left err -> expectationFailure ("Store error: " <> show err)- Right () -> pure ()- collected <- readIORef ref- length collected `shouldBe` 10-- -- =================================================================- -- withSubscription bracket tests (EP-2 F25)- -- =================================================================- describe "withSubscription" $ do- -- F25 regression — bracket cancels the worker on normal scope exit.- it "cancels the worker on normal scope exit" $ \store -> do- handleRef <- newIORef Nothing- let cfg =- SubscriptionConfig- { name = SubscriptionName "withsub-normal"- , target = AllStreams- , handler = \_ -> pure Continue- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- withSubscription store cfg $ \h -> do- writeIORef handleRef (Just h)- threadDelay 100_000- Just h <- readIORef handleRef- -- Worker must terminate now that the scope has exited.- outcome <- Async.race (threadDelay 2_000_000) (wait h)- case outcome of- Left () -> expectationFailure "worker did not exit after withSubscription scope"- Right _ -> pure ()-- -- F25 regression — bracket cancels the worker even when the body throws.- it "cancels the worker when the body throws" $ \store -> do- handleRef <- newIORef Nothing- let cfg =- SubscriptionConfig- { name = SubscriptionName "withsub-throw"- , target = AllStreams- , handler = \_ -> pure Continue- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- result <-- Control.Exception.try @SomeException $- withSubscription store cfg $ \h -> do- writeIORef handleRef (Just h)- threadDelay 100_000- error "withSubscription body deliberately throws"- case result of- Left _ -> pure ()- Right () -> expectationFailure "expected exception to propagate"- Just h <- readIORef handleRef- outcome <- Async.race (threadDelay 2_000_000) (wait h)- case outcome of- Left () -> expectationFailure "worker did not exit after exception in withSubscription body"- Right _ -> pure ()-- -- =================================================================- -- Pure helpers (no database fixture)- -- =================================================================-- -- F1 regression — pure detail parser used by attributeMultiStreamError.- -- The integration scenario that would exercise this code path- -- (ix_streams_stream_name 23505 raised inside an appendMultiStream txn)- -- is unreachable via current SQL because every append CTE uses ON CONFLICT- -- DO NOTHING / DO UPDATE. The pure helper is unit-tested here so a future- -- schema change that introduces such a path arrives with the attribution- -- already correct.- describe "extractStreamNameFromDetail" $ do- it "extracts the stream name from a typical PostgreSQL detail" $ do- extractStreamNameFromDetail "Key (stream_name)=(orders-1) already exists."- `shouldBe` Just "orders-1"- it "extracts a stream name containing dashes and digits" $ do- extractStreamNameFromDetail "Key (stream_name)=(multi-c-2) already exists."- `shouldBe` Just "multi-c-2"- it "returns Nothing when the detail is empty" $ do- extractStreamNameFromDetail "" `shouldBe` Nothing- it "returns Nothing when the detail does not contain '=('" $ do- extractStreamNameFromDetail "no key here" `shouldBe` Nothing- 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-- -- The store retries an append once on these codes, so a caller only- -- sees one when the conflict repeated. It must arrive as the retryable- -- constructor: 'UnexpectedServerError' documents itself as "not- -- generally retryable — investigate", which would send a caller to- -- diagnose a condition whose correct handling is to retry.- it "maps both transient SQLSTATEs to TransientTransactionFailure" $ do- forM_ ["40001", "40P01"] $ \code -> do- mapUsageError "orders-1" AnyVersion (serverUsage code)- `shouldBe` TransientTransactionFailure code "server error"- mapGenericUsageError (serverUsage code)- `shouldBe` TransientTransactionFailure code "server error"- mapTransactionUsageError (serverUsage code)- `shouldBe` TransientTransactionFailure code "server error"- mapLinkUsageError (StreamName "orders-1") (serverUsage code)- `shouldBe` TransientTransactionFailure code "server error"- attributeMultiStreamError- [(StreamName "orders-1", AnyVersion)]- (serverUsage code)- `shouldBe` TransientTransactionFailure code "server error"-- it "leaves non-transient server codes on UnexpectedServerError" $ do- mapGenericUsageError (serverUsage "42883")- `shouldBe` UnexpectedServerError "42883" "server error"-- -- =================================================================- -- Notifier reconnection tests (EP-3 F1)- -- =================================================================- describe "Notifier reconnection" $ do- -- F1 regression — the listener loop reconnects on backend termination,- -- and `stopNotifier` must release the *current* (post-reconnect)- -- connection. Without the fix, the original Notifier.listenerConn was- -- a frozen reference to the first conn and the reconnected conn leaked- -- past `withStore` exit. We assert no kiroku-listener backend remains- -- in pg_stat_activity after the store shuts down.- it "releases the reconnected listener connection on shutdown" $ \() -> do- n <- withMigratedTestDatabase $ \connStr -> do- let settings = defaultConnectionSettings connStr- withStore settings $ \store -> do- pid1 <- waitForListenerPid (store ^. #pool) 5_000_000- terminateBackend (store ^. #pool) pid1- pid2 <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000- pid2 `shouldNotBe` pid1- -- After withStore exits, no kiroku-listener connection remains.- listenerCount connStr- n `shouldBe` (0 :: Int64)-- -- =================================================================- -- Health monitoring tests (M6.8)- -- =================================================================- describe "observationHandler" $ do- it "receives observations during store operations" $ \() -> do- ref <- newIORef ([] :: [Observation])- let handler obs = modifyIORef' ref (obs :)- withTestStoreSettings- (\settings -> settings{observationHandler = Just handler})- $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "obs-test") NoStream [makeEvent "X" (Aeson.object [])]- pure ()- observations <- readIORef ref- length observations `shouldSatisfy` (> 0)-- -- =================================================================- -- KirokuEvent observation surface (EP-5 F1, F13, F14)- -- =================================================================- describe "KirokuEvent observation" $ do- -- EP-5 F1 regression — terminating the listener backend triggers- -- KirokuEventNotifierReconnecting then KirokuEventNotifierReconnected.- -- Before EP-5 the reconnect path emitted no signal at all.- it "emits notifier reconnect events on backend termination (F1)" $ \() -> do- ref <- newIORef ([] :: [KirokuEvent])- let evtHandler e = modifyIORef' ref (e :)- withMigratedTestDatabase $ \connStr -> do- let settings =- defaultConnectionSettings connStr- & #eventHandler .~ Just evtHandler- withStore settings $ \store -> do- pid1 <- waitForListenerPid (store ^. #pool) 5_000_000- terminateBackend (store ^. #pool) pid1- _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000- pure ()- evts <- readIORef ref- let isReconnecting (KirokuEventNotifierReconnecting _ _) = True- isReconnecting _ = False- isReconnected KirokuEventNotifierReconnected = True- isReconnected _ = False- any isReconnecting evts `shouldBe` True- any isReconnected evts `shouldBe` True-- -- EP-5 F14 regression — subscriptions emit started, caught-up, and- -- stopped events. Before EP-5 the lifecycle was invisible.- --- -- Subscribe before appending so the worker enters catch-up at- -- position 0, immediately reaches the publisher's position 0,- -- emits CaughtUp, transitions to live mode, then receives the- -- two appended events and stops.- it "emits subscription lifecycle events (F14)" $ \() -> do- ref <- newIORef ([] :: [KirokuEvent])- let evtHandler e = modifyIORef' ref (e :)- withTestStoreSettings- (& #eventHandler .~ Just evtHandler)- $ \store -> do- countRef <- newTVarIO (0 :: Int)- let h _ = do- n <- atomically $ do- c <- readTVar countRef- writeTVar countRef (c + 1)- pure (c + 1)- if n >= 2 then pure Stop else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "lifecycle-test"- , target = AllStreams- , handler = h- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- -- Give the worker a moment to enter live mode.- threadDelay 200_000- Right _ <-- runStoreIO store $- appendToStream (StreamName "lifecycle-1") NoStream [makeEvent "X" (Aeson.object [])]- Right _ <-- runStoreIO store $- appendToStream (StreamName "lifecycle-1") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])]- _ <- waitWithTimeout 10_000_000 handle- pure ()- evts <- readIORef ref- let isStarted (KirokuEventSubscriptionStarted (SubscriptionName "lifecycle-test") _ _) = True- isStarted _ = False- isCaughtUp (KirokuEventSubscriptionCaughtUp (SubscriptionName "lifecycle-test") _ _) = True- isCaughtUp _ = False- isStopped (KirokuEventSubscriptionStopped (SubscriptionName "lifecycle-test") _ StopHandlerRequested _) = True- isStopped _ = False- any isStarted evts `shouldBe` True- any isCaughtUp evts `shouldBe` True- any isStopped evts `shouldBe` True-- -- EP-5 F13 regression — hard-delete emits a fail-safe audit event.- -- Before EP-5 there was no in-band audit signal.- it "emits a hard-delete event when the stream existed (F13)" $ \() -> do- ref <- newIORef ([] :: [KirokuEvent])- let evtHandler e = modifyIORef' ref (e :)- withTestStoreSettings- (& #eventHandler .~ Just evtHandler)- $ \store -> do- Right _ <-- runStoreIO store $- appendToStream (StreamName "hard-delete-evt") NoStream [makeEvent "X" (Aeson.object [])]- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-delete-evt")- -- A hard-delete on a non-existent stream must not emit- -- the event — verify by issuing a second one.- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "never-existed")- pure ()- evts <- readIORef ref- let hardDeletes =- [ name'- | KirokuEventHardDeleteIssued (StreamName name') _ <- evts- ]- hardDeletes `shouldBe` ["hard-delete-evt"]+import Test.NamespaceQueryPlans qualified as NamespaceQueryPlans+import Test.NotifyGuard qualified as NotifyGuard+import Test.PerformanceStructure qualified as PerformanceStructure+import Test.Properties qualified as Properties+import Test.PublisherCallbackResilience qualified as PublisherCallbackResilience+import Test.PublisherDropCounter qualified as PublisherDropCounter+import Test.PublisherIdleAdvance qualified as PublisherIdleAdvance+import Test.PublisherRestartNoRebroadcast qualified as PublisherRestartNoRebroadcast+import Test.ReadStream qualified as ReadStream+import Test.ScriptSessionErrors qualified as ScriptSessionErrors+import Test.StartupFailureSurfacing qualified as StartupFailureSurfacing+import Test.StreamBridgeTermination qualified as StreamBridgeTermination+import Test.StreamHead qualified as StreamHead+import Test.StreamHeadIsolation qualified as StreamHeadIsolation+import Test.StreamHeadMock qualified as StreamHeadMock+import Test.StreamHistoryGuard qualified as StreamHistoryGuard+import Test.StreamNameLookup qualified as StreamNameLookup+import Test.SubscriptionCheckpointInitialization qualified as SubscriptionCheckpointInitialization+import Test.SubscriptionCheckpointInitializationMock qualified as SubscriptionCheckpointInitializationMock+import Test.SubscriptionCheckpointInventory qualified as SubscriptionCheckpointInventory+import Test.SubscriptionCheckpointInventoryMock qualified as SubscriptionCheckpointInventoryMock+import Test.SubscriptionCheckpointReset qualified as SubscriptionCheckpointReset+import Test.SubscriptionCheckpointWorker qualified as SubscriptionCheckpointWorker+import Test.SubscriptionDeadLetters qualified as SubscriptionDeadLetters+import Test.SubscriptionDeadLettersMock qualified as SubscriptionDeadLettersMock+import Test.SubscriptionNamespace qualified as SubscriptionNamespace+import Test.SubscriptionPauseResume qualified as SubscriptionPauseResume+import Test.SubscriptionReconnect qualified as SubscriptionReconnect+import Test.SubscriptionRegistry qualified as SubscriptionRegistry+import Test.SubscriptionRetryDeadLetter qualified as SubscriptionRetryDeadLetter+import Test.SubscriptionState qualified as SubscriptionState+import Test.SubscriptionTarget qualified as SubscriptionTarget+import Test.Transaction qualified as Transaction+import Test.TruncateBefore qualified as TruncateBefore+import Test.UniqueViolationMapping qualified as UniqueViolationMapping+import Test.VisibleGlobalHeadPosition qualified as VisibleGlobalHeadPosition+import Test.VisibleGlobalHeadPositionMock qualified as VisibleGlobalHeadPositionMock++main :: IO ()+main = withSharedMigratedPostgres $ hspec $ do+ BrowseReads.spec+ BrowseReadsMock.spec+ SubscriptionDeadLetters.spec+ SubscriptionDeadLettersMock.spec+ UniqueViolationMapping.spec+ SubscriptionTarget.spec+ SubscriptionNamespace.spec+ NamespaceQueryPlans.spec+ Category.spec+ Properties.spec+ Concurrency.spec+ FailureInjection.spec+ HistoryRetention.spec+ HistoryRetentionMock.spec+ Transaction.spec+ ScriptSessionErrors.spec+ TruncateBefore.spec+ ReadStream.spec+ StreamNameLookup.spec+ StreamBridgeTermination.spec+ StreamHistoryGuard.spec+ InterpreterHooks.spec+ Causation.spec+ ConsumerGroupSql.spec+ ConsumerGroupResize.spec+ ConsumerGroup.spec+ ConsumerGroupEffect.spec+ describe "performance structure" $ do+ BrowseQueryPlans.spec+ StreamHeadIsolation.spec+ PerformanceStructure.spec+ NotifyGuard.spec+ StreamNameLookup.noOpSpec+ CategoryIdleNoSpin.spec+ HandlerStall.spec+ PublisherCallbackResilience.spec+ PublisherDropCounter.spec+ PublisherIdleAdvance.spec+ PublisherRestartNoRebroadcast.spec+ CatchupDbErrorNoPrematureSwitch.spec+ SubscriptionPauseResume.spec+ SubscriptionCheckpointInitialization.spec+ SubscriptionCheckpointInitializationMock.spec+ SubscriptionCheckpointWorker.spec+ SubscriptionCheckpointInventory.spec+ SubscriptionCheckpointInventoryMock.spec+ SubscriptionCheckpointReset.spec+ SubscriptionReconnect.spec+ StartupFailureSurfacing.spec+ SubscriptionState.spec+ SubscriptionRegistry.spec+ SubscriptionRetryDeadLetter.spec+ EventTypeFilter.spec+ StreamHead.spec+ StreamHeadMock.spec+ VisibleGlobalHeadPosition.spec+ VisibleGlobalHeadPositionMock.spec+ around withTestStore $ do+ describe "schema migrations" $ do+ it "installs every Kiroku table under the kiroku schema" $ \store -> do+ let tables = ["streams", "events", "stream_events", "subscriptions"]+ results <- mapM (\t -> tableExists store ("kiroku." <> t)) tables+ results `shouldBe` replicate (length tables) True++ it "leaves the public schema free of Kiroku tables" $ \store -> do+ let tables = ["streams", "events", "stream_events", "subscriptions"]+ results <- mapM (\t -> tableExists store ("public." <> t)) tables+ results `shouldBe` replicate (length tables) False++ it "provides a UUIDv7 database default for direct event inserts" $ \store -> do+ eventIdText <- insertEventUsingDefaultId store+ version <- serverVersionNum store+ let isV7 = T.length eventIdText > 14 && T.index eventIdText 14 == '7'+ unless isV7 $+ expectationFailure+ ( "expected database-generated event_id to be UUIDv7 on PostgreSQL "+ <> T.unpack version+ <> ", got "+ <> T.unpack eventIdText+ )++ describe "appendToStream" $ do+ describe "NoStream" $ do+ it "creates a new stream and appends events" $ \store -> do+ let event = makeEvent "OrderCreated" (Aeson.object [("orderId", Aeson.String "123")])+ result <- runStoreIO store $ appendToStream (StreamName "order-123") NoStream [event]+ case result of+ Left err -> expectationFailure ("Expected success, got: " <> show err)+ Right r -> do+ (r ^. #streamVersion) `shouldBe` StreamVersion 1+ (r ^. #globalPosition) `shouldBe` GlobalPosition 1++ it "fails when stream already exists" $ \store -> do+ _ <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+ result <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderUpdated" (Aeson.object [])]+ case result of+ 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 allBefore <- 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 allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+ fmap (^. #globalPosition) (V.toList allAfter) `shouldBe` fmap (^. #globalPosition) (V.toList allBefore)++ 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 [])]+ (r1 ^. #streamVersion) `shouldBe` StreamVersion 1++ result <- runStoreIO store $ appendToStream (StreamName "order-789") (ExactVersion (StreamVersion 1)) [makeEvent "OrderUpdated" (Aeson.object [])]+ case result of+ Left err -> expectationFailure ("Expected success, got: " <> show err)+ Right r -> do+ (r ^. #streamVersion) `shouldBe` StreamVersion 2+ (r ^. #globalPosition) `shouldBe` GlobalPosition 2++ it "fails on version conflict" $ \store -> do+ _ <- runStoreIO store $ appendToStream (StreamName "order-conflict") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+ result <- runStoreIO store $ appendToStream (StreamName "order-conflict") (ExactVersion (StreamVersion 0)) [makeEvent "OrderUpdated" (Aeson.object [])]+ case result of+ Left (WrongExpectedVersion _ _ _) -> pure ()+ other -> expectationFailure ("Expected WrongExpectedVersion, got: " <> show other)++ describe "StreamExists" $ do+ it "appends to an existing stream" $ \store -> do+ _ <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") NoStream [makeEvent "Created" (Aeson.object [])]+ result <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") StreamExists [makeEvent "Updated" (Aeson.object [])]+ case result of+ Left err -> expectationFailure ("Expected success, got: " <> show err)+ Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2++ it "fails when stream does not exist" $ \store -> do+ result <- runStoreIO store $ appendToStream (StreamName "nonexistent-stream") StreamExists [makeEvent "Created" (Aeson.object [])]+ case result of+ Left (StreamNotFound _) -> pure ()+ other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)++ describe "AnyVersion" $ do+ it "creates a new stream" $ \store -> do+ result <- runStoreIO store $ appendToStream (StreamName "any-new") AnyVersion [makeEvent "Created" (Aeson.object [])]+ case result of+ Left err -> expectationFailure ("Expected success, got: " <> show err)+ Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1++ it "appends to an existing stream" $ \store -> do+ _ <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Created" (Aeson.object [])]+ result <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Updated" (Aeson.object [])]+ case result of+ Left err -> expectationFailure ("Expected success, got: " <> show err)+ Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2++ describe "batch append" $ do+ it "appends multiple events with sequential versions" $ \store -> do+ let events =+ [ makeEvent "Event1" (Aeson.object [("n", Aeson.Number 1)]),+ makeEvent "Event2" (Aeson.object [("n", Aeson.Number 2)]),+ makeEvent "Event3" (Aeson.object [("n", Aeson.Number 3)])+ ]+ result <- runStoreIO store $ appendToStream (StreamName "batch-test") NoStream events+ case result of+ Left err -> expectationFailure ("Expected success, got: " <> show err)+ Right r -> do+ (r ^. #streamVersion) `shouldBe` StreamVersion 3+ (r ^. #globalPosition) `shouldBe` GlobalPosition 3++ describe "global position contiguity" $ do+ it "assigns contiguous global positions across streams" $ \store -> do+ Right r1 <- runStoreIO store $ appendToStream (StreamName "stream-a") NoStream [makeEvent "A" (Aeson.object [])]+ (r1 ^. #globalPosition) `shouldBe` GlobalPosition 1++ Right r2 <- runStoreIO store $ appendToStream (StreamName "stream-b") NoStream [makeEvent "B" (Aeson.object [])]+ (r2 ^. #globalPosition) `shouldBe` GlobalPosition 2++ Right r3 <- runStoreIO store $ appendToStream (StreamName "stream-c") NoStream [makeEvent "C" (Aeson.object [])]+ (r3 ^. #globalPosition) `shouldBe` GlobalPosition 3++ describe "duplicate event ID" $ do+ it "returns the caller event id on same-stream retry and leaves one stored event" $ \store -> do+ let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)+ event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just eid+ stream = StreamName "same-stream-duplicate"+ Right _ <- runStoreIO store $ appendToStream stream NoStream [event]+ forM_ [AnyVersion, StreamExists, ExactVersion (StreamVersion 1)] $ \expected -> do+ result <- runStoreIO store $ appendToStream stream expected [event]+ result `shouldBe` Left (DuplicateEvent (Just eid))+ countEvents store `shouldReturn` 1+ Right stored <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 100+ Right global <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ map (\e -> e ^. #eventId) (V.toList stored) `shouldBe` [eid]+ map (\e -> e ^. #eventId) (V.toList global) `shouldBe` [eid]+ Right info <- runStoreIO store $ getStream stream+ fmap (\s -> s ^. #version) info `shouldBe` Just (StreamVersion 1)++ it "rejects duplicate event IDs" $ \store -> do+ let eid = EventId (case UUID.fromString "01234567-89ab-7def-8012-34567890abcd" of Just u -> u; Nothing -> error "bad uuid")+ let event1 =+ EventData+ { eventId = Just eid,+ eventType = EventType "Created",+ payload = Aeson.object [],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }+ Right _ <- runStoreIO store $ appendToStream (StreamName "dup-test-1") NoStream [event1]++ let event2 =+ EventData+ { eventId = Just eid,+ eventType = EventType "Created",+ payload = Aeson.object [],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }+ result <- runStoreIO store $ appendToStream (StreamName "dup-test-2") NoStream [event2]+ case result of+ Left (DuplicateEvent _) -> pure ()+ other -> expectationFailure ("Expected DuplicateEvent, got: " <> show other)++ describe "stream-name contract" $ do+ it "treats system-looking names other than $all as ordinary streams" $ \store -> do+ let names =+ [ StreamName "invoice-payment",+ StreamName "$invoice-payment",+ StreamName "invoice,payment",+ StreamName "invoicepayment"+ ]+ mapM_+ ( \name -> do+ Right r <- runStoreIO store $ appendToStream name NoStream [makeEvent "StreamNameContract" (Aeson.object [])]+ (r ^. #streamVersion) `shouldBe` StreamVersion 1+ Right events <- runStoreIO store $ readStreamForward name (StreamVersion 0) 10+ V.length events `shouldBe` 1+ )+ names++ it "rejects $all as an application append target" $ \store -> do+ result <- runStoreIO store $ appendToStream (StreamName "$all") AnyVersion [makeEvent "BadAllAppend" (Aeson.object [])]+ case result of+ Left (ReservedStreamName (StreamName "$all")) -> pure ()+ other -> expectationFailure ("Expected ReservedStreamName for $all append, got: " <> show other)+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+ V.length allEvents `shouldBe` 0++ it "rejects $all as a multi-stream append target without partial commit" $ \store -> do+ result <-+ runStoreIO store $+ appendMultiStream+ [ (StreamName "multi-reserved-ok", NoStream, [makeEvent "ShouldRollback" (Aeson.object [])]),+ (StreamName "$all", AnyVersion, [makeEvent "BadAllMulti" (Aeson.object [])])+ ]+ case result of+ Left (ReservedStreamName (StreamName "$all")) -> pure ()+ other -> expectationFailure ("Expected ReservedStreamName for $all multi-stream append, got: " <> show other)+ Right info <- runStoreIO store $ getStream (StreamName "multi-reserved-ok")+ info `shouldBe` Nothing++ it "rejects $all as a link target" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "reserved-link-source") NoStream [makeEvent "Source" (Aeson.object [])]+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "reserved-link-source") (StreamVersion 0) 10+ let eid = V.head srcEvents ^. #eventId+ result <- runStoreIO store $ linkToStream (StreamName "$all") [eid]+ case result of+ Left (ReservedStreamName (StreamName "$all")) -> pure ()+ other -> expectationFailure ("Expected ReservedStreamName for $all link, got: " <> show other)+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+ V.length allEvents `shouldBe` 1++ it "rejects lifecycle operations against $all" $ \store -> do+ soft <- runStoreIO store $ softDeleteStream (StreamName "$all")+ hard <- runStoreIO store $ hardDeleteStream (StreamName "$all")+ undel <- runStoreIO store $ undeleteStream (StreamName "$all")+ let shouldBeReserved label result =+ case result of+ Left (ReservedStreamName (StreamName "$all")) -> pure ()+ other -> expectationFailure ("Expected ReservedStreamName for " <> label <> ", got: " <> show other)+ shouldBeReserved "softDeleteStream $all" soft+ shouldBeReserved "hardDeleteStream $all" hard+ shouldBeReserved "undeleteStream $all" undel+ Right info <- runStoreIO store $ getStream (StreamName "$all")+ case info of+ 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 =+ [ makeEvent "A" (Aeson.object [("n", Aeson.Number 1)]),+ makeEvent "B" (Aeson.object [("n", Aeson.Number 2)]),+ makeEvent "C" (Aeson.object [("n", Aeson.Number 3)])+ ]+ Right _ <- runStoreIO store $ appendToStream (StreamName "read-fwd") NoStream events+ Right result <- runStoreIO store $ readStreamForward (StreamName "read-fwd") (StreamVersion 0) 100+ V.length result `shouldBe` 3+ (V.head result ^. #eventType) `shouldBe` EventType "A"+ (result V.! 1 ^. #eventType) `shouldBe` EventType "B"+ (result V.! 2 ^. #eventType) `shouldBe` EventType "C"+ (V.head result ^. #streamVersion) `shouldBe` StreamVersion 1+ (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2+ (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3++ describe "readStreamBackward" $ do+ it "reads events in backward order" $ \store -> do+ let events =+ [ makeEvent "A" (Aeson.object []),+ makeEvent "B" (Aeson.object []),+ makeEvent "C" (Aeson.object [])+ ]+ Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd") NoStream events+ Right result <- runStoreIO store $ readStreamBackward (StreamName "read-bwd") (StreamVersion 0) 100+ V.length result `shouldBe` 3+ (V.head result ^. #eventType) `shouldBe` EventType "C"+ (result V.! 1 ^. #eventType) `shouldBe` EventType "B"+ (result V.! 2 ^. #eventType) `shouldBe` EventType "A"+ (V.head result ^. #streamVersion) `shouldBe` StreamVersion 3+ (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]+ Right _ <- runStoreIO store $ appendToStream (StreamName "read-page") NoStream events+ -- Read first page of 2+ Right page1 <- runStoreIO store $ readStreamForward (StreamName "read-page") (StreamVersion 0) 2+ V.length page1 `shouldBe` 2+ let cursor1 = page1 V.! 1 ^. #streamVersion+ cursor1 `shouldBe` StreamVersion 2+ -- Read second page from cursor+ Right page2 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor1 2+ V.length page2 `shouldBe` 2+ (V.head page2 ^. #streamVersion) `shouldBe` StreamVersion 3+ -- Read third page — only 1 event left+ let cursor2 = page2 V.! 1 ^. #streamVersion+ Right page3 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor2 2+ V.length page3 `shouldBe` 1+ -- Read past end — empty+ let cursor3 = V.head page3 ^. #streamVersion+ Right page4 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor3 2+ V.length page4 `shouldBe` 0++ describe "readAllForward" $ do+ it "reads events from $all in global order" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "all-s1") NoStream [makeEvent "X" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "all-s2") NoStream [makeEvent "Y" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "all-s3") NoStream [makeEvent "Z" (Aeson.object [])]+ Right result <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ V.length result `shouldBe` 3+ (V.head result ^. #eventType) `shouldBe` EventType "X"+ (result V.! 1 ^. #eventType) `shouldBe` EventType "Y"+ (result V.! 2 ^. #eventType) `shouldBe` EventType "Z"+ -- Global positions should be contiguous+ (V.head result ^. #globalPosition) `shouldBe` GlobalPosition 1+ (result V.! 1 ^. #globalPosition) `shouldBe` GlobalPosition 2+ (result V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3++ describe "readAllBackward" $ do+ it "reads events from $all in reverse order" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s1") NoStream [makeEvent "X" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s2") NoStream [makeEvent "Y" (Aeson.object [])]+ Right result <- runStoreIO store $ readAllBackward (GlobalPosition 0) 100+ V.length result `shouldBe` 2+ (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+ V.length result `shouldBe` 0++ describe "getStream" $ do+ it "returns metadata for existing stream" $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "meta-test")+ NoStream+ [ makeEvent "A" (Aeson.object []),+ makeEvent "B" (Aeson.object [])+ ]+ Right info <- runStoreIO store $ getStream (StreamName "meta-test")+ case info of+ Just si -> do+ (si ^. #name) `shouldBe` StreamName "meta-test"+ (si ^. #version) `shouldBe` StreamVersion 2+ Nothing -> expectationFailure "Expected Just StreamInfo, got Nothing"++ it "returns Nothing for nonexistent stream" $ \store -> do+ Right info <- runStoreIO store $ getStream (StreamName "no-such-stream")+ info `shouldBe` Nothing++ describe "lookupStreamId" $ do+ it "returns the same id as getStream for a live stream" $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "lookup-live")+ NoStream+ [makeEvent "A" (Aeson.object [])]+ Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-live")+ Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-live")+ case (mInfo, mSid) of+ (Just info, Just sid) ->+ (info ^. #id) `shouldBe` sid+ _ ->+ expectationFailure "Expected both getStream and lookupStreamId to return Just"++ it "returns Nothing for a stream that has never been created" $ \store -> do+ Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-missing")+ mSid `shouldBe` Nothing++ it "returns Just the same id for a soft-deleted stream" $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "lookup-soft")+ NoStream+ [makeEvent "A" (Aeson.object [])]+ Right mSidBefore <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "lookup-soft")+ Right mSidAfter <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")+ mSidAfter `shouldBe` mSidBefore+ Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-soft")+ case (mInfo, mSidAfter) of+ (Just info, Just sid) -> (info ^. #id) `shouldBe` sid+ _ -> expectationFailure "Expected Just on a soft-deleted stream"++ describe "integration: full lifecycle through withStore" $ do+ it "append, read forward, read $all, getStream — all through public API" $ \store -> do+ -- Append events to two streams+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "integ-orders")+ NoStream+ [ makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")]),+ makeEvent "OrderShipped" (Aeson.object [("id", Aeson.String "1")])+ ]+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "integ-users")+ NoStream+ [ makeEvent "UserRegistered" (Aeson.object [("name", Aeson.String "alice")])+ ]++ -- Read from a named stream+ Right orders <- runStoreIO store $ readStreamForward (StreamName "integ-orders") (StreamVersion 0) 100+ V.length orders `shouldBe` 2+ (V.head orders ^. #eventType) `shouldBe` EventType "OrderCreated"+ (orders V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"++ -- Read from $all — should see all 3 events in global order+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ V.length allEvents `shouldBe` 3+ (V.head allEvents ^. #globalPosition) `shouldBe` GlobalPosition 1+ (allEvents V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3++ -- Query stream metadata+ Right orderInfo <- runStoreIO store $ getStream (StreamName "integ-orders")+ case orderInfo of+ Just si -> (si ^. #version) `shouldBe` StreamVersion 2+ Nothing -> expectationFailure "Expected stream info"++ Right noInfo <- runStoreIO store $ getStream (StreamName "nonexistent")+ noInfo `shouldBe` Nothing++ -- =================================================================+ -- Link tests (M5.8)+ -- =================================================================+ describe "linkToStream" $ do+ it "links a single event to a new stream" $ \store -> do+ let event = makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")])+ Right appendR <- runStoreIO store $ appendToStream (StreamName "source-1") NoStream [event]+ -- Read back to get the event ID+ Right events <- runStoreIO store $ readStreamForward (StreamName "source-1") (StreamVersion 0) 100+ let eid = V.head events ^. #eventId+ -- Link it+ Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-1") [eid]+ (linkR ^. #streamVersion) `shouldBe` StreamVersion 1+ -- Read the linked stream+ Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-1") (StreamVersion 0) 100+ V.length linked `shouldBe` 1+ (V.head linked ^. #eventId) `shouldBe` eid+ (V.head linked ^. #originalStreamId) `shouldBe` (appendR ^. #streamId)+ (V.head linked ^. #originalVersion) `shouldBe` StreamVersion 1++ it "links multiple events with sequential versions" $ \store -> do+ let events =+ [ makeEvent "A" (Aeson.object []),+ makeEvent "B" (Aeson.object []),+ makeEvent "C" (Aeson.object [])+ ]+ Right _ <- runStoreIO store $ appendToStream (StreamName "source-multi") NoStream events+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "source-multi") (StreamVersion 0) 100+ let eids = V.toList (V.map (^. #eventId) srcEvents)+ Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-multi") eids+ (linkR ^. #streamVersion) `shouldBe` StreamVersion 3+ Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-multi") (StreamVersion 0) 100+ V.length linked `shouldBe` 3+ (V.head linked ^. #streamVersion) `shouldBe` StreamVersion 1+ (linked V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2+ (linked V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3++ it "links events to an existing stream and bumps version" $ \store -> do+ let events1 = [makeEvent "A" (Aeson.object [])]+ events2 = [makeEvent "B" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "src-a") NoStream events1+ Right _ <- runStoreIO store $ appendToStream (StreamName "src-b") NoStream events2+ Right evA <- runStoreIO store $ readStreamForward (StreamName "src-a") (StreamVersion 0) 100+ Right evB <- runStoreIO store $ readStreamForward (StreamName "src-b") (StreamVersion 0) 100+ let eidA = V.head evA ^. #eventId+ eidB = V.head evB ^. #eventId+ -- Link first event+ Right r1 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidA]+ (r1 ^. #streamVersion) `shouldBe` StreamVersion 1+ -- Link second event to same stream+ Right r2 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidB]+ (r2 ^. #streamVersion) `shouldBe` StreamVersion 2+ -- Read all linked events+ Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-existing") (StreamVersion 0) 100+ V.length linked `shouldBe` 2++ it "linked events still appear in $all at original global positions" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "orig-all") NoStream [makeEvent "X" (Aeson.object [])]+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "orig-all") (StreamVersion 0) 100+ let eid = V.head srcEvents ^. #eventId+ _ <- runStoreIO store $ linkToStream (StreamName "linked-all") [eid]+ -- Read $all — should have exactly 1 event (not duplicated)+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ let matchingEvents = V.filter (\e -> (e ^. #eventId) == eid) allEvents+ V.length matchingEvents `shouldBe` 1++ it "rejects linking the same event to the same stream twice" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "src-dup") NoStream [makeEvent "X" (Aeson.object [])]+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "src-dup") (StreamVersion 0) 100+ let eid = V.head srcEvents ^. #eventId+ Right _ <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]+ result <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]+ case result of+ 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.+ -- Before the fix, the CTE's `JOIN LATERAL` silently dropped link rows for+ -- event_ids that had no `stream_events` row (e.g. never existed, or were+ -- hard-deleted). The `stream_upsert` had already bumped `stream_version`,+ -- so the stream advanced by N but only some link rows were inserted.+ it "rejects link when the source event does not exist" $ \store -> do+ 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 (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")+ info `shouldBe` Nothing++ it "rejects link with a mix of valid and missing events (no partial commit)" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f3-mixed-src") NoStream [makeEvent "Real" (Aeson.object [])]+ Right realEvents <- runStoreIO store $ readStreamForward (StreamName "f3-mixed-src") (StreamVersion 0) 100+ let realId = V.head realEvents ^. #eventId+ 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 (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++ -- F5 regression — linkToStream against a soft-deleted target stream.+ -- Symmetric with the appendAnyVersion soft-delete check from F2: the+ -- DO UPDATE clause's WHERE filters on deleted_at IS NULL, so the+ -- upsert returns no row and the interpreter maps that to StreamNotFound.+ it "rejects link to a soft-deleted target stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f5-src") NoStream [makeEvent "X" (Aeson.object [])]+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f5-src") (StreamVersion 0) 100+ let eid = V.head srcEvents ^. #eventId+ Right _ <- runStoreIO store $ appendToStream (StreamName "f5-target") NoStream [makeEvent "Init" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "f5-target")+ result <- runStoreIO store $ linkToStream (StreamName "f5-target") [eid]+ case result of+ Left (StreamNotFound _) -> pure ()+ other -> expectationFailure ("Expected StreamNotFound for soft-deleted target, got: " <> show other)+ -- Soft-deleted target's version must not have advanced.+ Right info <- runStoreIO store $ getStream (StreamName "f5-target")+ case info of+ Just si -> (si ^. #version) `shouldBe` StreamVersion 1+ Nothing -> expectationFailure "soft-deleted stream row should still exist"++ -- =================================================================+ -- Category read tests (M5.8)+ -- =================================================================+ describe "readCategory" $ do+ it "reads events from matching category streams in global position order" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]+ Right result <- runStoreIO store $ readCategory (CategoryName "order") (GlobalPosition 0) 100+ V.length result `shouldBe` 2+ (V.head result ^. #eventType) `shouldBe` EventType "OrderCreated"+ (result V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"+ -- Verify global positions are ascending+ (V.head result ^. #globalPosition) `shouldSatisfy` (< (result V.! 1 ^. #globalPosition))++ it "supports pagination with start position and limit" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "cat-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "cat-2") NoStream [makeEvent "B" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "cat-3") NoStream [makeEvent "C" (Aeson.object [])]+ -- Read first 2+ Right page1 <- runStoreIO store $ readCategory (CategoryName "cat") (GlobalPosition 0) 2+ V.length page1 `shouldBe` 2+ -- Read from cursor+ let cursor = page1 V.! 1 ^. #globalPosition+ Right page2 <- runStoreIO store $ readCategory (CategoryName "cat") cursor 100+ V.length page2 `shouldBe` 1++ it "returns empty for nonexistent category" $ \store -> do+ Right result <- runStoreIO store $ readCategory (CategoryName "nope") (GlobalPosition 0) 100+ V.length result `shouldBe` 0++ it "includes linked events that originate from category streams" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "evt-1") NoStream [makeEvent "EventA" (Aeson.object [])]+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "evt-1") (StreamVersion 0) 100+ let eid = V.head srcEvents ^. #eventId+ -- Link into a different-category stream+ Right _ <- runStoreIO store $ linkToStream (StreamName "projection-1") [eid]+ -- Category "evt" should still return the event (it originates from evt-1)+ Right result <- runStoreIO store $ readCategory (CategoryName "evt") (GlobalPosition 0) 100+ V.length result `shouldBe` 1+ (V.head result ^. #eventType) `shouldBe` EventType "EventA"++ -- =================================================================+ -- Multi-stream transaction tests (M5.8)+ -- =================================================================+ describe "appendMultiStream" $ do+ it "atomically appends to two streams" $ \store -> do+ let ops =+ [ (StreamName "multi-a", NoStream, [makeEvent "A" (Aeson.object [])]),+ (StreamName "multi-b", NoStream, [makeEvent "B" (Aeson.object [])])+ ]+ Right results <- runStoreIO store $ appendMultiStream ops+ length results `shouldBe` 2+ ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 1+ ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1++ it "rolls back all streams on version conflict" $ \store -> do+ -- Create stream multi-c+ Right _ <- runStoreIO store $ appendToStream (StreamName "multi-c") NoStream [makeEvent "C" (Aeson.object [])]+ let ops =+ [ (StreamName "multi-d", NoStream, [makeEvent "D" (Aeson.object [])]),+ (StreamName "multi-c", NoStream, [makeEvent "C2" (Aeson.object [])]) -- conflict: already exists+ ]+ result <- runStoreIO store $ appendMultiStream ops+ case result of+ Left _ -> do+ -- Verify multi-d was NOT created (rollback)+ Right info <- runStoreIO store $ getStream (StreamName "multi-d")+ info `shouldBe` Nothing+ Right _ -> expectationFailure "Expected error for version conflict"++ it "appends to three streams with different expected versions" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "tri-1") NoStream [makeEvent "X" (Aeson.object [])]+ let ops =+ [ (StreamName "tri-1", ExactVersion (StreamVersion 1), [makeEvent "X2" (Aeson.object [])]),+ (StreamName "tri-2", NoStream, [makeEvent "Y" (Aeson.object [])]),+ (StreamName "tri-3", AnyVersion, [makeEvent "Z" (Aeson.object [])])+ ]+ Right results <- runStoreIO store $ appendMultiStream ops+ length results `shouldBe` 3+ ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 2+ ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1+ ((results !! 2) ^. #streamVersion) `shouldBe` StreamVersion 1++ -- F4 regression — pre-lock pass over named streams in stream_id order.+ -- The deterministic deadlock-prevention scenario (two concurrent calls+ -- touching the same streams in opposite orders) is covered by EP-6's+ -- planned concurrency-test harness; this test verifies that the+ -- pre-lock does not change user-visible ordering of global positions+ -- within a single multi-stream call.+ it "preserves user-supplied ordering of global positions" $ \store -> do+ let ops =+ [ (StreamName "f4-zzz", NoStream, [makeEvent "Z" (Aeson.object [])]),+ (StreamName "f4-mmm", NoStream, [makeEvent "M" (Aeson.object [])]),+ (StreamName "f4-aaa", NoStream, [makeEvent "A" (Aeson.object [])])+ ]+ Right results <- runStoreIO store $ appendMultiStream ops+ length results `shouldBe` 3+ let p0 = (results !! 0) ^. #globalPosition+ p1 = (results !! 1) ^. #globalPosition+ p2 = (results !! 2) ^. #globalPosition+ p0 `shouldSatisfy` (< p1)+ p1 `shouldSatisfy` (< p2)+ -- Read $all and confirm the events appear in user-supplied order.+ Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ let names = V.toList (V.map (^. #eventType) allEvts)+ names `shouldBe` [EventType "Z", EventType "M", EventType "A"]++ -- =================================================================+ -- Soft delete tests (M6.8)+ -- =================================================================+ describe "softDeleteStream" $ do+ it "soft-deletes a stream and getStream shows deletedAt" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "del-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-1")+ mId `shouldSatisfy` (/= Nothing)+ Right info <- runStoreIO store $ getStream (StreamName "del-1")+ case info of+ Just si -> (si ^. #deletedAt) `shouldSatisfy` (/= Nothing)+ Nothing -> expectationFailure "Expected stream info with deletedAt set"++ it "returns empty for reads from a soft-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "del-read") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-read")+ Right fwd <- runStoreIO store $ readStreamForward (StreamName "del-read") (StreamVersion 0) 100+ V.length fwd `shouldBe` 0+ Right bwd <- runStoreIO store $ readStreamBackward (StreamName "del-read") (StreamVersion 0) 100+ V.length bwd `shouldBe` 0++ it "rejects appends to a soft-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "del-append") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-append")+ result <- runStoreIO store $ appendToStream (StreamName "del-append") StreamExists [makeEvent "B" (Aeson.object [])]+ case result of+ Left (StreamNotFound _) -> pure ()+ other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)++ it "returns Nothing for nonexistent stream" $ \store -> do+ Right mId <- runStoreIO store $ softDeleteStream (StreamName "no-such")+ mId `shouldBe` Nothing++ it "returns Nothing for already-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "del-twice") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-twice")+ Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-twice")+ mId `shouldBe` Nothing++ it "events from soft-deleted stream still appear in $all" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "del-all") NoStream [makeEvent "KeepInAll" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-all")+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ let matching = V.filter (\e -> (e ^. #eventType) == EventType "KeepInAll") allEvents+ V.length matching `shouldBe` 1++ -- F2 regression — the protection against writes to a soft-deleted stream is+ -- enforced by a `deleted_at IS NULL` filter inside each append CTE, not by a+ -- pre-check that races concurrent soft-deletes. These tests exercise every+ -- append variant so a reader can see at a glance which constructor maps to+ -- which error after a soft-delete.+ it "ExactVersion append rejected against soft-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") NoStream [makeEvent "Init" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-exv")+ result <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])]+ case result of+ Left (WrongExpectedVersion _ _ _) -> pure ()+ Left (StreamNotFound _) -> pure ()+ other -> expectationFailure ("Expected WrongExpectedVersion or StreamNotFound, got: " <> show other)++ it "AnyVersion append rejected against soft-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") NoStream [makeEvent "Init" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-any")+ result <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") AnyVersion [makeEvent "X" (Aeson.object [])]+ case result of+ Left (StreamNotFound _) -> pure ()+ other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)++ it "NoStream append rejected against soft-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "Init" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-no")+ result <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "X" (Aeson.object [])]+ case result of+ Left (StreamAlreadyExists _) -> pure ()+ other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other)++ -- =================================================================+ -- Undelete tests (M6.8)+ -- =================================================================+ describe "undeleteStream" $ do+ it "restores a soft-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "undel-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-1")+ Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-1")+ mId `shouldSatisfy` (/= Nothing)+ Right info <- runStoreIO store $ getStream (StreamName "undel-1")+ case info of+ Just si -> (si ^. #deletedAt) `shouldBe` Nothing+ Nothing -> expectationFailure "Expected stream info"++ it "reads work after undelete" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "undel-read") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-read")+ Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-read")+ Right events <- runStoreIO store $ readStreamForward (StreamName "undel-read") (StreamVersion 0) 100+ V.length events `shouldBe` 1++ it "appends work after undelete" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "undel-append") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-append")+ Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-append")+ Right r <- runStoreIO store $ appendToStream (StreamName "undel-append") StreamExists [makeEvent "B" (Aeson.object [])]+ (r ^. #streamVersion) `shouldBe` StreamVersion 2++ it "returns Nothing for non-deleted stream" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "undel-noop") NoStream [makeEvent "A" (Aeson.object [])]+ Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-noop")+ mId `shouldBe` Nothing++ -- =================================================================+ -- Hard delete tests (M6.8)+ -- =================================================================+ describe "hardDeleteStream" $ do+ it "hard-deletes a stream completely" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "hard-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-1")+ mId `shouldSatisfy` (/= Nothing)+ Right info <- runStoreIO store $ getStream (StreamName "hard-1")+ info `shouldBe` Nothing++ it "events from hard-deleted stream no longer appear in $all" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "hard-all") NoStream [makeEvent "HardDelEvent" (Aeson.object [])]+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-all")+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ let matching = V.filter (\e -> (e ^. #eventType) == EventType "HardDelEvent") allEvents+ V.length matching `shouldBe` 0++ it "returns Nothing for nonexistent stream" $ \store -> do+ Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-no-such")+ mId `shouldBe` Nothing++ -- F6 regression — TRUNCATE on protected tables must be gated by the+ -- same GUC as DELETE. Without the BEFORE TRUNCATE triggers added in+ -- EP-1 F6, an operator could TRUNCATE events / stream_events / streams+ -- without setting kiroku.enable_hard_deletes, bypassing the row-level+ -- protect_deletion check.+ it "TRUNCATE on events is rejected without the GUC" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate") NoStream [makeEvent "X" (Aeson.object [])]+ rejected <- truncateRejected store "events"+ rejected `shouldBe` True++ it "TRUNCATE on stream_events is rejected without the GUC" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-se") NoStream [makeEvent "X" (Aeson.object [])]+ rejected <- truncateRejected store "stream_events"+ rejected `shouldBe` True++ it "TRUNCATE on streams is rejected without the GUC" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-s") NoStream [makeEvent "X" (Aeson.object [])]+ rejected <- truncateRejected store "streams"+ rejected `shouldBe` True++ -- F1 regression — events orphaned in `events` table after hard-delete.+ it "removes orphan event payloads from the events table" $ \store -> do+ let evts = map (\i -> makeEvent ("F1Orphan" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "f1-orphan") NoStream evts+ countBefore <- countEvents store+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-orphan")+ countAfter <- countEvents store+ (countBefore - countAfter) `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+ Right _ <- runStoreIO store $ appendToStream (StreamName "f1-keep-src") NoStream [makeEvent "Keep" (Aeson.object [])]+ Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f1-keep-src") (StreamVersion 0) 100+ let eid = V.head srcEvents ^. #eventId+ -- Append another stream that gets a different event so we can hard-delete keep-src+ -- without touching the linked stream's source. Then link f1-keep-src's event to a+ -- third stream we will leave alone.+ Right _ <- runStoreIO store $ appendToStream (StreamName "f1-other") NoStream [makeEvent "Other" (Aeson.object [])]+ -- Link via appendToStream + linkToStream to a different stream+ _ <- runStoreIO store $ linkToStream (StreamName "f1-keep-link") [eid]+ -- Hard-deleting f1-keep-src removes the source's events even though the link+ -- referenced them: link rows have original_stream_id = f1-keep-src's id, so they+ -- match the junction-delete WHERE clause. The contract is documented in F1's fix+ -- commit: hard-delete cascades through link junctions of the deleted stream's+ -- original events. (See SQL.hs hard-delete documentation.)+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-keep-src")+ -- f1-other's event must still exist (not affected at all).+ Right otherEvents <- runStoreIO store $ readStreamForward (StreamName "f1-other") (StreamVersion 0) 100+ V.length otherEvents `shouldBe` 1++ -- =================================================================+ -- Subscription tests (M7.7)+ -- =================================================================+ describe "subscribe" $ do+ it "catches up from position 0 on a store with existing events" $ \store -> do+ -- Append 10 events+ let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-1") NoStream events+ -- Wait until the EventPublisher has ingested all 10 events.+ waitForPublisher store (GlobalPosition 10)+ -- Subscribe from position 0+ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n >= 10+ then pure Stop+ else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "catchup-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 10++ it "receives live events appended after subscription starts" $ \store -> do+ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n >= 5+ then pure Stop+ else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "live-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ -- Give subscription time to start+ threadDelay 100_000+ -- Append 5 events+ let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "live-1") NoStream events+ -- Wait for subscription to complete+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 5++ it "persists checkpoint and resumes from saved position" $ \store -> do+ -- Append 10 events+ let events = map (\i -> makeEvent ("CP" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "ckpt-1") NoStream events+ waitForPublisher store (GlobalPosition 10)+ -- First subscription: process 5 events then stop+ countRef1 <- newTVarIO (0 :: Int)+ let handler1 _evt = do+ n <- atomically $ do+ c <- readTVar countRef1+ let c' = c + 1+ writeTVar countRef1 c'+ pure c'+ if n >= 5+ then pure Stop+ else pure Continue+ let cfg1 =+ SubscriptionConfig+ { name = SubscriptionName "ckpt-test",+ target = AllStreams,+ handler = handler1,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h1 <- subscribe store cfg1+ _ <- waitWithTimeout 10_000_000 h1+ -- Second subscription with same name: should resume from position 5+ ref2 <- newIORef ([] :: [RecordedEvent])+ countRef2 <- newTVarIO (0 :: Int)+ let handler2 evt = do+ modifyIORef' ref2 (evt :)+ n <- atomically $ do+ c <- readTVar countRef2+ let c' = c + 1+ writeTVar countRef2 c'+ pure c'+ if n >= 5+ then pure Stop+ else pure Continue+ let cfg2 =+ SubscriptionConfig+ { name = SubscriptionName "ckpt-test",+ target = AllStreams,+ handler = handler2,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h2 <- subscribe store cfg2+ _ <- waitWithTimeout 10_000_000 h2+ collected <- readIORef ref2+ -- Should receive events 6–10 only+ length collected `shouldBe` 5++ it "does not replay catch-up events when switching to all-stream live mode" $ \store -> do+ let seedEvents = map (\i -> makeEvent ("TransitionSeed" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "transition-seed") NoStream seedEvents+ waitForPublisher store (GlobalPosition 5)++ firstSeen <- newEmptyMVar+ releaseFirst <- newEmptyMVar+ countRef <- newTVarIO (0 :: Int)+ ref <- newIORef ([] :: [RecordedEvent])+ let stopType = EventType "TransitionStopAfterLive"+ handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n == 1+ then do+ putMVar firstSeen ()+ takeMVar releaseFirst+ else pure ()+ if evt ^. #eventType == stopType+ then pure Stop+ else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "transition-no-duplicates",+ target = AllStreams,+ handler = handler',+ batchSize = either (error . show) Prelude.id (mkBatchSize 2),+ queueCapacity = 32,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ takeMVar firstSeen++ mapM_+ ( \i -> do+ let sn = StreamName ("transition-during-" <> T.pack (show (i :: Int)))+ Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("TransitionDuring" <> T.pack (show i)) (Aeson.object [])]+ pure ()+ )+ [6 .. 10]+ waitForPublisher store (GlobalPosition 10)+ putMVar releaseFirst ()+ atomically $ do+ c <- readTVar countRef+ check (c >= 10)++ Right _ <- runStoreIO store $ appendToStream (StreamName "transition-stop") NoStream [makeEvent "TransitionStopAfterLive" (Aeson.object [])]+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()++ collected <- reverse <$> readIORef ref+ map (^. #globalPosition) collected `shouldBe` map GlobalPosition [1 .. 11]++ it "does not skip an event when cancelled before checkpoint save" $ \store -> do+ let events = map (\i -> makeEvent ("CancelReplay" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-replay") NoStream events+ waitForPublisher store (GlobalPosition 3)++ firstSeen <- newEmptyMVar+ block <- newEmptyMVar+ firstRef <- newIORef ([] :: [RecordedEvent])+ let handler1 evt = do+ modifyIORef' firstRef (evt :)+ putMVar firstSeen ()+ _ <- takeMVar block+ pure Continue+ cfg1 =+ SubscriptionConfig+ { name = SubscriptionName "cancel-replay-test",+ target = AllStreams,+ handler = handler1,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h1 <- subscribe store cfg1+ takeMVar firstSeen+ cancel h1+ _ <- waitWithTimeout 5_000_000 h1++ ref2 <- newIORef ([] :: [RecordedEvent])+ let handler2 evt = do+ modifyIORef' ref2 (evt :)+ pure Stop+ cfg2 =+ SubscriptionConfig+ { name = SubscriptionName "cancel-replay-test",+ target = AllStreams,+ handler = handler2,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h2 <- subscribe store cfg2+ result <- waitWithTimeout 10_000_000 h2+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ replayed <- reverse <$> readIORef ref2+ map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 1]++ it "saves checkpoints at Stop boundaries without skipping the next event" $ \store -> do+ let events = map (\i -> makeEvent ("StopBoundary" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "stop-boundary") NoStream events+ waitForPublisher store (GlobalPosition 5)++ countRef1 <- newTVarIO (0 :: Int)+ let handler1 _evt = do+ n <- atomically $ do+ c <- readTVar countRef1+ let c' = c + 1+ writeTVar countRef1 c'+ pure c'+ if n >= 3 then pure Stop else pure Continue+ cfg1 =+ SubscriptionConfig+ { name = SubscriptionName "stop-boundary-test",+ target = AllStreams,+ handler = handler1,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h1 <- subscribe store cfg1+ _ <- waitWithTimeout 10_000_000 h1++ ref2 <- newIORef ([] :: [RecordedEvent])+ countRef2 <- newTVarIO (0 :: Int)+ let handler2 evt = do+ modifyIORef' ref2 (evt :)+ n <- atomically $ do+ c <- readTVar countRef2+ let c' = c + 1+ writeTVar countRef2 c'+ pure c'+ if n >= 2 then pure Stop else pure Continue+ cfg2 =+ SubscriptionConfig+ { name = SubscriptionName "stop-boundary-test",+ target = AllStreams,+ handler = handler2,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h2 <- subscribe store cfg2+ result <- waitWithTimeout 10_000_000 h2+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ replayed <- reverse <$> readIORef ref2+ map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 4, GlobalPosition 5]++ -- F18 regression — Category subscriptions in live mode previously+ -- received unfiltered events from $all because `filterEvents` was+ -- a no-op for Category. The fix routes Category live-mode through+ -- a DB-driven loop that re-uses the SQL category filter. Before+ -- the fix, the handler would observe the UserNoise event below.+ it "delivers only category-matching events during live mode (F18)" $ \store -> do+ seedSeen <- newEmptyMVar+ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ -- After the catch-up seed event, signal that we are+ -- entering live mode. Subsequent appends exercise+ -- the live path under the new DB-driven loop.+ if n == 1+ then putMVar seedSeen ()+ else pure ()+ if n >= 3 then pure Stop else pure Continue+ -- Pre-seed an order event so catch-up fires the handler once.+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "order-seed")+ NoStream+ [makeEvent "OrderSeed" (Aeson.object [])]+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "f18-live-test",+ target = Category (CategoryName "order"),+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ -- Block until catch-up has delivered the seed event — the+ -- worker has finished catch-up and is about to enter live+ -- mode.+ takeMVar seedSeen+ -- Append a non-matching user event followed by two matching+ -- order events. Under the bug, UserNoise would slip through+ -- the live broadcast unfiltered.+ Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserNoise" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderTwo" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "order-3") NoStream [makeEvent "OrderThree" (Aeson.object [])]+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 3+ -- No UserNoise event should have been delivered.+ mapM_+ (\e -> (e ^. #eventType) `shouldNotBe` EventType "UserNoise")+ collected++ it "delivers only category-matching events during catch-up" $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 3)+ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n >= 2+ then pure Stop+ else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "cat-sub-test",+ target = Category (CategoryName "order"),+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ _ <- waitWithTimeout 10_000_000 handle+ collected <- readIORef ref+ length collected `shouldBe` 2+ -- All events should be order events+ mapM_ (\e -> (e ^. #eventType) `shouldSatisfy` (\(EventType t) -> T.isPrefixOf "Order" t)) collected++ it "preserves category subscription order under mixed invoice-payment writes" $ \store -> do+ let appendOne sn typ =+ runStoreIO store (appendToStream sn AnyVersion [makeEvent typ (Aeson.object [])]) >>= \result ->+ case result of+ Right _ -> pure ()+ Left err -> expectationFailure ("append failed: " <> show err)+ appendOne (StreamName "invoice-payment") "InvoicePaymentStarted"+ appendOne (StreamName "user-1") "UserNoiseOne"+ appendOne (StreamName "invoice-reminder") "InvoiceReminderSent"+ appendOne (StreamName "order-1") "OrderNoiseOne"+ appendOne (StreamName "invoice-payment") "InvoicePaymentFinished"+ appendOne (StreamName "user-2") "UserNoiseTwo"+ appendOne (StreamName "invoice-refund") "InvoiceRefundFinished"+ waitForPublisher store (GlobalPosition 7)++ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n >= 4 then pure Stop else pure Continue+ cfg =+ SubscriptionConfig+ { name = SubscriptionName "invoice-category-ordering",+ target = Category (CategoryName "invoice"),+ handler = handler',+ batchSize = either (error . show) Prelude.id (mkBatchSize 2),+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- reverse <$> readIORef ref+ map (^. #globalPosition) collected `shouldBe` [GlobalPosition 1, GlobalPosition 3, GlobalPosition 5, GlobalPosition 7]+ map (^. #eventType) collected+ `shouldBe` [ EventType "InvoicePaymentStarted",+ EventType "InvoiceReminderSent",+ EventType "InvoicePaymentFinished",+ EventType "InvoiceRefundFinished"+ ]++ it "cancels a running subscription cleanly" $ \store -> do+ let handler' _evt = pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "cancel-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ -- Give it time to start+ threadDelay 100_000+ cancel handle+ result <- waitWithTimeout 5_000_000 handle+ -- Should exit cleanly (AsyncCancelled or Right ())+ case result of+ Left _timeout -> expectationFailure "Cancel did not terminate in time"+ Right (Left _) -> pure () -- AsyncCancelled is expected+ Right (Right ()) -> pure ()++ it "receives events appended to an initially empty store" $ \store -> do+ ref <- newIORef ([] :: [RecordedEvent])+ let handler' evt = do+ modifyIORef' ref (evt :)+ pure Stop+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "empty-store-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ -- Give subscription time to start in live mode+ threadDelay 100_000+ -- Append one event+ Right _ <- runStoreIO store $ appendToStream (StreamName "empty-1") NoStream [makeEvent "First" (Aeson.object [])]+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 1++ it "handles rapid appends without losing events (debouncing)" $ \store -> do+ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n >= 50+ then pure Stop+ else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "debounce-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ -- Sized to absorb 50 publisher batches without+ -- backpressure overflow. The default 16 was+ -- intermittently overrun under load (the+ -- publisher emits one batch per append because+ -- there is no inter-append debounce window in+ -- this loop), surfacing as+ -- SubscriptionOverflowed in flaky CI runs.+ -- Coverage of the bounded-queue policy lives+ -- in the F6 overflow test below, not here.+ queueCapacity = 64,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ threadDelay 100_000+ -- Append 50 events rapidly to different streams+ mapM_+ ( \i -> do+ let sn = StreamName ("rapid-" <> T.pack (show (i :: Int)))+ Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("R" <> T.pack (show i)) (Aeson.object [])]+ pure ()+ )+ [1 .. 50]+ result <- waitWithTimeout 30_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 50++ -- F6 regression — the publisher's broadcast was an unbounded TChan;+ -- a slow subscriber's dupTChan grew without limit until the host+ -- ran out of memory. The fix replaces the broadcast with a+ -- per-subscriber bounded TBQueue plus an overflow policy. With+ -- DropSubscription, the publisher signals overflow on the+ -- subscriber's status TVar; the worker observes it on its next+ -- STM read and surfaces SubscriptionOverflowed via wait/waitCatch.+ it "surfaces SubscriptionOverflowed when a slow subscriber overruns its queue (F6)" $ \store -> do+ firstSeen <- newEmptyMVar+ release <- newEmptyMVar+ seenCount <- newTVarIO (0 :: Int)+ let handler' _evt = do+ n <- atomically $ do+ c <- readTVar seenCount+ let c' = c + 1+ writeTVar seenCount c'+ pure c'+ if n == 1+ then do+ putMVar firstSeen ()+ takeMVar release+ else pure ()+ pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "f6-overflow-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 1,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ -- This fixture requires a live handler; catch-up could otherwise+ -- consume the queued range before observing overflow.+ let awaitLive = do+ st <- currentState handle+ case st of+ Just Fsm.Live {} -> pure ()+ _ -> threadDelay 1_000 >> awaitLive+ Async.race (threadDelay 5_000_000) awaitLive >>= (`shouldBe` Right ())+ -- First append: triggers handler, which blocks on the release MVar.+ Right _ <- runStoreIO store $ appendToStream (StreamName "f6-1") NoStream [makeEvent "E1" (Aeson.object [])]+ takeMVar firstSeen+ -- While the worker is stuck inside the handler, append events one+ -- at a time and wait for the publisher to fetch each individually.+ -- Each append corresponds to a separate publisher batch written+ -- to the subscriber's queue. Capacity is 1, so the second+ -- write fills it and the third triggers DropSubscription.+ let waitForPub n = atomically $ do+ GlobalPosition p <- publisherPosition (store ^. #publisher)+ check (p >= n)+ let appendOne i = do+ let sn = StreamName ("f6-" <> T.pack (show (i :: Int)))+ Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]+ waitForPub (fromIntegral i)+ -- Append 4 more events with publisher synchronisation. Combined+ -- with the first event, that is 5 publisher batches.+ appendOne 2+ appendOne 3+ appendOne 4+ appendOne 5+ -- Release the handler. Worker exits processEvents and on the+ -- next STM read observes Overflowed.+ putMVar release ()+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got clean exit"+ Right (Left e) ->+ case Control.Exception.fromException e of+ Just (SubscriptionOverflowed sn) ->+ sn `shouldBe` SubscriptionName "f6-overflow-test"+ Nothing ->+ expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)++ ref2 <- newIORef ([] :: [RecordedEvent])+ countRef2 <- newTVarIO (0 :: Int)+ let handler2 evt = do+ modifyIORef' ref2 (evt :)+ n <- atomically $ do+ c <- readTVar countRef2+ let c' = c + 1+ writeTVar countRef2 c'+ pure c'+ if n >= 4 then pure Stop else pure Continue+ cfg2 =+ SubscriptionConfig+ { name = SubscriptionName "f6-overflow-test",+ target = AllStreams,+ handler = handler2,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ h2 <- subscribe store cfg2+ replayResult <- waitWithTimeout 10_000_000 h2+ case replayResult of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed after overflow restart: " <> show err)+ Right (Right ()) -> pure ()+ replayed <- reverse <$> readIORef ref2+ map (^. #globalPosition) replayed `shouldBe` map GlobalPosition [2 .. 5]++ it "catches up with an Eff-based handler via the effectful API" $ \store -> do+ -- Append 10 events+ let events = map (\i -> makeEvent ("Eff" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+ Right _ <- runStoreIO store $ appendToStream (StreamName "eff-sub-1") NoStream events+ waitForPublisher store (GlobalPosition 10)+ -- Subscribe using the effectful API with an Eff-based handler+ ref <- newIORef ([] :: [RecordedEvent])+ countRef <- newTVarIO (0 :: Int)+ result <- runEff . runErrorNoCallStack @StoreError . runStorePool store . runSubscription store $ do+ let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult+ effHandler evt = do+ liftIO $ modifyIORef' ref (evt :)+ n <- liftIO $ atomically $ do+ c <- readTVar countRef+ let c' = c + 1+ writeTVar countRef c'+ pure c'+ if n >= 10+ then pure Stop+ else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "eff-catchup-test",+ target = AllStreams,+ handler = effHandler,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- SubEff.subscribe cfg+ liftIO $ do+ r <- waitWithTimeout 10_000_000 handle+ case r of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ case result of+ Left err -> expectationFailure ("Store error: " <> show err)+ Right () -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 10++ -- =================================================================+ -- withSubscription bracket tests (EP-2 F25)+ -- =================================================================+ describe "withSubscription" $ do+ -- F25 regression — bracket cancels the worker on normal scope exit.+ it "cancels the worker on normal scope exit" $ \store -> do+ handleRef <- newIORef Nothing+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "withsub-normal",+ target = AllStreams,+ handler = \_ -> pure Continue,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ withSubscription store cfg $ \h -> do+ writeIORef handleRef (Just h)+ threadDelay 100_000+ Just h <- readIORef handleRef+ -- Worker must terminate now that the scope has exited.+ outcome <- Async.race (threadDelay 2_000_000) (wait h)+ case outcome of+ Left () -> expectationFailure "worker did not exit after withSubscription scope"+ Right _ -> pure ()++ -- F25 regression — bracket cancels the worker even when the body throws.+ it "cancels the worker when the body throws" $ \store -> do+ handleRef <- newIORef Nothing+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "withsub-throw",+ target = AllStreams,+ handler = \_ -> pure Continue,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ result <-+ Control.Exception.try @SomeException $+ withSubscription store cfg $ \h -> do+ writeIORef handleRef (Just h)+ threadDelay 100_000+ error "withSubscription body deliberately throws"+ case result of+ Left _ -> pure ()+ Right () -> expectationFailure "expected exception to propagate"+ Just h <- readIORef handleRef+ outcome <- Async.race (threadDelay 2_000_000) (wait h)+ case outcome of+ Left () -> expectationFailure "worker did not exit after exception in withSubscription body"+ Right _ -> pure ()++ -- =================================================================+ -- Pure helpers (no database fixture)+ -- =================================================================++ -- F1 regression — pure detail parser used by attributeMultiStreamError.+ -- The integration scenario that would exercise this code path+ -- (ix_streams_stream_name 23505 raised inside an appendMultiStream txn)+ -- is unreachable via current SQL because every append CTE uses ON CONFLICT+ -- DO NOTHING / DO UPDATE. The pure helper is unit-tested here so a future+ -- schema change that introduces such a path arrives with the attribution+ -- already correct.+ describe "extractStreamNameFromDetail" $ do+ it "extracts the stream name from a typical PostgreSQL detail" $ do+ extractStreamNameFromDetail "Key (stream_name)=(orders-1) already exists."+ `shouldBe` Just "orders-1"+ it "extracts a stream name containing dashes and digits" $ do+ extractStreamNameFromDetail "Key (stream_name)=(multi-c-2) already exists."+ `shouldBe` Just "multi-c-2"+ it "returns Nothing when the detail is empty" $ do+ extractStreamNameFromDetail "" `shouldBe` Nothing+ it "returns Nothing when the detail does not contain '=('" $ do+ extractStreamNameFromDetail "no key here" `shouldBe` Nothing+ 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++ -- The store retries an append once on these codes, so a caller only+ -- sees one when the conflict repeated. It must arrive as the retryable+ -- constructor: 'UnexpectedServerError' documents itself as "not+ -- generally retryable — investigate", which would send a caller to+ -- diagnose a condition whose correct handling is to retry.+ it "maps both transient SQLSTATEs to TransientTransactionFailure" $ do+ forM_ ["40001", "40P01"] $ \code -> do+ mapUsageError "orders-1" AnyVersion (serverUsage code)+ `shouldBe` TransientTransactionFailure code "server error"+ mapGenericUsageError (serverUsage code)+ `shouldBe` TransientTransactionFailure code "server error"+ mapTransactionUsageError (serverUsage code)+ `shouldBe` TransientTransactionFailure code "server error"+ mapLinkUsageError (StreamName "orders-1") (serverUsage code)+ `shouldBe` TransientTransactionFailure code "server error"+ attributeMultiStreamError+ [(StreamName "orders-1", AnyVersion)]+ (serverUsage code)+ `shouldBe` TransientTransactionFailure code "server error"++ it "leaves non-transient server codes on UnexpectedServerError" $ do+ mapGenericUsageError (serverUsage "42883")+ `shouldBe` UnexpectedServerError "42883" "server error"++ -- =================================================================+ -- Notifier reconnection tests (EP-3 F1)+ -- =================================================================+ describe "Notifier reconnection" $ do+ -- F1 regression — the listener loop reconnects on backend termination,+ -- and `stopNotifier` must release the *current* (post-reconnect)+ -- connection. Without the fix, the original Notifier.listenerConn was+ -- a frozen reference to the first conn and the reconnected conn leaked+ -- past `withStore` exit. We assert no kiroku-listener backend remains+ -- in pg_stat_activity after the store shuts down.+ it "releases the reconnected listener connection on shutdown" $ \() -> do+ n <- withMigratedTestDatabase $ \connStr -> do+ let settings = defaultConnectionSettings connStr+ withStore settings $ \store -> do+ pid1 <- waitForListenerPid (store ^. #pool) 5_000_000+ terminateBackend (store ^. #pool) pid1+ pid2 <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000+ pid2 `shouldNotBe` pid1+ -- After withStore exits, no kiroku-listener connection remains.+ listenerCount connStr+ n `shouldBe` (0 :: Int64)++ -- =================================================================+ -- Health monitoring tests (M6.8)+ -- =================================================================+ describe "observationHandler" $ do+ it "receives observations during store operations" $ \() -> do+ ref <- newIORef ([] :: [Observation])+ let handler obs = modifyIORef' ref (obs :)+ withTestStoreSettings+ (\settings -> settings {observationHandler = Just handler})+ $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "obs-test") NoStream [makeEvent "X" (Aeson.object [])]+ pure ()+ observations <- readIORef ref+ length observations `shouldSatisfy` (> 0)++ -- =================================================================+ -- KirokuEvent observation surface (EP-5 F1, F13, F14)+ -- =================================================================+ describe "KirokuEvent observation" $ do+ -- EP-5 F1 regression — terminating the listener backend triggers+ -- KirokuEventNotifierReconnecting then KirokuEventNotifierReconnected.+ -- Before EP-5 the reconnect path emitted no signal at all.+ it "emits notifier reconnect events on backend termination (F1)" $ \() -> do+ ref <- newIORef ([] :: [KirokuEvent])+ let evtHandler e = modifyIORef' ref (e :)+ withMigratedTestDatabase $ \connStr -> do+ let settings =+ defaultConnectionSettings connStr+ & #eventHandler .~ Just evtHandler+ withStore settings $ \store -> do+ pid1 <- waitForListenerPid (store ^. #pool) 5_000_000+ terminateBackend (store ^. #pool) pid1+ _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000+ pure ()+ evts <- readIORef ref+ let isReconnecting (KirokuEventNotifierReconnecting _ _) = True+ isReconnecting _ = False+ isReconnected KirokuEventNotifierReconnected = True+ isReconnected _ = False+ any isReconnecting evts `shouldBe` True+ any isReconnected evts `shouldBe` True++ -- EP-5 F14 regression — subscriptions emit started, caught-up, and+ -- stopped events. Before EP-5 the lifecycle was invisible.+ --+ -- Subscribe before appending so the worker enters catch-up at+ -- position 0, immediately reaches the publisher's position 0,+ -- emits CaughtUp, transitions to live mode, then receives the+ -- two appended events and stops.+ it "emits subscription lifecycle events (F14)" $ \() -> do+ ref <- newIORef ([] :: [KirokuEvent])+ let evtHandler e = modifyIORef' ref (e :)+ withTestStoreSettings+ (& #eventHandler .~ Just evtHandler)+ $ \store -> do+ countRef <- newTVarIO (0 :: Int)+ let h _ = do+ n <- atomically $ do+ c <- readTVar countRef+ writeTVar countRef (c + 1)+ pure (c + 1)+ if n >= 2 then pure Stop else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "lifecycle-test",+ target = AllStreams,+ handler = h,+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ -- Give the worker a moment to enter live mode.+ threadDelay 200_000+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "lifecycle-1") NoStream [makeEvent "X" (Aeson.object [])]+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "lifecycle-1") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])]+ _ <- waitWithTimeout 10_000_000 handle+ pure ()+ evts <- readIORef ref+ let isStarted (KirokuEventSubscriptionStarted (SubscriptionName "lifecycle-test") _ _) = True+ isStarted _ = False+ isCaughtUp (KirokuEventSubscriptionCaughtUp (SubscriptionName "lifecycle-test") _ _) = True+ isCaughtUp _ = False+ isStopped (KirokuEventSubscriptionStopped (SubscriptionName "lifecycle-test") _ StopHandlerRequested _) = True+ isStopped _ = False+ any isStarted evts `shouldBe` True+ any isCaughtUp evts `shouldBe` True+ any isStopped evts `shouldBe` True++ -- EP-5 F13 regression — hard-delete emits a fail-safe audit event.+ -- Before EP-5 there was no in-band audit signal.+ it "emits a hard-delete event when the stream existed (F13)" $ \() -> do+ ref <- newIORef ([] :: [KirokuEvent])+ let evtHandler e = modifyIORef' ref (e :)+ withTestStoreSettings+ (& #eventHandler .~ Just evtHandler)+ $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "hard-delete-evt") NoStream [makeEvent "X" (Aeson.object [])]+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-delete-evt")+ -- A hard-delete on a non-existent stream must not emit+ -- the event — verify by issuing a second one.+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "never-existed")+ pure ()+ evts <- readIORef ref+ let hardDeletes =+ [ name'+ | KirokuEventHardDeleteIssued (StreamName name') _ <- evts+ ]+ hardDeletes `shouldBe` ["hard-delete-evt"]
test/Test/BrowseQueryPlans.hs view
@@ -25,72 +25,74 @@ spec :: Spec spec = describe "browse reads prepared query work" $ do- around withTestStore $ it "bounds production generic and custom index seeks on a 40000-stream catalog" preparedChecks- it "uses the same bounded production statements in an English ICU database" $ withIcuStore $ \store -> do- preparedChecks store- Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]- Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]- let page = either (error . show) (\size -> size) (mkBrowsePageSize 11)- Right rows <- runStoreIO store $ listStreams (Just (CategoryName "orders")) (Just "orders-é") Nothing page- map (\row -> row ^. #name) (V.toList rows) `shouldBe` [StreamName "orders-éclair"]+ around withTestStore $ it "bounds production generic and custom index seeks on a 40000-stream catalog" preparedChecks+ it "uses the same bounded production statements in an English ICU database" $ withIcuStore $ \store -> do+ preparedChecks store+ Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]+ Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]+ let page = either (error . show) (\size -> size) (mkBrowsePageSize 11)+ Right rows <- runStoreIO store $ listStreams (Just (CategoryName "orders")) (Just "orders-é") Nothing page+ map (\row -> row ^. #name) (V.toList rows) `shouldBe` [StreamName "orders-éclair"] preparedChecks :: KirokuStore -> IO () preparedChecks store = do- seeded <-- Pool.use (store ^. #pool) $- Session.script- "INSERT INTO streams(stream_name) SELECT c || '-' || lpad(n::text,10,'0') FROM unnest(ARRAY['orders','noise']) c CROSS JOIN generate_series(1,20000) n; ANALYZE streams;"- seeded `shouldBe` Right ()- forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do- forM_ [(SQL.InclusiveLower, "'orders-', 'orders.', 11"), (SQL.ExclusiveLower, "'orders-0000019990', 'orders.', 11"), (SQL.InclusiveLower, "'absent', 'absenu', 11"), (SQL.InclusiveLower, "'', NULL, 11")] $ \(bound, args) -> do- plan <- explainPrepared store mode "text,text,int4" args (SQL.listStreamsRangeStmt bound)- expectBounded plan 11- plan <- explainPrepared store mode "text,text,text,text,int4" "'orders', NULL, 'orders-', 'orders.', 11" (SQL.listStreamsPairStmt SQL.ExactName SQL.InclusiveLower)- expectBounded plan 22- forM_ [(False, "NULL, 11"), (True, "'orders', 11")] $ \(hasCursor, args) -> do- categoryPlan <- explainPrepared store mode "text,int4" args (SQL.listCategoriesStmt hasCursor)- indexNames categoryPlan `shouldContain` ["ix_streams_category"]- -- Each loose-index seek returns at most one distinct category.- scannedRows categoryPlan `shouldSatisfy` (<= 12)+ seeded <-+ Pool.use (store ^. #pool) $+ Session.script+ "INSERT INTO streams(stream_name) SELECT c || '-' || lpad(n::text,10,'0') FROM unnest(ARRAY['orders','noise']) c CROSS JOIN generate_series(1,20000) n; ANALYZE streams;"+ seeded `shouldBe` Right ()+ forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do+ forM_ [(SQL.InclusiveLower, "'orders-', 'orders.', 11"), (SQL.ExclusiveLower, "'orders-0000019990', 'orders.', 11"), (SQL.InclusiveLower, "'absent', 'absenu', 11"), (SQL.InclusiveLower, "'', NULL, 11")] $ \(bound, args) -> do+ plan <- explainPrepared store mode "text,text,int4" args (SQL.listStreamsRangeStmt bound)+ expectBounded plan 11+ plan <- explainPrepared store mode "text,text,text,text,int4" "'orders', NULL, 'orders-', 'orders.', 11" (SQL.listStreamsPairStmt SQL.ExactName SQL.InclusiveLower)+ expectBounded plan 22+ forM_ [(False, "NULL, 11"), (True, "'orders', 11")] $ \(hasCursor, args) -> do+ categoryPlan <- explainPrepared store mode "text,int4" args (SQL.listCategoriesStmt hasCursor)+ indexNames categoryPlan `shouldContain` ["ix_streams_category"]+ -- Each loose-index seek returns at most one distinct category.+ scannedRows categoryPlan `shouldSatisfy` (<= 12) explainPrepared :: KirokuStore -> Text -> Text -> Text -> Statement p r -> IO Value explainPrepared store mode types args statement = do- result <- Pool.use (store ^. #pool) $ do- Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_browse(" <> types <> ") AS " <> Statement.toSql statement)- bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE mp13_browse(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))- Session.script "DEALLOCATE mp13_browse; RESET plan_cache_mode"- pure bytes- case result of- Left err -> expectationFailure (show err) >> fail "EXPLAIN failed"- Right bytes -> decodePlan bytes+ result <- Pool.use (store ^. #pool) $ do+ Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_browse(" <> types <> ") AS " <> Statement.toSql statement)+ bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE mp13_browse(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))+ Session.script "DEALLOCATE mp13_browse; RESET plan_cache_mode"+ pure bytes+ case result of+ Left err -> expectationFailure (show err) >> fail "EXPLAIN failed"+ Right bytes -> decodePlan bytes where decodePlan :: ByteString -> IO Value decodePlan bytes = either fail pure (Aeson.eitherDecodeStrict' bytes) expectBounded :: Value -> Double -> Expectation expectBounded plan budget = do- indexNames plan `shouldContain` ["ix_streams_browse_name"]- scannedRows plan `shouldSatisfy` (<= budget)- case plan of- Array entries- | Object entry <- V.head entries- , Just (Object top) <- KM.lookup "Plan" entry ->- number "Shared Hit Blocks" top + number "Shared Read Blocks" top `shouldSatisfy` (<= 64)- _ -> expectationFailure (show plan)+ indexNames plan `shouldContain` ["ix_streams_browse_name"]+ scannedRows plan `shouldSatisfy` (<= budget)+ case plan of+ Array entries+ | Object entry <- V.head entries,+ Just (Object top) <- KM.lookup "Plan" entry ->+ number "Shared Hit Blocks" top + number "Shared Read Blocks" top `shouldSatisfy` (<= 64)+ _ -> expectationFailure (show plan) -- Count rows examined at relation scans (including filters), not only results. scannedRows :: Value -> Double scannedRows (Object fields) = current + sum (map scannedRows (KM.elems fields)) where current = case KM.lookup "Relation Name" fields of- Just (String "streams") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields- _ -> 0+ Just (String "streams") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields+ _ -> 0 scannedRows (Array values) = sum (map scannedRows (foldr (:) [] values)) scannedRows _ = 0+ indexNames :: Value -> [Text] indexNames (Object fields) = [name | Just (String name) <- [KM.lookup "Index Name" fields]] <> concatMap indexNames (KM.elems fields) indexNames (Array values) = concatMap indexNames (foldr (:) [] values) indexNames _ = []+ number :: Aeson.Key -> Aeson.Object -> Double number key fields = case KM.lookup key fields of Just (Number n) -> realToFrac n; _ -> 0 @@ -98,12 +100,12 @@ -- rather than altering existing columns or using a different hand-written query. withIcuStore :: (KirokuStore -> IO ()) -> IO () withIcuStore action = withMigratedTestDatabase $ \connection ->- withStore (defaultConnectionSettings connection) $ \admin -> do- let sql command = Pool.use (admin ^. #pool) (Session.script command) >>= either (fail . show) pure- newConnection = T.unwords (filter (not . T.isPrefixOf "dbname=") (T.words connection) <> ["dbname=mp13_browse_icu"])- bracket_- (sql "CREATE DATABASE mp13_browse_icu TEMPLATE template0 LOCALE_PROVIDER icu ICU_LOCALE 'en'")- (sql "DROP DATABASE mp13_browse_icu")- $ do- migrateTestDatabase newConnection- withStore (defaultConnectionSettings newConnection) action+ withStore (defaultConnectionSettings connection) $ \admin -> do+ let sql command = Pool.use (admin ^. #pool) (Session.script command) >>= either (fail . show) pure+ newConnection = T.unwords (filter (not . T.isPrefixOf "dbname=") (T.words connection) <> ["dbname=mp13_browse_icu"])+ bracket_+ (sql "CREATE DATABASE mp13_browse_icu TEMPLATE template0 LOCALE_PROVIDER icu ICU_LOCALE 'en'")+ (sql "DROP DATABASE mp13_browse_icu")+ $ do+ migrateTestDatabase newConnection+ withStore (defaultConnectionSettings newConnection) action
test/Test/BrowseReads.hs view
@@ -22,73 +22,73 @@ seed :: KirokuStore -> [Text] -> IO () seed store = mapM_ $ \name -> do- result <- runStoreIO store $ appendToStream (StreamName name) NoStream [makeEvent "Created" (Aeson.object [])]- result `shouldSatisfy` either (const False) (const True)+ result <- runStoreIO store $ appendToStream (StreamName name) NoStream [makeEvent "Created" (Aeson.object [])]+ result `shouldSatisfy` either (const False) (const True) spec :: Spec spec = describe "browse reads" $ do- it "validates bounded page sizes" $ do- forM_ [minBound, -1, 0, 1002, maxBound] $ \n -> mkBrowsePageSize n `shouldBe` Left (InvalidBrowsePageSize n)- forM_ [1, 1000, 1001] $ \n -> fmap browsePageSizeValue (mkBrowsePageSize n) `shouldBe` Right (fromIntegral n)- around withTestStore $ do- it "excludes only the reserved all row and pages in UTF-8 byte order" $ \store -> do- runStoreIO store (listStreams Nothing Nothing Nothing (page 10)) `shouldReturn` Right V.empty- let values = ["orders-2", "orders", "orders-1", "$all-x", "singleton", "é-1", "Z-1", "-empty-category"]- seed store values- Right first <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 3)- names first `shouldBe` take 3 (sort values)- Right rest <- runStoreIO store $ listStreams Nothing Nothing (Just (StreamName (last (names first)))) (page 100)- names first <> names rest `shouldBe` sort values- runStoreIO store (listStreams Nothing Nothing (Just (StreamName (last (sort values)))) (page 10)) `shouldReturn` Right V.empty- it "intersects exact categories, literal prefixes and exclusive cursors" $ \store -> do- let maxScalar = T.singleton (chr 0x10ffff)- values = ["orders", "orders-1", "orders-2", "orders:other-1", "orders_-1", "orders%-1", "$all-x", "-a", "é-a", "é-a", maxScalar, maxScalar <> "-a", "\xD7FF-a", "\xE000-a"]- seed store values- forM_ [Nothing, Just (CategoryName "orders"), Just (CategoryName ""), Just (CategoryName "$all"), Just (CategoryName "not-a-category"), Just (CategoryName maxScalar)] $ \category ->- forM_ [Nothing, Just "", Just "orders", Just "orders-", Just "orders%", Just "orders_", Just "absent", Just "é", Just maxScalar, Just "\xD7FF"] $ \prefix ->- forM_ [Nothing, Just (StreamName "orders"), Just (StreamName "orders-1"), Just (StreamName "zzz")] $ \cursor -> do- let expected = sort [v | v <- values, maybe True (== categoryName (StreamName v)) category, maybe True (`T.isPrefixOf` v) prefix, maybe True (\(StreamName c) -> v > c) cursor]- Right actual <- runStoreIO store $ listStreams category prefix cursor (page 100)- names actual `shouldBe` expected- it "enumerates distinct categories including bare and empty categories" $ \store -> do- seed store ["orders", "orders-1", "orders-2", "$all-x", "-a", "singleton"]- Right first <- runStoreIO store $ listCategories Nothing (page 2)- V.toList first `shouldBe` [CategoryName "", CategoryName "$all"]- Right rest <- runStoreIO store $ listCategories (Just (V.last first)) (page 10)- V.toList rest `shouldBe` [CategoryName "orders", CategoryName "singleton"]- it "keeps soft-deleted summaries and removes hard-deleted summaries" $ \store -> do- seed store ["orders-soft", "orders-hard"]- void $ runStoreIO store $ softDeleteStream (StreamName "orders-soft")- void $ runStoreIO store $ setStreamTruncateBefore (StreamName "orders-soft") (StreamVersion 1)- void $ runStoreIO store $ hardDeleteStream (StreamName "orders-hard")- Right rows <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 10)- names rows `shouldBe` ["orders-soft"]- (V.head rows ^. #deletedAt) `shouldSatisfy` maybe False (const True)- it "returns the canonical global row once even when linked, then nothing after hard deletion" $ \store -> do- seed store ["orders-1"]- Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10- let event = V.head events- Right _ <- runStoreIO store $ linkToStream (StreamName "links-1") [event ^. #eventId]- runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right (Just event)- runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Right Nothing- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "orders-1")- runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right Nothing- it "applies the configured decode hook to event lookup" $ do- let marker = Aeson.object ["decoded" Aeson..= True]- hook event = pure (Right (event & #metadata .~ Just marker))- withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}) $ \store -> do- seed store ["orders-1"]- Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10- Right (Just event) <- runStoreIO store $ getEvent (V.head events ^. #eventId)- (event ^. #metadata) `shouldBe` Just marker+ it "validates bounded page sizes" $ do+ forM_ [minBound, -1, 0, 1002, maxBound] $ \n -> mkBrowsePageSize n `shouldBe` Left (InvalidBrowsePageSize n)+ forM_ [1, 1000, 1001] $ \n -> fmap browsePageSizeValue (mkBrowsePageSize n) `shouldBe` Right (fromIntegral n)+ around withTestStore $ do+ it "excludes only the reserved all row and pages in UTF-8 byte order" $ \store -> do+ runStoreIO store (listStreams Nothing Nothing Nothing (page 10)) `shouldReturn` Right V.empty+ let values = ["orders-2", "orders", "orders-1", "$all-x", "singleton", "é-1", "Z-1", "-empty-category"]+ seed store values+ Right first <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 3)+ names first `shouldBe` take 3 (sort values)+ Right rest <- runStoreIO store $ listStreams Nothing Nothing (Just (StreamName (last (names first)))) (page 100)+ names first <> names rest `shouldBe` sort values+ runStoreIO store (listStreams Nothing Nothing (Just (StreamName (last (sort values)))) (page 10)) `shouldReturn` Right V.empty+ it "intersects exact categories, literal prefixes and exclusive cursors" $ \store -> do+ let maxScalar = T.singleton (chr 0x10ffff)+ values = ["orders", "orders-1", "orders-2", "orders:other-1", "orders_-1", "orders%-1", "$all-x", "-a", "é-a", "é-a", maxScalar, maxScalar <> "-a", "\xD7FF-a", "\xE000-a"]+ seed store values+ forM_ [Nothing, Just (CategoryName "orders"), Just (CategoryName ""), Just (CategoryName "$all"), Just (CategoryName "not-a-category"), Just (CategoryName maxScalar)] $ \category ->+ forM_ [Nothing, Just "", Just "orders", Just "orders-", Just "orders%", Just "orders_", Just "absent", Just "é", Just maxScalar, Just "\xD7FF"] $ \prefix ->+ forM_ [Nothing, Just (StreamName "orders"), Just (StreamName "orders-1"), Just (StreamName "zzz")] $ \cursor -> do+ let expected = sort [v | v <- values, maybe True (== categoryName (StreamName v)) category, maybe True (`T.isPrefixOf` v) prefix, maybe True (\(StreamName c) -> v > c) cursor]+ Right actual <- runStoreIO store $ listStreams category prefix cursor (page 100)+ names actual `shouldBe` expected+ it "enumerates distinct categories including bare and empty categories" $ \store -> do+ seed store ["orders", "orders-1", "orders-2", "$all-x", "-a", "singleton"]+ Right first <- runStoreIO store $ listCategories Nothing (page 2)+ V.toList first `shouldBe` [CategoryName "", CategoryName "$all"]+ Right rest <- runStoreIO store $ listCategories (Just (V.last first)) (page 10)+ V.toList rest `shouldBe` [CategoryName "orders", CategoryName "singleton"]+ it "keeps soft-deleted summaries and removes hard-deleted summaries" $ \store -> do+ seed store ["orders-soft", "orders-hard"]+ void $ runStoreIO store $ softDeleteStream (StreamName "orders-soft")+ void $ runStoreIO store $ setStreamTruncateBefore (StreamName "orders-soft") (StreamVersion 1)+ void $ runStoreIO store $ hardDeleteStream (StreamName "orders-hard")+ Right rows <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 10)+ names rows `shouldBe` ["orders-soft"]+ (V.head rows ^. #deletedAt) `shouldSatisfy` maybe False (const True)+ it "returns the canonical global row once even when linked, then nothing after hard deletion" $ \store -> do+ seed store ["orders-1"]+ Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+ let event = V.head events+ Right _ <- runStoreIO store $ linkToStream (StreamName "links-1") [event ^. #eventId]+ runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right (Just event)+ runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Right Nothing+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "orders-1")+ runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right Nothing+ it "applies the configured decode hook to event lookup" $ do+ let marker = Aeson.object ["decoded" Aeson..= True]+ hook event = pure (Right (event & #metadata .~ Just marker))+ withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}) $ \store -> do+ seed store ["orders-1"]+ Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+ Right (Just event) <- runStoreIO store $ getEvent (V.head events ^. #eventId)+ (event ^. #metadata) `shouldBe` Just marker - it "propagates typed decode failure from event lookup" $ do- let hook event = pure (Left (DecodeFailure (event ^. #eventId) "failure"))- withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}) $ \store -> do- seed store ["orders-1"]- Right rows <- runStoreIO store $ readStreamForward (StreamName "missing") (StreamVersion 0) 1- rows `shouldBe` V.empty- -- Use the append-provided id to avoid decoding a fixture through the failing hook.- let event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just (EventId UUID.nil)- Right _ <- runStoreIO store $ appendToStream (StreamName "orders-2") NoStream [event]- runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Left (EventDecodeFailed (DecodeFailure (EventId UUID.nil) "failure"))+ it "propagates typed decode failure from event lookup" $ do+ let hook event = pure (Left (DecodeFailure (event ^. #eventId) "failure"))+ withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}) $ \store -> do+ seed store ["orders-1"]+ Right rows <- runStoreIO store $ readStreamForward (StreamName "missing") (StreamVersion 0) 1+ rows `shouldBe` V.empty+ -- Use the append-provided id to avoid decoding a fixture through the failing hook.+ let event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just (EventId UUID.nil)+ Right _ <- runStoreIO store $ appendToStream (StreamName "orders-2") NoStream [event]+ runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Left (EventDecodeFailed (DecodeFailure (EventId UUID.nil) "failure"))
test/Test/BrowseReadsMock.hs view
@@ -13,24 +13,24 @@ spec :: Spec spec = describe "browse reads mock" $ it "dispatches each public wrapper exactly once with its parameters" $ do- calls <- newIORef ([] :: [String])- let page = either (error . show) (\size -> size) (mkBrowsePageSize 7)- runner = interpret_ $ \case- ListStreams category prefix cursor size -> do- liftIO $ (category, prefix, cursor, size) `shouldBe` (Just (CategoryName "orders"), Just "orders-", Just (StreamName "orders-1"), page)- liftIO $ modifyIORef' calls (<> ["streams"])- pure V.empty- ListCategories cursor size -> do- liftIO $ (cursor, size) `shouldBe` (Just (CategoryName "orders"), page)- liftIO $ modifyIORef' calls (<> ["categories"])- pure V.empty- GetEvent eid -> do- liftIO $ eid `shouldBe` EventId UUID.nil- liftIO $ modifyIORef' calls (<> ["event"])- pure Nothing- _ -> error "unexpected browse mock operation"- _ <- runEff $ runner $ do- _ <- listStreams (Just (CategoryName "orders")) (Just "orders-") (Just (StreamName "orders-1")) page- _ <- listCategories (Just (CategoryName "orders")) page- getEvent (EventId UUID.nil)- readIORef calls `shouldReturn` ["streams", "categories", "event"]+ calls <- newIORef ([] :: [String])+ let page = either (error . show) (\size -> size) (mkBrowsePageSize 7)+ runner = interpret_ $ \case+ ListStreams category prefix cursor size -> do+ liftIO $ (category, prefix, cursor, size) `shouldBe` (Just (CategoryName "orders"), Just "orders-", Just (StreamName "orders-1"), page)+ liftIO $ modifyIORef' calls (<> ["streams"])+ pure V.empty+ ListCategories cursor size -> do+ liftIO $ (cursor, size) `shouldBe` (Just (CategoryName "orders"), page)+ liftIO $ modifyIORef' calls (<> ["categories"])+ pure V.empty+ GetEvent eid -> do+ liftIO $ eid `shouldBe` EventId UUID.nil+ liftIO $ modifyIORef' calls (<> ["event"])+ pure Nothing+ _ -> error "unexpected browse mock operation"+ _ <- runEff $ runner $ do+ _ <- listStreams (Just (CategoryName "orders")) (Just "orders-") (Just (StreamName "orders-1")) page+ _ <- listCategories (Just (CategoryName "orders")) page+ getEvent (EventId UUID.nil)+ readIORef calls `shouldReturn` ["streams", "categories", "event"]
test/Test/CatchupDbErrorNoPrematureSwitch.hs view
@@ -12,91 +12,91 @@ import Kiroku.Store import Kiroku.Store.Subscription.Types qualified as SubTypes import Kiroku.Store.Subscription.Worker (withFetchBatchHookForTest)-import Test.Helpers (- caughtUpEventHandler,+import Test.Helpers+ ( caughtUpEventHandler, makeEvent, waitForPublisher, waitForSubscriptionLive, withTestStoreSettings,- )+ ) import Test.Hspec spec :: Spec spec =- describe "catch-up DB error handling" $- it "retries a transient fetch error instead of switching to live mode at a stale cursor" $ \() -> do- deliveredRef <- newIORef ([] :: [GlobalPosition])- failedOnce <- newIORef False- eventRef <- newIORef ([] :: [KirokuEvent])- seenCount <- newTVarIO (0 :: Int)- caughtUp <- newEmptyMVar+ describe "catch-up DB error handling" $+ it "retries a transient fetch error instead of switching to live mode at a stale cursor" $ \() -> do+ deliveredRef <- newIORef ([] :: [GlobalPosition])+ failedOnce <- newIORef False+ eventRef <- newIORef ([] :: [KirokuEvent])+ seenCount <- newTVarIO (0 :: Int)+ caughtUp <- newEmptyMVar - let subName = SubscriptionName "catchup-db-error-no-gap"- targetTail = GlobalPosition 3- handler' evt = do- modifyIORef' deliveredRef ((evt ^. #globalPosition) :)- atomically $ do- n <- readTVar seenCount- writeTVar seenCount (n + 1)- pure Continue- cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- observe evt = do- modifyIORef' eventRef (evt :)- caughtUpEventHandler subName caughtUp Nothing evt- injectOnce config cursor- | SubTypes.name config == subName && cursor == GlobalPosition 0 = do- alreadyFailed <- readIORef failedOnce- if alreadyFailed- then pure Nothing- else do- writeIORef failedOnce True- pure (Just (Left Pool.AcquisitionTimeoutUsageError))- | otherwise = pure Nothing+ let subName = SubscriptionName "catchup-db-error-no-gap"+ targetTail = GlobalPosition 3+ handler' evt = do+ modifyIORef' deliveredRef ((evt ^. #globalPosition) :)+ atomically $ do+ n <- readTVar seenCount+ writeTVar seenCount (n + 1)+ pure Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ observe evt = do+ modifyIORef' eventRef (evt :)+ caughtUpEventHandler subName caughtUp Nothing evt+ injectOnce config cursor+ | SubTypes.name config == subName && cursor == GlobalPosition 0 = do+ alreadyFailed <- readIORef failedOnce+ if alreadyFailed+ then pure Nothing+ else do+ writeIORef failedOnce True+ pure (Just (Left Pool.AcquisitionTimeoutUsageError))+ | otherwise = pure Nothing - withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-1") NoStream [makeEvent "One" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-2") NoStream [makeEvent "Two" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-3") NoStream [makeEvent "Three" (Aeson.object [])]- waitForPublisher store targetTail+ withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-1") NoStream [makeEvent "One" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-2") NoStream [makeEvent "Two" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-3") NoStream [makeEvent "Three" (Aeson.object [])]+ waitForPublisher store targetTail - withFetchBatchHookForTest injectOnce $ do- handle <- subscribe store cfg- waitForSubscriptionLive caughtUp- atomically $ do- n <- readTVar seenCount- check (n >= 3)- cancel handle+ withFetchBatchHookForTest injectOnce $ do+ handle <- subscribe store cfg+ waitForSubscriptionLive caughtUp+ atomically $ do+ n <- readTVar seenCount+ check (n >= 3)+ cancel handle - delivered <- readIORef deliveredRef- List.sort delivered `shouldBe` [GlobalPosition 1, GlobalPosition 2, GlobalPosition 3]+ delivered <- readIORef deliveredRef+ List.sort delivered `shouldBe` [GlobalPosition 1, GlobalPosition 2, GlobalPosition 3] - observed <- readIORef eventRef- let isFetchDbError = \case- KirokuEventSubscriptionDbError n FetchBatch Pool.AcquisitionTimeoutUsageError _- | n == subName -> True- _ -> False- caughtUpPositions =- [ pos- | KirokuEventSubscriptionCaughtUp n pos _ <- observed- , n == subName- ]+ observed <- readIORef eventRef+ let isFetchDbError = \case+ KirokuEventSubscriptionDbError n FetchBatch Pool.AcquisitionTimeoutUsageError _+ | n == subName -> True+ _ -> False+ caughtUpPositions =+ [ pos+ | KirokuEventSubscriptionCaughtUp n pos _ <- observed,+ n == subName+ ] - any isFetchDbError observed `shouldBe` True- V.fromList caughtUpPositions `shouldBe` V.singleton targetTail+ any isFetchDbError observed `shouldBe` True+ V.fromList caughtUpPositions `shouldBe` V.singleton targetTail
test/Test/Category.hs view
@@ -1,14 +1,13 @@-{- | 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'.--}+-- | 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)@@ -20,17 +19,15 @@ 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.--}+-- | 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.--}+-- | 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@@ -44,27 +41,27 @@ 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"+ 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 "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 "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))+ 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 "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+ 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/CategoryIdleNoSpin.hs view
@@ -1,41 +1,40 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for ExecPlan 37 — the live DB-driven subscription loops must-not busy-spin when idle while the store's global @$all@ position advances for-/other/ categories or partitions.--Two paths are covered:-- * Non-group @Category@ subscription- ('Kiroku.Store.Subscription.Worker.liveLoopCategoryNotify'): an idle category- blocks on its own per-category NOTIFY generation and does __zero__ database- fetches while a different category receives sustained traffic. A real append- to the subscribed category still wakes it (liveness).- * Consumer-group members of a @Category@ (plan 91 M5): they share the plain- category loop and its per-category generation, so both members of an idle- category do __zero__ fetches while another category is busy, and an append- to the category wakes them and the owning member delivers it.- * Consumer-group member of @AllStreams@- ('Kiroku.Store.Subscription.Worker.liveLoopDbDriven', corrected gate): the- member gates on the /last observed global position/ rather than its- per-partition cursor, so it wakes at most once per global advance — a bounded- number of fetches — instead of the unbounded spin the original cursor-gate- produced.--The deterministic signal is the additive-'Kiroku.Store.Observability.KirokuEventSubscriptionFetched' event, emitted once-per live DB-driven fetch. The store's @eventHandler@ counts those whose-'SubscriptionName' matches the subscriber under test; the subscription's own-handler counts deliveries. (@pg_stat_statements@ is not available in this suite —-see the plan's Decision Log.)--To confirm these specs actually pin the regression: temporarily restore the old-cursor-gated 'liveLoopDbDriven' body and route @Category@ subscriptions back-through it — the idle fetch counts then explode and the assertions fail. Routing-only @(Just _, Category{})@ back through the corrected 'liveLoopDbDriven' fails-the zero-fetch assertion for group category members.--}+-- | Regression tests for ExecPlan 37 — the live DB-driven subscription loops must+-- not busy-spin when idle while the store's global @$all@ position advances for+-- /other/ categories or partitions.+--+-- Two paths are covered:+--+-- * Non-group @Category@ subscription+-- ('Kiroku.Store.Subscription.Worker.liveLoopCategoryNotify'): an idle category+-- blocks on its own per-category NOTIFY generation and does __zero__ database+-- fetches while a different category receives sustained traffic. A real append+-- to the subscribed category still wakes it (liveness).+-- * Consumer-group members of a @Category@ (plan 91 M5): they share the plain+-- category loop and its per-category generation, so both members of an idle+-- category do __zero__ fetches while another category is busy, and an append+-- to the category wakes them and the owning member delivers it.+-- * Consumer-group member of @AllStreams@+-- ('Kiroku.Store.Subscription.Worker.liveLoopDbDriven', corrected gate): the+-- member gates on the /last observed global position/ rather than its+-- per-partition cursor, so it wakes at most once per global advance — a bounded+-- number of fetches — instead of the unbounded spin the original cursor-gate+-- produced.+--+-- The deterministic signal is the additive+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionFetched' event, emitted once+-- per live DB-driven fetch. The store's @eventHandler@ counts those whose+-- 'SubscriptionName' matches the subscriber under test; the subscription's own+-- handler counts deliveries. (@pg_stat_statements@ is not available in this suite —+-- see the plan's Decision Log.)+--+-- To confirm these specs actually pin the regression: temporarily restore the old+-- cursor-gated 'liveLoopDbDriven' body and route @Category@ subscriptions back+-- through it — the idle fetch counts then explode and the assertions fail. Routing+-- only @(Just _, Category{})@ back through the corrected 'liveLoopDbDriven' fails+-- the zero-fetch assertion for group category members. module Test.CategoryIdleNoSpin (spec) where import Control.Concurrent (threadDelay)@@ -53,162 +52,162 @@ -- | Poll an @IO Bool@ predicate until it holds or the microsecond budget runs out. waitUntil :: Int -> IO Bool -> IO () waitUntil budget act- | budget <= 0 = pure ()- | otherwise = do- ok <- act- if ok- then pure ()- else do- threadDelay 20_000- waitUntil (budget - 20_000) act+ | budget <= 0 = pure ()+ | otherwise = do+ ok <- act+ if ok+ then pure ()+ else do+ threadDelay 20_000+ waitUntil (budget - 20_000) act -- | Append one event to each named stream, failing the test on a store error. appendEach :: KirokuStore -> [T.Text] -> T.Text -> IO () appendEach store names typ =- mapM_- ( \sn -> do- r <- runStoreIO store $ appendToStream (StreamName sn) NoStream [makeEvent typ (Aeson.object [])]- either (error . ("append failed: " <>) . show) (const (pure ())) r- )- names+ mapM_+ ( \sn -> do+ r <- runStoreIO store $ appendToStream (StreamName sn) NoStream [makeEvent typ (Aeson.object [])]+ either (error . ("append failed: " <>) . show) (const (pure ())) r+ )+ names spec :: Spec spec = describe "live loops do not busy-spin while idle (plan 37)" $ do- it "an idle Category subscriber does zero fetches while another category is active, then wakes on its own event" $ do- let subName = SubscriptionName "alpha-sub"- deliveredVar <- newTVarIO (0 :: Int)- fetchVar <- newTVarIO (0 :: Int)- liveBarrier <- newEmptyMVar- let countFetch evt = case evt of- KirokuEventSubscriptionFetched n _ _- | n == subName -> atomically (modifyTVar' fetchVar (+ 1))- _ -> pure ()- obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)- withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do- let deliver _evt = do- atomically (modifyTVar' deliveredVar (+ 1))- pure Continue- cfg = defaultSubscriptionConfig subName (Category (CategoryName "alpha")) deliver- bracket (subscribe store cfg) cancel $ \_handle -> do- -- Wait until the worker reaches live mode and completes its initial- -- post-catch-up drain (one empty fetch), so the baseline is settled.- waitForSubscriptionLive liveBarrier- waitUntil 5_000_000 ((>= 1) <$> readTVarIO fetchVar)- base <- readTVarIO fetchVar+ it "an idle Category subscriber does zero fetches while another category is active, then wakes on its own event" $ do+ let subName = SubscriptionName "alpha-sub"+ deliveredVar <- newTVarIO (0 :: Int)+ fetchVar <- newTVarIO (0 :: Int)+ liveBarrier <- newEmptyMVar+ let countFetch evt = case evt of+ KirokuEventSubscriptionFetched n _ _+ | n == subName -> atomically (modifyTVar' fetchVar (+ 1))+ _ -> pure ()+ obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)+ withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do+ let deliver _evt = do+ atomically (modifyTVar' deliveredVar (+ 1))+ pure Continue+ cfg = defaultSubscriptionConfig subName (Category (CategoryName "alpha")) deliver+ bracket (subscribe store cfg) cancel $ \_handle -> do+ -- Wait until the worker reaches live mode and completes its initial+ -- post-catch-up drain (one empty fetch), so the baseline is settled.+ waitForSubscriptionLive liveBarrier+ waitUntil 5_000_000 ((>= 1) <$> readTVarIO fetchVar)+ base <- readTVarIO fetchVar - -- Drive a DIFFERENT category hard: the global tail races far ahead of- -- alpha's cursor. Under the old gate alpha woke per global tick and- -- spun one empty fetch each time; under the fix it never wakes (its- -- category generation is untouched).- let betaCount = 20 :: Int- betaStreams = ["beta-" <> T.pack (show i) | i <- [1 .. betaCount]]- appendEach store betaStreams "Beta"- waitForPublisher store (GlobalPosition (fromIntegral betaCount))- -- Give a (hypothetical) spinning worker ample time to accumulate fetches.- threadDelay 500_000+ -- Drive a DIFFERENT category hard: the global tail races far ahead of+ -- alpha's cursor. Under the old gate alpha woke per global tick and+ -- spun one empty fetch each time; under the fix it never wakes (its+ -- category generation is untouched).+ let betaCount = 20 :: Int+ betaStreams = ["beta-" <> T.pack (show i) | i <- [1 .. betaCount]]+ appendEach store betaStreams "Beta"+ waitForPublisher store (GlobalPosition (fromIntegral betaCount))+ -- Give a (hypothetical) spinning worker ample time to accumulate fetches.+ threadDelay 500_000 - afterIdle <- readTVarIO fetchVar- deliveredIdle <- readTVarIO deliveredVar- (afterIdle - base) `shouldBe` 0- deliveredIdle `shouldBe` 0+ afterIdle <- readTVarIO fetchVar+ deliveredIdle <- readTVarIO deliveredVar+ (afterIdle - base) `shouldBe` 0+ deliveredIdle `shouldBe` 0 - -- Liveness: a real append to alpha must wake the worker and deliver.- appendEach store ["alpha-1"] "Alpha"- waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)- deliveredFinal <- readTVarIO deliveredVar- deliveredFinal `shouldBe` 1+ -- Liveness: a real append to alpha must wake the worker and deliver.+ appendEach store ["alpha-1"] "Alpha"+ waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)+ deliveredFinal <- readTVarIO deliveredVar+ deliveredFinal `shouldBe` 1 - it "idle consumer-group category members do zero fetches while another category is active, then the owner wakes on its own event" $ do- -- Two members (0 and 1 of size 2) over category "quiet". Each runs under- -- its own subscription name so each has its own live barrier and fetch- -- counter; the partition predicate depends only on (member, size).- let names = [SubscriptionName "quiet-grp-0", SubscriptionName "quiet-grp-1"]- deliveredVar <- newTVarIO (0 :: Int)- fetchVars <- mapM (const (newTVarIO (0 :: Int))) names- barriers <- mapM (const newEmptyMVar) names- let countFetch evt = case evt of- KirokuEventSubscriptionFetched n _ _ ->- sequence_- [ atomically (modifyTVar' v (+ 1))- | (subName, v) <- zip names fetchVars- , n == subName- ]- _ -> pure ()- obsHandler evt = do- sequence_ [caughtUpEventHandler subName barrier Nothing evt | (subName, barrier) <- zip names barriers]- countFetch evt- deliver _evt = do- atomically (modifyTVar' deliveredVar (+ 1))- pure Continue- memberConfig subName member =- (defaultSubscriptionConfig subName (Category (CategoryName "quiet")) deliver)- { consumerGroup = Just (validConsumerGroup member 2)- }- withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store ->- bracket (subscribe store (memberConfig (names !! 0) 0)) cancel $ \_ ->- bracket (subscribe store (memberConfig (names !! 1) 1)) cancel $ \_ -> do- -- Settle: each member reaches live mode and finishes its initial- -- post-catch-up drain (one empty fetch).- mapM_ waitForSubscriptionLive barriers- mapM_ (\v -> waitUntil 5_000_000 ((>= 1) <$> readTVarIO v)) fetchVars- bases <- mapM readTVarIO fetchVars+ it "idle consumer-group category members do zero fetches while another category is active, then the owner wakes on its own event" $ do+ -- Two members (0 and 1 of size 2) over category "quiet". Each runs under+ -- its own subscription name so each has its own live barrier and fetch+ -- counter; the partition predicate depends only on (member, size).+ let names = [SubscriptionName "quiet-grp-0", SubscriptionName "quiet-grp-1"]+ deliveredVar <- newTVarIO (0 :: Int)+ fetchVars <- mapM (const (newTVarIO (0 :: Int))) names+ barriers <- mapM (const newEmptyMVar) names+ let countFetch evt = case evt of+ KirokuEventSubscriptionFetched n _ _ ->+ sequence_+ [ atomically (modifyTVar' v (+ 1))+ | (subName, v) <- zip names fetchVars,+ n == subName+ ]+ _ -> pure ()+ obsHandler evt = do+ sequence_ [caughtUpEventHandler subName barrier Nothing evt | (subName, barrier) <- zip names barriers]+ countFetch evt+ deliver _evt = do+ atomically (modifyTVar' deliveredVar (+ 1))+ pure Continue+ memberConfig subName member =+ (defaultSubscriptionConfig subName (Category (CategoryName "quiet")) deliver)+ { consumerGroup = Just (validConsumerGroup member 2)+ }+ withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store ->+ bracket (subscribe store (memberConfig (names !! 0) 0)) cancel $ \_ ->+ bracket (subscribe store (memberConfig (names !! 1) 1)) cancel $ \_ -> do+ -- Settle: each member reaches live mode and finishes its initial+ -- post-catch-up drain (one empty fetch).+ mapM_ waitForSubscriptionLive barriers+ mapM_ (\v -> waitUntil 5_000_000 ((>= 1) <$> readTVarIO v)) fetchVars+ bases <- mapM readTVarIO fetchVars - -- Drive a different category. Before plan 91 M5 each member woke on- -- every global advance and ran one empty fetch per wake; now both- -- wait on the "quiet" category's generation, which never moves.- let busyCount = 20 :: Int- busyStreams = ["busy-" <> T.pack (show i) | i <- [1 .. busyCount]]- appendEach store busyStreams "Busy"- waitForPublisher store (GlobalPosition (fromIntegral busyCount))- threadDelay 500_000+ -- Drive a different category. Before plan 91 M5 each member woke on+ -- every global advance and ran one empty fetch per wake; now both+ -- wait on the "quiet" category's generation, which never moves.+ let busyCount = 20 :: Int+ busyStreams = ["busy-" <> T.pack (show i) | i <- [1 .. busyCount]]+ appendEach store busyStreams "Busy"+ waitForPublisher store (GlobalPosition (fromIntegral busyCount))+ threadDelay 500_000 - afterIdle <- mapM readTVarIO fetchVars- zipWith (-) afterIdle bases `shouldBe` [0, 0]- readTVarIO deliveredVar `shouldReturn` 0+ afterIdle <- mapM readTVarIO fetchVars+ zipWith (-) afterIdle bases `shouldBe` [0, 0]+ readTVarIO deliveredVar `shouldReturn` 0 - -- Liveness: an append to the quiet category wakes both members;- -- exactly the owner of "quiet-1" delivers it.- appendEach store ["quiet-1"] "Quiet"- waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)- threadDelay 200_000- readTVarIO deliveredVar `shouldReturn` 1+ -- Liveness: an append to the quiet category wakes both members;+ -- exactly the owner of "quiet-1" delivers it.+ appendEach store ["quiet-1"] "Quiet"+ waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)+ threadDelay 200_000+ readTVarIO deliveredVar `shouldReturn` 1 - it "an idle AllStreams consumer-group member wakes a bounded number of times, not a busy spin" $ do- let subName = SubscriptionName "grp-all-sub"- deliveredVar <- newTVarIO (0 :: Int)- fetchVar <- newTVarIO (0 :: Int)- liveBarrier <- newEmptyMVar- let countFetch evt = case evt of- KirokuEventSubscriptionFetched n _ _- | n == subName -> atomically (modifyTVar' fetchVar (+ 1))- _ -> pure ()- obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)- withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do- let deliver _evt = do- atomically (modifyTVar' deliveredVar (+ 1))- pure Continue- -- Member 0 of 3 over $all. Its fetches apply the partition- -- predicate, so it receives only its slice of the flood, but every- -- append advances the global position it gates on.- cfg =- (defaultSubscriptionConfig subName AllStreams deliver)- { consumerGroup = Just (validConsumerGroup 0 3)- }- bracket (subscribe store cfg) cancel $ \_handle -> do- waitForSubscriptionLive liveBarrier+ it "an idle AllStreams consumer-group member wakes a bounded number of times, not a busy spin" $ do+ let subName = SubscriptionName "grp-all-sub"+ deliveredVar <- newTVarIO (0 :: Int)+ fetchVar <- newTVarIO (0 :: Int)+ liveBarrier <- newEmptyMVar+ let countFetch evt = case evt of+ KirokuEventSubscriptionFetched n _ _+ | n == subName -> atomically (modifyTVar' fetchVar (+ 1))+ _ -> pure ()+ obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)+ withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do+ let deliver _evt = do+ atomically (modifyTVar' deliveredVar (+ 1))+ pure Continue+ -- Member 0 of 3 over $all. Its fetches apply the partition+ -- predicate, so it receives only its slice of the flood, but every+ -- append advances the global position it gates on.+ cfg =+ (defaultSubscriptionConfig subName AllStreams deliver)+ { consumerGroup = Just (validConsumerGroup 0 3)+ }+ bracket (subscribe store cfg) cancel $ \_handle -> do+ waitForSubscriptionLive liveBarrier - let floodCount = 20 :: Int- floodStreams = ["flood-" <> T.pack (show i) | i <- [1 .. floodCount]]- appendEach store floodStreams "Flood"- waitForPublisher store (GlobalPosition (fromIntegral floodCount))- threadDelay 500_000+ let floodCount = 20 :: Int+ floodStreams = ["flood-" <> T.pack (show i) | i <- [1 .. floodCount]]+ appendEach store floodStreams "Flood"+ waitForPublisher store (GlobalPosition (fromIntegral floodCount))+ threadDelay 500_000 - afterIdle <- readTVarIO fetchVar- delivered <- readTVarIO deliveredVar- -- The corrected gate wakes at most once per observed global position- -- and drains to empty, so fetches stay bounded by the flood size- -- (plus the drain's empty fetch per wake) — NOT the unbounded- -- busy-spin of the old cursor gate, which racks up thousands of- -- empty fetches in 500ms.- afterIdle `shouldSatisfy` (< 50)- delivered `shouldSatisfy` (<= floodCount)+ afterIdle <- readTVarIO fetchVar+ delivered <- readTVarIO deliveredVar+ -- The corrected gate wakes at most once per observed global position+ -- and drains to empty, so fetches stay bounded by the flood size+ -- (plus the drain's empty fetch per wake) — NOT the unbounded+ -- busy-spin of the old cursor gate, which racks up thousands of+ -- empty fetches in 500ms.+ afterIdle `shouldSatisfy` (< 50)+ delivered `shouldSatisfy` (<= floodCount)
test/Test/Causation.hs view
@@ -1,15 +1,14 @@-{- | Tests for "Kiroku.Store.Causation" — the causation- and-correlation-walking helpers.--Three groups of tests exercise the three exported functions:-- * 'findCausationDescendants' over a 5-deep chain @A -> B -> C -> D -> E@- spread across three streams, plus single-element and empty cases.- * 'findCausationAncestors' over the same chain, asserting leaf-first- depth ordering.- * 'findByCorrelation' fanning in across multiple streams, plus a noise- correlation and an empty case.--}+-- | Tests for "Kiroku.Store.Causation" — the causation- and+-- correlation-walking helpers.+--+-- Three groups of tests exercise the three exported functions:+--+-- * 'findCausationDescendants' over a 5-deep chain @A -> B -> C -> D -> E@+-- spread across three streams, plus single-element and empty cases.+-- * 'findCausationAncestors' over the same chain, asserting leaf-first+-- depth ordering.+-- * 'findByCorrelation' fanning in across multiple streams, plus a noise+-- correlation and an empty case. module Test.Causation (spec) where import Control.Lens ((^.))@@ -28,87 +27,87 @@ spec :: Spec spec = around withTestStore $ do- describe "findCausationDescendants" $ do- it "returns the seed event and every descendant in global-position order" $ \store -> do- uuids@[uA, _uB, _uC, _uD, _uE] <- replicateUuids 5- appendChain store uuids- Right found <- runStoreIO store $ findCausationDescendants (EventId uA)- eventIds found `shouldBe` map EventId uuids- globalPositionsStrictlyIncreasing found `shouldBe` True+ describe "findCausationDescendants" $ do+ it "returns the seed event and every descendant in global-position order" $ \store -> do+ uuids@[uA, _uB, _uC, _uD, _uE] <- replicateUuids 5+ appendChain store uuids+ Right found <- runStoreIO store $ findCausationDescendants (EventId uA)+ eventIds found `shouldBe` map EventId uuids+ globalPositionsStrictlyIncreasing found `shouldBe` True - it "on an eventId with no descendants returns a vector of length 1 (the seed only)" $ \store -> do- uId <- UUIDv4.nextRandom- let ev = mkEventWithIds "Solo" (Just uId) Nothing Nothing- Right _ <- runStoreIO store $ appendToStream (StreamName "solo-stream") NoStream [ev]- Right found <- runStoreIO store $ findCausationDescendants (EventId uId)- V.length found `shouldBe` 1- (V.head found ^. #eventId) `shouldBe` EventId uId+ it "on an eventId with no descendants returns a vector of length 1 (the seed only)" $ \store -> do+ uId <- UUIDv4.nextRandom+ let ev = mkEventWithIds "Solo" (Just uId) Nothing Nothing+ Right _ <- runStoreIO store $ appendToStream (StreamName "solo-stream") NoStream [ev]+ Right found <- runStoreIO store $ findCausationDescendants (EventId uId)+ V.length found `shouldBe` 1+ (V.head found ^. #eventId) `shouldBe` EventId uId - it "on a non-existent eventId returns an empty vector" $ \store -> do- -- Make sure the store has *some* events so we know we're not- -- silently returning the whole table.- let ev = makeEvent "Other" Aeson.Null- Right _ <- runStoreIO store $ appendToStream (StreamName "other-stream") NoStream [ev]- Right found <- runStoreIO store $ findCausationDescendants (EventId UUID.nil)- V.null found `shouldBe` True+ it "on a non-existent eventId returns an empty vector" $ \store -> do+ -- Make sure the store has *some* events so we know we're not+ -- silently returning the whole table.+ let ev = makeEvent "Other" Aeson.Null+ Right _ <- runStoreIO store $ appendToStream (StreamName "other-stream") NoStream [ev]+ Right found <- runStoreIO store $ findCausationDescendants (EventId UUID.nil)+ V.null found `shouldBe` True - describe "findCausationAncestors" $ do- it "walks from a leaf back to the root, leaf first" $ \store -> do- uuids@[uA, uB, uC, uD, uE] <- replicateUuids 5- appendChain store uuids- Right found <- runStoreIO store $ findCausationAncestors (EventId uE)- eventIds found `shouldBe` map EventId [uE, uD, uC, uB, uA]+ describe "findCausationAncestors" $ do+ it "walks from a leaf back to the root, leaf first" $ \store -> do+ uuids@[uA, uB, uC, uD, uE] <- replicateUuids 5+ appendChain store uuids+ Right found <- runStoreIO store $ findCausationAncestors (EventId uE)+ eventIds found `shouldBe` map EventId [uE, uD, uC, uB, uA] - describe "findByCorrelation" $ do- it "returns every event with the given correlation across multiple streams in global-position order" $ \store -> do- cMain <- UUIDv4.nextRandom- cOther <- UUIDv4.nextRandom- -- 7 events with correlation = cMain across 4 streams.- let mkEv :: Int -> EventData- mkEv i =- mkEventWithIds- (mkName "Main" i)- Nothing- Nothing- (Just cMain)- mainPlan =- [ ("saga-a", NoStream, [mkEv 1, mkEv 2])- , ("saga-b", NoStream, [mkEv 3])- , ("saga-c", NoStream, [mkEv 4, mkEv 5, mkEv 6])- , ("saga-d", NoStream, [mkEv 7])- ]- mapM_- ( \(sn, ev, evts) -> do- Right _ <- runStoreIO store $ appendToStream (StreamName sn) ev evts- pure ()- )- mainPlan- -- Noise: one event with no correlation.- Right _ <- runStoreIO store $ appendToStream (StreamName "noise-stream") NoStream [makeEvent "Noise" Aeson.Null]- -- A separate correlation set we should not see.- let mkOther :: Int -> EventData- mkOther i =- mkEventWithIds- (mkName "Other" i)- Nothing- Nothing- (Just cOther)- Right _ <- runStoreIO store $ appendToStream (StreamName "other-saga") NoStream [mkOther i | i <- [1 .. 5 :: Int]]+ describe "findByCorrelation" $ do+ it "returns every event with the given correlation across multiple streams in global-position order" $ \store -> do+ cMain <- UUIDv4.nextRandom+ cOther <- UUIDv4.nextRandom+ -- 7 events with correlation = cMain across 4 streams.+ let mkEv :: Int -> EventData+ mkEv i =+ mkEventWithIds+ (mkName "Main" i)+ Nothing+ Nothing+ (Just cMain)+ mainPlan =+ [ ("saga-a", NoStream, [mkEv 1, mkEv 2]),+ ("saga-b", NoStream, [mkEv 3]),+ ("saga-c", NoStream, [mkEv 4, mkEv 5, mkEv 6]),+ ("saga-d", NoStream, [mkEv 7])+ ]+ mapM_+ ( \(sn, ev, evts) -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName sn) ev evts+ pure ()+ )+ mainPlan+ -- Noise: one event with no correlation.+ Right _ <- runStoreIO store $ appendToStream (StreamName "noise-stream") NoStream [makeEvent "Noise" Aeson.Null]+ -- A separate correlation set we should not see.+ let mkOther :: Int -> EventData+ mkOther i =+ mkEventWithIds+ (mkName "Other" i)+ Nothing+ Nothing+ (Just cOther)+ Right _ <- runStoreIO store $ appendToStream (StreamName "other-saga") NoStream [mkOther i | i <- [1 .. 5 :: Int]] - Right found <- runStoreIO store $ findByCorrelation cMain- V.length found `shouldBe` 7- all (\e -> e ^. #correlationId == Just cMain) found `shouldBe` True- globalPositionsStrictlyIncreasing found `shouldBe` True+ Right found <- runStoreIO store $ findByCorrelation cMain+ V.length found `shouldBe` 7+ all (\e -> e ^. #correlationId == Just cMain) found `shouldBe` True+ globalPositionsStrictlyIncreasing found `shouldBe` True - it "on an unknown correlation returns an empty vector" $ \store -> do- -- Insert *something* so we're not just observing an empty store.- let noise = mkEventWithIds "Noise" Nothing Nothing (Just UUID.nil)- Right _ <-- runStoreIO store $- appendToStream (StreamName "noise-stream") NoStream [noise]- unknown <- UUIDv4.nextRandom- Right found <- runStoreIO store $ findByCorrelation unknown- V.null found `shouldBe` True+ it "on an unknown correlation returns an empty vector" $ \store -> do+ -- Insert *something* so we're not just observing an empty store.+ let noise = mkEventWithIds "Noise" Nothing Nothing (Just UUID.nil)+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "noise-stream") NoStream [noise]+ unknown <- UUIDv4.nextRandom+ Right found <- runStoreIO store $ findByCorrelation unknown+ V.null found `shouldBe` True -- --------------------------------------------------------------------------- -- Local helpers@@ -117,43 +116,41 @@ replicateUuids :: Int -> IO [UUID] replicateUuids n = mapM (const UUIDv4.nextRandom) [1 .. n] -{- | Build an 'EventData' from explicit event-id, causation-id, and-correlation-id arguments. Wraps the field updates in a typed binding so-'DuplicateRecordFields' does not complain about ambiguous record-updates ('eventId', 'causationId', and 'correlationId' all appear on-both 'EventData' and 'RecordedEvent').--}+-- | Build an 'EventData' from explicit event-id, causation-id, and+-- correlation-id arguments. Wraps the field updates in a typed binding so+-- 'DuplicateRecordFields' does not complain about ambiguous record+-- updates ('eventId', 'causationId', and 'correlationId' all appear on+-- both 'EventData' and 'RecordedEvent'). mkEventWithIds :: Text -> Maybe UUID -> Maybe UUID -> Maybe UUID -> EventData mkEventWithIds typ mEid mCause mCorr =- EventData- { eventId = fmap EventId mEid- , eventType = EventType typ- , payload = Aeson.Null- , metadata = Nothing- , causationId = mCause- , correlationId = mCorr- }--{- | Append a 5-event causation chain @A -> B -> C -> D -> E@ across three-streams (@pm-trigger@, @pm-cmd@, @pm-result@). Each event's-@causationId@ points at the previous event's @eventId@.+ EventData+ { eventId = fmap EventId mEid,+ eventType = EventType typ,+ payload = Aeson.Null,+ metadata = Nothing,+ causationId = mCause,+ correlationId = mCorr+ } -Expects exactly five UUIDs; calls 'error' on any other length so the-caller's signature stays simple.--}+-- | Append a 5-event causation chain @A -> B -> C -> D -> E@ across three+-- streams (@pm-trigger@, @pm-cmd@, @pm-result@). Each event's+-- @causationId@ points at the previous event's @eventId@.+--+-- Expects exactly five UUIDs; calls 'error' on any other length so the+-- caller's signature stays simple. appendChain :: KirokuStore -> [UUID] -> IO () appendChain store [uA, uB, uC, uD, uE] = do- let evA = mkEventWithIds "A" (Just uA) Nothing Nothing- evB = mkEventWithIds "B" (Just uB) (Just uA) Nothing- evC = mkEventWithIds "C" (Just uC) (Just uB) Nothing- evD = mkEventWithIds "D" (Just uD) (Just uC) Nothing- evE = mkEventWithIds "E" (Just uE) (Just uD) Nothing- Right _ <- runStoreIO store $ appendToStream (StreamName "pm-trigger") NoStream [evA]- Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") NoStream [evB]- Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") AnyVersion [evC]- Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") NoStream [evD]- Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") AnyVersion [evE]- pure ()+ let evA = mkEventWithIds "A" (Just uA) Nothing Nothing+ evB = mkEventWithIds "B" (Just uB) (Just uA) Nothing+ evC = mkEventWithIds "C" (Just uC) (Just uB) Nothing+ evD = mkEventWithIds "D" (Just uD) (Just uC) Nothing+ evE = mkEventWithIds "E" (Just uE) (Just uD) Nothing+ Right _ <- runStoreIO store $ appendToStream (StreamName "pm-trigger") NoStream [evA]+ Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") NoStream [evB]+ Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") AnyVersion [evC]+ Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") NoStream [evD]+ Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") AnyVersion [evE]+ pure () appendChain _ _ = error "appendChain: expected exactly 5 UUIDs" eventIds :: Vector RecordedEvent -> [EventId]@@ -161,8 +158,8 @@ globalPositionsStrictlyIncreasing :: Vector RecordedEvent -> Bool globalPositionsStrictlyIncreasing v =- let ps = V.toList (V.map (\e -> let GlobalPosition p = e ^. #globalPosition in p) v)- in and (zipWith (<) ps (drop 1 ps))+ let ps = V.toList (V.map (\e -> let GlobalPosition p = e ^. #globalPosition in p) v)+ in and (zipWith (<) ps (drop 1 ps)) mkName :: String -> Int -> Text mkName tag i = T.pack (tag <> "-" <> show i)
test/Test/Concurrency.hs view
@@ -1,13 +1,12 @@-{- | Deterministic concurrency tests for kiroku-store.--Each test spawns multiple threads that race on append paths the-single-threaded scenario suite cannot exercise. The tests target the-EP-6 M1 Concurrency Scenarios (F9–F11) and verify EP-1 F4 (the sorted-@SELECT … FOR UPDATE@ pre-pass that prevents multi-stream deadlocks)-under deliberate adversarial ordering.--Each test acquires a fresh ephemeral PostgreSQL via 'withTestStore'.--}+-- | Deterministic concurrency tests for kiroku-store.+--+-- Each test spawns multiple threads that race on append paths the+-- single-threaded scenario suite cannot exercise. The tests target the+-- EP-6 M1 Concurrency Scenarios (F9–F11) and verify EP-1 F4 (the sorted+-- @SELECT … FOR UPDATE@ pre-pass that prevents multi-stream deadlocks)+-- under deliberate adversarial ordering.+--+-- Each test acquires a fresh ephemeral PostgreSQL via 'withTestStore'. module Test.Concurrency (spec) where import Control.Concurrent.Async qualified as Async@@ -28,301 +27,301 @@ spec :: Spec spec = describe "kiroku-store concurrency (deterministic)" $ do- it "many AnyVersion writers to one stream preserve stream and global order" $- withTestStore $ \store -> do- let writerCount = 24- stream = StreamName "stress-one-stream"- results <-- Async.forConcurrently [1 .. writerCount] $ \i -> do- runStoreIO store $- appendToStream- stream- AnyVersion- [makeEvent ("Stress" <> T.pack (show i)) (Aeson.object [])]- mapM_ assertRightAppend results- Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000- V.length streamEvents `shouldBe` writerCount- streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000- V.length allEvents `shouldBe` writerCount- globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]+ it "many AnyVersion writers to one stream preserve stream and global order" $+ withTestStore $ \store -> do+ let writerCount = 24+ stream = StreamName "stress-one-stream"+ results <-+ Async.forConcurrently [1 .. writerCount] $ \i -> do+ runStoreIO store $+ appendToStream+ stream+ AnyVersion+ [makeEvent ("Stress" <> T.pack (show i)) (Aeson.object [])]+ mapM_ assertRightAppend results+ Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000+ V.length streamEvents `shouldBe` writerCount+ streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+ V.length allEvents `shouldBe` writerCount+ globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount] - it "many AnyVersion writers to invoice-payment preserve stream and global order" $- withTestStore $ \store -> do- let writerCount = 32- stream = StreamName "invoice-payment"- results <-- Async.forConcurrently [1 .. writerCount] $ \i -> do- runStoreIO store $- appendToStream- stream- AnyVersion- [makeEvent ("InvoicePayment" <> T.pack (show i)) (Aeson.object [])]- mapM_ assertRightAppend results- Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000- V.length streamEvents `shouldBe` writerCount- streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000- V.length allEvents `shouldBe` writerCount- globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]- Set.fromList (eventIds streamEvents) `shouldBe` Set.fromList (eventIds allEvents)+ it "many AnyVersion writers to invoice-payment preserve stream and global order" $+ withTestStore $ \store -> do+ let writerCount = 32+ stream = StreamName "invoice-payment"+ results <-+ Async.forConcurrently [1 .. writerCount] $ \i -> do+ runStoreIO store $+ appendToStream+ stream+ AnyVersion+ [makeEvent ("InvoicePayment" <> T.pack (show i)) (Aeson.object [])]+ mapM_ assertRightAppend results+ Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000+ V.length streamEvents `shouldBe` writerCount+ streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+ V.length allEvents `shouldBe` writerCount+ globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]+ Set.fromList (eventIds streamEvents) `shouldBe` Set.fromList (eventIds allEvents) - it "many batched writers to different streams preserve batch-local and global order" $- withTestStore $ \store -> do- let streamCount = 12- batchSize = 10- totalEvents = streamCount * batchSize- mkStream i = StreamName ("stress-batch-" <> T.pack (show i))- mkBatch i =- [ makeEvent ("Batch" <> T.pack (show i) <> "-" <> T.pack (show j)) (Aeson.object [])- | j <- [1 .. batchSize]- ]- results <-- Async.forConcurrently [1 .. streamCount] $ \i -> do- runStoreIO store $ appendToStream (mkStream i) NoStream (mkBatch i)- mapM_ assertRightAppend results- forM_ [1 .. streamCount] $ \i -> do- Right streamEvents <- runStoreIO store $ readStreamForward (mkStream i) (StreamVersion 0) 1000- streamVersions streamEvents `shouldBe` [1 .. fromIntegral batchSize]- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000- V.length allEvents `shouldBe` totalEvents- globalPositions allEvents `shouldBe` [1 .. fromIntegral totalEvents]+ it "many batched writers to different streams preserve batch-local and global order" $+ withTestStore $ \store -> do+ let streamCount = 12+ batchSize = 10+ totalEvents = streamCount * batchSize+ mkStream i = StreamName ("stress-batch-" <> T.pack (show i))+ mkBatch i =+ [ makeEvent ("Batch" <> T.pack (show i) <> "-" <> T.pack (show j)) (Aeson.object [])+ | j <- [1 .. batchSize]+ ]+ results <-+ Async.forConcurrently [1 .. streamCount] $ \i -> do+ runStoreIO store $ appendToStream (mkStream i) NoStream (mkBatch i)+ mapM_ assertRightAppend results+ forM_ [1 .. streamCount] $ \i -> do+ Right streamEvents <- runStoreIO store $ readStreamForward (mkStream i) (StreamVersion 0) 1000+ streamVersions streamEvents `shouldBe` [1 .. fromIntegral batchSize]+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+ V.length allEvents `shouldBe` totalEvents+ globalPositions allEvents `shouldBe` [1 .. fromIntegral totalEvents] - it "large append batches return the final stream and global positions" $- withTestStore $ \store -> do- let stream = StreamName "stress-large-batches"- mkBatch label n =- [ makeEvent (label <> "-" <> T.pack (show i)) (Aeson.object [])- | i <- [1 .. n]- ]- Right r10 <- runStoreIO store $ appendToStream stream NoStream (mkBatch "Batch10" (10 :: Int))- (r10 ^. #streamVersion) `shouldBe` StreamVersion 10- (r10 ^. #globalPosition) `shouldBe` GlobalPosition 10- Right r100 <- runStoreIO store $ appendToStream stream AnyVersion (mkBatch "Batch100" (100 :: Int))- (r100 ^. #streamVersion) `shouldBe` StreamVersion 110- (r100 ^. #globalPosition) `shouldBe` GlobalPosition 110- Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 200- streamVersions streamEvents `shouldBe` [1 .. 110]- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 200- globalPositions allEvents `shouldBe` [1 .. 110]+ it "large append batches return the final stream and global positions" $+ withTestStore $ \store -> do+ let stream = StreamName "stress-large-batches"+ mkBatch label n =+ [ makeEvent (label <> "-" <> T.pack (show i)) (Aeson.object [])+ | i <- [1 .. n]+ ]+ Right r10 <- runStoreIO store $ appendToStream stream NoStream (mkBatch "Batch10" (10 :: Int))+ (r10 ^. #streamVersion) `shouldBe` StreamVersion 10+ (r10 ^. #globalPosition) `shouldBe` GlobalPosition 10+ Right r100 <- runStoreIO store $ appendToStream stream AnyVersion (mkBatch "Batch100" (100 :: Int))+ (r100 ^. #streamVersion) `shouldBe` StreamVersion 110+ (r100 ^. #globalPosition) `shouldBe` GlobalPosition 110+ Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 200+ streamVersions streamEvents `shouldBe` [1 .. 110]+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 200+ globalPositions allEvents `shouldBe` [1 .. 110] - it "overlapping appendMultiStream stress preserves all-or-nothing ordering" $- withTestStore $ \store -> do- let streams = [StreamName "stress-multi-a", StreamName "stress-multi-b", StreamName "stress-multi-c"]- rotations =- [ streams- , [StreamName "stress-multi-b", StreamName "stress-multi-c", StreamName "stress-multi-a"]- , [StreamName "stress-multi-c", StreamName "stress-multi-a", StreamName "stress-multi-b"]- ]- opsFor i =- [ (sn, AnyVersion, [makeEvent ("Multi" <> T.pack (show i) <> "-" <> labelStream sn) (Aeson.object [])])- | sn <- rotations !! (i `mod` length rotations)- ]- forM_ streams $ \sn -> do- result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "init" (Aeson.object [])]- assertRightAppend result- results <-- Async.forConcurrently [1 .. 9] $ \i -> do- runStoreIO store $ appendMultiStream (opsFor i)- mapM_ assertRightMulti results- forM_ streams $ \sn -> do- Right streamEvents <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100- streamVersions streamEvents `shouldBe` [1 .. 10]- Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- V.length allEvents `shouldBe` 30- globalPositions allEvents `shouldBe` [1 .. 30]+ it "overlapping appendMultiStream stress preserves all-or-nothing ordering" $+ withTestStore $ \store -> do+ let streams = [StreamName "stress-multi-a", StreamName "stress-multi-b", StreamName "stress-multi-c"]+ rotations =+ [ streams,+ [StreamName "stress-multi-b", StreamName "stress-multi-c", StreamName "stress-multi-a"],+ [StreamName "stress-multi-c", StreamName "stress-multi-a", StreamName "stress-multi-b"]+ ]+ opsFor i =+ [ (sn, AnyVersion, [makeEvent ("Multi" <> T.pack (show i) <> "-" <> labelStream sn) (Aeson.object [])])+ | sn <- rotations !! (i `mod` length rotations)+ ]+ forM_ streams $ \sn -> do+ result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "init" (Aeson.object [])]+ assertRightAppend result+ results <-+ Async.forConcurrently [1 .. 9] $ \i -> do+ runStoreIO store $ appendMultiStream (opsFor i)+ mapM_ assertRightMulti results+ forM_ streams $ \sn -> do+ Right streamEvents <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100+ streamVersions streamEvents `shouldBe` [1 .. 10]+ Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ V.length allEvents `shouldBe` 30+ globalPositions allEvents `shouldBe` [1 .. 30] - it "duplicate event failure leaves touched streams and $all unchanged" $- withTestStore $ \store -> do- let duplicate =- EventId $- case UUID.fromString "01234567-89ab-7def-8012-34567890abce" of- Just u -> u- Nothing -> error "bad uuid"- duplicateEvent =- EventData- { eventId = Just duplicate- , eventType = EventType "Duplicate"- , payload = Aeson.object []- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }- Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-seed") NoStream [duplicateEvent]- Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-a") NoStream [makeEvent "init-a" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-b") NoStream [makeEvent "init-b" (Aeson.object [])]- beforeCount <- countEvents store- Right allBefore <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- result <-- runStoreIO store $- appendMultiStream- [ (StreamName "rollback-a", AnyVersion, [duplicateEvent])- , (StreamName "rollback-b", AnyVersion, [makeEvent "should-not-commit" (Aeson.object [])])- ]- case result of- Left (DuplicateEvent _) -> pure ()- other -> expectationFailure ("duplicate event should abort the multi-stream transaction, got: " <> show other)- countEvents store `shouldReturn` beforeCount- Right allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- globalPositions allAfter `shouldBe` globalPositions allBefore- Right streamA <- runStoreIO store $ readStreamForward (StreamName "rollback-a") (StreamVersion 0) 100- Right streamB <- runStoreIO store $ readStreamForward (StreamName "rollback-b") (StreamVersion 0) 100- streamVersions streamA `shouldBe` [1]- streamVersions streamB `shouldBe` [1]+ it "duplicate event failure leaves touched streams and $all unchanged" $+ withTestStore $ \store -> do+ let duplicate =+ EventId $+ case UUID.fromString "01234567-89ab-7def-8012-34567890abce" of+ Just u -> u+ Nothing -> error "bad uuid"+ duplicateEvent =+ EventData+ { eventId = Just duplicate,+ eventType = EventType "Duplicate",+ payload = Aeson.object [],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }+ Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-seed") NoStream [duplicateEvent]+ Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-a") NoStream [makeEvent "init-a" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-b") NoStream [makeEvent "init-b" (Aeson.object [])]+ beforeCount <- countEvents store+ Right allBefore <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ result <-+ runStoreIO store $+ appendMultiStream+ [ (StreamName "rollback-a", AnyVersion, [duplicateEvent]),+ (StreamName "rollback-b", AnyVersion, [makeEvent "should-not-commit" (Aeson.object [])])+ ]+ case result of+ Left (DuplicateEvent _) -> pure ()+ other -> expectationFailure ("duplicate event should abort the multi-stream transaction, got: " <> show other)+ countEvents store `shouldReturn` beforeCount+ Right allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ globalPositions allAfter `shouldBe` globalPositions allBefore+ Right streamA <- runStoreIO store $ readStreamForward (StreamName "rollback-a") (StreamVersion 0) 100+ Right streamB <- runStoreIO store $ readStreamForward (StreamName "rollback-b") (StreamVersion 0) 100+ streamVersions streamA `shouldBe` [1]+ streamVersions streamB `shouldBe` [1] - -- F9 — Two concurrent appends to different streams. Both calls- -- must succeed, the global positions must be unique and contiguous,- -- and the test must not deadlock.- it "two concurrent appends to different streams both succeed (F9)" $- withTestStore $ \store -> do- (rA, rB) <-- Async.concurrently- (runStoreIO store $ appendToStream (StreamName "f9-a") NoStream [makeEvent "A" (Aeson.object [])])- (runStoreIO store $ appendToStream (StreamName "f9-b") NoStream [makeEvent "B" (Aeson.object [])])- case (rA, rB) of- (Right resA, Right resB) -> do- let pA = case resA ^. #globalPosition of GlobalPosition n -> n- pB = case resB ^. #globalPosition of GlobalPosition n -> n- pA `shouldNotBe` pB- Set.fromList [pA, pB] `shouldBe` Set.fromList [1, 2]- other -> expectationFailure ("F9: both should succeed, got: " <> show other)+ -- F9 — Two concurrent appends to different streams. Both calls+ -- must succeed, the global positions must be unique and contiguous,+ -- and the test must not deadlock.+ it "two concurrent appends to different streams both succeed (F9)" $+ withTestStore $ \store -> do+ (rA, rB) <-+ Async.concurrently+ (runStoreIO store $ appendToStream (StreamName "f9-a") NoStream [makeEvent "A" (Aeson.object [])])+ (runStoreIO store $ appendToStream (StreamName "f9-b") NoStream [makeEvent "B" (Aeson.object [])])+ case (rA, rB) of+ (Right resA, Right resB) -> do+ let pA = case resA ^. #globalPosition of GlobalPosition n -> n+ pB = case resB ^. #globalPosition of GlobalPosition n -> n+ pA `shouldNotBe` pB+ Set.fromList [pA, pB] `shouldBe` Set.fromList [1, 2]+ other -> expectationFailure ("F9: both should succeed, got: " <> show other) - -- F10 — Two concurrent appends to the same stream with the same- -- ExactVersion. Exactly one must succeed; the other must fail- -- with WrongExpectedVersion. No deadlock. Stream is pre-created- -- because ExactVersion 0 against a non-existent stream is itself- -- an error in kiroku (streams start at version 1).- it "two concurrent ExactVersion appends to same stream — one wins (F10)" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "f10") NoStream [makeEvent "Init" (Aeson.object [])]- (r1, r2) <-- Async.concurrently- (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])])- (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])])- case (r1, r2) of- (Right _, Left (WrongExpectedVersion _ _ _)) -> pure ()- (Left (WrongExpectedVersion _ _ _), Right _) -> pure ()- other -> expectationFailure ("F10: exactly one should win with the loser returning WrongExpectedVersion, got: " <> show other)- -- Stream must have exactly init + winner = 2 events after the race.- Right events <- runStoreIO store $ readStreamForward (StreamName "f10") (StreamVersion 0) 100- V.length events `shouldBe` 2+ -- F10 — Two concurrent appends to the same stream with the same+ -- ExactVersion. Exactly one must succeed; the other must fail+ -- with WrongExpectedVersion. No deadlock. Stream is pre-created+ -- because ExactVersion 0 against a non-existent stream is itself+ -- an error in kiroku (streams start at version 1).+ it "two concurrent ExactVersion appends to same stream — one wins (F10)" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "f10") NoStream [makeEvent "Init" (Aeson.object [])]+ (r1, r2) <-+ Async.concurrently+ (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])])+ (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])])+ case (r1, r2) of+ (Right _, Left (WrongExpectedVersion _ _ _)) -> pure ()+ (Left (WrongExpectedVersion _ _ _), Right _) -> pure ()+ other -> expectationFailure ("F10: exactly one should win with the loser returning WrongExpectedVersion, got: " <> show other)+ -- Stream must have exactly init + winner = 2 events after the race.+ Right events <- runStoreIO store $ readStreamForward (StreamName "f10") (StreamVersion 0) 100+ V.length events `shouldBe` 2 - -- F11 — Two concurrent appendMultiStream calls touching the same- -- streams in opposite order. EP-1 F4's sorted SELECT FOR UPDATE- -- pre-pass ensures both transactions acquire row locks in the- -- same canonical order, preventing the classic two-resource- -- deadlock. Without the fix, this test would intermittently fail- -- with PostgreSQL deadlock detection (40P01).- it "two concurrent multi-stream appends in opposite order do not deadlock (F11)" $- withTestStore $ \store -> do- -- Pre-create both streams so the multi-stream calls take- -- the existing-stream path (where pre-locking matters).- Right _ <- runStoreIO store $ appendToStream (StreamName "f11-x") NoStream [makeEvent "init-x" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "f11-y") NoStream [makeEvent "init-y" (Aeson.object [])]- let opsXY =- [ (StreamName "f11-x", AnyVersion, [makeEvent "Ax" (Aeson.object [])])- , (StreamName "f11-y", AnyVersion, [makeEvent "Ay" (Aeson.object [])])- ]- opsYX =- [ (StreamName "f11-y", AnyVersion, [makeEvent "By" (Aeson.object [])])- , (StreamName "f11-x", AnyVersion, [makeEvent "Bx" (Aeson.object [])])- ]- (rA, rB) <-- Async.concurrently- (runStoreIO store $ appendMultiStream opsXY)- (runStoreIO store $ appendMultiStream opsYX)- case (rA, rB) of- (Right _, Right _) -> pure ()- other -> expectationFailure ("F11: both calls must succeed without deadlock, got: " <> show other)- -- Each stream contains its init event plus one event from- -- each of the two concurrent multi-stream appends (3 each).- Right xs <- runStoreIO store $ readStreamForward (StreamName "f11-x") (StreamVersion 0) 100- Right ys <- runStoreIO store $ readStreamForward (StreamName "f11-y") (StreamVersion 0) 100- V.length xs `shouldBe` 3- V.length ys `shouldBe` 3- -- \$all has 6 events total (2 inits + 4 from concurrent calls).- Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- V.length allEvts `shouldBe` 6- let positions =- map- (\e -> case e ^. #globalPosition of GlobalPosition n -> n)- (V.toList allEvts)- sort positions `shouldBe` [1, 2, 3, 4, 5, 6]+ -- F11 — Two concurrent appendMultiStream calls touching the same+ -- streams in opposite order. EP-1 F4's sorted SELECT FOR UPDATE+ -- pre-pass ensures both transactions acquire row locks in the+ -- same canonical order, preventing the classic two-resource+ -- deadlock. Without the fix, this test would intermittently fail+ -- with PostgreSQL deadlock detection (40P01).+ it "two concurrent multi-stream appends in opposite order do not deadlock (F11)" $+ withTestStore $ \store -> do+ -- Pre-create both streams so the multi-stream calls take+ -- the existing-stream path (where pre-locking matters).+ Right _ <- runStoreIO store $ appendToStream (StreamName "f11-x") NoStream [makeEvent "init-x" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "f11-y") NoStream [makeEvent "init-y" (Aeson.object [])]+ let opsXY =+ [ (StreamName "f11-x", AnyVersion, [makeEvent "Ax" (Aeson.object [])]),+ (StreamName "f11-y", AnyVersion, [makeEvent "Ay" (Aeson.object [])])+ ]+ opsYX =+ [ (StreamName "f11-y", AnyVersion, [makeEvent "By" (Aeson.object [])]),+ (StreamName "f11-x", AnyVersion, [makeEvent "Bx" (Aeson.object [])])+ ]+ (rA, rB) <-+ Async.concurrently+ (runStoreIO store $ appendMultiStream opsXY)+ (runStoreIO store $ appendMultiStream opsYX)+ case (rA, rB) of+ (Right _, Right _) -> pure ()+ other -> expectationFailure ("F11: both calls must succeed without deadlock, got: " <> show other)+ -- Each stream contains its init event plus one event from+ -- each of the two concurrent multi-stream appends (3 each).+ Right xs <- runStoreIO store $ readStreamForward (StreamName "f11-x") (StreamVersion 0) 100+ Right ys <- runStoreIO store $ readStreamForward (StreamName "f11-y") (StreamVersion 0) 100+ V.length xs `shouldBe` 3+ V.length ys `shouldBe` 3+ -- \$all has 6 events total (2 inits + 4 from concurrent calls).+ Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ V.length allEvts `shouldBe` 6+ let positions =+ map+ (\e -> case e ^. #globalPosition of GlobalPosition n -> n)+ (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.- --- -- Note what is and is not asserted. The store retries once, and no bounded- -- retry can promise that a repeated conflict never reaches the caller --- -- under contention this shape does deadlock, because a multi-stream append- -- whose later stream is fresh takes $all before that stream's row while a- -- single-stream append takes them in the opposite order (see IR-7). So a- -- surfaced conflict is allowed; what is not allowed is surfacing it as- -- UnexpectedServerError, which documents itself as "not generally- -- retryable -- investigate" and would send a caller to diagnose a- -- condition whose correct handling is to retry.- 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+ -- 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.+ --+ -- Note what is and is not asserted. The store retries once, and no bounded+ -- retry can promise that a repeated conflict never reaches the caller --+ -- under contention this shape does deadlock, because a multi-stream append+ -- whose later stream is fresh takes $all before that stream's row while a+ -- single-stream append takes them in the opposite order (see IR-7). So a+ -- surfaced conflict is allowed; what is not allowed is surfacing it as+ -- UnexpectedServerError, which documents itself as "not generally+ -- retryable -- investigate" and would send a caller to diagnose a+ -- condition whose correct handling is to retry.+ 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 ()- Left err -> expectationFailure ("append should succeed, got: " <> show err)+ Right _ -> pure ()+ Left err -> expectationFailure ("append should succeed, got: " <> show err) assertRightMulti :: Either StoreError [AppendResult] -> IO () assertRightMulti = \case- Right _ -> pure ()- Left err -> expectationFailure ("appendMultiStream should succeed, got: " <> show err)+ Right _ -> pure ()+ Left err -> expectationFailure ("appendMultiStream should succeed, got: " <> show err) assertNoTransientLeak :: String -> Either StoreError a -> IO () assertNoTransientLeak label = \case- Left (UnexpectedServerError code message)- | code == "40P01" || code == "40001" ->- expectationFailure- ( label- <> ": transient SQLSTATE "- <> show code- <> " surfaced as UnexpectedServerError instead of "- <> "TransientTransactionFailure: "- <> show message- )- _ ->- pure ()+ Left (UnexpectedServerError code message)+ | code == "40P01" || code == "40001" ->+ expectationFailure+ ( label+ <> ": transient SQLSTATE "+ <> show code+ <> " surfaced as UnexpectedServerError instead of "+ <> "TransientTransactionFailure: "+ <> show message+ )+ _ ->+ pure () streamVersions :: V.Vector RecordedEvent -> [Int64] streamVersions =- map- (\e -> case e ^. #streamVersion of StreamVersion n -> n)- . V.toList+ map+ (\e -> case e ^. #streamVersion of StreamVersion n -> n)+ . V.toList globalPositions :: V.Vector RecordedEvent -> [Int64] globalPositions =- map- (\e -> case e ^. #globalPosition of GlobalPosition n -> n)- . V.toList+ map+ (\e -> case e ^. #globalPosition of GlobalPosition n -> n)+ . V.toList eventIds :: V.Vector RecordedEvent -> [EventId] eventIds =- map (^. #eventId)- . V.toList+ map (^. #eventId)+ . V.toList labelStream :: StreamName -> Text labelStream (StreamName name) = name
test/Test/ConsumerGroup.hs view
@@ -1,13 +1,12 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Runtime tests for consumer groups (ExecPlan 29 / EP-2). Each member runs as-one in-process subscription worker over a fresh ephemeral PostgreSQL. The-properties proven here are the user-visible contract: a group's members deliver a-/disjoint/, /complete/, /per-stream-ordered/ partition of the source; a size-1-group equals a plain subscription; and each member resumes from its /own/-checkpoint.--}+-- | Runtime tests for consumer groups (ExecPlan 29 / EP-2). Each member runs as+-- one in-process subscription worker over a fresh ephemeral PostgreSQL. The+-- properties proven here are the user-visible contract: a group's members deliver a+-- /disjoint/, /complete/, /per-stream-ordered/ partition of the source; a size-1+-- group equals a plain subscription; and each member resumes from its /own/+-- checkpoint. module Test.ConsumerGroup (spec) where import Control.Concurrent (threadDelay)@@ -42,296 +41,294 @@ -- | Extract @(originalStreamId, globalPosition)@ as raw 'Int64's from an event. pairOf :: RecordedEvent -> (Int64, Int64) pairOf e =- ( case e ^. #originalStreamId of StreamId s -> s- , case e ^. #globalPosition of GlobalPosition p -> p- )+ ( case e ^. #originalStreamId of StreamId s -> s,+ case e ^. #globalPosition of GlobalPosition p -> p+ ) -- | Append @perStream@ events to each named stream, seeding the store. seed :: KirokuStore -> [Text] -> Int -> IO () seed store names perStream =- mapM_- ( \sn -> do- let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]- r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs- case r of- Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)- Right _ -> pure ()- )- names+ mapM_+ ( \sn -> do+ let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]+ r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs+ case r of+ Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)+ Right _ -> pure ()+ )+ names -- | Poll an @IO Bool@ predicate until it holds or the microsecond budget runs out. waitUntil :: Int -> IO Bool -> IO () waitUntil budget act- | budget <= 0 = pure ()- | otherwise = do- ok <- act- if ok- then pure ()- else do- threadDelay 20_000- waitUntil (budget - 20_000) act+ | budget <= 0 = pure ()+ | otherwise = do+ ok <- act+ if ok+ then pure ()+ else do+ threadDelay 20_000+ waitUntil (budget - 20_000) act -{- | Assert that within each originating stream the global positions, taken in-delivery order, are strictly ascending (i.e. equal to their sorted form).--}+-- | Assert that within each originating stream the global positions, taken in+-- delivery order, are strictly ascending (i.e. equal to their sorted form). assertPerStreamAscending :: [(Int64, Int64)] -> Expectation assertPerStreamAscending chrono =- let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]- in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)+ let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]+ in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream) -- | A size-@n@ category-group config for member @m@ with the given handler. memberConfig ::- Text -> Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig+ Text -> Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig memberConfig nm cat m n h =- (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) h)- { consumerGroup = Just (validConsumerGroup m n)- }+ (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) h)+ { consumerGroup = Just (validConsumerGroup m n)+ } -- | A size-@n@ @$all@-group config for member @m@ with the given handler. memberConfigAll ::- Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig+ Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig memberConfigAll nm m n h =- (defaultSubscriptionConfig (SubscriptionName nm) AllStreams h)- { consumerGroup = Just (validConsumerGroup m n)- }+ (defaultSubscriptionConfig (SubscriptionName nm) AllStreams h)+ { consumerGroup = Just (validConsumerGroup m n)+ } -{- | Run a subscription built from the given config-completer, collecting the-global positions it delivers and stopping the handler after @k@ events. Returns-the @k@ positions in delivery order.--}+-- | Run a subscription built from the given config-completer, collecting the+-- global positions it delivers and stopping the handler after @k@ events. Returns+-- the @k@ positions in delivery order. collectStopAfter :: KirokuStore -> (EventHandler -> SubscriptionConfig) -> Int -> IO [Int64] collectStopAfter store mkCfg k = do- ref <- newIORef []- countVar <- newTVarIO (0 :: Int)- let h evt = do- modifyIORef' ref (snd (pairOf evt) :)- c <- atomically $ do- c0 <- readTVar countVar- let c1 = c0 + 1- writeTVar countVar c1- pure c1- pure (if c >= k then Stop else Continue)- handle <- subscribe store (mkCfg h)- _ <- waitWithTimeout 15_000_000 handle- reverse <$> readIORef ref+ ref <- newIORef []+ countVar <- newTVarIO (0 :: Int)+ let h evt = do+ modifyIORef' ref (snd (pairOf evt) :)+ c <- atomically $ do+ c0 <- readTVar countVar+ let c1 = c0 + 1+ writeTVar countVar c1+ pure c1+ pure (if c >= k then Stop else Continue)+ handle <- subscribe store (mkCfg h)+ _ <- waitWithTimeout 15_000_000 handle+ reverse <$> readIORef ref -- | Run a Hasql session against the store pool, failing the test on a usage error. runStmtP :: KirokuStore -> Session.Session a -> IO a runStmtP store session = do- r <- Pool.use (store ^. #pool) session- either (error . show) pure r+ r <- Pool.use (store ^. #pool) session+ either (error . show) pure r spec :: Spec spec = describe "consumer groups" $ do- it "delivers a disjoint, complete, per-stream-ordered partition (size-4 category group)" $- withTestStore $ \store -> do- let nStreams = 40- perStream = 3- total = nStreams * perStream- streams = ["acct-" <> T.pack (show i) | i <- [1 .. nStreams]]- seed store streams perStream- waitForPublisher store (GlobalPosition (fromIntegral total))+ it "delivers a disjoint, complete, per-stream-ordered partition (size-4 category group)" $+ withTestStore $ \store -> do+ let nStreams = 40+ perStream = 3+ total = nStreams * perStream+ streams = ["acct-" <> T.pack (show i) | i <- [1 .. nStreams]]+ seed store streams perStream+ waitForPublisher store (GlobalPosition (fromIntegral total)) - let n = 4 :: Int32- refs <- mapM (const (newIORef [])) [0 .. n - 1]- handles <-- mapM- ( \m -> do- let ref = refs !! fromIntegral m- h evt = do- modifyIORef' ref (pairOf evt :)- pure Continue- subscribe store (memberConfig "cg-cat" "acct" m n h)- )- [0 .. n - 1]+ let n = 4 :: Int32+ refs <- mapM (const (newIORef [])) [0 .. n - 1]+ handles <-+ mapM+ ( \m -> do+ let ref = refs !! fromIntegral m+ h evt = do+ modifyIORef' ref (pairOf evt :)+ pure Continue+ subscribe store (memberConfig "cg-cat" "acct" m n h)+ )+ [0 .. n - 1] - let collectedCount = sum <$> mapM (fmap length . readIORef) refs- waitUntil 15_000_000 (fmap (>= total) collectedCount)- mapM_ cancel handles+ let collectedCount = sum <$> mapM (fmap length . readIORef) refs+ waitUntil 15_000_000 (fmap (>= total) collectedCount)+ mapM_ cancel handles - collected <- mapM readIORef refs- -- (1) Disjoint + complete: the multiset of all delivered positions is- -- exactly [1..total] — no duplicate (would lengthen it) and no gap.- let allPositions = sort (concatMap (map snd) collected)- allPositions `shouldBe` [1 .. fromIntegral total]- -- (2) Per-stream ordering within each member (collector prepended, so- -- reverse to recover delivery order).- mapM_ (assertPerStreamAscending . reverse) collected+ collected <- mapM readIORef refs+ -- (1) Disjoint + complete: the multiset of all delivered positions is+ -- exactly [1..total] — no duplicate (would lengthen it) and no gap.+ let allPositions = sort (concatMap (map snd) collected)+ allPositions `shouldBe` [1 .. fromIntegral total]+ -- (2) Per-stream ordering within each member (collector prepended, so+ -- reverse to recover delivery order).+ mapM_ (assertPerStreamAscending . reverse) collected - it "size-1 group delivers the same set as a plain subscription" $- withTestStore $ \store -> do- let nStreams = 4- perStream = 3- total = nStreams * perStream- streams = ["uno-" <> T.pack (show i) | i <- [1 .. nStreams]]- seed store streams perStream- waitForPublisher store (GlobalPosition (fromIntegral total))+ it "size-1 group delivers the same set as a plain subscription" $+ withTestStore $ \store -> do+ let nStreams = 4+ perStream = 3+ total = nStreams * perStream+ streams = ["uno-" <> T.pack (show i) | i <- [1 .. nStreams]]+ seed store streams perStream+ waitForPublisher store (GlobalPosition (fromIntegral total)) - let runCollector cfgFor nm = do- ref <- newIORef []- countVar <- newTVarIO (0 :: Int)- let h evt = do- modifyIORef' ref (snd (pairOf evt) :)- c <- atomically $ do- c0 <- readTVar countVar- let c1 = c0 + 1- writeTVar countVar c1- pure c1- pure (if c >= total then Stop else Continue)- handle <- subscribe store (cfgFor nm h)- _ <- waitWithTimeout 15_000_000 handle- sort <$> readIORef ref+ let runCollector cfgFor nm = do+ ref <- newIORef []+ countVar <- newTVarIO (0 :: Int)+ let h evt = do+ modifyIORef' ref (snd (pairOf evt) :)+ c <- atomically $ do+ c0 <- readTVar countVar+ let c1 = c0 + 1+ writeTVar countVar c1+ pure c1+ pure (if c >= total then Stop else Continue)+ handle <- subscribe store (cfgFor nm h)+ _ <- waitWithTimeout 15_000_000 handle+ sort <$> readIORef ref - plain <-- runCollector- (\nm h -> defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName "uno")) h)- "uno-plain"- grouped <-- runCollector- (\nm h -> memberConfig nm "uno" 0 1 h)- "uno-group"+ plain <-+ runCollector+ (\nm h -> defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName "uno")) h)+ "uno-plain"+ grouped <-+ runCollector+ (\nm h -> memberConfig nm "uno" 0 1 h)+ "uno-group" - plain `shouldBe` [1 .. fromIntegral total]- grouped `shouldBe` plain+ plain `shouldBe` [1 .. fromIntegral total]+ grouped `shouldBe` plain - it "$all group partitions the whole store across members" $- withTestStore $ \store -> do- let cats = ["acct", "user", "order"]- perCat = 10 :: Int- perStream = 2- streams = [c <> "-" <> T.pack (show i) | c <- cats, i <- [1 .. perCat]]- total = length streams * perStream- seed store streams perStream- waitForPublisher store (GlobalPosition (fromIntegral total))+ it "$all group partitions the whole store across members" $+ withTestStore $ \store -> do+ let cats = ["acct", "user", "order"]+ perCat = 10 :: Int+ perStream = 2+ streams = [c <> "-" <> T.pack (show i) | c <- cats, i <- [1 .. perCat]]+ total = length streams * perStream+ seed store streams perStream+ waitForPublisher store (GlobalPosition (fromIntegral total)) - let n = 4 :: Int32- refs <- mapM (const (newIORef [])) [0 .. n - 1]- handles <-- mapM- ( \m -> do- let ref = refs !! fromIntegral m- h evt = do- modifyIORef' ref (pairOf evt :)- pure Continue- subscribe store (memberConfigAll "cg-all" m n h)- )- [0 .. n - 1]+ let n = 4 :: Int32+ refs <- mapM (const (newIORef [])) [0 .. n - 1]+ handles <-+ mapM+ ( \m -> do+ let ref = refs !! fromIntegral m+ h evt = do+ modifyIORef' ref (pairOf evt :)+ pure Continue+ subscribe store (memberConfigAll "cg-all" m n h)+ )+ [0 .. n - 1] - let collectedCount = sum <$> mapM (fmap length . readIORef) refs- waitUntil 15_000_000 (fmap (>= total) collectedCount)- mapM_ cancel handles+ let collectedCount = sum <$> mapM (fmap length . readIORef) refs+ waitUntil 15_000_000 (fmap (>= total) collectedCount)+ mapM_ cancel handles - collected <- mapM readIORef refs- let allPositions = sort (concatMap (map snd) collected)- allPositions `shouldBe` [1 .. fromIntegral total]- mapM_ (assertPerStreamAscending . reverse) collected+ collected <- mapM readIORef refs+ let allPositions = sort (concatMap (map snd) collected)+ allPositions `shouldBe` [1 .. fromIntegral total]+ mapM_ (assertPerStreamAscending . reverse) collected - it "resumes member 2 from its own (name, member) checkpoint" $- withTestStore $ \store -> do- let nStreams = 60 :: Int- streams = ["rz-" <> T.pack (show i) | i <- [1 .. nStreams]]- seed store streams 1- waitForPublisher store (GlobalPosition (fromIntegral nStreams))+ it "resumes member 2 from its own (name, member) checkpoint" $+ withTestStore $ \store -> do+ let nStreams = 60 :: Int+ streams = ["rz-" <> T.pack (show i) | i <- [1 .. nStreams]]+ seed store streams 1+ waitForPublisher store (GlobalPosition (fromIntegral nStreams)) - -- Member 2's slice positions, in order, straight from the EP-1 partition- -- SQL — the deterministic ground truth for what member 2 should receive.- sliceV <-- runStmtP store $- Session.statement- (0 :: Int64, "rz" :: Text, 2 :: Int32, 4 :: Int32, 100000 :: Int32)- SQL.readCategoryForwardConsumerGroupStmt- let slice = map (snd . pairOf) (V.toList sliceV)- length slice `shouldSatisfy` (>= 4)+ -- Member 2's slice positions, in order, straight from the EP-1 partition+ -- SQL — the deterministic ground truth for what member 2 should receive.+ sliceV <-+ runStmtP store $+ Session.statement+ (0 :: Int64, "rz" :: Text, 2 :: Int32, 4 :: Int32, 100000 :: Int32)+ SQL.readCategoryForwardConsumerGroupStmt+ let slice = map (snd . pairOf) (V.toList sliceV)+ length slice `shouldSatisfy` (>= 4) - -- Run 1: member 2 stops after 2 events; checkpoint (rz-sub, 2) = slice!!1.- run1 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2- run1 `shouldBe` take 2 slice+ -- Run 1: member 2 stops after 2 events; checkpoint (rz-sub, 2) = slice!!1.+ run1 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2+ run1 `shouldBe` take 2 slice - -- A competing, much higher checkpoint for a DIFFERENT member under the- -- SAME name. If checkpoints were keyed by name only, member 2's restart- -- would resume from here and skip its events; member-keyed checkpoints- -- must ignore it.- runStmtP store $- Session.statement- ("rz-sub" :: Text, 0 :: Int32, 10_000_000 :: Int64, 4 :: Int32, "category", Just "rz")- SQL.saveCheckpointMemberStmt+ -- A competing, much higher checkpoint for a DIFFERENT member under the+ -- SAME name. If checkpoints were keyed by name only, member 2's restart+ -- would resume from here and skip its events; member-keyed checkpoints+ -- must ignore it.+ runStmtP store $+ Session.statement+ ("rz-sub" :: Text, 0 :: Int32, 10_000_000 :: Int64, 4 :: Int32, "category", Just "rz")+ SQL.saveCheckpointMemberStmt - -- Run 2: member 2 restarts and must resume from its OWN checkpoint- -- (slice!!1), delivering the next two member-2 events.- run2 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2- run2 `shouldBe` take 2 (drop 2 slice)+ -- Run 2: member 2 restarts and must resume from its OWN checkpoint+ -- (slice!!1), delivering the next two member-2 events.+ run2 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2+ run2 `shouldBe` take 2 (drop 2 slice) - it "lifecycle events carry the consumer-group member context (GroupMember 2 4)" $ do- ref <- newIORef ([] :: [KirokuEvent])- let evtHandler e = modifyIORef' ref (e :)- isStartedMember2 ev = case ev of- KirokuEventSubscriptionStarted (SubscriptionName "obs-sub") _ (GroupMember 2 4) -> True- _ -> False- withTestStoreSettings (\s -> s & #eventHandler .~ Just evtHandler) $ \store -> do- handle <- subscribe store (memberConfig "obs-sub" "obs" 2 4 (\_ -> pure Continue))- waitUntil 5_000_000 (any isStartedMember2 <$> readIORef ref)- cancel handle- evts <- readIORef ref- any isStartedMember2 evts `shouldBe` True+ it "lifecycle events carry the consumer-group member context (GroupMember 2 4)" $ do+ ref <- newIORef ([] :: [KirokuEvent])+ let evtHandler e = modifyIORef' ref (e :)+ isStartedMember2 ev = case ev of+ KirokuEventSubscriptionStarted (SubscriptionName "obs-sub") _ (GroupMember 2 4) -> True+ _ -> False+ withTestStoreSettings (\s -> s & #eventHandler .~ Just evtHandler) $ \store -> do+ handle <- subscribe store (memberConfig "obs-sub" "obs" 2 4 (\_ -> pure Continue))+ waitUntil 5_000_000 (any isStartedMember2 <$> readIORef ref)+ cancel handle+ evts <- readIORef ref+ any isStartedMember2 evts `shouldBe` True - it "consumerGroupGuard fails fast when another holder holds the (name, member) lock" $ do- withMigratedTestDatabase $ \cs -> do- withStore (defaultConnectionSettings cs) $ \store -> do- -- Holder: a dedicated connection takes the SESSION-level advisory- -- lock for the same key the worker's guard probes, namely- -- hashtextextended("guard-sub:3", 0). It is held until release.- eConn <- Connection.acquire (Conn.connectionString cs)- conn <- either (\e -> error ("guard holder connect failed: " <> show e)) pure eConn- let holdStmt :: Statement Text ()- holdStmt =- preparable- "SELECT pg_advisory_lock(hashtextextended($1, 0))"- (E.param (E.nonNullable E.text))- D.noResult- lockRes <- Connection.use conn (Session.statement "guard-sub:3" holdStmt)- either (\e -> error ("guard holder lock failed: " <> show e)) pure lockRes+ it "consumerGroupGuard fails fast when another holder holds the (name, member) lock" $ do+ withMigratedTestDatabase $ \cs -> do+ withStore (defaultConnectionSettings cs) $ \store -> do+ -- Holder: a dedicated connection takes the SESSION-level advisory+ -- lock for the same key the worker's guard probes, namely+ -- hashtextextended("guard-sub:3", 0). It is held until release.+ eConn <- Connection.acquire (Conn.connectionString cs)+ conn <- either (\e -> error ("guard holder connect failed: " <> show e)) pure eConn+ let holdStmt :: Statement Text ()+ holdStmt =+ preparable+ "SELECT pg_advisory_lock(hashtextextended($1, 0))"+ (E.param (E.nonNullable E.text))+ D.noResult+ lockRes <- Connection.use conn (Session.statement "guard-sub:3" holdStmt)+ either (\e -> error ("guard holder lock failed: " <> show e)) pure lockRes - let cfg =- (defaultSubscriptionConfig (SubscriptionName "guard-sub") (Category (CategoryName "guardcat")) (\_ -> pure Continue))- { consumerGroup = Just (validConsumerGroup 3 4)- , consumerGroupGuard = True- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- res <- waitWithTimeout 5_000_000 handle- Connection.release conn- case res of- Right (Left e)- | Just (ConsumerGroupGuardConflict (SubscriptionName "guard-sub") 3) <- fromException e ->- pure ()- other ->- expectationFailure ("expected ConsumerGroupGuardConflict, got: " <> show other)+ let cfg =+ (defaultSubscriptionConfig (SubscriptionName "guard-sub") (Category (CategoryName "guardcat")) (\_ -> pure Continue))+ { consumerGroup = Just (validConsumerGroup 3 4),+ consumerGroupGuard = True,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ res <- waitWithTimeout 5_000_000 handle+ Connection.release conn+ case res of+ Right (Left e)+ | Just (ConsumerGroupGuardConflict (SubscriptionName "guard-sub") 3) <- fromException e ->+ pure ()+ other ->+ expectationFailure ("expected ConsumerGroupGuardConflict, got: " <> show other) - it "subscriptionStream forwards the consumer-group field (member 0 of 2 sees its slice)" $- withTestStore $ \store -> do- let nStreams = 20- perStream = 2- total = nStreams * perStream- streams = ["bridge-" <> T.pack (show i) | i <- [1 .. nStreams]]- seed store streams perStream- waitForPublisher store (GlobalPosition (fromIntegral total))+ it "subscriptionStream forwards the consumer-group field (member 0 of 2 sees its slice)" $+ withTestStore $ \store -> do+ let nStreams = 20+ perStream = 2+ total = nStreams * perStream+ streams = ["bridge-" <> T.pack (show i) | i <- [1 .. nStreams]]+ seed store streams perStream+ waitForPublisher store (GlobalPosition (fromIntegral total)) - sliceV <-- runStmtP store $- Session.statement- (0 :: Int64, "bridge" :: Text, 0 :: Int32, 2 :: Int32, 100000 :: Int32)- SQL.readCategoryForwardConsumerGroupStmt- let slicePos = sort (map (snd . pairOf) (V.toList sliceV))- k = length slicePos- -- A proper, non-trivial subset of the whole category.- k `shouldSatisfy` (\x -> x > 0 && x < total)+ sliceV <-+ runStmtP store $+ Session.statement+ (0 :: Int64, "bridge" :: Text, 0 :: Int32, 2 :: Int32, 100000 :: Int32)+ SQL.readCategoryForwardConsumerGroupStmt+ let slicePos = sort (map (snd . pairOf) (V.toList sliceV))+ k = length slicePos+ -- A proper, non-trivial subset of the whole category.+ k `shouldSatisfy` (\x -> x > 0 && x < total) - (stream, cancelStream) <-- subscriptionStream store (memberConfig "bridge-sub" "bridge" 0 2 (\_ -> pure Continue)) (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 64))- pulled <- Stream.toList (Stream.take k stream)- cancelStream- sort (map (snd . pairOf) pulled) `shouldBe` slicePos+ (stream, cancelStream) <-+ subscriptionStream store (memberConfig "bridge-sub" "bridge" 0 2 (\_ -> pure Continue)) (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 64))+ pulled <- Stream.toList (Stream.take k stream)+ cancelStream+ sort (map (snd . pairOf) pulled) `shouldBe` slicePos
test/Test/ConsumerGroupEffect.hs view
@@ -2,35 +2,34 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -{- | Consumer-group tests for the /effectful/ subscription API (ExecPlan 30 /-EP-3). EP-2's 'Test.ConsumerGroup' proves the partitioning contract through the-plain-IO 'Kiroku.Store.Subscription.subscribe'; this module proves the same-contract holds through 'Kiroku.Store.Subscription.Effect' — the higher-order-'Subscription' effect interpreted by 'runSubscription' inside an @Eff@ stack.--Two properties are proven:-- * Each member of a size-2 category group, driven through @runSubscription@ /- @subscribe@, receives exactly its hash-assigned slice; the two slices are- /disjoint/, their union is /complete/, and each is /per-stream-ordered/.- * The @ConcUnlift Persistent (Limited 1)@ strategy used by 'runSubscription'- keeps the effect environment alive across handler calls: an- 'Effectful.State.Static.Local.State' counter mutated inside the handler- accumulates @1, 2, 3, ...@ across deliveries rather than resetting to @1@- each call (which is what an @Ephemeral@ unlift would produce).--__Why every handler returns 'Stop' rather than relying on external cancel.__-A worker started through the effectful interpreter runs its handler via the-captured @localUnliftIO@ environment. Cancelling such a worker from outside-(e.g. through the effectful 'Kiroku.Store.Subscription.Effect.withSubscription'-bracket) while it is blocked inside that unlift does not terminate cleanly — it-hangs the worker thread. Every member here therefore stops itself once it has-seen its expected number of events, so the worker exits on its own and the-@Eff@ scope closes with no live thread (the same shape as the working effectful-test in @kiroku-store/test/Main.hs@, "catches up with an Eff-based handler via-the effectful API"). See this module's Surprises note in-@docs/plans/30-consumer-group-effect-api-and-shibuya-adapter-integration.md@.--}+-- | Consumer-group tests for the /effectful/ subscription API (ExecPlan 30 /+-- EP-3). EP-2's 'Test.ConsumerGroup' proves the partitioning contract through the+-- plain-IO 'Kiroku.Store.Subscription.subscribe'; this module proves the same+-- contract holds through 'Kiroku.Store.Subscription.Effect' — the higher-order+-- 'Subscription' effect interpreted by 'runSubscription' inside an @Eff@ stack.+--+-- Two properties are proven:+--+-- * Each member of a size-2 category group, driven through @runSubscription@ /+-- @subscribe@, receives exactly its hash-assigned slice; the two slices are+-- /disjoint/, their union is /complete/, and each is /per-stream-ordered/.+-- * The @ConcUnlift Persistent (Limited 1)@ strategy used by 'runSubscription'+-- keeps the effect environment alive across handler calls: an+-- 'Effectful.State.Static.Local.State' counter mutated inside the handler+-- accumulates @1, 2, 3, ...@ across deliveries rather than resetting to @1@+-- each call (which is what an @Ephemeral@ unlift would produce).+--+-- __Why every handler returns 'Stop' rather than relying on external cancel.__+-- A worker started through the effectful interpreter runs its handler via the+-- captured @localUnliftIO@ environment. Cancelling such a worker from outside+-- (e.g. through the effectful 'Kiroku.Store.Subscription.Effect.withSubscription'+-- bracket) while it is blocked inside that unlift does not terminate cleanly — it+-- hangs the worker thread. Every member here therefore stops itself once it has+-- seen its expected number of events, so the worker exits on its own and the+-- @Eff@ scope closes with no live thread (the same shape as the working effectful+-- test in @kiroku-store/test/Main.hs@, "catches up with an Eff-based handler via+-- the effectful API"). See this module's Surprises note in+-- @docs/plans/30-consumer-group-effect-api-and-shibuya-adapter-integration.md@. module Test.ConsumerGroupEffect (spec) where import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)@@ -58,150 +57,148 @@ -- | Extract @(originalStreamId, globalPosition)@ as raw 'Int64's from an event. pairOf :: RecordedEvent -> (Int64, Int64) pairOf e =- ( case e ^. #originalStreamId of StreamId s -> s- , case e ^. #globalPosition of GlobalPosition p -> p- )+ ( case e ^. #originalStreamId of StreamId s -> s,+ case e ^. #globalPosition of GlobalPosition p -> p+ ) -- | Append @perStream@ events to each named stream, seeding the store. seed :: KirokuStore -> [Text] -> Int -> IO () seed store names perStream =- mapM_- ( \sn -> do- let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]- r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs- case r of- Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)- Right _ -> pure ()- )- names+ mapM_+ ( \sn -> do+ let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]+ r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs+ case r of+ Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)+ Right _ -> pure ()+ )+ names -{- | Assert that within each originating stream the global positions, taken in-delivery order, are strictly ascending (i.e. equal to their sorted form).--}+-- | Assert that within each originating stream the global positions, taken in+-- delivery order, are strictly ascending (i.e. equal to their sorted form). assertPerStreamAscending :: [(Int64, Int64)] -> Expectation assertPerStreamAscending chrono =- let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]- in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)+ let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]+ in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream) -- | Run a Hasql session against the store pool, failing the test on error. runStmtP :: KirokuStore -> Session.Session a -> IO a runStmtP store session = do- r <- Pool.use (store ^. #pool) session- either (error . show) pure r+ r <- Pool.use (store ^. #pool) session+ either (error . show) pure r -{- | Drive one effectful category-group member to self-completion: it 'Stop's-after receiving exactly @k@ events, so the worker exits on its own (no external-cancel). Returns the @(streamId, globalPosition)@ pairs in delivery order. The-handler runs in @Eff es@ (needing only 'IOE'), exercising the @runSubscription@-record-update pass-through of the @consumerGroup@ field.--}+-- | Drive one effectful category-group member to self-completion: it 'Stop's+-- after receiving exactly @k@ events, so the worker exits on its own (no external+-- cancel). Returns the @(streamId, globalPosition)@ pairs in delivery order. The+-- handler runs in @Eff es@ (needing only 'IOE'), exercising the @runSubscription@+-- record-update pass-through of the @consumerGroup@ field. runEffMember :: KirokuStore -> Text -> Text -> Int32 -> Int32 -> Int -> IO [(Int64, Int64)] runEffMember store nm cat m n k = do- ref <- newIORef []- countVar <- newTVarIO (0 :: Int)- let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult- effHandler evt = do- liftIO $ modifyIORef' ref (pairOf evt :)- c <- liftIO $ atomically $ do- c0 <- readTVar countVar- writeTVar countVar (c0 + 1)- pure (c0 + 1)- pure (if c >= k then Stop else Continue)- cfg =- (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) effHandler)- { consumerGroup = Just (validConsumerGroup m n)- }- runEff $ SubEff.runSubscription store $ do- handle <- SubEff.subscribe cfg- liftIO $ do- r <- waitWithTimeout 15_000_000 handle- case r of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("effectful member failed: " <> show err)- Right (Right ()) -> pure ()- reverse <$> readIORef ref+ ref <- newIORef []+ countVar <- newTVarIO (0 :: Int)+ let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult+ effHandler evt = do+ liftIO $ modifyIORef' ref (pairOf evt :)+ c <- liftIO $ atomically $ do+ c0 <- readTVar countVar+ writeTVar countVar (c0 + 1)+ pure (c0 + 1)+ pure (if c >= k then Stop else Continue)+ cfg =+ (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) effHandler)+ { consumerGroup = Just (validConsumerGroup m n)+ }+ runEff $ SubEff.runSubscription store $ do+ handle <- SubEff.subscribe cfg+ liftIO $ do+ r <- waitWithTimeout 15_000_000 handle+ case r of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("effectful member failed: " <> show err)+ Right (Right ()) -> pure ()+ reverse <$> readIORef ref spec :: Spec spec = describe "consumer groups (effectful)" $ do- it "routes each member through the effectful API to its own disjoint, complete, per-stream-ordered slice (size-2 category group)" $- withTestStore $ \store -> do- let nStreams = 40- perStream = 3- total = nStreams * perStream- streams = ["effcg-" <> T.pack (show i) | i <- [1 .. nStreams]]- seed store streams perStream- waitForPublisher store (GlobalPosition (fromIntegral total))+ it "routes each member through the effectful API to its own disjoint, complete, per-stream-ordered slice (size-2 category group)" $+ withTestStore $ \store -> do+ let nStreams = 40+ perStream = 3+ total = nStreams * perStream+ streams = ["effcg-" <> T.pack (show i) | i <- [1 .. nStreams]]+ seed store streams perStream+ waitForPublisher store (GlobalPosition (fromIntegral total)) - -- Deterministic ground truth: each member's slice size straight from the- -- EP-1 partition SQL. The effectful workers must reproduce exactly these.- let sliceSize m =- V.length- <$> runStmtP- store- ( Session.statement- (0 :: Int64, "effcg" :: Text, m, 2 :: Int32, 100000 :: Int32)- SQL.readCategoryForwardConsumerGroupStmt- )- k0 <- sliceSize 0- k1 <- sliceSize 1- k0 `shouldSatisfy` (> 0)- k1 `shouldSatisfy` (> 0)- (k0 + k1) `shouldBe` total+ -- Deterministic ground truth: each member's slice size straight from the+ -- EP-1 partition SQL. The effectful workers must reproduce exactly these.+ let sliceSize m =+ V.length+ <$> runStmtP+ store+ ( Session.statement+ (0 :: Int64, "effcg" :: Text, m, 2 :: Int32, 100000 :: Int32)+ SQL.readCategoryForwardConsumerGroupStmt+ )+ k0 <- sliceSize 0+ k1 <- sliceSize 1+ k0 `shouldSatisfy` (> 0)+ k1 `shouldSatisfy` (> 0)+ (k0 + k1) `shouldBe` total - -- Each member is driven through runSubscription/subscribe and self-exits.- -- Same subscription name, distinct members → independent (name, member)- -- checkpoints, so running them in sequence is safe.- m0 <- runEffMember store "eff-cg-cat" "effcg" 0 2 k0- m1 <- runEffMember store "eff-cg-cat" "effcg" 1 2 k1+ -- Each member is driven through runSubscription/subscribe and self-exits.+ -- Same subscription name, distinct members → independent (name, member)+ -- checkpoints, so running them in sequence is safe.+ m0 <- runEffMember store "eff-cg-cat" "effcg" 0 2 k0+ m1 <- runEffMember store "eff-cg-cat" "effcg" 1 2 k1 - -- (1) Disjoint + complete: the union of both members' delivered positions- -- is exactly [1..total] — no duplicate and no gap.- let allPositions = sort (map snd m0 ++ map snd m1)- allPositions `shouldBe` [1 .. fromIntegral total]- -- (2) Per-stream ordering preserved within each member.- assertPerStreamAscending m0- assertPerStreamAscending m1+ -- (1) Disjoint + complete: the union of both members' delivered positions+ -- is exactly [1..total] — no duplicate and no gap.+ let allPositions = sort (map snd m0 ++ map snd m1)+ allPositions `shouldBe` [1 .. fromIntegral total]+ -- (2) Per-stream ordering preserved within each member.+ assertPerStreamAscending m0+ assertPerStreamAscending m1 - it "preserves State across handler calls within one member (Persistent unlift)" $- withTestStore $ \store -> do- -- Five events on one stream of category "effstate"; a size-1 member sees all.- seed store ["effstate-1"] 5- waitForPublisher store (GlobalPosition 5)+ it "preserves State across handler calls within one member (Persistent unlift)" $+ withTestStore $ \store -> do+ -- Five events on one stream of category "effstate"; a size-1 member sees all.+ seed store ["effstate-1"] 5+ waitForPublisher store (GlobalPosition 5) - -- 'seenRef' records the Effectful State value observed after each- -- increment; 'nVar' counts deliveries and drives the Stop (so self-exit- -- never depends on the property under test). With the Persistent unlift- -- the cloned environment is reused across calls and the counter- -- accumulates 1,2,3,4,5; an Ephemeral unlift would record 1 every time.- seenRef <- newIORef ([] :: [Int])- nVar <- newTVarIO (0 :: Int)- runEff $- SubEff.runSubscription store $- State.evalState (0 :: Int) $ do- let cfg =- ( defaultSubscriptionConfig- (SubscriptionName "eff-state-sub")- (Category (CategoryName "effstate"))- ( \_ -> do- State.modify @Int (+ 1)- s <- State.get @Int- liftIO (modifyIORef' seenRef (s :))- c <- liftIO $ atomically $ do- c0 <- readTVar nVar- writeTVar nVar (c0 + 1)- pure (c0 + 1)- pure (if c >= 5 then Stop else Continue)- )- )- { consumerGroup = Just (validConsumerGroup 0 1)- }- handle <- SubEff.subscribe cfg- liftIO $ do- r <- waitWithTimeout 10_000_000 handle- case r of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("effectful state member failed: " <> show err)- Right (Right ()) -> pure ()+ -- 'seenRef' records the Effectful State value observed after each+ -- increment; 'nVar' counts deliveries and drives the Stop (so self-exit+ -- never depends on the property under test). With the Persistent unlift+ -- the cloned environment is reused across calls and the counter+ -- accumulates 1,2,3,4,5; an Ephemeral unlift would record 1 every time.+ seenRef <- newIORef ([] :: [Int])+ nVar <- newTVarIO (0 :: Int)+ runEff $+ SubEff.runSubscription store $+ State.evalState (0 :: Int) $ do+ let cfg =+ ( defaultSubscriptionConfig+ (SubscriptionName "eff-state-sub")+ (Category (CategoryName "effstate"))+ ( \_ -> do+ State.modify @Int (+ 1)+ s <- State.get @Int+ liftIO (modifyIORef' seenRef (s :))+ c <- liftIO $ atomically $ do+ c0 <- readTVar nVar+ writeTVar nVar (c0 + 1)+ pure (c0 + 1)+ pure (if c >= 5 then Stop else Continue)+ )+ )+ { consumerGroup = Just (validConsumerGroup 0 1)+ }+ handle <- SubEff.subscribe cfg+ liftIO $ do+ r <- waitWithTimeout 10_000_000 handle+ case r of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("effectful state member failed: " <> show err)+ Right (Right ()) -> pure () - seen <- reverse <$> readIORef seenRef- seen `shouldBe` [1, 2, 3, 4, 5]+ seen <- reverse <$> readIORef seenRef+ seen `shouldBe` [1, 2, 3, 4, 5]
test/Test/ConsumerGroupResize.hs view
@@ -32,174 +32,174 @@ spec :: Spec spec = describe "consumer-group resize" $ do- it "rejects invalid sizes and member indices at construction" $ do- mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)- mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))- let size2 = groupSize 2- mkConsumerGroup (-1) size2 `shouldBe` Left (InvalidConsumerGroup (-1) 2)- mkConsumerGroup 2 size2 `shouldBe` Left (InvalidConsumerGroup 2 2)- mkConsumerGroup 1 size2 `shouldBe` Right (validConsumerGroup 1 2)+ it "rejects invalid sizes and member indices at construction" $ do+ mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)+ mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))+ let size2 = groupSize 2+ mkConsumerGroup (-1) size2 `shouldBe` Left (InvalidConsumerGroup (-1) 2)+ mkConsumerGroup 2 size2 `shouldBe` Left (InvalidConsumerGroup 2 2)+ mkConsumerGroup 1 size2 `shouldBe` Right (validConsumerGroup 1 2) - it "refuses a configured size that disagrees with stored topology before creating a member" $ do- refusals <- newIORef []- let observe = \case- KirokuEventSubscriptionGroupSizeMismatch refusal group -> modifyIORef' refusals ((refusal, group) :)- _ -> pure ()- withTestStoreSettings (\settings -> settings{eventHandler = Just observe}) $ \store -> do- seedCheckpoint store "mismatch" 0 2 5- called <- newIORef False- handle <- subscribe store $ config "mismatch" 2 3 $ \_ -> writeIORef called True >> pure Continue- expectMismatch handle "mismatch" 3 [2]- readIORef called `shouldReturn` False- rows store "mismatch" `shouldReturn` [(0, 2, 5)]- readIORef refusals `shouldReturn` [(ConsumerGroupSizeMismatch (SubscriptionName "mismatch") 3 (Vector.singleton 2), GroupMember 2 3)]+ it "refuses a configured size that disagrees with stored topology before creating a member" $ do+ refusals <- newIORef []+ let observe = \case+ KirokuEventSubscriptionGroupSizeMismatch refusal group -> modifyIORef' refusals ((refusal, group) :)+ _ -> pure ()+ withTestStoreSettings (\settings -> settings {eventHandler = Just observe}) $ \store -> do+ seedCheckpoint store "mismatch" 0 2 5+ called <- newIORef False+ handle <- subscribe store $ config "mismatch" 2 3 $ \_ -> writeIORef called True >> pure Continue+ expectMismatch handle "mismatch" 3 [2]+ readIORef called `shouldReturn` False+ rows store "mismatch" `shouldReturn` [(0, 2, 5)]+ readIORef refusals `shouldReturn` [(ConsumerGroupSizeMismatch (SubscriptionName "mismatch") 3 (Vector.singleton 2), GroupMember 2 3)] - it "also refuses an ordinary subscription over an existing larger group" $- withTestStore $ \store -> do- seedCheckpoint store "ordinary-mismatch" 0 2 0- handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "ordinary-mismatch") AllStreams (\_ -> pure Continue))- expectMismatch handle "ordinary-mismatch" 1 [2]+ it "also refuses an ordinary subscription over an existing larger group" $+ withTestStore $ \store -> do+ seedCheckpoint store "ordinary-mismatch" 0 2 0+ handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "ordinary-mismatch") AllStreams (\_ -> pure Continue))+ expectMismatch handle "ordinary-mismatch" 1 [2] - it "refuses an underestimated derived topology until resized" $- withTestStore $ \store -> do- -- The upgrade test proves a lone legacy member derives size 1.- seedCheckpoint store "underestimated" 0 1 8- refused <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))- expectMismatch refused "underestimated" 2 [1]- void $ resize store "underestimated" 2- accepted <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))- awaitLive accepted- cancel accepted- rows store "underestimated" `shouldReturn` [(0, 2, 8), (1, 2, 8)]+ it "refuses an underestimated derived topology until resized" $+ withTestStore $ \store -> do+ -- The upgrade test proves a lone legacy member derives size 1.+ seedCheckpoint store "underestimated" 0 1 8+ refused <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))+ expectMismatch refused "underestimated" 2 [1]+ void $ resize store "underestimated" 2+ accepted <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))+ awaitLive accepted+ cancel accepted+ rows store "underestimated" `shouldReturn` [(0, 2, 8), (1, 2, 8)] - it "refuses mixed stored sizes even when its own row agrees" $- withTestStore $ \store -> do- seedCheckpoint store "mixed" 0 2 0- seedCheckpoint store "mixed" 1 3 0- handle <- subscribe store (config "mixed" 0 2 (\_ -> pure Continue))- expectMismatch handle "mixed" 2 [2, 3]+ it "refuses mixed stored sizes even when its own row agrees" $+ withTestStore $ \store -> do+ seedCheckpoint store "mixed" 0 2 0+ seedCheckpoint store "mixed" 1 3 0+ handle <- subscribe store (config "mixed" 0 2 (\_ -> pure Continue))+ expectMismatch handle "mixed" 2 [2, 3] - it "serializes concurrent initializers with competing group sizes" $- withTestStore $ \store -> do- [firstHandle, secondHandle] <- Async.mapConcurrently (\n -> subscribe store (config "concurrent" 0 n (\_ -> pure Continue))) [2, 3]- -- The winner reaches Live; the loser exits with a typed refusal.- result <- timeout 5_000_000 $ Async.race (wait firstHandle) (wait secondHandle)- case result of- Just (Left outcome) -> assertMismatch outcome- Just (Right outcome) -> assertMismatch outcome- Nothing -> expectationFailure "competing topology startup timed out"- mapM_ cancel [firstHandle, secondHandle]- stored <- rows store "concurrent"- length stored `shouldBe` 1- stored `shouldSatisfy` \case [(0, n, 0)] -> n == 2 || n == 3; _ -> False+ it "serializes concurrent initializers with competing group sizes" $+ withTestStore $ \store -> do+ [firstHandle, secondHandle] <- Async.mapConcurrently (\n -> subscribe store (config "concurrent" 0 n (\_ -> pure Continue))) [2, 3]+ -- The winner reaches Live; the loser exits with a typed refusal.+ result <- timeout 5_000_000 $ Async.race (wait firstHandle) (wait secondHandle)+ case result of+ Just (Left outcome) -> assertMismatch outcome+ Just (Right outcome) -> assertMismatch outcome+ Nothing -> expectationFailure "competing topology startup timed out"+ mapM_ cancel [firstHandle, secondHandle]+ stored <- rows store "concurrent"+ length stored `shouldBe` 1+ stored `shouldSatisfy` \case [(0, n, 0)] -> n == 2 || n == 3; _ -> False - it "persists configured topology on initialization, ordinary saves and dead-letter saves" $- withTestStore $ \store -> do- void $ runStoreIO store $ appendToStream (StreamName "persist-1") NoStream [makeEvent "E" (Aeson.object [])]- -- A size-1 group exercises all rows deterministically. Size >1 is- -- checked below via explicit ordinary and dead-letter SQL paths.- handle <- subscribe store (config "saved" 0 1 (\_ -> pure Stop))- expectClean handle- rows store "saved" `shouldReturn` [(0, 1, 1)]- live <- subscribe store ((config "initialized" 2 3 (\_ -> pure Continue)){missingCheckpointPolicy = FromCurrentHead})- awaitLive live- cancel live- rows store "initialized" `shouldReturn` [(2, 3, 1)]- seedCheckpoint store "saved-size" 1 4 20- seedCheckpoint store "saved-size" 1 4 10- rows store "saved-size" `shouldReturn` [(1, 4, 20)]- Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)- let event = Vector.head events- EventId eid = event ^. #eventId- params = SQL.DeadLetterParams "dead-letter-size" 1 "unbound" Nothing 4 1 eid (Aeson.object []) "test" 1- runSession store (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)- rows store "dead-letter-size" `shouldReturn` [(1, 4, 1)]- let StreamId sid = event ^. #originalStreamId- ownerStmt =- preparable- "SELECT (((hashtextextended($1::bigint::text, 0) % 4) + 4) % 4)::int4"- (E.param (E.nonNullable E.int8))- (D.singleRow (D.column (D.nonNullable D.int4)))- owner <- runSession store (Session.statement sid ownerStmt)- saved <- subscribe store (config "worker-save-size" owner 4 (\_ -> pure Stop))- expectClean saved- rows store "worker-save-size" `shouldReturn` [(owner, 4, 1)]- deadLettered <- subscribe store (config "worker-dead-letter-size" owner 4 (\_ -> pure (DeadLetter (DeadLetterPoison "explicit consumer decision"))))- awaitLive deadLettered- cancel deadLettered- rows store "worker-dead-letter-size" `shouldReturn` [(owner, 4, 1)]+ it "persists configured topology on initialization, ordinary saves and dead-letter saves" $+ withTestStore $ \store -> do+ void $ runStoreIO store $ appendToStream (StreamName "persist-1") NoStream [makeEvent "E" (Aeson.object [])]+ -- A size-1 group exercises all rows deterministically. Size >1 is+ -- checked below via explicit ordinary and dead-letter SQL paths.+ handle <- subscribe store (config "saved" 0 1 (\_ -> pure Stop))+ expectClean handle+ rows store "saved" `shouldReturn` [(0, 1, 1)]+ live <- subscribe store ((config "initialized" 2 3 (\_ -> pure Continue)) {missingCheckpointPolicy = FromCurrentHead})+ awaitLive live+ cancel live+ rows store "initialized" `shouldReturn` [(2, 3, 1)]+ seedCheckpoint store "saved-size" 1 4 20+ seedCheckpoint store "saved-size" 1 4 10+ rows store "saved-size" `shouldReturn` [(1, 4, 20)]+ Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)+ let event = Vector.head events+ EventId eid = event ^. #eventId+ params = SQL.DeadLetterParams "dead-letter-size" 1 "unbound" Nothing 4 1 eid (Aeson.object []) "test" 1+ runSession store (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)+ rows store "dead-letter-size" `shouldReturn` [(1, 4, 1)]+ let StreamId sid = event ^. #originalStreamId+ ownerStmt =+ preparable+ "SELECT (((hashtextextended($1::bigint::text, 0) % 4) + 4) % 4)::int4"+ (E.param (E.nonNullable E.int8))+ (D.singleRow (D.column (D.nonNullable D.int4)))+ owner <- runSession store (Session.statement sid ownerStmt)+ saved <- subscribe store (config "worker-save-size" owner 4 (\_ -> pure Stop))+ expectClean saved+ rows store "worker-save-size" `shouldReturn` [(owner, 4, 1)]+ deadLettered <- subscribe store (config "worker-dead-letter-size" owner 4 (\_ -> pure (DeadLetter (DeadLetterPoison "explicit consumer decision"))))+ awaitLive deadLettered+ cancel deadLettered+ rows store "worker-dead-letter-size" `shouldReturn` [(owner, 4, 1)] - it "delivers every seeded event after equalizing size 2 to size 3" $- withTestStore $ \store -> do- forM_ [1 .. 40 :: Int] $ \i -> do- result <- runStoreIO store $ appendToStream (StreamName ("resize-" <> Text.pack (show i))) NoStream [makeEvent "E" (Aeson.object [])]- result `shouldSatisfy` either (const False) (const True)- Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 100)- let expected = Set.fromList [event ^. #eventId | event <- Vector.toList events]- -- Member 0 has seen nothing, member 1 has crossed the full log.- -- Remember its actual delivered set; a naive size change loses- -- streams that move from old member 0 to new member 1.- oldFast <- runSession store (Session.statement (0, 1, 2, 100) SQL.readAllForwardConsumerGroupStmt)- naive <- mapM (\(m, p) -> runSession store (Session.statement (p, m, 3, 100) SQL.readAllForwardConsumerGroupStmt)) [(0, 0), (1, 40), (2, 0)]- let alreadySeen = Set.fromList [e ^. #eventId | e <- Vector.toList oldFast]- naiveSeen = Set.fromList [e ^. #eventId | batch <- naive, e <- Vector.toList batch]- Set.union alreadySeen naiveSeen `shouldNotBe` expected- seedCheckpoint store "skewed" 0 2 0- seedCheckpoint store "skewed" 1 2 40- refused <- subscribe store (config "skewed" 1 3 (\_ -> pure Continue))- expectMismatch refused "skewed" 3 [2]- report <- resize store "skewed" 3- report `shouldBe` ConsumerGroupResizeReport (Vector.singleton 2) 2 (groupSize 3) (GlobalPosition 0)- delivered <- newTVarIO alreadySeen- handles <- mapM (\m -> subscribe store (config "skewed" m 3 (\e -> atomically (modifyTVar' delivered (Set.insert (e ^. #eventId))) >> pure Continue))) [0, 1, 2]- complete <- timeout 15_000_000 (atomically (readTVar delivered >>= check . (== expected)))- mapM_ cancel handles- complete `shouldBe` Just ()- readTVarIO delivered `shouldReturn` expected+ it "delivers every seeded event after equalizing size 2 to size 3" $+ withTestStore $ \store -> do+ forM_ [1 .. 40 :: Int] $ \i -> do+ result <- runStoreIO store $ appendToStream (StreamName ("resize-" <> Text.pack (show i))) NoStream [makeEvent "E" (Aeson.object [])]+ result `shouldSatisfy` either (const False) (const True)+ Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 100)+ let expected = Set.fromList [event ^. #eventId | event <- Vector.toList events]+ -- Member 0 has seen nothing, member 1 has crossed the full log.+ -- Remember its actual delivered set; a naive size change loses+ -- streams that move from old member 0 to new member 1.+ oldFast <- runSession store (Session.statement (0, 1, 2, 100) SQL.readAllForwardConsumerGroupStmt)+ naive <- mapM (\(m, p) -> runSession store (Session.statement (p, m, 3, 100) SQL.readAllForwardConsumerGroupStmt)) [(0, 0), (1, 40), (2, 0)]+ let alreadySeen = Set.fromList [e ^. #eventId | e <- Vector.toList oldFast]+ naiveSeen = Set.fromList [e ^. #eventId | batch <- naive, e <- Vector.toList batch]+ Set.union alreadySeen naiveSeen `shouldNotBe` expected+ seedCheckpoint store "skewed" 0 2 0+ seedCheckpoint store "skewed" 1 2 40+ refused <- subscribe store (config "skewed" 1 3 (\_ -> pure Continue))+ expectMismatch refused "skewed" 3 [2]+ report <- resize store "skewed" 3+ report `shouldBe` ConsumerGroupResizeReport (Vector.singleton 2) 2 (groupSize 3) (GlobalPosition 0)+ delivered <- newTVarIO alreadySeen+ handles <- mapM (\m -> subscribe store (config "skewed" m 3 (\e -> atomically (modifyTVar' delivered (Set.insert (e ^. #eventId))) >> pure Continue))) [0, 1, 2]+ complete <- timeout 15_000_000 (atomically (readTVar delivered >>= check . (== expected)))+ mapM_ cancel handles+ complete `shouldBe` Just ()+ readTVarIO delivered `shouldReturn` expected - it "is idempotent when repeated at the same size and retains surviving member identities" $- withTestStore $ \store -> do- seedCheckpoint store "repeat" 0 2 5- seedCheckpoint store "repeat" 1 2 20- oldIds <- memberIds store "repeat"- void $ resize store "repeat" 3- originalRows <- rows store "repeat"- resizedIds <- memberIds store "repeat"- take 2 resizedIds `shouldBe` oldIds- second <- resize store "repeat" 3- rows store "repeat" `shouldReturn` originalRows- memberIds store "repeat" `shouldReturn` resizedIds- originalRows `shouldBe` [(0, 3, 5), (1, 3, 5), (2, 3, 5)]- resumePosition second `shouldBe` GlobalPosition 5- previousMemberCount second `shouldBe` 3+ it "is idempotent when repeated at the same size and retains surviving member identities" $+ withTestStore $ \store -> do+ seedCheckpoint store "repeat" 0 2 5+ seedCheckpoint store "repeat" 1 2 20+ oldIds <- memberIds store "repeat"+ void $ resize store "repeat" 3+ originalRows <- rows store "repeat"+ resizedIds <- memberIds store "repeat"+ take 2 resizedIds `shouldBe` oldIds+ second <- resize store "repeat" 3+ rows store "repeat" `shouldReturn` originalRows+ memberIds store "repeat" `shouldReturn` resizedIds+ originalRows `shouldBe` [(0, 3, 5), (1, 3, 5), (2, 3, 5)]+ resumePosition second `shouldBe` GlobalPosition 5+ previousMemberCount second `shouldBe` 3 - it "starts a missing group at zero and removes obsolete members on shrink" $- withTestStore $ \store -> do- report <- resize store "missing" 3- previousMemberCount report `shouldBe` 0- resumePosition report `shouldBe` GlobalPosition 0- rows store "missing" `shouldReturn` [(0, 3, 0), (1, 3, 0), (2, 3, 0)]- void $ resize store "missing" 1- rows store "missing" `shouldReturn` [(0, 1, 0)]+ it "starts a missing group at zero and removes obsolete members on shrink" $+ withTestStore $ \store -> do+ report <- resize store "missing" 3+ previousMemberCount report `shouldBe` 0+ resumePosition report `shouldBe` GlobalPosition 0+ rows store "missing" `shouldReturn` [(0, 3, 0), (1, 3, 0), (2, 3, 0)]+ void $ resize store "missing" 1+ rows store "missing" `shouldReturn` [(0, 1, 0)] - it "rolls back the complete resize with the caller transaction" $- withTestStore $ \store -> do- seedCheckpoint store "rollback" 0 2 5- seedCheckpoint store "rollback" 1 2 20- void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do- report <- resizeConsumerGroupTx (SubscriptionName "rollback") (groupSize 3)- Tx.condemn- pure report- rows store "rollback" `shouldReturn` [(0, 2, 5), (1, 2, 20)]+ it "rolls back the complete resize with the caller transaction" $+ withTestStore $ \store -> do+ seedCheckpoint store "rollback" 0 2 5+ seedCheckpoint store "rollback" 1 2 20+ void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ report <- resizeConsumerGroupTx (SubscriptionName "rollback") (groupSize 3)+ Tx.condemn+ pure report+ rows store "rollback" `shouldReturn` [(0, 2, 5), (1, 2, 20)] groupSize :: Int32 -> ConsumerGroupSize groupSize = either (error . show) Prelude.id . mkConsumerGroupSize config :: Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig config name m n handler =- (defaultSubscriptionConfig (SubscriptionName name) AllStreams handler)- { consumerGroup = Just (validConsumerGroup m n)- }+ (defaultSubscriptionConfig (SubscriptionName name) AllStreams handler)+ { consumerGroup = Just (validConsumerGroup m n)+ } seedCheckpoint :: KirokuStore -> Text -> Int32 -> Int32 -> Int64 -> IO () seedCheckpoint store name m n p = runSession store (Session.statement (name, m, p, n, "unbound", Nothing) SQL.saveCheckpointMemberStmt)@@ -211,47 +211,47 @@ rows store name = Vector.toList <$> runSession store (Session.statement name stmt) where stmt =- preparable- "SELECT consumer_group_member, consumer_group_size, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"- (E.param (E.nonNullable E.text))- (D.rowVector ((,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8)))+ preparable+ "SELECT consumer_group_member, consumer_group_size, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"+ (E.param (E.nonNullable E.text))+ (D.rowVector ((,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8))) runSession :: KirokuStore -> Session.Session a -> IO a runSession store session = Pool.use (store ^. #pool) session >>= either (error . show) pure expectMismatch :: SubscriptionHandle -> Text -> Int32 -> [Int32] -> IO () expectMismatch handle name n observed = do- result <- waitWithTimeout 5_000_000 handle- case result of- Right (Left exception) -> fromException exception `shouldBe` Just (ConsumerGroupSizeMismatch (SubscriptionName name) n (Vector.fromList observed))- other -> expectationFailure ("expected topology mismatch, got " <> show other)+ result <- waitWithTimeout 5_000_000 handle+ case result of+ Right (Left exception) -> fromException exception `shouldBe` Just (ConsumerGroupSizeMismatch (SubscriptionName name) n (Vector.fromList observed))+ other -> expectationFailure ("expected topology mismatch, got " <> show other) assertMismatch :: Either SomeException () -> IO () assertMismatch = \case- Left exception -> (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)- Right () -> expectationFailure "expected competing-size startup refusal"+ Left exception -> (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)+ Right () -> expectationFailure "expected competing-size startup refusal" expectClean :: SubscriptionHandle -> IO () expectClean handle =- waitWithTimeout 5_000_000 handle >>= \case- Right (Right ()) -> pure ()- other -> expectationFailure (show other)+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Right ()) -> pure ()+ other -> expectationFailure (show other) awaitLive :: SubscriptionHandle -> IO () awaitLive handle = do- result <- timeout 5_000_000 loop- result `shouldBe` Just ()+ result <- timeout 5_000_000 loop+ result `shouldBe` Just () where loop =- currentState handle >>= \case- Just state | stateName state == "live" -> pure ()- _ -> threadDelay 1_000 >> loop+ currentState handle >>= \case+ Just state | stateName state == "live" -> pure ()+ _ -> threadDelay 1_000 >> loop memberIds :: KirokuStore -> Text -> IO [Int64] memberIds store name =- runSession store $- Session.statement name $- preparable- "SELECT subscription_id FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"- (E.param (E.nonNullable E.text))- (D.rowList (D.column (D.nonNullable D.int8)))+ runSession store $+ Session.statement name $+ preparable+ "SELECT subscription_id FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"+ (E.param (E.nonNullable E.text))+ (D.rowList (D.column (D.nonNullable D.int8)))
test/Test/ConsumerGroupSql.hs view
@@ -1,15 +1,14 @@ {-# LANGUAGE OverloadedStrings #-} -{- | SQL-level tests for consumer-group partition routing and per-member-checkpoints (ExecPlan 28 / EP-1). These exercise the new prepared statements in-"Kiroku.Store.SQL" directly through the connection pool, with no subscription-runtime, on a fresh ephemeral PostgreSQL per test.--Terms: a /consumer group/ of /size/ N has members 0..N-1; each source stream is-assigned to exactly one member by 'Kiroku.Store.SQL'-encoded hash routing. The-properties proven here — disjointness, completeness, per-stream affinity,-determinism, and size-1 equivalence — are the contract EP-2's runtime depends on.--}+-- | SQL-level tests for consumer-group partition routing and per-member+-- checkpoints (ExecPlan 28 / EP-1). These exercise the new prepared statements in+-- "Kiroku.Store.SQL" directly through the connection pool, with no subscription+-- runtime, on a fresh ephemeral PostgreSQL per test.+--+-- Terms: a /consumer group/ of /size/ N has members 0..N-1; each source stream is+-- assigned to exactly one member by 'Kiroku.Store.SQL'-encoded hash routing. The+-- properties proven here — disjointness, completeness, per-stream affinity,+-- determinism, and size-1 equivalence — are the contract EP-2's runtime depends on. module Test.ConsumerGroupSql (spec) where import Contravariant.Extras (contrazip2)@@ -41,26 +40,25 @@ -- | Run a statement against the store's pool, failing the test on a usage error. runStmt :: KirokuStore -> Session.Session a -> IO a runStmt store session = do- result <- Pool.use (store ^. #pool) session- case result of- Left err -> error ("ConsumerGroupSql statement failed: " <> show err)- Right a -> pure a+ result <- Pool.use (store ^. #pool) session+ case result of+ Left err -> error ("ConsumerGroupSql statement failed: " <> show err)+ Right a -> pure a -{- | Append one event to each of the named streams, seeding the store. Each-stream gets a single event; per-stream affinity is what we test, so one event-per stream is enough, but the helper appends 'n' events to vary versions.--}+-- | Append one event to each of the named streams, seeding the store. Each+-- stream gets a single event; per-stream affinity is what we test, so one event+-- per stream is enough, but the helper appends 'n' events to vary versions. seedStreams :: KirokuStore -> [Text] -> Int -> IO () seedStreams store names n =- mapM_- ( \name -> do- let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. n]- r <- runStoreIO store $ appendToStream (StreamName name) NoStream events- case r of- Left err -> error ("seed append failed for " <> T.unpack name <> ": " <> show err)- Right _ -> pure ()- )- names+ mapM_+ ( \name -> do+ let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. n]+ r <- runStoreIO store $ appendToStream (StreamName name) NoStream events+ case r of+ Left err -> error ("seed append failed for " <> T.unpack name <> ": " <> show err)+ Right _ -> pure ()+ )+ names -- | Collect the event ids from a read result, in order. eventIds :: Vector RecordedEvent -> [UUID]@@ -69,30 +67,29 @@ -- | Collect (originalStreamId, globalPosition) pairs from a read result. streamPositions :: Vector RecordedEvent -> [(Int64, Int64)] streamPositions =- map- ( \e ->- ( case e ^. #originalStreamId of StreamId s -> s- , case e ^. #globalPosition of GlobalPosition p -> p- )+ map+ ( \e ->+ ( case e ^. #originalStreamId of StreamId s -> s,+ case e ^. #globalPosition of GlobalPosition p -> p )- . V.toList+ )+ . V.toList -{- | Direct call to the partition rule for one stream id and size, returning the-member index PostgreSQL computes. Mirrors IP-1 exactly so the test pins the-formula, not just its consequences.--}+-- | Direct call to the partition rule for one stream id and size, returning the+-- member index PostgreSQL computes. Mirrors IP-1 exactly so the test pins the+-- formula, not just its consequences. runMemberOf :: KirokuStore -> Int64 -> Int32 -> IO Int32 runMemberOf store streamId size = runStmt store (Session.statement (streamId, size) stmt) where stmt :: Statement (Int64, Int32) Int32 stmt =- preparable- "SELECT (((hashtextextended($1::text, 0) % $2) + $2) % $2)::int4"- ( contrazip2- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.int4))- )- (D.singleRow (D.column (D.nonNullable D.int4)))+ preparable+ "SELECT (((hashtextextended($1::text, 0) % $2) + $2) % $2)::int4"+ ( contrazip2+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.int4))+ )+ (D.singleRow (D.column (D.nonNullable D.int4))) -- | All distinct (originalStreamId) values present in the unpartitioned read. distinctStreamIds :: Vector RecordedEvent -> [Int64]@@ -100,11 +97,11 @@ spec :: Spec spec = do- describe "ConsumerGroupSql" $ do- categorySpec- checkpointSpec- allSpec- memberOfSpec+ describe "ConsumerGroupSql" $ do+ categorySpec+ checkpointSpec+ allSpec+ memberOfSpec -- --------------------------------------------------------------------------- -- M1: category partitioning@@ -112,66 +109,66 @@ categorySpec :: Spec categorySpec = around withTestStore $ do- describe "category consumer-group partitioning (size 4)" $ do- let cat = "acct"- names = map (\i -> "acct-" <> T.pack (show i)) [1 .. 50 :: Int]- size = 4 :: Int32+ describe "category consumer-group partitioning (size 4)" $ do+ let cat = "acct"+ names = map (\i -> "acct-" <> T.pack (show i)) [1 .. 50 :: Int]+ size = 4 :: Int32 - let readMember store m =- runStmt store $- Session.statement (0 :: Int64, cat, m, size, bigLimit) SQL.readCategoryForwardConsumerGroupStmt- readFull store =- runStmt store $- Session.statement (0 :: Int64, cat, bigLimit) SQL.readCategoryForwardStmt+ let readMember store m =+ runStmt store $+ Session.statement (0 :: Int64, cat, m, size, bigLimit) SQL.readCategoryForwardConsumerGroupStmt+ readFull store =+ runStmt store $+ Session.statement (0 :: Int64, cat, bigLimit) SQL.readCategoryForwardStmt - it "splits a category into 4 pairwise-disjoint member slices" $ \store -> do- seedStreams store names 2- slices <- mapM (readMember store) [0 .. size - 1]- let idSets = map (Set.fromList . eventIds) slices- -- pairwise disjoint: union of sizes equals size of union- let totalIds = sum (map Set.size idSets)- unionIds = Set.size (Set.unions idSets)- totalIds `shouldBe` unionIds+ it "splits a category into 4 pairwise-disjoint member slices" $ \store -> do+ seedStreams store names 2+ slices <- mapM (readMember store) [0 .. size - 1]+ let idSets = map (Set.fromList . eventIds) slices+ -- pairwise disjoint: union of sizes equals size of union+ let totalIds = sum (map Set.size idSets)+ unionIds = Set.size (Set.unions idSets)+ totalIds `shouldBe` unionIds - it "union of all member slices equals the unpartitioned category read" $ \store -> do- seedStreams store names 2- slices <- mapM (readMember store) [0 .. size - 1]- full <- readFull store- let unionIds = Set.unions (map (Set.fromList . eventIds) slices)- fullIds = Set.fromList (eventIds full)- unionIds `shouldBe` fullIds+ it "union of all member slices equals the unpartitioned category read" $ \store -> do+ seedStreams store names 2+ slices <- mapM (readMember store) [0 .. size - 1]+ full <- readFull store+ let unionIds = Set.unions (map (Set.fromList . eventIds) slices)+ fullIds = Set.fromList (eventIds full)+ unionIds `shouldBe` fullIds - it "every stream's events go to exactly one member, in ascending global position" $ \store -> do- seedStreams store names 3- slices <- mapM (readMember store) [0 .. size - 1]- -- For each member slice, every stream present must have all its events- -- in ascending global position, and no stream may appear in two slices.- let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices- -- per-stream affinity: stream sets are pairwise disjoint- let totalStreams = sum (map Set.size perMemberStreams)- unionStreams = Set.size (Set.unions perMemberStreams)- totalStreams `shouldBe` unionStreams- -- ascending global position within each slice- mapM_- ( \slice -> do- let ps = map snd (streamPositions slice)- ps `shouldBe` sort ps- )- slices+ it "every stream's events go to exactly one member, in ascending global position" $ \store -> do+ seedStreams store names 3+ slices <- mapM (readMember store) [0 .. size - 1]+ -- For each member slice, every stream present must have all its events+ -- in ascending global position, and no stream may appear in two slices.+ let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices+ -- per-stream affinity: stream sets are pairwise disjoint+ let totalStreams = sum (map Set.size perMemberStreams)+ unionStreams = Set.size (Set.unions perMemberStreams)+ totalStreams `shouldBe` unionStreams+ -- ascending global position within each slice+ mapM_+ ( \slice -> do+ let ps = map snd (streamPositions slice)+ ps `shouldBe` sort ps+ )+ slices - it "member assignment is deterministic across repeated reads" $ \store -> do- seedStreams store names 1- firstReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]- secondReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]- firstReads `shouldBe` secondReads+ it "member assignment is deterministic across repeated reads" $ \store -> do+ seedStreams store names 1+ firstReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]+ secondReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]+ firstReads `shouldBe` secondReads - it "size 1 is equivalent to an unpartitioned category read" $ \store -> do- seedStreams store names 2- one <-- runStmt store $- Session.statement (0 :: Int64, cat, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readCategoryForwardConsumerGroupStmt- full <- readFull store- eventIds one `shouldBe` eventIds full+ it "size 1 is equivalent to an unpartitioned category read" $ \store -> do+ seedStreams store names 2+ one <-+ runStmt store $+ Session.statement (0 :: Int64, cat, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readCategoryForwardConsumerGroupStmt+ full <- readFull store+ eventIds one `shouldBe` eventIds full -- --------------------------------------------------------------------------- -- M2: per-member checkpoints@@ -179,34 +176,34 @@ checkpointSpec :: Spec checkpointSpec = around withTestStore $ do- describe "per-member checkpoints" $ do- let subName = "proj-acct" :: Text+ describe "per-member checkpoints" $ do+ let subName = "proj-acct" :: Text - it "stores and reads independent checkpoints per member" $ \store -> do- runStmt store $ Session.statement (subName, 0 :: Int32, 7 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt- runStmt store $ Session.statement (subName, 1 :: Int32, 13 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt- m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt- m1 <- runStmt store $ Session.statement (subName, 1 :: Int32) SQL.getCheckpointMemberStmt- m0 `shouldBe` Just 7- m1 `shouldBe` Just 13+ it "stores and reads independent checkpoints per member" $ \store -> do+ runStmt store $ Session.statement (subName, 0 :: Int32, 7 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+ runStmt store $ Session.statement (subName, 1 :: Int32, 13 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+ m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt+ m1 <- runStmt store $ Session.statement (subName, 1 :: Int32) SQL.getCheckpointMemberStmt+ m0 `shouldBe` Just 7+ m1 `shouldBe` Just 13 - it "never moves a member checkpoint backward (GREATEST monotonicity)" $ \store -> do- runStmt store $ Session.statement (subName, 0 :: Int32, 20 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt- runStmt store $ Session.statement (subName, 0 :: Int32, 5 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt- m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt- m0 `shouldBe` Just 20+ it "never moves a member checkpoint backward (GREATEST monotonicity)" $ \store -> do+ runStmt store $ Session.statement (subName, 0 :: Int32, 20 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+ runStmt store $ Session.statement (subName, 0 :: Int32, 5 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+ m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt+ m0 `shouldBe` Just 20 - it "missing member checkpoint reads as Nothing" $ \store -> do- m9 <- runStmt store $ Session.statement (subName, 9 :: Int32) SQL.getCheckpointMemberStmt- m9 `shouldBe` Nothing+ it "missing member checkpoint reads as Nothing" $ \store -> do+ m9 <- runStmt store $ Session.statement (subName, 9 :: Int32) SQL.getCheckpointMemberStmt+ m9 `shouldBe` Nothing - it "the existing name-keyed checkpoint statements still round-trip (as member 0)" $ \store -> do- runStmt store $ Session.statement (subName, 42 :: Int64) SQL.saveCheckpointStmt- -- name-keyed read returns the same row (member 0)- byName <- runStmt store $ Session.statement subName SQL.getCheckpointStmt- byMember0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt- byName `shouldBe` Just 42- byMember0 `shouldBe` Just 42+ it "the existing name-keyed checkpoint statements still round-trip (as member 0)" $ \store -> do+ runStmt store $ Session.statement (subName, 42 :: Int64) SQL.saveCheckpointStmt+ -- name-keyed read returns the same row (member 0)+ byName <- runStmt store $ Session.statement subName SQL.getCheckpointStmt+ byMember0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt+ byName `shouldBe` Just 42+ byMember0 `shouldBe` Just 42 -- --------------------------------------------------------------------------- -- M3: $all partitioning@@ -214,58 +211,58 @@ allSpec :: Spec allSpec = around withTestStore $ do- describe "$all consumer-group partitioning (size 4)" $ do- -- \$all spans several categories; partitioning is by originating stream.- let names =- concatMap- (\c -> map (\i -> c <> "-" <> T.pack (show i)) [1 .. 20 :: Int])- ["acct", "user", "order"]- size = 4 :: Int32+ describe "$all consumer-group partitioning (size 4)" $ do+ -- \$all spans several categories; partitioning is by originating stream.+ let names =+ concatMap+ (\c -> map (\i -> c <> "-" <> T.pack (show i)) [1 .. 20 :: Int])+ ["acct", "user", "order"]+ size = 4 :: Int32 - let readMember store m =- runStmt store $- Session.statement (0 :: Int64, m, size, bigLimit) SQL.readAllForwardConsumerGroupStmt- readFull store =- runStmt store $- Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt+ let readMember store m =+ runStmt store $+ Session.statement (0 :: Int64, m, size, bigLimit) SQL.readAllForwardConsumerGroupStmt+ readFull store =+ runStmt store $+ Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt - it "splits $all into 4 pairwise-disjoint member slices" $ \store -> do- seedStreams store names 2- slices <- mapM (readMember store) [0 .. size - 1]- let idSets = map (Set.fromList . eventIds) slices- totalIds = sum (map Set.size idSets)- unionIds = Set.size (Set.unions idSets)- totalIds `shouldBe` unionIds+ it "splits $all into 4 pairwise-disjoint member slices" $ \store -> do+ seedStreams store names 2+ slices <- mapM (readMember store) [0 .. size - 1]+ let idSets = map (Set.fromList . eventIds) slices+ totalIds = sum (map Set.size idSets)+ unionIds = Set.size (Set.unions idSets)+ totalIds `shouldBe` unionIds - it "union of all member slices equals the unpartitioned $all read" $ \store -> do- seedStreams store names 2- slices <- mapM (readMember store) [0 .. size - 1]- full <- readFull store- let unionIds = Set.unions (map (Set.fromList . eventIds) slices)- fullIds = Set.fromList (eventIds full)- unionIds `shouldBe` fullIds+ it "union of all member slices equals the unpartitioned $all read" $ \store -> do+ seedStreams store names 2+ slices <- mapM (readMember store) [0 .. size - 1]+ full <- readFull store+ let unionIds = Set.unions (map (Set.fromList . eventIds) slices)+ fullIds = Set.fromList (eventIds full)+ unionIds `shouldBe` fullIds - it "every stream's events go to exactly one member, in ascending global position" $ \store -> do- seedStreams store names 3- slices <- mapM (readMember store) [0 .. size - 1]- let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices- totalStreams = sum (map Set.size perMemberStreams)- unionStreams = Set.size (Set.unions perMemberStreams)- totalStreams `shouldBe` unionStreams- mapM_- ( \slice -> do- let ps = map snd (streamPositions slice)- ps `shouldBe` sort ps- )- slices+ it "every stream's events go to exactly one member, in ascending global position" $ \store -> do+ seedStreams store names 3+ slices <- mapM (readMember store) [0 .. size - 1]+ let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices+ totalStreams = sum (map Set.size perMemberStreams)+ unionStreams = Set.size (Set.unions perMemberStreams)+ totalStreams `shouldBe` unionStreams+ mapM_+ ( \slice -> do+ let ps = map snd (streamPositions slice)+ ps `shouldBe` sort ps+ )+ slices - it "size 1 is equivalent to an unpartitioned $all read" $ \store -> do- seedStreams store names 2- one <-- runStmt store $- Session.statement (0 :: Int64, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readAllForwardConsumerGroupStmt- full <- readFull store- eventIds one `shouldBe` eventIds full+ it "size 1 is equivalent to an unpartitioned $all read" $ \store -> do+ seedStreams store names 2+ one <-+ runStmt store $+ Session.statement (0 :: Int64, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readAllForwardConsumerGroupStmt+ full <- readFull store+ eventIds one `shouldBe` eventIds full -- --------------------------------------------------------------------------- -- The partition rule, pinned directly.@@ -273,15 +270,15 @@ memberOfSpec :: Spec memberOfSpec = around withTestStore $ do- describe "member_of assignment rule" $ do- it "returns a member index in [0, size) for every stream" $ \store -> do- let names = map (\i -> "rule-" <> T.pack (show i)) [1 .. 30 :: Int]- seedStreams store names 1- full <- runStmt store $ Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt- let sids = distinctStreamIds full- mapM_- ( \sid -> do- m <- runMemberOf store sid 4- m `shouldSatisfy` (\x -> x >= 0 && x < 4)- )- sids+ describe "member_of assignment rule" $ do+ it "returns a member index in [0, size) for every stream" $ \store -> do+ let names = map (\i -> "rule-" <> T.pack (show i)) [1 .. 30 :: Int]+ seedStreams store names 1+ full <- runStmt store $ Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt+ let sids = distinctStreamIds full+ mapM_+ ( \sid -> do+ m <- runMemberOf store sid 4+ m `shouldSatisfy` (\x -> x >= 0 && x < 4)+ )+ sids
test/Test/DeadLetterQueryPlans.hs view
@@ -25,74 +25,80 @@ spec :: Spec spec = describe "dead-letter production query plans" $ around withTestStore $ it "bounds historical-member enumeration and first/later pages in generic/custom plans as history grows" $ \store -> do- execute store "INSERT INTO events(event_id,event_type,data,created_at) VALUES ('00000000-0000-0000-0000-000000000001','E','{}',now())"- forM_ ["1000", "20000"] $ \history -> do- execute store ("INSERT INTO dead_letters(subscription_name,consumer_group_member,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'plans',m,n,'00000000-0000-0000-0000-000000000001','{}','fixture',1 FROM unnest(ARRAY[0,1,9]) m CROSS JOIN generate_series(1," <> history <> ") n ON CONFLICT DO NOTHING; ANALYZE dead_letters")- forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do- memberFirst <- explain store mode "text,int4,int4" "'plans',9,6" SQL.listSubscriptionMemberDeadLettersFromStartStmt- memberLater <- explain store mode "text,int4,int8,int8,int4" "'plans',9,500,9223372036854775807,6" SQL.listSubscriptionMemberDeadLettersStmt- allFirst <- explain store mode "text,int4" "'plans',6" SQL.listSubscriptionDeadLettersFromStartStmt- allLater <- explain store mode "text,int8,int8,int4" "'plans',500,9223372036854775807,6" SQL.listSubscriptionDeadLettersStmt- forM_ [memberFirst, memberLater] $ \plan -> do- expect ("index" :: Text) plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)- expect "member page work" plan (scanned plan <= 6)- expect "no member sort" plan ("Sort" `notElem` texts "Node Type" plan)- forM_ [allFirst, allLater] $ \plan -> do- -- Three loose member probes plus three bounded lateral scans.- expect "bounded member enumeration/page" plan (scanned plan <= 24)- expect "bounded merge input" plan (sum (sortInputs plan) <= 18)- -- The existing natural-key index has the same (name, member)- -- prefix and can serve the loose member probes too.- expect "existing ordered indexes only" plan (all (`elem` ["ix_dead_letters_subscription_position", "dead_letters_subscription_name_consumer_group_member_global_key"]) (texts "Index Name" plan))- expect "ordered historical member advance" plan (any ("consumer_group_member >" `contains`) (texts "Index Cond" plan))- expect "recency index for lateral pages" plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)- expect "no historical sequential scan" plan ("Seq Scan" `notElem` texts "Node Type" plan)- forM_ [memberLater, allLater] $ \plan ->- expect "tuple cursor in index condition" plan (any (\condition -> "ROW(global_position, dead_letter_id)" `contains` condition) (texts "Index Cond" plan))- forM_ [memberFirst, memberLater, allFirst, allLater] $ \plan ->- expect "bounded shared buffers" plan (buffers plan <= 128)- -- Preserve the actual production EXPLAIN evidence in the test log.- mapM_ (LBS.putStrLn . Aeson.encode) [memberFirst, memberLater, allFirst, allLater]+ execute store "INSERT INTO events(event_id,event_type,data,created_at) VALUES ('00000000-0000-0000-0000-000000000001','E','{}',now())"+ forM_ ["1000", "20000"] $ \history -> do+ execute store ("INSERT INTO dead_letters(subscription_name,consumer_group_member,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'plans',m,n,'00000000-0000-0000-0000-000000000001','{}','fixture',1 FROM unnest(ARRAY[0,1,9]) m CROSS JOIN generate_series(1," <> history <> ") n ON CONFLICT DO NOTHING; ANALYZE dead_letters")+ forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do+ memberFirst <- explain store mode "text,int4,int4" "'plans',9,6" SQL.listSubscriptionMemberDeadLettersFromStartStmt+ memberLater <- explain store mode "text,int4,int8,int8,int4" "'plans',9,500,9223372036854775807,6" SQL.listSubscriptionMemberDeadLettersStmt+ allFirst <- explain store mode "text,int4" "'plans',6" SQL.listSubscriptionDeadLettersFromStartStmt+ allLater <- explain store mode "text,int8,int8,int4" "'plans',500,9223372036854775807,6" SQL.listSubscriptionDeadLettersStmt+ forM_ [memberFirst, memberLater] $ \plan -> do+ expect ("index" :: Text) plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)+ expect "member page work" plan (scanned plan <= 6)+ expect "no member sort" plan ("Sort" `notElem` texts "Node Type" plan)+ forM_ [allFirst, allLater] $ \plan -> do+ -- Three loose member probes plus three bounded lateral scans.+ expect "bounded member enumeration/page" plan (scanned plan <= 24)+ expect "bounded merge input" plan (sum (sortInputs plan) <= 18)+ -- The existing natural-key index has the same (name, member)+ -- prefix and can serve the loose member probes too.+ expect "existing ordered indexes only" plan (all (`elem` ["ix_dead_letters_subscription_position", "dead_letters_subscription_name_consumer_group_member_global_key"]) (texts "Index Name" plan))+ expect "ordered historical member advance" plan (any ("consumer_group_member >" `contains`) (texts "Index Cond" plan))+ expect "recency index for lateral pages" plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)+ expect "no historical sequential scan" plan ("Seq Scan" `notElem` texts "Node Type" plan)+ forM_ [memberLater, allLater] $ \plan ->+ expect "tuple cursor in index condition" plan (any (\condition -> "ROW(global_position, dead_letter_id)" `contains` condition) (texts "Index Cond" plan))+ forM_ [memberFirst, memberLater, allFirst, allLater] $ \plan ->+ expect "bounded shared buffers" plan (buffers plan <= 128)+ -- Preserve the actual production EXPLAIN evidence in the test log.+ mapM_ (LBS.putStrLn . Aeson.encode) [memberFirst, memberLater, allFirst, allLater] where contains = Data.Text.isInfixOf execute :: KirokuStore -> Text -> IO () execute store sql = Pool.use (store ^. #pool) (Session.script sql) >>= either (fail . show) pure+ explain :: KirokuStore -> Text -> Text -> Text -> Statement p r -> IO Value explain store mode types args statement = do- Right bytes <- Pool.use (store ^. #pool) $ do- Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_deadletters(" <> types <> ") AS " <> Statement.toSql statement)- bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE,BUFFERS,TIMING OFF,FORMAT JSON) EXECUTE mp13_deadletters(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))- Session.script "DEALLOCATE mp13_deadletters; RESET plan_cache_mode"- pure bytes- either fail pure (Aeson.eitherDecodeStrict' bytes)+ Right bytes <- Pool.use (store ^. #pool) $ do+ Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_deadletters(" <> types <> ") AS " <> Statement.toSql statement)+ bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE,BUFFERS,TIMING OFF,FORMAT JSON) EXECUTE mp13_deadletters(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))+ Session.script "DEALLOCATE mp13_deadletters; RESET plan_cache_mode"+ pure bytes+ either fail pure (Aeson.eitherDecodeStrict' bytes)+ expect :: Text -> Value -> Bool -> Expectation expect label plan ok = if ok then pure () else expectationFailure (show label <> ": " <> show plan)+ texts :: Aeson.Key -> Value -> [Text] texts key (Object fields) = [s | Just (String s) <- [KM.lookup key fields]] <> concatMap (texts key) (KM.elems fields) texts key (Array values) = concatMap (texts key) values texts _ _ = []+ number :: Aeson.Key -> Aeson.Object -> Double number key fields = case KM.lookup key fields of Just (Number n) -> realToFrac n; _ -> 0+ scanned :: Value -> Double scanned (Object fields) = current + sum (map scanned (KM.elems fields)) where current = case KM.lookup "Relation Name" fields of- Just (String "dead_letters") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields- _ -> 0+ Just (String "dead_letters") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields+ _ -> 0 scanned (Array values) = sum (map scanned (foldr (:) [] values)) scanned _ = 0+ sortInputs :: Value -> [Double] sortInputs (Object fields) = current <> concatMap sortInputs (KM.elems fields) where current = case (KM.lookup "Node Type" fields, KM.lookup "Plans" fields) of- (Just (String "Sort"), Just (Array children)) -> [number "Actual Rows" child * number "Actual Loops" child | Object child <- foldr (:) [] children]- _ -> []+ (Just (String "Sort"), Just (Array children)) -> [number "Actual Rows" child * number "Actual Loops" child | Object child <- foldr (:) [] children]+ _ -> [] sortInputs (Array values) = concatMap sortInputs values sortInputs _ = [] buffers :: Value -> Double buffers (Array values) = case foldr (:) [] values of- Object entry : _ | Just (Object top) <- KM.lookup "Plan" entry -> number "Shared Hit Blocks" top + number "Shared Read Blocks" top- _ -> 1 / 0+ Object entry : _ | Just (Object top) <- KM.lookup "Plan" entry -> number "Shared Hit Blocks" top + number "Shared Read Blocks" top+ _ -> 1 / 0 buffers _ = 1 / 0
test/Test/EventTypeFilter.hs view
@@ -1,38 +1,37 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for EP-43 (MasterPlan 6, child plan 4) — per-subscription-event-type filtering in @kiroku-store@.--These pin the behavioral acceptance criteria from the ExecPlan:-- * __selective delivery__: a subscription with- @'eventTypeFilter' = 'OnlyEventTypes' {A}@ over a mixed @A@\/@B@ stream- delivers only the @A@ events to its handler;- * __checkpoint advances past filtered events__: the persisted @last_seen@- equals the global position of the /last event of any type/, including a- trailing filtered-out event, so the subscription never re-scans skipped- events after a restart;- * __no stall__: a long run of non-matching events (1 @A@, 1000 @B@, 1 @A@)- does not hang the subscription — both @A@s are delivered and the checkpoint- reaches 1002;- * __filter before dead-letter__: a handler that dead-letters /every/ event it- sees, behind a filter that admits no events in the stream, is never invoked,- writes no dead-letter rows, and still advances the checkpoint.--They also cover the opaque 'selector' escape hatch (a @RecordedEvent -> Bool@-predicate the type set cannot express, e.g. on payload\/metadata):-- * __selector no-stall__: a selector over a payload tag delivers only matching- events and advances the checkpoint past a long run of rejected ones;- * __AND composition__: 'selector' and 'eventTypeFilter' compose as a logical- AND — an event is delivered only when it passes both.--All use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery path).-Events are appended and the publisher is awaited before subscribing, so delivery-happens during catch-up; the worker applies the filter in 'processEvents' before-the handler call.--}+-- | Regression tests for EP-43 (MasterPlan 6, child plan 4) — per-subscription+-- event-type filtering in @kiroku-store@.+--+-- These pin the behavioral acceptance criteria from the ExecPlan:+--+-- * __selective delivery__: a subscription with+-- @'eventTypeFilter' = 'OnlyEventTypes' {A}@ over a mixed @A@\/@B@ stream+-- delivers only the @A@ events to its handler;+-- * __checkpoint advances past filtered events__: the persisted @last_seen@+-- equals the global position of the /last event of any type/, including a+-- trailing filtered-out event, so the subscription never re-scans skipped+-- events after a restart;+-- * __no stall__: a long run of non-matching events (1 @A@, 1000 @B@, 1 @A@)+-- does not hang the subscription — both @A@s are delivered and the checkpoint+-- reaches 1002;+-- * __filter before dead-letter__: a handler that dead-letters /every/ event it+-- sees, behind a filter that admits no events in the stream, is never invoked,+-- writes no dead-letter rows, and still advances the checkpoint.+--+-- They also cover the opaque 'selector' escape hatch (a @RecordedEvent -> Bool@+-- predicate the type set cannot express, e.g. on payload\/metadata):+--+-- * __selector no-stall__: a selector over a payload tag delivers only matching+-- events and advances the checkpoint past a long run of rejected ones;+-- * __AND composition__: 'selector' and 'eventTypeFilter' compose as a logical+-- AND — an event is delivered only when it passes both.+--+-- All use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery path).+-- Events are appended and the publisher is awaited before subscribing, so delivery+-- happens during catch-up; the worker applies the filter in 'processEvents' before+-- the handler call. module Test.EventTypeFilter (spec) where import Control.Concurrent (threadDelay)@@ -57,17 +56,17 @@ readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64) readCheckpoint store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)- case result of- Left err -> error ("readCheckpoint failed: " <> show err)- Right mPos -> pure mPos+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+ case result of+ Left err -> error ("readCheckpoint failed: " <> show err)+ Right mPos -> pure mPos readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord] readDeadLetters store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)- case result of- Left err -> error ("readDeadLetters failed: " <> show err)- Right v -> pure (V.toList v)+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)+ case result of+ Left err -> error ("readDeadLetters failed: " <> show err)+ Right v -> pure (V.toList v) -- Poll the checkpoint until it reaches @target@ (or ~10s elapse). Because the -- worker saves the checkpoint at the batch tail only after every handler call in@@ -77,12 +76,12 @@ waitForCheckpoint store subName target = go (0 :: Int) where go n- | n > 200 = pure False- | otherwise = do- mPos <- readCheckpoint store subName- case mPos of- Just p | p >= target -> pure True- _ -> threadDelay 50_000 >> go (n + 1)+ | n > 200 = pure False+ | otherwise = do+ mPos <- readCheckpoint store subName+ case mPos of+ Just p | p >= target -> pure True+ _ -> threadDelay 50_000 >> go (n + 1) mkE :: Text -> EventData mkE typ = makeEvent typ (Aeson.object [])@@ -102,205 +101,205 @@ spec :: Spec spec = describe "event-type filter" $ do- it "delivers only matching types and advances the checkpoint past filtered events" $ do- withTestStore $ \store -> do- let subT = "etf-mixed" :: Text- subName = SubscriptionName subT- -- A, B, A, B, A on one stream -> global positions 1..5.- Right _ <-- runStoreIO store $- appendToStream- (StreamName "etf-mixed-stream")- NoStream- [mkE "A", mkE "B", mkE "A", mkE "B", mkE "A"]- waitForPublisher store (GlobalPosition 5)+ it "delivers only matching types and advances the checkpoint past filtered events" $ do+ withTestStore $ \store -> do+ let subT = "etf-mixed" :: Text+ subName = SubscriptionName subT+ -- A, B, A, B, A on one stream -> global positions 1..5.+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "etf-mixed-stream")+ NoStream+ [mkE "A", mkE "B", mkE "A", mkE "B", mkE "A"]+ waitForPublisher store (GlobalPosition 5) - deliveredRef <- newIORef ([] :: [Text])- let handler' ev = do- modifyIORef' deliveredRef (<> [typeOf ev])- pure Continue- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])- }- handle <- subscribe store cfg- reached <- waitForCheckpoint store subT 5- cancel handle+ deliveredRef <- newIORef ([] :: [Text])+ let handler' ev = do+ modifyIORef' deliveredRef (<> [typeOf ev])+ pure Continue+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])+ }+ handle <- subscribe store cfg+ reached <- waitForCheckpoint store subT 5+ cancel handle - reached `shouldBe` True- delivered <- readIORef deliveredRef- delivered `shouldBe` ["A", "A", "A"]- readCheckpoint store subT `shouldReturn` Just 5+ reached `shouldBe` True+ delivered <- readIORef deliveredRef+ delivered `shouldBe` ["A", "A", "A"]+ readCheckpoint store subT `shouldReturn` Just 5 - it "advances the checkpoint past a trailing filtered-out event" $ do- withTestStore $ \store -> do- let subT = "etf-trailing" :: Text- subName = SubscriptionName subT- -- A then B: the last event (B at position 2) is filtered out.- Right _ <-- runStoreIO store $- appendToStream- (StreamName "etf-trailing-stream")- NoStream- [mkE "A", mkE "B"]- waitForPublisher store (GlobalPosition 2)+ it "advances the checkpoint past a trailing filtered-out event" $ do+ withTestStore $ \store -> do+ let subT = "etf-trailing" :: Text+ subName = SubscriptionName subT+ -- A then B: the last event (B at position 2) is filtered out.+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "etf-trailing-stream")+ NoStream+ [mkE "A", mkE "B"]+ waitForPublisher store (GlobalPosition 2) - deliveredRef <- newIORef ([] :: [Text])- let handler' ev = do- modifyIORef' deliveredRef (<> [typeOf ev])- pure Continue- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])- }- handle <- subscribe store cfg- reached <- waitForCheckpoint store subT 2- cancel handle+ deliveredRef <- newIORef ([] :: [Text])+ let handler' ev = do+ modifyIORef' deliveredRef (<> [typeOf ev])+ pure Continue+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])+ }+ handle <- subscribe store cfg+ reached <- waitForCheckpoint store subT 2+ cancel handle - reached `shouldBe` True- delivered <- readIORef deliveredRef- delivered `shouldBe` ["A"]- -- last_seen is 2 (the trailing B), proving the cursor advanced past- -- a filtered-out event rather than stopping at the last delivered A.- readCheckpoint store subT `shouldReturn` Just 2+ reached `shouldBe` True+ delivered <- readIORef deliveredRef+ delivered `shouldBe` ["A"]+ -- last_seen is 2 (the trailing B), proving the cursor advanced past+ -- a filtered-out event rather than stopping at the last delivered A.+ readCheckpoint store subT `shouldReturn` Just 2 - it "never stalls on a long run of non-matching events (1 A, 1000 B, 1 A)" $ do- withTestStore $ \store -> do- let subT = "etf-nostall" :: Text- subName = SubscriptionName subT- events = [mkE "A"] <> replicate 1000 (mkE "B") <> [mkE "A"]- Right _ <-- runStoreIO store $- appendToStream (StreamName "etf-nostall-stream") NoStream events- waitForPublisher store (GlobalPosition 1002)+ it "never stalls on a long run of non-matching events (1 A, 1000 B, 1 A)" $ do+ withTestStore $ \store -> do+ let subT = "etf-nostall" :: Text+ subName = SubscriptionName subT+ events = [mkE "A"] <> replicate 1000 (mkE "B") <> [mkE "A"]+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "etf-nostall-stream") NoStream events+ waitForPublisher store (GlobalPosition 1002) - deliveredRef <- newIORef ([] :: [Text])- let handler' ev = do- modifyIORef' deliveredRef (<> [typeOf ev])- pure Continue- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])- , batchSize = either (error . show) Prelude.id (mkBatchSize 2000)- }- handle <- subscribe store cfg- reached <- waitForCheckpoint store subT 1002- cancel handle+ deliveredRef <- newIORef ([] :: [Text])+ let handler' ev = do+ modifyIORef' deliveredRef (<> [typeOf ev])+ pure Continue+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"]),+ batchSize = either (error . show) Prelude.id (mkBatchSize 2000)+ }+ handle <- subscribe store cfg+ reached <- waitForCheckpoint store subT 1002+ cancel handle - reached `shouldBe` True- delivered <- readIORef deliveredRef- -- Exactly the two As; the 1000 Bs advanced the cursor without delivery.- delivered `shouldBe` ["A", "A"]- readCheckpoint store subT `shouldReturn` Just 1002+ reached `shouldBe` True+ delivered <- readIORef deliveredRef+ -- Exactly the two As; the 1000 Bs advanced the cursor without delivery.+ delivered `shouldBe` ["A", "A"]+ readCheckpoint store subT `shouldReturn` Just 1002 - it "does not dead-letter a filtered-out type even when the handler would" $ do- withTestStore $ \store -> do- let subT = "etf-no-dl" :: Text- subName = SubscriptionName subT- -- Only Bs; the filter admits only A, so the handler never runs.- Right _ <-- runStoreIO store $- appendToStream- (StreamName "etf-no-dl-stream")- NoStream- [mkE "B", mkE "B", mkE "B", mkE "B", mkE "B"]- waitForPublisher store (GlobalPosition 5)+ it "does not dead-letter a filtered-out type even when the handler would" $ do+ withTestStore $ \store -> do+ let subT = "etf-no-dl" :: Text+ subName = SubscriptionName subT+ -- Only Bs; the filter admits only A, so the handler never runs.+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "etf-no-dl-stream")+ NoStream+ [mkE "B", mkE "B", mkE "B", mkE "B", mkE "B"]+ waitForPublisher store (GlobalPosition 5) - deliveredRef <- newIORef ([] :: [Text])- let handler' ev = do- -- The handler would dead-letter everything it sees — but a- -- filtered-out event must never reach it.- modifyIORef' deliveredRef (<> [typeOf ev])- pure (DeadLetter (DeadLetterPoison "should-never-fire"))- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])- }- handle <- subscribe store cfg- reached <- waitForCheckpoint store subT 5- cancel handle+ deliveredRef <- newIORef ([] :: [Text])+ let handler' ev = do+ -- The handler would dead-letter everything it sees — but a+ -- filtered-out event must never reach it.+ modifyIORef' deliveredRef (<> [typeOf ev])+ pure (DeadLetter (DeadLetterPoison "should-never-fire"))+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])+ }+ handle <- subscribe store cfg+ reached <- waitForCheckpoint store subT 5+ cancel handle - reached `shouldBe` True- delivered <- readIORef deliveredRef- delivered `shouldBe` []- -- No dead-letter rows were written for the filtered-out Bs, and the- -- checkpoint still advanced past them.- dls <- readDeadLetters store subT- length dls `shouldBe` 0- readCheckpoint store subT `shouldReturn` Just 5+ reached `shouldBe` True+ delivered <- readIORef deliveredRef+ delivered `shouldBe` []+ -- No dead-letter rows were written for the filtered-out Bs, and the+ -- checkpoint still advanced past them.+ dls <- readDeadLetters store subT+ length dls `shouldBe` 0+ readCheckpoint store subT `shouldReturn` Just 5 - -- The opaque 'selector' escape hatch: a predicate over the whole- -- 'RecordedEvent' (here, its payload) that the 'eventTypeFilter' type set- -- cannot express. Same no-stall / checkpoint-advances guarantees apply.- it "delivers only events matching an opaque selector and advances past the rest (no stall)" $ do- withTestStore $ \store -> do- let subT = "sel-nostall" :: Text- subName = SubscriptionName subT- -- All one type "A" (so eventTypeFilter cannot distinguish them);- -- only the payload tag differs. keep, 1000 skips, keep.- events =- [mkTagged "A" True]- <> replicate 1000 (mkTagged "A" False)- <> [mkTagged "A" True]- Right _ <-- runStoreIO store $- appendToStream (StreamName "sel-nostall-stream") NoStream events- waitForPublisher store (GlobalPosition 1002)+ -- The opaque 'selector' escape hatch: a predicate over the whole+ -- 'RecordedEvent' (here, its payload) that the 'eventTypeFilter' type set+ -- cannot express. Same no-stall / checkpoint-advances guarantees apply.+ it "delivers only events matching an opaque selector and advances past the rest (no stall)" $ do+ withTestStore $ \store -> do+ let subT = "sel-nostall" :: Text+ subName = SubscriptionName subT+ -- All one type "A" (so eventTypeFilter cannot distinguish them);+ -- only the payload tag differs. keep, 1000 skips, keep.+ events =+ [mkTagged "A" True]+ <> replicate 1000 (mkTagged "A" False)+ <> [mkTagged "A" True]+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "sel-nostall-stream") NoStream events+ waitForPublisher store (GlobalPosition 1002) - deliveredRef <- newIORef ([] :: [Int64])- let handler' ev = do- modifyIORef' deliveredRef (<> [posOf ev])- pure Continue- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { selector = Just keepOnly- , batchSize = either (error . show) Prelude.id (mkBatchSize 2000)- }- handle <- subscribe store cfg- reached <- waitForCheckpoint store subT 1002- cancel handle+ deliveredRef <- newIORef ([] :: [Int64])+ let handler' ev = do+ modifyIORef' deliveredRef (<> [posOf ev])+ pure Continue+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { selector = Just keepOnly,+ batchSize = either (error . show) Prelude.id (mkBatchSize 2000)+ }+ handle <- subscribe store cfg+ reached <- waitForCheckpoint store subT 1002+ cancel handle - reached `shouldBe` True- delivered <- readIORef deliveredRef- -- Only the two keep events (positions 1 and 1002); the 1000 skips- -- advanced the cursor without delivery.- delivered `shouldBe` [1, 1002]- readCheckpoint store subT `shouldReturn` Just 1002+ reached `shouldBe` True+ delivered <- readIORef deliveredRef+ -- Only the two keep events (positions 1 and 1002); the 1000 skips+ -- advanced the cursor without delivery.+ delivered `shouldBe` [1, 1002]+ readCheckpoint store subT `shouldReturn` Just 1002 - it "composes the selector with eventTypeFilter as a logical AND" $ do- withTestStore $ \store -> do- let subT = "sel-and-type" :: Text- subName = SubscriptionName subT- -- A/keep, A/skip, B/keep, B/skip, A/keep -> positions 1..5.- -- Only events that are type A AND keep survive: positions 1, 5.- events =- [ mkTagged "A" True- , mkTagged "A" False- , mkTagged "B" True- , mkTagged "B" False- , mkTagged "A" True- ]- Right _ <-- runStoreIO store $- appendToStream (StreamName "sel-and-type-stream") NoStream events- waitForPublisher store (GlobalPosition 5)+ it "composes the selector with eventTypeFilter as a logical AND" $ do+ withTestStore $ \store -> do+ let subT = "sel-and-type" :: Text+ subName = SubscriptionName subT+ -- A/keep, A/skip, B/keep, B/skip, A/keep -> positions 1..5.+ -- Only events that are type A AND keep survive: positions 1, 5.+ events =+ [ mkTagged "A" True,+ mkTagged "A" False,+ mkTagged "B" True,+ mkTagged "B" False,+ mkTagged "A" True+ ]+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "sel-and-type-stream") NoStream events+ waitForPublisher store (GlobalPosition 5) - deliveredRef <- newIORef ([] :: [Int64])- let handler' ev = do- modifyIORef' deliveredRef (<> [posOf ev])- pure Continue- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])- , selector = Just keepOnly- }- handle <- subscribe store cfg- reached <- waitForCheckpoint store subT 5- cancel handle+ deliveredRef <- newIORef ([] :: [Int64])+ let handler' ev = do+ modifyIORef' deliveredRef (<> [posOf ev])+ pure Continue+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"]),+ selector = Just keepOnly+ }+ handle <- subscribe store cfg+ reached <- waitForCheckpoint store subT 5+ cancel handle - reached `shouldBe` True- delivered <- readIORef deliveredRef- -- B/keep (pos 3) is rejected by the type filter; A/skip (pos 2) by the- -- selector. Only A-and-keep at 1 and 5 are delivered.- delivered `shouldBe` [1, 5]- readCheckpoint store subT `shouldReturn` Just 5+ reached `shouldBe` True+ delivered <- readIORef deliveredRef+ -- B/keep (pos 3) is rejected by the type filter; A/skip (pos 2) by the+ -- selector. Only A-and-keep at 1 and 5 are delivered.+ delivered `shouldBe` [1, 5]+ readCheckpoint store subT `shouldReturn` Just 5
test/Test/FailureInjection.hs view
@@ -1,24 +1,23 @@-{- | Failure-injection tests for kiroku-store.--Each test deliberately disrupts a runtime dependency (the LISTEN-connection, the connection pool) and asserts the system recovers per its-documented contract.--Currently covers:-- * F14: Listener killed during a window where an event is appended.- The event is still delivered after the listener reconnects, because- the publisher re-queries the database from its last position when- the next NOTIFY arrives — no safety-poll wait needed.--F15 (pool exhaustion → 'PoolAcquisitionTimeout') is deferred until the-'acquisitionTimeout' field is exposed in 'ConnectionSettings'. The-mapping itself ('Kiroku.Store.Error.mapUsageError'-@AcquisitionTimeoutUsageError -> PoolAcquisitionTimeout@) is-straightforward; without a way to set a sub-second acquisition timeout-from the test, the test would either hang (default unbounded timeout)-or require modifying the production type. See the EP-6 plan's M2 notes.--}+-- | Failure-injection tests for kiroku-store.+--+-- Each test deliberately disrupts a runtime dependency (the LISTEN+-- connection, the connection pool) and asserts the system recovers per its+-- documented contract.+--+-- Currently covers:+--+-- * F14: Listener killed during a window where an event is appended.+-- The event is still delivered after the listener reconnects, because+-- the publisher re-queries the database from its last position when+-- the next NOTIFY arrives — no safety-poll wait needed.+--+-- F15 (pool exhaustion → 'PoolAcquisitionTimeout') is deferred until the+-- 'acquisitionTimeout' field is exposed in 'ConnectionSettings'. The+-- mapping itself ('Kiroku.Store.Error.mapUsageError'+-- @AcquisitionTimeoutUsageError -> PoolAcquisitionTimeout@) is+-- straightforward; without a way to set a sub-second acquisition timeout+-- from the test, the test would either hang (default unbounded timeout)+-- or require modifying the production type. See the EP-6 plan's M2 notes. module Test.FailureInjection (spec) where import Control.Concurrent.STM (atomically, newTVarIO, readTVar, retry, writeTVar)@@ -27,90 +26,90 @@ import Data.Generics.Labels () import Data.IORef (modifyIORef', newIORef, readIORef) import Kiroku.Store-import Test.Helpers (- makeEvent,+import Test.Helpers+ ( makeEvent, terminateBackend, waitForListenerPid, waitForListenerPidNotEqual, waitWithTimeout, withTestStoreSettings,- )+ ) import Test.Hspec spec :: Spec spec = describe "kiroku-store failure injection" $ do- -- F14 — Listener killed during a window where an event is- -- appended. The publisher's broadcast loop pulls events directly- -- from PostgreSQL when the next NOTIFY arrives, so the- -- down-window event is delivered as soon as a post-reconnect- -- event triggers a tick. No 30-second safety-poll wait required.- it "delivers events appended during listener-down window after reconnect (F14)" $ \() -> do- ref <- newIORef ([] :: [RecordedEvent])- countVar <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref (evt :)- n <- atomically $ do- c <- readTVar countVar- let c' = c + 1- writeTVar countVar c'- pure c'- if n >= 3 then pure Stop else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "f14-down-window"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- withTestStoreSettings (& #eventHandler .~ Nothing) $ \store -> do- handle <- subscribe store cfg- -- Pre-event: confirm the subscription is alive and the- -- listener has reached pg_stat_activity.- Right _ <- runStoreIO store $ appendToStream (StreamName "f14-pre") NoStream [makeEvent "Pre" (Aeson.object [])]- atomically $ do- c <- readTVar countVar- if c >= 1 then pure () else retry- -- Find listener pid, terminate it. The listener loop- -- will reconnect with a fresh pid.- --- -- NOTE: pg_terminate_backend is the "administrator command"- -- that makes PostgreSQL report- -- FATAL: terminating connection due to administrator command- -- on the targeted backend. The kiroku-listener's libpq socket- -- (inside hasql-notifications' waitForNotifications) receives- -- that as it dies, so that FATAL line on stderr during this- -- test is EXPECTED — it is the fault we are injecting, not a- -- connection leak or a teardown-ordering bug. (log_min_messages- -- is already PANIC, so this is the client seeing a server-- -- initiated termination, not server-side logging.)- pid1 <- waitForListenerPid (store ^. #pool) 5_000_000- terminateBackend (store ^. #pool) pid1- -- Append the during-down-window event. No NOTIFY reaches- -- the publisher right now because the listener is- -- disconnected.- Right _ <- runStoreIO store $ appendToStream (StreamName "f14-during") NoStream [makeEvent "During" (Aeson.object [])]- -- Wait for the listener to come back with a fresh pid.- _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000- -- Append the post-reconnect event. The new NOTIFY- -- triggers the publisher to fetch from its last position- -- — which picks up both the during-down event AND this- -- one.- Right _ <- runStoreIO store $ appendToStream (StreamName "f14-after") NoStream [makeEvent "After" (Aeson.object [])]- result <- waitWithTimeout 30_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- collected <- readIORef ref- length collected `shouldBe` 3+ -- F14 — Listener killed during a window where an event is+ -- appended. The publisher's broadcast loop pulls events directly+ -- from PostgreSQL when the next NOTIFY arrives, so the+ -- down-window event is delivered as soon as a post-reconnect+ -- event triggers a tick. No 30-second safety-poll wait required.+ it "delivers events appended during listener-down window after reconnect (F14)" $ \() -> do+ ref <- newIORef ([] :: [RecordedEvent])+ countVar <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref (evt :)+ n <- atomically $ do+ c <- readTVar countVar+ let c' = c + 1+ writeTVar countVar c'+ pure c'+ if n >= 3 then pure Stop else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "f14-down-window",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ withTestStoreSettings (& #eventHandler .~ Nothing) $ \store -> do+ handle <- subscribe store cfg+ -- Pre-event: confirm the subscription is alive and the+ -- listener has reached pg_stat_activity.+ Right _ <- runStoreIO store $ appendToStream (StreamName "f14-pre") NoStream [makeEvent "Pre" (Aeson.object [])]+ atomically $ do+ c <- readTVar countVar+ if c >= 1 then pure () else retry+ -- Find listener pid, terminate it. The listener loop+ -- will reconnect with a fresh pid.+ --+ -- NOTE: pg_terminate_backend is the "administrator command"+ -- that makes PostgreSQL report+ -- FATAL: terminating connection due to administrator command+ -- on the targeted backend. The kiroku-listener's libpq socket+ -- (inside hasql-notifications' waitForNotifications) receives+ -- that as it dies, so that FATAL line on stderr during this+ -- test is EXPECTED — it is the fault we are injecting, not a+ -- connection leak or a teardown-ordering bug. (log_min_messages+ -- is already PANIC, so this is the client seeing a server-+ -- initiated termination, not server-side logging.)+ pid1 <- waitForListenerPid (store ^. #pool) 5_000_000+ terminateBackend (store ^. #pool) pid1+ -- Append the during-down-window event. No NOTIFY reaches+ -- the publisher right now because the listener is+ -- disconnected.+ Right _ <- runStoreIO store $ appendToStream (StreamName "f14-during") NoStream [makeEvent "During" (Aeson.object [])]+ -- Wait for the listener to come back with a fresh pid.+ _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000+ -- Append the post-reconnect event. The new NOTIFY+ -- triggers the publisher to fetch from its last position+ -- — which picks up both the during-down event AND this+ -- one.+ Right _ <- runStoreIO store $ appendToStream (StreamName "f14-after") NoStream [makeEvent "After" (Aeson.object [])]+ result <- waitWithTimeout 30_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ collected <- readIORef ref+ length collected `shouldBe` 3
test/Test/HandlerStall.hs view
@@ -7,6 +7,7 @@ import Control.Lens ((&), (.~), (^.)) import Control.Monad (forM_) import Data.Aeson qualified as Aeson+import Data.IORef (atomicModifyIORef', newIORef, readIORef) import Data.Maybe (isNothing) import Data.Time (NominalDiffTime) import Data.Vector qualified as V@@ -21,234 +22,245 @@ appendEvents :: KirokuStore -> IO () appendEvents store = do- Right _ <-- runStoreIO store $- appendToStream- (StreamName "stall-events")- NoStream- [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]- pure ()+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "stall-events")+ NoStream+ [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]+ pure () checkpoint :: KirokuStore -> IO GlobalPosition checkpoint store = do- Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory- case [savedPosition | SubscriptionCheckpoint name _ savedPosition _ <- V.toList rows, name == SubscriptionName "stall-test"] of- [savedPosition] -> pure savedPosition- other -> fail ("unexpected checkpoints: " <> show other)+ Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory+ case [savedPosition | SubscriptionCheckpoint name _ savedPosition _ <- V.toList rows, name == SubscriptionName "stall-test"] of+ [savedPosition] -> pure savedPosition+ other -> fail ("unexpected checkpoints: " <> show other) spec :: Spec spec = describe "handler stall" $ do- forM_- [ (AllStreams, Nothing, False)- , (AllStreams, Nothing, True)- , (Category (CategoryName "stall"), Nothing, True)- , (AllStreams, Just (validConsumerGroup 0 1), True)- , (Category (CategoryName "stall"), Just (validConsumerGroup 0 1), False)- ]- $ \(target', group, live) ->- it ("warns without acknowledging or checkpointing a blocked handler for " <> show (target', group, live)) $ do- release <- newEmptyMVar- caughtUp <- newEmptyMVar- warning <- STM.newEmptyTMVarIO- count <- STM.newTVarIO (0 :: Int)- let subName = SubscriptionName "stall-test"- observe event = do- caughtUpEventHandler subName caughtUp Nothing event- case event of- KirokuEventSubscriptionHandlerStalled name pos eid elapsed groupContext | name == subName -> STM.atomically $ do- STM.modifyTVar' count (+ 1)- _ <- STM.tryPutTMVar warning (pos, eid, elapsed, groupContext)- pure ()- _ -> pure ()- tweak settings = settings & #eventHandler .~ Just observe- handler event =- if event ^. #globalPosition == GlobalPosition 1- then takeMVar release >> pure Continue- else pure Stop- withTestStoreSettings tweak $ \store -> do- if live then pure () else appendEvents store >> waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig subName target' handler)- { consumerGroup = group- , handlerStallWarnAfter = Just 0.05- }- )- if live then waitForSubscriptionLive caughtUp >> appendEvents store else pure ()- (pos, eid, elapsed, groupContext) <- within (STM.atomically (STM.readTMVar warning))- pos `shouldBe` GlobalPosition 1- Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 1)- eid `shouldBe` (V.head events ^. #eventId)- elapsed `shouldSatisfy` (>= 0.05)- groupContext `shouldBe` maybe NonGroup (\_ -> GroupMember 0 1) group- checkpoint store `shouldReturn` GlobalPosition 0- putMVar release ()- within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))- checkpoint store `shouldReturn` GlobalPosition 2- stoppedCount <- STM.readTVarIO count- threadDelay 120_000- STM.readTVarIO count `shouldReturn` stoppedCount- currentState handle >>= (`shouldSatisfy` isNothing)-- it "warns periodically while pending and joins the watchdog on cancellation" $ do- blocked <- newEmptyMVar- warnings <- STM.newTQueueIO- let observe event = case event of- KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> do- now <- getMonotonicTimeNSec- STM.atomically (STM.writeTQueue warnings (pos, elapsed, now))- _ -> pure ()- withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do- appendEvents store- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar blocked >> pure Stop))- { handlerStallWarnAfter = Just 0.05- }- )- (_, firstElapsed, firstAt) <- within (STM.atomically (STM.readTQueue warnings))- (_, secondElapsed, secondAt) <- within (STM.atomically (STM.readTQueue warnings))- secondElapsed `shouldSatisfy` (> firstElapsed)- secondAt - firstAt `shouldSatisfy` (>= 45_000_000)- checkpoint store `shouldReturn` GlobalPosition 0- within (cancel handle)- _ <- STM.atomically (STM.flushTQueue warnings)- threadDelay 120_000- STM.atomically (STM.isEmptyTQueue warnings) `shouldReturn` True- currentState handle >>= (`shouldSatisfy` isNothing)-- it "warns for the current invocation after replacing a quick handler" $ do+ forM_+ [ (AllStreams, Nothing, False),+ (AllStreams, Nothing, True),+ (Category (CategoryName "stall"), Nothing, True),+ (AllStreams, Just (validConsumerGroup 0 1), True),+ (Category (CategoryName "stall"), Just (validConsumerGroup 0 1), False)+ ]+ $ \(target', group, live) ->+ it ("warns without acknowledging or checkpointing a blocked handler for " <> show (target', group, live)) $ do release <- newEmptyMVar+ caughtUp <- newEmptyMVar warning <- STM.newEmptyTMVarIO- let observe event = case event of- KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> STM.atomically $ do- _ <- STM.tryPutTMVar warning (pos, elapsed)- pure ()+ count <- STM.newTVarIO (0 :: Int)+ let subName = SubscriptionName "stall-test"+ observe event = do+ caughtUpEventHandler subName caughtUp Nothing event+ case event of+ KirokuEventSubscriptionHandlerStalled name pos eid elapsed groupContext | name == subName -> STM.atomically $ do+ STM.modifyTVar' count (+ 1)+ _ <- STM.tryPutTMVar warning (pos, eid, elapsed, groupContext)+ pure () _ -> pure ()+ tweak settings = settings & #eventHandler .~ Just observe handler event =- if event ^. #globalPosition == GlobalPosition 1- then pure Continue- else takeMVar release >> pure Stop- withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do- appendEvents store- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams handler)- { handlerStallWarnAfter = Just 0.05- }- )- (pos, elapsed) <- within (STM.atomically (STM.readTMVar warning))- pos `shouldBe` GlobalPosition 2- elapsed `shouldSatisfy` (>= 0.05)- -- Normal progress is persisted at the batch boundary.- checkpoint store `shouldReturn` GlobalPosition 0- putMVar release ()- _ <- within (wait handle)+ if event ^. #globalPosition == GlobalPosition 1+ then takeMVar release >> pure Continue+ else pure Stop+ withTestStoreSettings tweak $ \store -> do+ if live then pure () else appendEvents store >> waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig subName target' handler)+ { consumerGroup = group,+ handlerStallWarnAfter = Just 0.05+ }+ )+ if live then waitForSubscriptionLive caughtUp >> appendEvents store else pure ()+ (pos, eid, elapsed, groupContext) <- within (STM.atomically (STM.readTMVar warning))+ pos `shouldBe` GlobalPosition 1+ Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 1)+ eid `shouldBe` (V.head events ^. #eventId)+ elapsed `shouldSatisfy` (>= 0.05)+ groupContext `shouldBe` maybe NonGroup (\_ -> GroupMember 0 1) group+ checkpoint store `shouldReturn` GlobalPosition 0+ putMVar release ()+ within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))+ checkpoint store `shouldReturn` GlobalPosition 2+ stoppedCount <- STM.readTVarIO count+ threadDelay 120_000+ STM.readTVarIO count `shouldReturn` stoppedCount+ currentState handle >>= (`shouldSatisfy` isNothing)++ it "warns periodically while pending and joins the watchdog on cancellation" $ do+ blocked <- newEmptyMVar+ warnings <- STM.newTQueueIO+ let observe event = case event of+ KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> do+ now <- getMonotonicTimeNSec+ STM.atomically (STM.writeTQueue warnings (pos, elapsed, now))+ _ -> pure ()+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ appendEvents store+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar blocked >> pure Stop))+ { handlerStallWarnAfter = Just 0.05+ }+ )+ (_, firstElapsed, firstAt) <- within (STM.atomically (STM.readTQueue warnings))+ (_, secondElapsed, secondAt) <- within (STM.atomically (STM.readTQueue warnings))+ secondElapsed `shouldSatisfy` (> firstElapsed)+ secondAt - firstAt `shouldSatisfy` (>= 45_000_000)+ checkpoint store `shouldReturn` GlobalPosition 0+ within (cancel handle)+ _ <- STM.atomically (STM.flushTQueue warnings)+ threadDelay 120_000+ STM.atomically (STM.isEmptyTQueue warnings) `shouldReturn` True+ currentState handle >>= (`shouldSatisfy` isNothing)++ it "warns for the current invocation after replacing a quick handler" $ do+ release <- newEmptyMVar+ warning <- STM.newEmptyTMVarIO+ let observe event = case event of+ KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> STM.atomically $ do+ _ <- STM.tryPutTMVar warning (pos, elapsed) pure ()+ _ -> pure ()+ handler event =+ if event ^. #globalPosition == GlobalPosition 1+ then pure Continue+ else takeMVar release >> pure Stop+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ appendEvents store+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams handler)+ { handlerStallWarnAfter = Just 0.05+ }+ )+ (pos, elapsed) <- within (STM.atomically (STM.readTMVar warning))+ pos `shouldBe` GlobalPosition 2+ elapsed `shouldSatisfy` (>= 0.05)+ -- Normal progress is persisted at the batch boundary.+ checkpoint store `shouldReturn` GlobalPosition 0+ putMVar release ()+ _ <- within (wait handle)+ pure () - it "contains a throwing diagnostic callback and still completes the handler" $ do- release <- newEmptyMVar- observed <- STM.newEmptyTMVarIO- let observe KirokuEventSubscriptionHandlerStalled{} = do- STM.atomically $ do- _ <- STM.tryPutTMVar observed ()- pure ()- ioError (userError "diagnostic callback failed")- observe _ = pure ()- withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do- appendEvents store- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> pure Stop))- { handlerStallWarnAfter = Just 0.05- }- )- within (STM.atomically (STM.readTMVar observed))- putMVar release ()- within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))- checkpoint store `shouldReturn` GlobalPosition 1+ it "contains a throwing diagnostic callback and still completes the handler" $ do+ release <- newEmptyMVar+ observed <- STM.newEmptyTMVarIO+ let observe KirokuEventSubscriptionHandlerStalled {} = do+ STM.atomically $ do+ _ <- STM.tryPutTMVar observed ()+ pure ()+ ioError (userError "diagnostic callback failed")+ observe _ = pure ()+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ appendEvents store+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> pure Stop))+ { handlerStallWarnAfter = Just 0.05+ }+ )+ within (STM.atomically (STM.readTMVar observed))+ putMVar release ()+ within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))+ checkpoint store `shouldReturn` GlobalPosition 1 - it "clears tracking and joins the watchdog when a handler throws" $ do- release <- newEmptyMVar- count <- STM.newTVarIO (0 :: Int)- let observe KirokuEventSubscriptionHandlerStalled{} = STM.atomically (STM.modifyTVar' count (+ 1))- observe _ = pure ()- withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do- appendEvents store- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> ioError (userError "handler failed")))- { handlerStallWarnAfter = Just 0.05- }- )- within (STM.atomically (STM.readTVar count >>= STM.check . (> 0)))- putMVar release ()- within (wait handle) >>= (`shouldSatisfy` either (const True) (const False))- stoppedCount <- STM.readTVarIO count- threadDelay 120_000- STM.readTVarIO count `shouldReturn` stoppedCount- checkpoint store `shouldReturn` GlobalPosition 0+ it "clears tracking and joins the watchdog when a handler throws" $ do+ release <- newEmptyMVar+ count <- STM.newTVarIO (0 :: Int)+ let observe KirokuEventSubscriptionHandlerStalled {} = STM.atomically (STM.modifyTVar' count (+ 1))+ observe _ = pure ()+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ appendEvents store+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> ioError (userError "handler failed")))+ { handlerStallWarnAfter = Just 0.05+ }+ )+ within (STM.atomically (STM.readTVar count >>= STM.check . (> 0)))+ putMVar release ()+ within (wait handle) >>= (`shouldSatisfy` either (const True) (const False))+ stoppedCount <- STM.readTVarIO count+ threadDelay 120_000+ STM.readTVarIO count `shouldReturn` stoppedCount+ checkpoint store `shouldReturn` GlobalPosition 0 - it "emits nothing when the handler completes before the threshold" $ do- count <- STM.newTVarIO (0 :: Int)- let observe KirokuEventSubscriptionHandlerStalled{} = STM.atomically (STM.modifyTVar' count (+ 1))- observe _ = pure ()- withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do- appendEvents store- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))- { handlerStallWarnAfter = Just 0.1- }- )- within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))- threadDelay 150_000- STM.readTVarIO count `shouldReturn` 0+ it "emits nothing when the handler completes before the threshold" $ do+ count <- STM.newTVarIO (0 :: Int)+ let observe KirokuEventSubscriptionHandlerStalled {} = STM.atomically (STM.modifyTVar' count (+ 1))+ observe _ = pure ()+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ appendEvents store+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))+ { handlerStallWarnAfter = Just 0.1+ }+ )+ within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))+ threadDelay 150_000+ STM.readTVarIO count `shouldReturn` 0 - it "keeps warnings disabled by default even when a handler is blocked" $ do- entered <- newEmptyMVar- release <- newEmptyMVar- count <- STM.newTVarIO (0 :: Int)- let observe KirokuEventSubscriptionHandlerStalled{} = STM.atomically (STM.modifyTVar' count (+ 1))- observe _ = pure ()- withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do- appendEvents store- waitForPublisher store (GlobalPosition 2)- let config = defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> putMVar entered () >> takeMVar release >> pure Stop)- handlerStallWarnAfter config `shouldBe` Nothing- handle <- subscribe store config- within (takeMVar entered)- threadDelay 150_000- STM.readTVarIO count `shouldReturn` 0- putMVar release ()- _ <- within (wait handle)- pure ()+ it "keeps warnings disabled by default even when a handler is blocked" $ do+ entered <- newEmptyMVar+ release <- newEmptyMVar+ count <- STM.newTVarIO (0 :: Int)+ let observe KirokuEventSubscriptionHandlerStalled {} = STM.atomically (STM.modifyTVar' count (+ 1))+ observe _ = pure ()+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ appendEvents store+ waitForPublisher store (GlobalPosition 2)+ let config = defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> putMVar entered () >> takeMVar release >> pure Stop)+ handlerStallWarnAfter config `shouldBe` Nothing+ handle <- subscribe store config+ within (takeMVar entered)+ threadDelay 150_000+ STM.readTVarIO count `shouldReturn` 0+ putMVar release ()+ _ <- within (wait handle)+ pure () - forM_ [0, -1 :: NominalDiffTime] $ \threshold ->- it ("refuses a nonpositive warning interval before checkpoint initialization: " <> show threshold) $- withTestStoreSettings Prelude.id $ \store -> do- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))- { handlerStallWarnAfter = Just threshold- }- )- within (wait handle) >>= \case- Left err -> do- (fromException err :: Maybe InvalidHandlerStallWarnAfter) `shouldBe` Just (InvalidHandlerStallWarnAfter threshold)- (fromException err :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` (not . isNothing)- Right () -> expectationFailure "invalid interval was accepted"- Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory- V.null rows `shouldBe` True+ forM_ [0, -1 :: NominalDiffTime] $ \threshold ->+ it ("refuses a nonpositive warning interval with exactly one crashed stop before checkpoint initialization: " <> show threshold) $ do+ events <- newIORef []+ let observe event = atomicModifyIORef' events (\seen -> (event : seen, ()))+ withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))+ { handlerStallWarnAfter = Just threshold+ }+ )+ within (wait handle) >>= \case+ Left err -> do+ (fromException err :: Maybe InvalidHandlerStallWarnAfter) `shouldBe` Just (InvalidHandlerStallWarnAfter threshold)+ (fromException err :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` (not . isNothing)+ Right () -> expectationFailure "invalid interval was accepted"+ Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory+ V.null rows `shouldBe` True+ seen <- readIORef events+ case [(n, p, e, g) | KirokuEventSubscriptionStopped n p (StopWorkerCrashed e) g <- seen] of+ [(n, p, e, g)] -> do+ n `shouldBe` SubscriptionName "stall-test"+ p `shouldBe` GlobalPosition 0+ g `shouldBe` NonGroup+ fromException e `shouldBe` Just (InvalidHandlerStallWarnAfter threshold)+ other -> expectationFailure ("expected exactly one crashed stop, got " <> show other)+ length [() | KirokuEventSubscriptionStopped {} <- seen] `shouldBe` 1
test/Test/Helpers.hs view
@@ -1,21 +1,20 @@-{- | Shared fixtures and synchronization helpers for the kiroku-store-test suite.--Three groups of helpers live here:-- * Database fixture: 'withTestStore' brackets an ephemeral PostgreSQL- instance and a 'KirokuStore'.- * Event/wait helpers: 'makeEvent', 'waitWithTimeout', plus the- listener-pid and TRUNCATE-rejection utilities used by the- Notifier/Lifecycle regression tests.- * Deterministic synchronization: 'waitForPublisher' and- 'waitForSubscriptionLive' replace 'threadDelay'-based sleeps in- subscription tests with STM- and event-handler-driven barriers- (per EP-6 F12/F14 and the threadDelay inventory in- @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@).--}-module Test.Helpers (- -- * Database fixture+-- | Shared fixtures and synchronization helpers for the kiroku-store+-- test suite.+--+-- Three groups of helpers live here:+--+-- * Database fixture: 'withTestStore' brackets an ephemeral PostgreSQL+-- instance and a 'KirokuStore'.+-- * Event/wait helpers: 'makeEvent', 'waitWithTimeout', plus the+-- listener-pid and TRUNCATE-rejection utilities used by the+-- Notifier/Lifecycle regression tests.+-- * Deterministic synchronization: 'waitForPublisher' and+-- 'waitForSubscriptionLive' replace 'threadDelay'-based sleeps in+-- subscription tests with STM- and event-handler-driven barriers+-- (per EP-6 F12/F14 and the threadDelay inventory in+-- @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@).+module Test.Helpers+ ( -- * Database fixture withSharedMigratedPostgres, withTestStore, withTestStoreSettings,@@ -48,7 +47,8 @@ waitForPublisher, waitForSubscriptionLive, caughtUpEventHandler,-) where+ )+where import Control.Concurrent (threadDelay) import Control.Concurrent.Async qualified as Async@@ -74,61 +74,57 @@ import Kiroku.Store.Subscription.EventPublisher (publisherPosition) import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres) -{- | Bracket that creates an ephemeral PostgreSQL database, applies the-Kiroku migrations, and provides a 'KirokuStore' handle.--}+-- | Bracket that creates an ephemeral PostgreSQL database, applies the+-- Kiroku migrations, and provides a 'KirokuStore' handle. withTestStore :: (KirokuStore -> IO ()) -> IO () withTestStore = withTestStoreSettings Prelude.id -{- | Variant of 'withTestStore' that lets the caller transform the-'ConnectionSettings' before the store is opened. Used by tests that-install an 'eventHandler' or other observation hook.--}+-- | Variant of 'withTestStore' that lets the caller transform the+-- 'ConnectionSettings' before the store is opened. Used by tests that+-- install an 'eventHandler' or other observation hook. withTestStoreSettings :: (ConnectionSettings -> ConnectionSettings) -> (KirokuStore -> IO ()) -> IO () withTestStoreSettings tweak action =- withMigratedTestDatabase $ \connStr ->- withStore (tweak (defaultConnectionSettings connStr)) action+ withMigratedTestDatabase $ \connStr ->+ withStore (tweak (defaultConnectionSettings connStr)) action -- | Create a simple 'EventData' with an auto-generated id. makeEvent :: Text -> Value -> EventData makeEvent typ payload =- EventData- { eventId = Nothing- , eventType = EventType typ- , payload = payload- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }+ EventData+ { eventId = Nothing,+ eventType = EventType typ,+ payload = payload,+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ } -{- | Wait for a subscription with a timeout (in microseconds).-Returns 'Left' on timeout, or the subscription result.--}+-- | Wait for a subscription with a timeout (in microseconds).+-- Returns 'Left' on timeout, or the subscription result. waitWithTimeout :: Int -> SubscriptionHandle -> IO (Either String (Either SomeException ())) waitWithTimeout micros handle = do- result <- Async.race (threadDelay micros) (wait handle)- case result of- Left () -> do- cancel handle- pure (Left "Subscription timed out")- Right r -> pure (Right r)+ result <- Async.race (threadDelay micros) (wait handle)+ case result of+ Left () -> do+ cancel handle+ pure (Left "Subscription timed out")+ Right r -> pure (Right r) -{- | Count rows in the @events@ table via raw SQL. Used by hard-delete-regression tests that need to assert event payloads are actually removed-(not just unlinked).--}+-- | Count rows in the @events@ table via raw SQL. Used by hard-delete+-- regression tests that need to assert event payloads are actually removed+-- (not just unlinked). countEvents :: KirokuStore -> IO Int64 countEvents store = do- let stmt :: Statement () Int64- stmt =- preparable- "SELECT COUNT(*) FROM events"- E.noParams- (D.singleRow (D.column (D.nonNullable D.int8)))- result <- Pool.use (store ^. #pool) (Session.statement () stmt)- case result of- Left err -> error ("countEvents failed: " <> show err)- Right n -> pure n+ let stmt :: Statement () Int64+ stmt =+ preparable+ "SELECT COUNT(*) FROM events"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.int8)))+ 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 ()@@ -136,220 +132,212 @@ insertDeadLetterWith :: KirokuStore -> Text -> Int32 -> Aeson.Value -> Text -> Int32 -> RecordedEvent -> IO () insertDeadLetterWith store subscriptionName selectedMember reason summary attempts event = do- let EventId eid = event ^. #eventId- GlobalPosition globalPosition = event ^. #globalPosition- params =- SQL.DeadLetterParams- { SQL.dlSubscriptionName = subscriptionName- , SQL.dlMember = selectedMember- , SQL.dlTargetKind = "unbound"- , SQL.dlTargetCategory = Nothing- , SQL.dlGroupSize = max 1 (selectedMember + 1)- , SQL.dlGlobalPosition = globalPosition- , SQL.dlEventId = eid- , SQL.dlReason = reason- , SQL.dlReasonSummary = summary- , SQL.dlAttemptCount = attempts- }- result <- Pool.use (store ^. #pool) (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)- case result of- Left err -> error ("insertDeadLetterForEvent failed: " <> show err)- Right () -> pure ()+ let EventId eid = event ^. #eventId+ GlobalPosition globalPosition = event ^. #globalPosition+ params =+ SQL.DeadLetterParams+ { SQL.dlSubscriptionName = subscriptionName,+ SQL.dlMember = selectedMember,+ SQL.dlTargetKind = "unbound",+ SQL.dlTargetCategory = Nothing,+ SQL.dlGroupSize = max 1 (selectedMember + 1),+ SQL.dlGlobalPosition = globalPosition,+ SQL.dlEventId = eid,+ SQL.dlReason = reason,+ SQL.dlReasonSummary = summary,+ SQL.dlAttemptCount = attempts+ }+ 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+ 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-default is responsible for UUID generation. Returns the generated UUID-text; used to prove the schema-level @uuidv7()@ fallback works.--}+-- | Insert directly into @events@ without an @event_id@ so the database+-- default is responsible for UUID generation. Returns the generated UUID+-- text; used to prove the schema-level @uuidv7()@ fallback works. insertEventUsingDefaultId :: KirokuStore -> IO Text insertEventUsingDefaultId store = do- let stmt :: Statement () Text- stmt =- preparable- "INSERT INTO events (event_type, data) VALUES ('DefaultUuidGenerated', '{}'::jsonb) RETURNING event_id::text"- E.noParams- (D.singleRow (D.column (D.nonNullable D.text)))- result <- Pool.use (store ^. #pool) (Session.statement () stmt)- case result of- Left err -> error ("insertEventUsingDefaultId failed: " <> show err)- Right eventIdText -> pure eventIdText+ let stmt :: Statement () Text+ stmt =+ preparable+ "INSERT INTO events (event_type, data) VALUES ('DefaultUuidGenerated', '{}'::jsonb) RETURNING event_id::text"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.text)))+ result <- Pool.use (store ^. #pool) (Session.statement () stmt)+ case result of+ Left err -> error ("insertEventUsingDefaultId failed: " <> show err)+ Right eventIdText -> pure eventIdText -{- | Report whether a schema-qualified relation exists, via-@to_regclass(name) IS NOT NULL@. The name is schema-qualified-(e.g. @"kiroku.streams"@ or @"public.streams"@), so the result does not-depend on the connection's @search_path@. Used by the schema-placement-tests that prove Kiroku objects install under @kiroku@ and not @public@.--}+-- | Report whether a schema-qualified relation exists, via+-- @to_regclass(name) IS NOT NULL@. The name is schema-qualified+-- (e.g. @"kiroku.streams"@ or @"public.streams"@), so the result does not+-- depend on the connection's @search_path@. Used by the schema-placement+-- tests that prove Kiroku objects install under @kiroku@ and not @public@. tableExists :: KirokuStore -> Text -> IO Bool tableExists store qualifiedName = do- let stmt :: Statement Text Bool- stmt =- preparable- "SELECT to_regclass($1) IS NOT NULL"- (E.param (E.nonNullable E.text))- (D.singleRow (D.column (D.nonNullable D.bool)))- result <- Pool.use (store ^. #pool) (Session.statement qualifiedName stmt)- case result of- Left err -> error ("tableExists failed: " <> show err)- Right exists -> pure exists+ let stmt :: Statement Text Bool+ stmt =+ preparable+ "SELECT to_regclass($1) IS NOT NULL"+ (E.param (E.nonNullable E.text))+ (D.singleRow (D.column (D.nonNullable D.bool)))+ result <- Pool.use (store ^. #pool) (Session.statement qualifiedName stmt)+ case result of+ Left err -> error ("tableExists failed: " <> show err)+ Right exists -> pure exists -- | Return PostgreSQL's numeric server version for failure messages. serverVersionNum :: KirokuStore -> IO Text serverVersionNum store = do- let stmt :: Statement () Text- stmt =- preparable- "SELECT current_setting('server_version_num')"- E.noParams- (D.singleRow (D.column (D.nonNullable D.text)))- result <- Pool.use (store ^. #pool) (Session.statement () stmt)- case result of- Left err -> error ("serverVersionNum failed: " <> show err)- Right version -> pure version+ let stmt :: Statement () Text+ stmt =+ preparable+ "SELECT current_setting('server_version_num')"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.text)))+ result <- Pool.use (store ^. #pool) (Session.statement () stmt)+ case result of+ Left err -> error ("serverVersionNum failed: " <> show err)+ Right version -> pure version -{- | Try to TRUNCATE the named table without the GUC; returns 'True' if the-operation was rejected by the @protect_truncation@ trigger (the expected-behavior after EP-1 F6) and 'False' if it succeeded.--}+-- | Try to TRUNCATE the named table without the GUC; returns 'True' if the+-- operation was rejected by the @protect_truncation@ trigger (the expected+-- behavior after EP-1 F6) and 'False' if it succeeded. truncateRejected :: KirokuStore -> Text -> IO Bool truncateRejected store tableName = do- let stmt :: Statement () ()- stmt = unpreparable ("TRUNCATE " <> tableName) E.noParams D.noResult- result <- Pool.use (store ^. #pool) (Session.statement () stmt)- case result of- Left _ -> pure True- Right () -> pure False+ let stmt :: Statement () ()+ stmt = unpreparable ("TRUNCATE " <> tableName) E.noParams D.noResult+ result <- Pool.use (store ^. #pool) (Session.statement () stmt)+ case result of+ Left _ -> pure True+ Right () -> pure False -- | Look up the pid of the kiroku-listener backend in @pg_stat_activity@. findListenerPid :: Pool.Pool -> IO (Maybe Int32) findListenerPid pool = do- let stmt :: Statement () (Maybe Int32)- stmt =- preparable- "SELECT pid::int4 FROM pg_stat_activity WHERE application_name = 'kiroku-listener' LIMIT 1"- E.noParams- (D.rowMaybe (D.column (D.nonNullable D.int4)))- result <- Pool.use pool (Session.statement () stmt)- case result of- Left err -> error ("findListenerPid failed: " <> show err)- Right mPid -> pure mPid+ let stmt :: Statement () (Maybe Int32)+ stmt =+ preparable+ "SELECT pid::int4 FROM pg_stat_activity WHERE application_name = 'kiroku-listener' LIMIT 1"+ E.noParams+ (D.rowMaybe (D.column (D.nonNullable D.int4)))+ result <- Pool.use pool (Session.statement () stmt)+ case result of+ Left err -> error ("findListenerPid failed: " <> show err)+ Right mPid -> pure mPid -- | Wait until the listener appears in @pg_stat_activity@, or fail after the budget. waitForListenerPid :: Pool.Pool -> Int -> IO Int32 waitForListenerPid pool budgetMicros- | budgetMicros <= 0 = error "waitForListenerPid: timeout — kiroku-listener never appeared in pg_stat_activity"- | otherwise = do- m <- findListenerPid pool- case m of- Just pid -> pure pid- Nothing -> do- threadDelay 50_000- waitForListenerPid pool (budgetMicros - 50_000)+ | budgetMicros <= 0 = error "waitForListenerPid: timeout — kiroku-listener never appeared in pg_stat_activity"+ | otherwise = do+ m <- findListenerPid pool+ case m of+ Just pid -> pure pid+ Nothing -> do+ threadDelay 50_000+ waitForListenerPid pool (budgetMicros - 50_000) -- | Wait until the listener pid in @pg_stat_activity@ differs from the supplied pid. waitForListenerPidNotEqual :: Pool.Pool -> Int32 -> Int -> IO Int32 waitForListenerPidNotEqual pool oldPid budgetMicros- | budgetMicros <= 0 = error "waitForListenerPidNotEqual: timeout — listener did not reconnect"- | otherwise = do- m <- findListenerPid pool- case m of- Just pid | pid /= oldPid -> pure pid- _ -> do- threadDelay 50_000- waitForListenerPidNotEqual pool oldPid (budgetMicros - 50_000)+ | budgetMicros <= 0 = error "waitForListenerPidNotEqual: timeout — listener did not reconnect"+ | otherwise = do+ m <- findListenerPid pool+ case m of+ Just pid | pid /= oldPid -> pure pid+ _ -> do+ threadDelay 50_000+ waitForListenerPidNotEqual pool oldPid (budgetMicros - 50_000) -- | Terminate the named backend pid via @pg_terminate_backend@. terminateBackend :: Pool.Pool -> Int32 -> IO () terminateBackend pool pid = do- let stmt :: Statement Int32 Bool- stmt =- preparable- "SELECT pg_terminate_backend($1::int4)"- (E.param (E.nonNullable E.int4))- (D.singleRow (D.column (D.nonNullable D.bool)))- result <- Pool.use pool (Session.statement pid stmt)- case result of- Left err -> error ("terminateBackend failed: " <> show err)- Right _ -> pure ()+ let stmt :: Statement Int32 Bool+ stmt =+ preparable+ "SELECT pg_terminate_backend($1::int4)"+ (E.param (E.nonNullable E.int4))+ (D.singleRow (D.column (D.nonNullable D.bool)))+ result <- Pool.use pool (Session.statement pid stmt)+ case result of+ Left err -> error ("terminateBackend failed: " <> show err)+ Right _ -> pure () -{- | Count kiroku-listener connections via a fresh connection (used after-the store has shut down, when the application pool is no longer-available).--}+-- | Count kiroku-listener connections via a fresh connection (used after+-- the store has shut down, when the application pool is no longer+-- available). listenerCount :: Text -> IO Int64 listenerCount connStr = do- eConn <- Connection.acquire (Conn.connectionString connStr)- case eConn of- Left err -> error ("listenerCount: failed to acquire verification conn: " <> show err)- Right conn -> do- let stmt :: Statement () Int64- stmt =- preparable- "SELECT COUNT(*) FROM pg_stat_activity WHERE application_name = 'kiroku-listener'"- E.noParams- (D.singleRow (D.column (D.nonNullable D.int8)))- r <- Connection.use conn (Session.statement () stmt)- Connection.release conn- case r of- Left err -> error ("listenerCount: query failed: " <> show err)- Right n -> pure n+ eConn <- Connection.acquire (Conn.connectionString connStr)+ case eConn of+ Left err -> error ("listenerCount: failed to acquire verification conn: " <> show err)+ Right conn -> do+ let stmt :: Statement () Int64+ stmt =+ preparable+ "SELECT COUNT(*) FROM pg_stat_activity WHERE application_name = 'kiroku-listener'"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.int8)))+ r <- Connection.use conn (Session.statement () stmt)+ Connection.release conn+ case r of+ Left err -> error ("listenerCount: query failed: " <> show err)+ Right n -> pure n -{- | Block until the EventPublisher has ingested events at or beyond the-given 'GlobalPosition'. Replaces @threadDelay 200_000@ heuristics in-subscription catch-up tests with a deterministic STM barrier.--}+-- | Block until the EventPublisher has ingested events at or beyond the+-- given 'GlobalPosition'. Replaces @threadDelay 200_000@ heuristics in+-- subscription catch-up tests with a deterministic STM barrier. waitForPublisher :: KirokuStore -> GlobalPosition -> IO () waitForPublisher store (GlobalPosition target) =- atomically $ do- GlobalPosition p <- publisherPosition (store ^. #publisher)- check (p >= target)+ atomically $ do+ GlobalPosition p <- publisherPosition (store ^. #publisher)+ check (p >= target) -{- | Block until the named subscription emits-'KirokuEventSubscriptionCaughtUp'. The caller passes an 'MVar' that is-wired into the test store's @eventHandler@ via 'caughtUpEventHandler'.-Replaces @threadDelay 100_000@ "wait for subscription to enter live-mode" in subscription tests.--}+-- | Block until the named subscription emits+-- 'KirokuEventSubscriptionCaughtUp'. The caller passes an 'MVar' that is+-- wired into the test store's @eventHandler@ via 'caughtUpEventHandler'.+-- Replaces @threadDelay 100_000@ "wait for subscription to enter live+-- mode" in subscription tests. waitForSubscriptionLive :: MVar () -> IO () waitForSubscriptionLive = takeMVar -{- | Build an 'eventHandler' that opens the supplied 'MVar' the first-time 'KirokuEventSubscriptionCaughtUp' fires for the named subscription.-Subsequent emissions and other event types are ignored by the barrier-('tryPutMVar' guarantees idempotence). Composes with a passthrough-caller for tests that need to inspect the full event stream.--}+-- | Build an 'eventHandler' that opens the supplied 'MVar' the first+-- time 'KirokuEventSubscriptionCaughtUp' fires for the named subscription.+-- Subsequent emissions and other event types are ignored by the barrier+-- ('tryPutMVar' guarantees idempotence). Composes with a passthrough+-- caller for tests that need to inspect the full event stream. caughtUpEventHandler ::- SubscriptionName ->- MVar () ->- Maybe (KirokuEvent -> IO ()) ->- KirokuEvent ->- IO ()+ SubscriptionName ->+ MVar () ->+ Maybe (KirokuEvent -> IO ()) ->+ KirokuEvent ->+ IO () caughtUpEventHandler name barrier passthrough evt = do- case evt of- KirokuEventSubscriptionCaughtUp n _ _- | n == name -> () <$ tryPutMVar barrier ()- _ -> pure ()- case passthrough of- Nothing -> pure ()- Just f -> f evt+ case evt of+ KirokuEventSubscriptionCaughtUp n _ _+ | n == name -> () <$ tryPutMVar barrier ()+ _ -> pure ()+ case passthrough of+ Nothing -> pure ()+ Just f -> f evt -{- | Build known-valid membership for test fixtures; invalid cases test the-smart constructors directly rather than passing a malformed worker config.--}+-- | Build known-valid membership for test fixtures; invalid cases test the+-- smart constructors directly rather than passing a malformed worker config. validConsumerGroup :: Int32 -> Int32 -> ConsumerGroup validConsumerGroup m n = either (error . show) Prelude.id (mkConsumerGroupSize n >>= mkConsumerGroup m)
test/Test/HistoryRetention.hs view
@@ -27,242 +27,242 @@ spec :: Spec spec = describe "history retention" $ do- it "validates UTF-8 byte lengths, duration bounds, and inventory bounds" $ do- mkHistoryRetentionLeaseOwner "" `shouldBe` Left HistoryRetentionLeaseOwnerEmpty- mkHistoryRetentionLeaseOwner (Text.replicate 256 "é") `shouldSatisfy` either (const False) (const True)- mkHistoryRetentionLeaseOwner (Text.replicate 257 "é")- `shouldBe` Left (HistoryRetentionLeaseOwnerTooLong 514)- mkHistoryRetentionLeaseReason "" `shouldBe` Left HistoryRetentionLeaseReasonEmpty- mkHistoryRetentionLeaseReason (Text.replicate 2049 "x")- `shouldBe` Left (HistoryRetentionLeaseReasonTooLong 2049)- mkHistoryRetentionLeaseDuration (secondsToDiffTime 0) `shouldSatisfy` isLeft- mkHistoryRetentionLeaseDuration (secondsToDiffTime 1) `shouldSatisfy` either (const False) (const True)- mkHistoryRetentionLeaseDuration (secondsToDiffTime 3600) `shouldSatisfy` either (const False) (const True)- mkHistoryRetentionLeaseDuration (secondsToDiffTime 3601) `shouldSatisfy` isLeft- mkHistoryRetentionInventoryLimit 0 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 0)- mkHistoryRetentionInventoryLimit 1000 `shouldSatisfy` either (const False) (const True)- mkHistoryRetentionInventoryLimit 1001 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 1001)+ it "validates UTF-8 byte lengths, duration bounds, and inventory bounds" $ do+ mkHistoryRetentionLeaseOwner "" `shouldBe` Left HistoryRetentionLeaseOwnerEmpty+ mkHistoryRetentionLeaseOwner (Text.replicate 256 "é") `shouldSatisfy` either (const False) (const True)+ mkHistoryRetentionLeaseOwner (Text.replicate 257 "é")+ `shouldBe` Left (HistoryRetentionLeaseOwnerTooLong 514)+ mkHistoryRetentionLeaseReason "" `shouldBe` Left HistoryRetentionLeaseReasonEmpty+ mkHistoryRetentionLeaseReason (Text.replicate 2049 "x")+ `shouldBe` Left (HistoryRetentionLeaseReasonTooLong 2049)+ mkHistoryRetentionLeaseDuration (secondsToDiffTime 0) `shouldSatisfy` isLeft+ mkHistoryRetentionLeaseDuration (secondsToDiffTime 1) `shouldSatisfy` either (const False) (const True)+ mkHistoryRetentionLeaseDuration (secondsToDiffTime 3600) `shouldSatisfy` either (const False) (const True)+ mkHistoryRetentionLeaseDuration (secondsToDiffTime 3601) `shouldSatisfy` isLeft+ mkHistoryRetentionInventoryLimit 0 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 0)+ mkHistoryRetentionInventoryLimit 1000 `shouldSatisfy` either (const False) (const True)+ mkHistoryRetentionInventoryLimit 1001 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 1001) - it "captures the authoritative frontier and database-derived expiry" $- withTestStore $ \store -> do- Right appended <-- runStoreIO store $- appendToStream- (StreamName "history-retention-frontier")- NoStream- [ makeEvent "One" (Aeson.object [])- , makeEvent "Two" (Aeson.object [])- , makeEvent "Three" (Aeson.object [])- ]- lease <- runTx store (acquireHistoryRetentionLeaseTx (request "rebuild" "frontier" 60))- lease ^. #protectedThrough `shouldBe` appended ^. #globalPosition- realToFrac (diffUTCTime (lease ^. #expiresAt) (lease ^. #createdAt))- `shouldBe` secondsToDiffTime 60- lease ^. #state `shouldBe` HistoryRetentionLeaseActive- rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))- rows `shouldBe` Vector.singleton lease+ it "captures the authoritative frontier and database-derived expiry" $+ withTestStore $ \store -> do+ Right appended <-+ runStoreIO store $+ appendToStream+ (StreamName "history-retention-frontier")+ NoStream+ [ makeEvent "One" (Aeson.object []),+ makeEvent "Two" (Aeson.object []),+ makeEvent "Three" (Aeson.object [])+ ]+ lease <- runTx store (acquireHistoryRetentionLeaseTx (request "rebuild" "frontier" 60))+ lease ^. #protectedThrough `shouldBe` appended ^. #globalPosition+ realToFrac (diffUTCTime (lease ^. #expiresAt) (lease ^. #createdAt))+ `shouldBe` secondsToDiffTime 60+ lease ^. #state `shouldBe` HistoryRetentionLeaseActive+ rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+ rows `shouldBe` Vector.singleton lease - it "rolls back acquisition when the surrounding transaction is condemned" $- withTestStore $ \store -> do- _ <- runTx store $ do- lease <- acquireHistoryRetentionLeaseTx (request "rollback" "condemned" 60)- Tx.condemn- pure lease- rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))- rows `shouldBe` Vector.empty+ it "rolls back acquisition when the surrounding transaction is condemned" $+ withTestStore $ \store -> do+ _ <- runTx store $ do+ lease <- acquireHistoryRetentionLeaseTx (request "rollback" "condemned" 60)+ Tx.condemn+ pure lease+ rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+ rows `shouldBe` Vector.empty - it "renews only the matching active owner and never shortens expiry" $- withTestStore $ \store -> do- lease <- runTx store (acquireHistoryRetentionLeaseTx (request "owner-a" "renew" 30))- let handle = leaseHandle lease- wrongHandle = HistoryRetentionLeaseHandle (lease ^. #leaseId) (validatedOwner "owner-b")- mismatch <- runTx store (renewHistoryRetentionLeaseTx wrongHandle (validatedDuration 60))- mismatch `shouldBe` Left HistoryRetentionRenewalOwnerMismatch- renewed <- runTx store (renewHistoryRetentionLeaseTx handle (validatedDuration 60))- case renewed of- Left err -> expectationFailure ("renewal failed: " <> show err)- Right value -> do- value ^. #expiresAt `shouldSatisfy` (> lease ^. #expiresAt)- value ^. #renewedAt `shouldSatisfy` (>= lease ^. #renewedAt)+ it "renews only the matching active owner and never shortens expiry" $+ withTestStore $ \store -> do+ lease <- runTx store (acquireHistoryRetentionLeaseTx (request "owner-a" "renew" 30))+ let handle = leaseHandle lease+ wrongHandle = HistoryRetentionLeaseHandle (lease ^. #leaseId) (validatedOwner "owner-b")+ mismatch <- runTx store (renewHistoryRetentionLeaseTx wrongHandle (validatedDuration 60))+ mismatch `shouldBe` Left HistoryRetentionRenewalOwnerMismatch+ renewed <- runTx store (renewHistoryRetentionLeaseTx handle (validatedDuration 60))+ case renewed of+ Left err -> expectationFailure ("renewal failed: " <> show err)+ Right value -> do+ value ^. #expiresAt `shouldSatisfy` (> lease ^. #expiresAt)+ value ^. #renewedAt `shouldSatisfy` (>= lease ^. #renewedAt) - it "derives expiry without a worker and refuses resurrection" $- withTestStore $ \store -> do- lease <- runTx store (acquireHistoryRetentionLeaseTx (request "crashed" "expiry" 1))- threadDelay 1_100_000- rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))- fmap (^. #state) (Vector.toList rows) `shouldBe` [HistoryRetentionLeaseExpired]- renewed <- runTx store (renewHistoryRetentionLeaseTx (leaseHandle lease) (validatedDuration 60))- renewed `shouldBe` Left HistoryRetentionRenewalExpired- released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle lease))- case released of- HistoryRetentionReleaseExpired expiredLease ->- expiredLease ^. #state `shouldBe` HistoryRetentionLeaseExpired- other -> expectationFailure ("expected expired release result, got " <> show other)+ it "derives expiry without a worker and refuses resurrection" $+ withTestStore $ \store -> do+ lease <- runTx store (acquireHistoryRetentionLeaseTx (request "crashed" "expiry" 1))+ threadDelay 1_100_000+ rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+ fmap (^. #state) (Vector.toList rows) `shouldBe` [HistoryRetentionLeaseExpired]+ renewed <- runTx store (renewHistoryRetentionLeaseTx (leaseHandle lease) (validatedDuration 60))+ renewed `shouldBe` Left HistoryRetentionRenewalExpired+ released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle lease))+ case released of+ HistoryRetentionReleaseExpired expiredLease ->+ expiredLease ^. #state `shouldBe` HistoryRetentionLeaseExpired+ other -> expectationFailure ("expected expired release result, got " <> show other) - it "releases idempotently and leaves another simultaneous lease active" $- withTestStore $ \store -> do- first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "release" 60))- second <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "release" 60))- released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))- released `shouldSatisfy` \case HistoryRetentionReleased{} -> True; _ -> False- repeated <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))- repeated `shouldSatisfy` \case HistoryRetentionAlreadyReleased{} -> True; _ -> False- rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))- fmap (^. #state) (Vector.toList rows)- `shouldBe` [HistoryRetentionLeaseReleased, HistoryRetentionLeaseActive]- Vector.last rows ^. #leaseId `shouldBe` second ^. #leaseId+ it "releases idempotently and leaves another simultaneous lease active" $+ withTestStore $ \store -> do+ first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "release" 60))+ second <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "release" 60))+ released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))+ released `shouldSatisfy` \case HistoryRetentionReleased {} -> True; _ -> False+ repeated <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))+ repeated `shouldSatisfy` \case HistoryRetentionAlreadyReleased {} -> True; _ -> False+ rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+ fmap (^. #state) (Vector.toList rows)+ `shouldBe` [HistoryRetentionLeaseReleased, HistoryRetentionLeaseActive]+ Vector.last rows ^. #leaseId `shouldBe` second ^. #leaseId - it "bounds inventory and prunes only terminal rows older than the cutoff" $- withTestStore $ \store -> do- first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "prune" 60))- _ <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "keep" 60))- _ <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))- bounded <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 1))- Vector.length bounded `shouldBe` 1- cutoff <- addUTCTime 1 <$> getCurrentTime- pruned <- runTx store (pruneHistoryRetentionLeasesTx cutoff)- pruned `shouldBe` HistoryRetentionPruneResult 0 1- remaining <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))- fmap (^. #state) (Vector.toList remaining) `shouldBe` [HistoryRetentionLeaseActive]+ it "bounds inventory and prunes only terminal rows older than the cutoff" $+ withTestStore $ \store -> do+ first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "prune" 60))+ _ <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "keep" 60))+ _ <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))+ bounded <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 1))+ Vector.length bounded `shouldBe` 1+ cutoff <- addUTCTime 1 <$> getCurrentTime+ pruned <- runTx store (pruneHistoryRetentionLeasesTx cutoff)+ pruned `shouldBe` HistoryRetentionPruneResult 0 1+ remaining <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+ fmap (^. #state) (Vector.toList remaining) `shouldBe` [HistoryRetentionLeaseActive] - it "emits committed effect transitions once and no false repeated-release event" $ do- observed <- newIORef ([] :: [KirokuEvent])- withTestStoreSettings- (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))- $ \store -> do- Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "events" "not-a-label" 60)- Right (Right _) <-- runStoreIO store $- renewHistoryRetentionLease (leaseHandle lease) (validatedDuration 120)- Right HistoryRetentionReleased{} <-- runStoreIO store $- releaseHistoryRetentionLease (leaseHandle lease)- Right HistoryRetentionAlreadyReleased{} <-- runStoreIO store $- releaseHistoryRetentionLease (leaseHandle lease)- cutoff <- addUTCTime 1 <$> getCurrentTime- Right (HistoryRetentionPruneResult 0 1) <-- runStoreIO store $- pruneHistoryRetentionLeases cutoff- pure ()- events <- readIORef observed- length [() | KirokuEventHistoryRetentionLeaseAcquired{} <- events] `shouldBe` 1- length [() | KirokuEventHistoryRetentionLeaseRenewed{} <- events] `shouldBe` 1- length [() | KirokuEventHistoryRetentionLeaseReleased{} <- events] `shouldBe` 1- length [() | KirokuEventHistoryRetentionLeasesPruned{} <- events] `shouldBe` 1+ it "emits committed effect transitions once and no false repeated-release event" $ do+ observed <- newIORef ([] :: [KirokuEvent])+ withTestStoreSettings+ (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))+ $ \store -> do+ Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "events" "not-a-label" 60)+ Right (Right _) <-+ runStoreIO store $+ renewHistoryRetentionLease (leaseHandle lease) (validatedDuration 120)+ Right HistoryRetentionReleased {} <-+ runStoreIO store $+ releaseHistoryRetentionLease (leaseHandle lease)+ Right HistoryRetentionAlreadyReleased {} <-+ runStoreIO store $+ releaseHistoryRetentionLease (leaseHandle lease)+ cutoff <- addUTCTime 1 <$> getCurrentTime+ Right (HistoryRetentionPruneResult 0 1) <-+ runStoreIO store $+ pruneHistoryRetentionLeases cutoff+ pure ()+ events <- readIORef observed+ length [() | KirokuEventHistoryRetentionLeaseAcquired {} <- events] `shouldBe` 1+ length [() | KirokuEventHistoryRetentionLeaseRenewed {} <- events] `shouldBe` 1+ length [() | KirokuEventHistoryRetentionLeaseReleased {} <- events] `shouldBe` 1+ length [() | KirokuEventHistoryRetentionLeasesPruned {} <- events] `shouldBe` 1 - it "returns and emits a typed hard-delete conflict without changing history while any lease is active" $ do- observed <- newIORef ([] :: [KirokuEvent])- withTestStoreSettings- (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))- $ \store -> do- let stream = StreamName "history-retention-hard-delete"- Right _ <- runStoreIO store $ appendToStream stream NoStream [makeEvent "Protected" (Aeson.object [])]- countBefore <- countEvents store- Right first <- runStoreIO store $ acquireHistoryRetentionLease (request "first" "protect" 60)- Right second <- runStoreIO store $ acquireHistoryRetentionLease (request "second" "protect" 60)- blocked <- runStoreIO store $ hardDeleteStream stream- case blocked of- Left (HistoryRetentionActive actual HistoryRetentionConflict{activeLeaseCount}) -> do- actual `shouldBe` stream- activeLeaseCount `shouldBe` 2- other -> expectationFailure ("expected typed retention conflict, got " <> show other)- countEvents store `shouldReturn` countBefore- Right (Just _) <- runStoreIO store $ getStream stream- Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle first)- stillBlocked <- runStoreIO store $ hardDeleteStream stream- stillBlocked `shouldSatisfy` \case Left HistoryRetentionActive{} -> True; _ -> False- Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle second)- deleted <- runStoreIO store (hardDeleteStream stream)- deleted `shouldSatisfy` \case Right (Just _) -> True; _ -> False- events <- readIORef observed- let conflictCounts =- [ activeLeaseCount- | KirokuEventHardDeleteHistoryRetentionConflict _ HistoryRetentionConflict{activeLeaseCount} <- reverse events- ]- conflictCounts `shouldBe` [2, 1]+ it "returns and emits a typed hard-delete conflict without changing history while any lease is active" $ do+ observed <- newIORef ([] :: [KirokuEvent])+ withTestStoreSettings+ (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))+ $ \store -> do+ let stream = StreamName "history-retention-hard-delete"+ Right _ <- runStoreIO store $ appendToStream stream NoStream [makeEvent "Protected" (Aeson.object [])]+ countBefore <- countEvents store+ Right first <- runStoreIO store $ acquireHistoryRetentionLease (request "first" "protect" 60)+ Right second <- runStoreIO store $ acquireHistoryRetentionLease (request "second" "protect" 60)+ blocked <- runStoreIO store $ hardDeleteStream stream+ case blocked of+ Left (HistoryRetentionActive actual HistoryRetentionConflict {activeLeaseCount}) -> do+ actual `shouldBe` stream+ activeLeaseCount `shouldBe` 2+ other -> expectationFailure ("expected typed retention conflict, got " <> show other)+ countEvents store `shouldReturn` countBefore+ Right (Just _) <- runStoreIO store $ getStream stream+ Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle first)+ stillBlocked <- runStoreIO store $ hardDeleteStream stream+ stillBlocked `shouldSatisfy` \case Left HistoryRetentionActive {} -> True; _ -> False+ Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle second)+ deleted <- runStoreIO store (hardDeleteStream stream)+ deleted `shouldSatisfy` \case Right (Just _) -> True; _ -> False+ events <- readIORef observed+ let conflictCounts =+ [ activeLeaseCount+ | KirokuEventHardDeleteHistoryRetentionConflict _ HistoryRetentionConflict {activeLeaseCount} <- reverse events+ ]+ conflictCounts `shouldBe` [2, 1] - describe "history retention raw SQL" $ do- it "serializes lease-first acquisition ahead of raw deletion" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-lease-first") NoStream [makeEvent "Raw" (Aeson.object [])]- countBefore <- countStreamEvents store- acquisition <- Async.async $ runStoreIO store $ runTransaction $ do- lease <- acquireHistoryRetentionLeaseTx (request "raw-race" "lease-first" 60)- _ <- Tx.statement () holdCoordinatorStmt- pure lease- waitForCoordinatorPhase store 100- deletion <- Async.async (runRawDestruction store rawDeleteStreamEventsStmt)- threadDelay 50_000- Async.poll deletion >>= \case- Nothing -> pure ()- Just _ -> expectationFailure "raw deletion completed while lease acquisition held the coordinator"- acquired <- waitWithin "lease-first acquisition" acquisition- acquired `shouldSatisfy` \case Right HistoryRetentionLease{} -> True; _ -> False- rejected <- waitWithin "lease-first raw deletion" deletion- rejected `shouldSatisfy` hasSqlState "KR001"- countStreamEvents store `shouldReturn` countBefore+ describe "history retention raw SQL" $ do+ it "serializes lease-first acquisition ahead of raw deletion" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-lease-first") NoStream [makeEvent "Raw" (Aeson.object [])]+ countBefore <- countStreamEvents store+ acquisition <- Async.async $ runStoreIO store $ runTransaction $ do+ lease <- acquireHistoryRetentionLeaseTx (request "raw-race" "lease-first" 60)+ _ <- Tx.statement () holdCoordinatorStmt+ pure lease+ waitForCoordinatorPhase store 100+ deletion <- Async.async (runRawDestruction store rawDeleteStreamEventsStmt)+ threadDelay 50_000+ Async.poll deletion >>= \case+ Nothing -> pure ()+ Just _ -> expectationFailure "raw deletion completed while lease acquisition held the coordinator"+ acquired <- waitWithin "lease-first acquisition" acquisition+ acquired `shouldSatisfy` \case Right HistoryRetentionLease {} -> True; _ -> False+ rejected <- waitWithin "lease-first raw deletion" deletion+ rejected `shouldSatisfy` hasSqlState "KR001"+ countStreamEvents store `shouldReturn` countBefore - it "serializes delete-first maintenance ahead of post-delete acquisition" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-delete-first") NoStream [makeEvent "Raw" (Aeson.object [])]- deletion <- Async.async (runRawDestructionHeld store rawDeleteStreamEventsStmt)- waitForCoordinatorPhase store 100- acquisition <- Async.async $ runStoreIO store $ acquireHistoryRetentionLease (request "raw-race" "delete-first" 60)- threadDelay 50_000- Async.poll acquisition >>= \case- Nothing -> pure ()- Just _ -> expectationFailure "lease acquisition completed while raw deletion held the coordinator"- waitWithin "delete-first raw deletion" deletion `shouldReturn` Right ()- acquired <- waitWithin "delete-first acquisition" acquisition- acquired `shouldSatisfy` \case Right HistoryRetentionLease{} -> True; _ -> False- countStreamEvents store `shouldReturn` 0+ it "serializes delete-first maintenance ahead of post-delete acquisition" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-delete-first") NoStream [makeEvent "Raw" (Aeson.object [])]+ deletion <- Async.async (runRawDestructionHeld store rawDeleteStreamEventsStmt)+ waitForCoordinatorPhase store 100+ acquisition <- Async.async $ runStoreIO store $ acquireHistoryRetentionLease (request "raw-race" "delete-first" 60)+ threadDelay 50_000+ Async.poll acquisition >>= \case+ Nothing -> pure ()+ Just _ -> expectationFailure "lease acquisition completed while raw deletion held the coordinator"+ waitWithin "delete-first raw deletion" deletion `shouldReturn` Right ()+ acquired <- waitWithin "delete-first acquisition" acquisition+ acquired `shouldSatisfy` \case Right HistoryRetentionLease {} -> True; _ -> False+ countStreamEvents store `shouldReturn` 0 - it "rejects GUC-enabled DELETE with KR001 and permits it after release" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "raw-delete") NoStream (replicate 2 (makeEvent "Raw" (Aeson.object [])))- countBefore <- countStreamEvents store- Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "delete" 60)- rejected <- runRawDestruction store rawDeleteStreamEventsStmt- rejected `shouldSatisfy` hasSqlState "KR001"- countStreamEvents store `shouldReturn` countBefore- Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)- runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()- countStreamEvents store `shouldReturn` 0+ it "rejects GUC-enabled DELETE with KR001 and permits it after release" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "raw-delete") NoStream (replicate 2 (makeEvent "Raw" (Aeson.object [])))+ countBefore <- countStreamEvents store+ Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "delete" 60)+ rejected <- runRawDestruction store rawDeleteStreamEventsStmt+ rejected `shouldSatisfy` hasSqlState "KR001"+ countStreamEvents store `shouldReturn` countBefore+ Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)+ runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()+ countStreamEvents store `shouldReturn` 0 - it "rejects GUC-enabled TRUNCATE with KR001 and permits it after release" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "raw-truncate") NoStream [makeEvent "Raw" (Aeson.object [])]- countBefore <- countEvents store- Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "truncate" 60)- rejected <- runRawDestruction store rawTruncateDataStmt- rejected `shouldSatisfy` hasSqlState "KR001"- countEvents store `shouldReturn` countBefore- Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)- runRawDestruction store rawTruncateDataStmt `shouldReturn` Right ()- countEvents store `shouldReturn` 0+ it "rejects GUC-enabled TRUNCATE with KR001 and permits it after release" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "raw-truncate") NoStream [makeEvent "Raw" (Aeson.object [])]+ countBefore <- countEvents store+ Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "truncate" 60)+ rejected <- runRawDestruction store rawTruncateDataStmt+ rejected `shouldSatisfy` hasSqlState "KR001"+ countEvents store `shouldReturn` countBefore+ Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)+ runRawDestruction store rawTruncateDataStmt `shouldReturn` Right ()+ countEvents store `shouldReturn` 0 - it "permits GUC-enabled maintenance after passive expiry" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "raw-expiry") NoStream [makeEvent "Raw" (Aeson.object [])]- Right _ <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "expiry" 1)- threadDelay 1_100_000- runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()+ it "permits GUC-enabled maintenance after passive expiry" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "raw-expiry") NoStream [makeEvent "Raw" (Aeson.object [])]+ Right _ <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "expiry" 1)+ threadDelay 1_100_000+ runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right () runTx :: KirokuStore -> Tx.Transaction value -> IO value runTx store transaction = do- result <- runStoreIO store (runTransaction transaction)- case result of- Left err -> expectationFailure ("history retention transaction failed: " <> show err) >> error "unreachable"- Right value -> pure value+ result <- runStoreIO store (runTransaction transaction)+ case result of+ Left err -> expectationFailure ("history retention transaction failed: " <> show err) >> error "unreachable"+ Right value -> pure value request :: Text.Text -> Text.Text -> Integer -> HistoryRetentionLeaseRequest request ownerText reasonText seconds =- HistoryRetentionLeaseRequest- { owner = validatedOwner ownerText- , reason = either (error . show) (\value -> value) (mkHistoryRetentionLeaseReason reasonText)- , duration = validatedDuration seconds- }+ HistoryRetentionLeaseRequest+ { owner = validatedOwner ownerText,+ reason = either (error . show) (\value -> value) (mkHistoryRetentionLeaseReason reasonText),+ duration = validatedDuration seconds+ } validatedOwner :: Text.Text -> HistoryRetentionLeaseOwner validatedOwner = either (error . show) (\value -> value) . mkHistoryRetentionLeaseOwner@@ -278,99 +278,99 @@ runRawDestruction :: KirokuStore -> Statement () () -> IO (Either Pool.UsageError ()) runRawDestruction store statement =- Pool.use (store ^. #pool) $- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do- Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"- Tx.statement () statement+ Pool.use (store ^. #pool) $+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+ Tx.statement () statement runRawDestructionHeld :: KirokuStore -> Statement () () -> IO (Either Pool.UsageError ()) runRawDestructionHeld store statement =- Pool.use (store ^. #pool) $- TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do- Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"- Tx.statement () statement- _ <- Tx.statement () holdCoordinatorStmt- pure ()+ Pool.use (store ^. #pool) $+ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+ Tx.statement () statement+ _ <- Tx.statement () holdCoordinatorStmt+ pure () waitForCoordinatorPhase :: KirokuStore -> Int -> IO () waitForCoordinatorPhase _ 0 = expectationFailure "coordinator holder never reached its held phase" waitForCoordinatorPhase store attempts = do- result <- Pool.use (store ^. #pool) (Session.statement () coordinatorActiveStmt)- case result of- Right True -> pure ()- Right False -> threadDelay 10_000 >> waitForCoordinatorPhase store (attempts - 1)- Left err -> expectationFailure ("could not observe coordinator phase: " <> show err)+ result <- Pool.use (store ^. #pool) (Session.statement () coordinatorActiveStmt)+ case result of+ Right True -> pure ()+ Right False -> threadDelay 10_000 >> waitForCoordinatorPhase store (attempts - 1)+ Left err -> expectationFailure ("could not observe coordinator phase: " <> show err) waitWithin :: String -> Async.Async value -> IO value waitWithin label action = do- result <- Async.race (threadDelay 2_000_000) (Async.wait action)- case result of- Left () -> do- Async.cancel action- expectationFailure (label <> " timed out")- error "unreachable"- Right value -> pure value+ result <- Async.race (threadDelay 2_000_000) (Async.wait action)+ case result of+ Left () -> do+ Async.cancel action+ expectationFailure (label <> " timed out")+ error "unreachable"+ Right value -> pure value hasSqlState :: Text.Text -> Either Pool.UsageError value -> Bool hasSqlState expected = \case- Left- ( Pool.SessionUsageError- ( Errors.StatementSessionError- _- _- _- _- _- (Errors.ServerStatementError (Errors.ServerError actual _ _ _ _))- )- ) -> actual == expected- _ -> False+ Left+ ( Pool.SessionUsageError+ ( Errors.StatementSessionError+ _+ _+ _+ _+ _+ (Errors.ServerStatementError (Errors.ServerError actual _ _ _ _))+ )+ ) -> actual == expected+ _ -> False countStreamEvents :: KirokuStore -> IO Int64 countStreamEvents store = do- result <- Pool.use (store ^. #pool) (TxSessions.transaction TxSessions.ReadCommitted TxSessions.Read (Tx.statement () countStreamEventsStmt))- case result of- Left err -> expectationFailure ("could not count stream junctions: " <> show err) >> error "unreachable"- Right value -> pure value+ result <- Pool.use (store ^. #pool) (TxSessions.transaction TxSessions.ReadCommitted TxSessions.Read (Tx.statement () countStreamEventsStmt))+ case result of+ Left err -> expectationFailure ("could not count stream junctions: " <> show err) >> error "unreachable"+ Right value -> pure value countStreamEventsStmt :: Statement () Int64 countStreamEventsStmt =- unpreparable- "SELECT count(*) FROM stream_events"- E.noParams- (D.singleRow (D.column (D.nonNullable D.int8)))+ unpreparable+ "SELECT count(*) FROM stream_events"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.int8))) rawDeleteStreamEventsStmt :: Statement () () rawDeleteStreamEventsStmt =- unpreparable- "DELETE FROM stream_events"- E.noParams- D.noResult+ unpreparable+ "DELETE FROM stream_events"+ E.noParams+ D.noResult rawTruncateDataStmt :: Statement () () rawTruncateDataStmt =- unpreparable- "TRUNCATE dead_letters, stream_events, events"- E.noParams- D.noResult+ unpreparable+ "TRUNCATE dead_letters, stream_events, events"+ E.noParams+ D.noResult holdCoordinatorStmt :: Statement () Bool holdCoordinatorStmt =- preparable- "SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */"- E.noParams- (D.singleRow (D.column (D.nonNullable D.bool)))+ preparable+ "SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.bool))) coordinatorActiveStmt :: Statement () Bool coordinatorActiveStmt =- preparable- """- SELECT EXISTS (- SELECT 1- FROM pg_stat_activity- WHERE state = 'active'- AND query = 'SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */'- )- """- E.noParams- (D.singleRow (D.column (D.nonNullable D.bool)))+ preparable+ """+ SELECT EXISTS (+ SELECT 1+ FROM pg_stat_activity+ WHERE state = 'active'+ AND query = 'SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */'+ )+ """+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.bool)))
test/Test/HistoryRetentionMock.hs view
@@ -16,71 +16,71 @@ spec :: Spec spec = describe "history retention mock" $ do- it "dispatches every closed lease operation exactly once" $ do- calls <- newIORef ([] :: [Text])- actual <- runEff $ runMock calls $ do- acquired <- acquireHistoryRetentionLease sampleRequest- renewed <- renewHistoryRetentionLease sampleHandle sampleDuration- released <- releaseHistoryRetentionLease sampleHandle- inventory <- historyRetentionLeaseInventory sampleQuery- pruned <- pruneHistoryRetentionLeases sampleTime- pure (acquired, renewed, released, inventory, pruned)- actual- `shouldBe` ( sampleLease- , Right sampleLease- , HistoryRetentionReleased sampleLease- , Vector.singleton sampleLease- , HistoryRetentionPruneResult 2 3- )- readIORef calls- `shouldReturn` ["acquire", "renew", "release", "inventory", "prune"]+ it "dispatches every closed lease operation exactly once" $ do+ calls <- newIORef ([] :: [Text])+ actual <- runEff $ runMock calls $ do+ acquired <- acquireHistoryRetentionLease sampleRequest+ renewed <- renewHistoryRetentionLease sampleHandle sampleDuration+ released <- releaseHistoryRetentionLease sampleHandle+ inventory <- historyRetentionLeaseInventory sampleQuery+ pruned <- pruneHistoryRetentionLeases sampleTime+ pure (acquired, renewed, released, inventory, pruned)+ actual+ `shouldBe` ( sampleLease,+ Right sampleLease,+ HistoryRetentionReleased sampleLease,+ Vector.singleton sampleLease,+ HistoryRetentionPruneResult 2 3+ )+ readIORef calls+ `shouldReturn` ["acquire", "renew", "release", "inventory", "prune"] runMock :: (IOE :> es) => IORef [Text] -> Eff (Store : es) a -> Eff es a runMock calls = interpret_ $ \case- AcquireHistoryRetentionLease request -> do- liftIO $ request `shouldBe` sampleRequest- record calls "acquire"- pure sampleLease- RenewHistoryRetentionLease handle duration -> do- liftIO $ (handle, duration) `shouldBe` (sampleHandle, sampleDuration)- record calls "renew"- pure (Right sampleLease)- ReleaseHistoryRetentionLease handle -> do- liftIO $ handle `shouldBe` sampleHandle- record calls "release"- pure (HistoryRetentionReleased sampleLease)- GetHistoryRetentionLeaseInventory query -> do- liftIO $ query `shouldBe` sampleQuery- record calls "inventory"- pure (Vector.singleton sampleLease)- PruneHistoryRetentionLeases cutoff -> do- liftIO $ cutoff `shouldBe` sampleTime- record calls "prune"- pure (HistoryRetentionPruneResult 2 3)- _ -> error "unexpected Store operation in history retention mock"+ AcquireHistoryRetentionLease request -> do+ liftIO $ request `shouldBe` sampleRequest+ record calls "acquire"+ pure sampleLease+ RenewHistoryRetentionLease handle duration -> do+ liftIO $ (handle, duration) `shouldBe` (sampleHandle, sampleDuration)+ record calls "renew"+ pure (Right sampleLease)+ ReleaseHistoryRetentionLease handle -> do+ liftIO $ handle `shouldBe` sampleHandle+ record calls "release"+ pure (HistoryRetentionReleased sampleLease)+ GetHistoryRetentionLeaseInventory query -> do+ liftIO $ query `shouldBe` sampleQuery+ record calls "inventory"+ pure (Vector.singleton sampleLease)+ PruneHistoryRetentionLeases cutoff -> do+ liftIO $ cutoff `shouldBe` sampleTime+ record calls "prune"+ pure (HistoryRetentionPruneResult 2 3)+ _ -> error "unexpected Store operation in history retention mock" record :: (IOE :> es) => IORef [Text] -> Text -> Eff es () record calls name = liftIO $ modifyIORef' calls (<> [name]) sampleRequest :: HistoryRetentionLeaseRequest sampleRequest =- HistoryRetentionLeaseRequest sampleOwner sampleReason sampleDuration+ HistoryRetentionLeaseRequest sampleOwner sampleReason sampleDuration sampleHandle :: HistoryRetentionLeaseHandle sampleHandle = HistoryRetentionLeaseHandle sampleId sampleOwner sampleLease :: HistoryRetentionLease sampleLease =- HistoryRetentionLease- sampleId- sampleOwner- sampleReason- (GlobalPosition 17)- sampleTime- sampleTime- sampleExpiry- Nothing- HistoryRetentionLeaseActive+ HistoryRetentionLease+ sampleId+ sampleOwner+ sampleReason+ (GlobalPosition 17)+ sampleTime+ sampleTime+ sampleExpiry+ Nothing+ HistoryRetentionLeaseActive sampleId :: HistoryRetentionLeaseId sampleId = HistoryRetentionLeaseId UUID.nil@@ -96,8 +96,8 @@ sampleQuery :: HistoryRetentionInventoryQuery sampleQuery =- HistoryRetentionInventoryQuery $- either (error . show) (\value -> value) (mkHistoryRetentionInventoryLimit 10)+ HistoryRetentionInventoryQuery $+ either (error . show) (\value -> value) (mkHistoryRetentionInventoryLimit 10) sampleTime :: UTCTime sampleTime = UTCTime (fromGregorian 2026 8 13) 0
test/Test/InterpreterHooks.hs view
@@ -1,17 +1,16 @@-{- | Tests for the interpreter-level event-data hooks installed via-'Kiroku.Store.Settings.StoreSettings'.--Three concerns are covered (added across the plan's milestones):-- * @enrichEvent@ fires on the append path before encoding, so an- appended event surfaces with the hook's mutation visible.- * @decodeHook@ fires on the read and subscription paths after- decoding, so both 'readAllForward' and a live 'subscribe' handler- see the hook's mutation.- * With both hooks 'Nothing' (the default), a round-trip is- byte-identical to the input — the no-op fast path introduces no- 'pure'-wrapping artefact.--}+-- | Tests for the interpreter-level event-data hooks installed via+-- 'Kiroku.Store.Settings.StoreSettings'.+--+-- Three concerns are covered (added across the plan's milestones):+--+-- * @enrichEvent@ fires on the append path before encoding, so an+-- appended event surfaces with the hook's mutation visible.+-- * @decodeHook@ fires on the read and subscription paths after+-- decoding, so both 'readAllForward' and a live 'subscribe' handler+-- see the hook's mutation.+-- * With both hooks 'Nothing' (the default), a round-trip is+-- byte-identical to the input — the no-op fast path introduces no+-- 'pure'-wrapping artefact. module Test.InterpreterHooks (spec) where import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar)@@ -27,9 +26,9 @@ spec :: Spec spec = describe "InterpreterHooks" $ do- describe "enrichEvent" appendHookFiresSpec- describe "decodeHook" readHookFiresSpec- describe "no hooks" noHookNoEffectSpec+ describe "enrichEvent" appendHookFiresSpec+ describe "decodeHook" readHookFiresSpec+ describe "no hooks" noHookNoEffectSpec -- --------------------------------------------------------------------------- -- enrichEvent@@ -37,50 +36,50 @@ appendHookFiresSpec :: Spec appendHookFiresSpec = do- it "applies enrichEvent to appended events surfaced through readStreamForward" $ do- let marker = Aeson.object [("hook", Aeson.String "applied")]- inject ed = pure $ ed & #metadata .~ Just marker- tweak cs =- cs- & #storeSettings- .~ defaultStoreSettings{enrichEvent = Just inject}- withTestStoreSettings tweak $ \store -> do- Right _ <-- runStoreIO store $- appendToStream- (StreamName "hook-append-1")- NoStream- [makeEvent "X" (Aeson.object [("seed", Aeson.Number 1)])]- evs <-- runStoreIO store $- readStreamForward (StreamName "hook-append-1") (StreamVersion 0) 10- case evs of- Right v- | V.length v == 1 ->- (V.head v ^. #metadata) `shouldBe` Just marker- other -> expectationFailure ("unexpected read result: " <> show other)+ it "applies enrichEvent to appended events surfaced through readStreamForward" $ do+ let marker = Aeson.object [("hook", Aeson.String "applied")]+ inject ed = pure $ ed & #metadata .~ Just marker+ tweak cs =+ cs+ & #storeSettings+ .~ defaultStoreSettings {enrichEvent = Just inject}+ withTestStoreSettings tweak $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "hook-append-1")+ NoStream+ [makeEvent "X" (Aeson.object [("seed", Aeson.Number 1)])]+ evs <-+ runStoreIO store $+ readStreamForward (StreamName "hook-append-1") (StreamVersion 0) 10+ case evs of+ Right v+ | V.length v == 1 ->+ (V.head v ^. #metadata) `shouldBe` Just marker+ other -> expectationFailure ("unexpected read result: " <> show other) - it "applies enrichEvent across appendMultiStream per-stream batches" $ do- countRef <- newIORef (0 :: Int)- let inject ed = do- modifyIORef' countRef (+ 1)- pure $ ed & #metadata .~ Just (Aeson.object [("multi", Aeson.Bool True)])- tweak cs =- cs- & #storeSettings- .~ defaultStoreSettings{enrichEvent = Just inject}- withTestStoreSettings tweak $ \store -> do- let ops =- [ (StreamName "hook-multi-A", NoStream, [makeEvent "A1" (Aeson.object []), makeEvent "A2" (Aeson.object [])])- , (StreamName "hook-multi-B", NoStream, [makeEvent "B1" (Aeson.object [])])- ]- Right _ <- runStoreIO store $ appendMultiStream ops- n <- readIORef countRef- n `shouldBe` 3- Right vA <- runStoreIO store $ readStreamForward (StreamName "hook-multi-A") (StreamVersion 0) 10- Right vB <- runStoreIO store $ readStreamForward (StreamName "hook-multi-B") (StreamVersion 0) 10- for_ (V.toList vA <> V.toList vB) $ \re ->- (re ^. #metadata) `shouldBe` Just (Aeson.object [("multi", Aeson.Bool True)])+ it "applies enrichEvent across appendMultiStream per-stream batches" $ do+ countRef <- newIORef (0 :: Int)+ let inject ed = do+ modifyIORef' countRef (+ 1)+ pure $ ed & #metadata .~ Just (Aeson.object [("multi", Aeson.Bool True)])+ tweak cs =+ cs+ & #storeSettings+ .~ defaultStoreSettings {enrichEvent = Just inject}+ withTestStoreSettings tweak $ \store -> do+ let ops =+ [ (StreamName "hook-multi-A", NoStream, [makeEvent "A1" (Aeson.object []), makeEvent "A2" (Aeson.object [])]),+ (StreamName "hook-multi-B", NoStream, [makeEvent "B1" (Aeson.object [])])+ ]+ Right _ <- runStoreIO store $ appendMultiStream ops+ n <- readIORef countRef+ n `shouldBe` 3+ Right vA <- runStoreIO store $ readStreamForward (StreamName "hook-multi-A") (StreamVersion 0) 10+ Right vB <- runStoreIO store $ readStreamForward (StreamName "hook-multi-B") (StreamVersion 0) 10+ for_ (V.toList vA <> V.toList vB) $ \re ->+ (re ^. #metadata) `shouldBe` Just (Aeson.object [("multi", Aeson.Bool True)]) -- --------------------------------------------------------------------------- -- decodeHook@@ -88,82 +87,82 @@ readHookFiresSpec :: Spec readHookFiresSpec = do- it "retains the no-hook batch without evaluating its events" $ do- batch <- decodeEvents defaultStoreSettings (V.replicate 100 (error "no-hook decoding traversed the vector"))- case batch of- UnchangedBatch events -> V.length events `shouldBe` 100- TransformedBatch _ -> expectationFailure "default decoding allocated per-event outcomes"+ it "retains the no-hook batch without evaluating its events" $ do+ batch <- decodeEvents defaultStoreSettings (V.replicate 100 (error "no-hook decoding traversed the vector"))+ case batch of+ UnchangedBatch events -> V.length events `shouldBe` 100+ TransformedBatch _ -> expectationFailure "default decoding allocated per-event outcomes" - it "applies decodeHook to readAllForward results" $ do- let marker = Aeson.object [("decoded", Aeson.String "yes")]- inject re = pure $ Right (re & #metadata .~ Just marker)- tweak cs =- cs- & #storeSettings- .~ defaultStoreSettings{decodeHook = Just inject}- withTestStoreSettings tweak $ \store -> do- Right _ <-- runStoreIO store $- appendToStream- (StreamName "hook-decode-1")- NoStream- [ makeEvent "E1" (Aeson.object [])- , makeEvent "E2" (Aeson.object [])- , makeEvent "E3" (Aeson.object [])- ]- Right v <-- runStoreIO store $- readAllForward (GlobalPosition 0) 10- V.length v `shouldBe` 3- for_ (V.toList v) $ \re ->- (re ^. #metadata) `shouldBe` Just marker+ it "applies decodeHook to readAllForward results" $ do+ let marker = Aeson.object [("decoded", Aeson.String "yes")]+ inject re = pure $ Right (re & #metadata .~ Just marker)+ tweak cs =+ cs+ & #storeSettings+ .~ defaultStoreSettings {decodeHook = Just inject}+ withTestStoreSettings tweak $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "hook-decode-1")+ NoStream+ [ makeEvent "E1" (Aeson.object []),+ makeEvent "E2" (Aeson.object []),+ makeEvent "E3" (Aeson.object [])+ ]+ Right v <-+ runStoreIO store $+ readAllForward (GlobalPosition 0) 10+ V.length v `shouldBe` 3+ for_ (V.toList v) $ \re ->+ (re ^. #metadata) `shouldBe` Just marker - it "applies decodeHook to subscription handlers across catch-up and live phases" $ do- caughtUp <- newEmptyMVar- let marker = Aeson.object [("sub", Aeson.String "tagged")]- inject re = pure $ Right (re & #metadata .~ Just marker)- subName = SubscriptionName "hook-sub-1"- tweak cs =- cs- & #storeSettings- .~ defaultStoreSettings{decodeHook = Just inject}- & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)- withTestStoreSettings tweak $ \store -> do- -- Pre-append one event so the worker's catch-up path runs- -- before live mode kicks in.- Right warm <-- runStoreIO store $- appendToStream- (StreamName "hook-sub-stream-1")- NoStream- [makeEvent "warm" (Aeson.object [])]- waitForPublisher store (warm ^. #globalPosition)+ it "applies decodeHook to subscription handlers across catch-up and live phases" $ do+ caughtUp <- newEmptyMVar+ let marker = Aeson.object [("sub", Aeson.String "tagged")]+ inject re = pure $ Right (re & #metadata .~ Just marker)+ subName = SubscriptionName "hook-sub-1"+ tweak cs =+ cs+ & #storeSettings+ .~ defaultStoreSettings {decodeHook = Just inject}+ & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+ withTestStoreSettings tweak $ \store -> do+ -- Pre-append one event so the worker's catch-up path runs+ -- before live mode kicks in.+ Right warm <-+ runStoreIO store $+ appendToStream+ (StreamName "hook-sub-stream-1")+ NoStream+ [makeEvent "warm" (Aeson.object [])]+ waitForPublisher store (warm ^. #globalPosition) - seen <- newIORef ([] :: [Maybe Aeson.Value])- done <- newEmptyMVar- let handlerFn re = do- modifyIORef' seen ((re ^. #metadata) :)- n <- length <$> readIORef seen- if n >= 2- then Stop <$ tryPutMVar done ()- else pure Continue- config = defaultSubscriptionConfig subName AllStreams handlerFn- sub <- subscribe store config- waitForSubscriptionLive caughtUp- -- Append a second event to land in live mode.- Right _ <-- runStoreIO store $- appendToStream- (StreamName "hook-sub-stream-2")- NoStream- [makeEvent "live" (Aeson.object [])]- takeMVar done- res <- waitWithTimeout 2_000_000 sub- case res of- Right (Right ()) -> pure ()- other -> expectationFailure ("subscription did not finish cleanly: " <> show other)- metas <- reverse <$> readIORef seen- metas `shouldBe` [Just marker, Just marker]+ seen <- newIORef ([] :: [Maybe Aeson.Value])+ done <- newEmptyMVar+ let handlerFn re = do+ modifyIORef' seen ((re ^. #metadata) :)+ n <- length <$> readIORef seen+ if n >= 2+ then Stop <$ tryPutMVar done ()+ else pure Continue+ config = defaultSubscriptionConfig subName AllStreams handlerFn+ sub <- subscribe store config+ waitForSubscriptionLive caughtUp+ -- Append a second event to land in live mode.+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "hook-sub-stream-2")+ NoStream+ [makeEvent "live" (Aeson.object [])]+ takeMVar done+ res <- waitWithTimeout 2_000_000 sub+ case res of+ Right (Right ()) -> pure ()+ other -> expectationFailure ("subscription did not finish cleanly: " <> show other)+ metas <- reverse <$> readIORef seen+ metas `shouldBe` [Just marker, Just marker] -- --------------------------------------------------------------------------- -- no-op fast path@@ -171,17 +170,17 @@ noHookNoEffectSpec :: Spec noHookNoEffectSpec = around withTestStore $- it "with defaultStoreSettings, a round-trip preserves payload and metadata exactly" $ \store -> do- let originalMeta = Just (Aeson.object [("k", Aeson.String "v")])- originalPayload = Aeson.object [("x", Aeson.Number 42), ("y", Aeson.String "z")]- event = makeEvent "Identity" originalPayload & #metadata .~ originalMeta- Right _ <-- runStoreIO store $- appendToStream (StreamName "no-hook-roundtrip") NoStream [event]- Right v <-- runStoreIO store $- readStreamForward (StreamName "no-hook-roundtrip") (StreamVersion 0) 10- V.length v `shouldBe` 1- let re = V.head v- (re ^. #payload) `shouldBe` originalPayload- (re ^. #metadata) `shouldBe` originalMeta+ it "with defaultStoreSettings, a round-trip preserves payload and metadata exactly" $ \store -> do+ let originalMeta = Just (Aeson.object [("k", Aeson.String "v")])+ originalPayload = Aeson.object [("x", Aeson.Number 42), ("y", Aeson.String "z")]+ event = makeEvent "Identity" originalPayload & #metadata .~ originalMeta+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "no-hook-roundtrip") NoStream [event]+ Right v <-+ runStoreIO store $+ readStreamForward (StreamName "no-hook-roundtrip") (StreamVersion 0) 10+ V.length v `shouldBe` 1+ let re = V.head v+ (re ^. #payload) `shouldBe` originalPayload+ (re ^. #metadata) `shouldBe` originalMeta
+ test/Test/NamespaceQueryPlans.hs view
@@ -0,0 +1,98 @@+module Test.NamespaceQueryPlans (spec) where++import Control.Lens ((^.))+import Control.Monad (forM_, unless)+import Data.Aeson (Value (..))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+import Kiroku.Store+import Kiroku.Store.SQL qualified as SQL+import System.Environment (lookupEnv)+import Test.Helpers (withTestStore)+import Test.Hspec++spec :: Spec+spec = describe "namespace production query bounds" $+ forM_ [1000, 20000 :: Int] $ \total ->+ it ("bounds generic/custom scans and payload probes on " <> show total <> " rows") $+ withTestStore $ \store -> do+ run store (Session.script (fixture total))+ forM_ ["force_custom_plan", "force_generic_plan"] $ \mode ->+ forM_ [32, 1024 :: Int] $ \width ->+ forM_ ["pm", "pm:sparse", "absent"] $ \namespace ->+ forM_ [0, total - 100] $ \cursor ->+ forM_ [False, True] $ \grouped -> do+ let query = if grouped then Statement.toSql SQL.readNamespaceGroupWindowStmt else Statement.toSql SQL.readNamespaceWindowStmt+ types = if grouped then "int8,int8,text,int4,int4,int4" else "int8,int8,text,int4"+ args = T.intercalate "," [t cursor, t total, "'" <> namespace <> "'", t width] <> if grouped then ",0,3" else ""+ bytes <- run store $ do+ Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE namespace_probe(" <> types <> ") AS " <> query)+ bytes <- Session.statement () (Statement.unpreparable ("EXPLAIN (ANALYZE,BUFFERS,TIMING OFF,FORMAT JSON) EXECUTE namespace_probe(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))+ Session.script "DEALLOCATE namespace_probe; RESET plan_cache_mode"+ pure bytes+ plan <- either fail pure (Aeson.eitherDecodeStrict' bytes)+ evidence <- lookupEnv "EP54_PLAN_EVIDENCE"+ forM_ evidence $ \path ->+ LBS.appendFile+ path+ ( Aeson.encode+ ( Aeson.object+ [ "rows" Aeson..= total,+ "mode" Aeson..= mode,+ "width" Aeson..= width,+ "namespace" Aeson..= namespace,+ "cursor" Aeson..= cursor,+ "grouped" Aeson..= grouped,+ "explain" Aeson..= plan+ ]+ )+ <> "\n"+ )+ let scans = nodes plan+ relation name = filter (\obj -> KM.lookup "Relation Name" obj == Just (String name)) scans+ countRows name = sum [num "Actual Loops" obj * (num "Actual Rows" obj + num "Rows Removed by Filter" obj) | obj <- relation name]+ payloads = sum [num "Actual Loops" obj * num "Actual Rows" obj | obj <- relation "events"]+ caseLabel = T.unpack (mode <> "/" <> namespace <> "/W=" <> t width <> "/cursor=" <> t cursor <> "/group=" <> T.pack (show grouped))+ let verify condition = unless condition (expectationFailure (caseLabel <> "\n" <> show [(KM.lookup "Relation Name" obj, KM.lookup "Node Type" obj, KM.lookup "Index Name" obj, num "Actual Rows" obj, num "Actual Loops" obj, num "Rows Removed by Filter" obj) | obj <- scans, KM.member "Relation Name" obj]))+ verify (null (relation "streams"))+ verify (countRows "stream_events" <= fromIntegral width)+ verify ([index | obj <- relation "stream_events", Just (String index) <- [KM.lookup "Index Name" obj]] == ["ux_stream_events_stream_version"])+ verify (payloads <= fromIntegral (min width (total - cursor)))+ if namespace == "absent" then verify (payloads == 0 && all (\obj -> num "Actual Loops" obj == 0) (relation "events")) else pure ()+ forM_ (relation "events") $ \obj ->+ verify (num "Actual Loops" obj == 0 || KM.lookup "Node Type" obj == Just (String "Index Scan"))++fixture :: Int -> Text+fixture count =+ T.unlines+ [ "WITH source AS (INSERT INTO streams(stream_name,stream_version) SELECT (CASE WHEN n % 100 = 0 THEN 'pm:sparse-' ELSE 'pm:dense-' END) || n,1 FROM generate_series(1," <> t count <> ") n RETURNING stream_id),",+ "f AS MATERIALIZED (SELECT stream_id, row_number() OVER (ORDER BY stream_id) AS pos, uuidv7() AS id FROM source),",+ "e AS (INSERT INTO events(event_id,event_type,data) SELECT id,'Fixture','{}' FROM f RETURNING event_id),",+ "se AS (INSERT INTO stream_events(event_id,stream_id,stream_version,original_stream_id,original_stream_version) SELECT id,stream_id,1,stream_id,1 FROM f JOIN e ON event_id=id RETURNING event_id)",+ "INSERT INTO stream_events(event_id,stream_id,stream_version,original_stream_id,original_stream_version,category) SELECT id,0,pos,stream_id,1, CASE WHEN pos % 100 = 0 THEN 'pm:sparse' ELSE 'pm:dense' END FROM f JOIN se ON event_id=id;",+ "UPDATE streams SET stream_version=" <> t count <> " WHERE stream_id=0; ANALYZE streams; ANALYZE stream_events; ANALYZE events;"+ ]++nodes :: Value -> [Aeson.Object]+nodes (Object fields) = fields : concatMap nodes (KM.elems fields)+nodes (Array values) = concatMap nodes (V.toList values)+nodes _ = []++num :: Aeson.Key -> Aeson.Object -> Double+num key obj = case KM.lookup key obj of Just (Number n) -> realToFrac n; _ -> 0++t :: (Show a) => a -> Text+t = T.pack . show++run :: KirokuStore -> Session.Session a -> IO a+run store action = Pool.use (store ^. #pool) action >>= either (fail . show) pure
test/Test/NotifyGuard.hs view
@@ -21,101 +21,101 @@ 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 []+ 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"+ 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 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 _ <- 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)+ 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,")+ 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 []+ 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")+ 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+ 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+ 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)+ 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+ 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+ 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)- ]+ 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 ()+ 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/PerformanceStructure.hs view
@@ -33,618 +33,616 @@ spec :: Spec spec = do- noOpAppendSpec- queryPlanSpec- DeadLetterQueryPlans.spec- categoryReadCostSpec- streamHeadQuerySpec--noOpAppendSpec :: Spec-noOpAppendSpec =- describe "no-op paths use no pooled connection" $ do- it "rejects invalid dead-letter page sizes before pool checkout" $ do- checkouts <- newIORef (0 :: Int)- withObservedStore checkouts $ \_ -> do- before <- readIORef checkouts- forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)- after <- readIORef checkouts- after - before `shouldBe` 0-- it "rejects invalid batch and buffer sizes before pool checkout" $ do- checkouts <- newIORef (0 :: Int)- withObservedStore checkouts $ \_ -> do- before <- readIORef checkouts- mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)- mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))- Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)- after <- readIORef checkouts- after - before `shouldBe` 0-- it "rejects invalid consumer-group values before pool checkout" $ do- checkouts <- newIORef (0 :: Int)- withObservedStore checkouts $ \_ -> do- before <- readIORef checkouts- mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)- let Right n = mkConsumerGroupSize 2- mkConsumerGroup 2 n `shouldBe` Left (InvalidConsumerGroup 2 2)- after <- readIORef checkouts- after - before `shouldBe` 0-- it "refuses mismatched topology in exactly one initialization checkout with no handler" $ do- workerThread <- newIORef Nothing- checkouts <- newIORef (0 :: Int)- delivered <- newIORef False- let observe (ConnectionObservation _ InUseConnectionStatus) = do- thread <- myThreadId- selected <- readIORef workerThread- if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()- observe _ = pure ()- withTestStoreSettings (\settings -> settings{observationHandler = Just observe}) $ \store -> do- seeded <- Pool.use (store ^. #pool) (Session.statement ("topology-checkout", 0, 0, 2, "unbound", Nothing) SQL.saveCheckpointMemberStmt)- seeded `shouldBe` Right ()- withLoadCheckpointHookForTest- ( \_ -> do- thread <- myThreadId- writeIORef workerThread (Just thread)- pure Nothing- )- $ do- let cfg =- (defaultSubscriptionConfig (SubscriptionName "topology-checkout") AllStreams (\_ -> writeIORef delivered True >> pure Continue))- { consumerGroup = Just (validConsumerGroup 0 3)- }- handle <- subscribe store cfg- waitWithTimeout 5_000_000 handle >>= \case- Right (Left exception) ->- (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)- other -> expectationFailure ("expected mismatch, got " <> show other)- readIORef checkouts `shouldReturn` 1- readIORef delivered `shouldReturn` False-- it "refuses mismatched target in exactly one initialization checkout with no handler" $ do- workerThread <- newIORef Nothing- checkouts <- newIORef (0 :: Int)- delivered <- newIORef False- let observe (ConnectionObservation _ InUseConnectionStatus) = do- thread <- myThreadId- selected <- readIORef workerThread- if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()- observe _ = pure ()- withTestStoreSettings (\settings -> settings{observationHandler = Just observe}) $ \store -> do- seeded <- Pool.use (store ^. #pool) (Session.statement ("target-checkout", 0, 0, 1, "all", Nothing) SQL.saveCheckpointMemberStmt)- seeded `shouldBe` Right ()- withLoadCheckpointHookForTest- ( \_ -> do- thread <- myThreadId- writeIORef workerThread (Just thread)- pure Nothing- )- $ do- let cfg =- (defaultSubscriptionConfig (SubscriptionName "target-checkout") (Category (CategoryName "other")) (\_ -> writeIORef delivered True >> pure Continue))-- handle <- subscribe store cfg- waitWithTimeout 5_000_000 handle >>= \case- Right (Left exception) ->- (fromException exception :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)- other -> expectationFailure ("expected mismatch, got " <> show other)- readIORef checkouts `shouldReturn` 1- readIORef delivered `shouldReturn` False-- it "keeps ordinary checkpoint saves as one unconditional monotonic upsert" $ do- forM_ [Statement.toSql SQL.saveCheckpointMemberStmt, Statement.toSql SQL.saveAllCheckpointMemberStmt, Statement.toSql SQL.saveCategoryCheckpointMemberStmt] $ \statement -> do- let sql = T.toLower statement- T.count "insert into" sql `shouldBe` 1- sql `shouldSatisfy` T.isInfixOf "greatest(subscriptions.last_seen, excluded.last_seen)"- sql `shouldNotSatisfy` T.isInfixOf "where"- sql `shouldNotSatisfy` T.isInfixOf "returning"-- it "rejects an empty appendToStream batch before pool checkout" $ do- checkouts <- newIORef (0 :: Int)- withObservedStore checkouts $ \store -> do- checkoutsBefore <- readIORef checkouts- result <- runStoreIO store $ appendToStream (StreamName "performance-empty-append") AnyVersion []- checkoutsAfter <- readIORef checkouts- result `shouldBe` Left (EmptyAppendBatch (StreamName "performance-empty-append"))- checkoutsAfter - checkoutsBefore `shouldBe` 0-- it "returns an empty appendMultiStream result before pool checkout" $ do- checkouts <- newIORef (0 :: Int)- withObservedStore checkouts $ \store -> do- checkoutsBefore <- readIORef checkouts- result <- runStoreIO store $ appendMultiStream []- checkoutsAfter <- readIORef checkouts- result `shouldBe` Right []- checkoutsAfter - checkoutsBefore `shouldBe` 0-- it "rejects invalid retention requests before pool checkout" $ do- checkouts <- newIORef (0 :: Int)- withObservedStore checkouts $ \_store -> do- checkoutsBefore <- readIORef checkouts- mkHistoryRetentionLeaseOwner "" `shouldSatisfy` either (const True) (const False)- mkHistoryRetentionLeaseReason "" `shouldSatisfy` either (const True) (const False)- mkHistoryRetentionInventoryLimit 0 `shouldSatisfy` either (const True) (const False)- checkoutsAfter <- readIORef checkouts- checkoutsAfter - checkoutsBefore `shouldBe` 0-- it "keeps every ordinary statement free of retention coordination" $ do- let ordinarySql =- [ Statement.toSql SQL.appendExpectedVersion- , Statement.toSql SQL.appendStreamExists- , Statement.toSql SQL.appendNoStream- , Statement.toSql SQL.appendAnyVersion- , Statement.toSql SQL.readStreamForwardStmt- , Statement.toSql SQL.readAllForwardStmt- , Statement.toSql SQL.softDeleteStreamStmt- , Statement.toSql SQL.undeleteStreamStmt- , Statement.toSql SQL.setStreamTruncateBeforeStmt- ]- forM_ ordinarySql $ \sql ->- T.toLower sql `shouldNotSatisfy` T.isInfixOf "history_retention"--queryPlanSpec :: Spec-queryPlanSpec =- describe "production query plans" $- aroundAll withQueryPlanStore $ do- it "visible global head lookup uses ux_stream_events_stream_version without Sort" $ \store -> do- plan <-- explainProductionStatement- store- SQL.visibleGlobalHeadPositionStmt- []- expectIndex "ux_stream_events_stream_version" plan- expectNoNodeType "Sort" plan-- it "dead-letter reads use ix_dead_letters_subscription_position without Sort" $ \store -> do- plan <-- explainProductionStatement- store- SQL.readDeadLettersStmt- [ ("$2", "0::int4")- , ("$1", "'performance-read'::text")- ]- expectIndex "ix_dead_letters_subscription_position" plan- expectNoNodeType "Sort" plan-- it "orphan dead-letter cleanup uses ix_dead_letters_event_id" $ \store -> do- plan <-- explainProductionStatement- store- SQL.deleteDeadLettersForOrphanedEventsStmt- [("$1", "ARRAY['00000000-0000-0000-0000-000000000001'::uuid]::uuid[]")]- expectIndex "ix_dead_letters_event_id" plan-- it "active retention lookup uses ix_history_retention_leases_unreleased_expiry" $ \store -> do- plan <- explainProductionStatement store activeLeaseLookupStmt []- expectIndex "ix_history_retention_leases_unreleased_expiry" plan-- it "installs no retention trigger for INSERT or UPDATE" $ \store -> do- result <- Pool.use (store ^. #pool) (Session.statement () retentionTriggerShapeStmt)- case result of- Left err -> expectationFailure ("could not inspect retention triggers: " <> show err)- Right shape -> shape `shouldBe` (6, 0)--{- | BUG-2. A category read's cost must follow the rows it returns, not the-number of streams in the category. The store holds the category-scaling-fixture: @performance@ with 200 streams, @idle@ with 20,000 one-event streams,-and @noise@, head at 'categoryScalingHead'.--}-categoryReadCostSpec :: Spec-categoryReadCostSpec =- describe "category read cost" $- aroundAll withCategoryScalingStore $ do- it "category high-cursor reads use ix_stream_events_all_by_category without Sort" $ \store -> do- plan <-- explainProductionStatement- store- SQL.readCategoryForwardStmt- [ ("$3", "100::int4")- , ("$2", "'performance'::text")- , ("$1", "15000::bigint")- ]- expectIndex "ix_stream_events_all_by_category" plan- expectNoNodeType "Sort" plan-- it "consumer-group category reads use ix_stream_events_all_by_category without Sort" $ \store -> do- plan <-- explainProductionStatement- store- SQL.readCategoryForwardConsumerGroupStmt- [ ("$5", "100::int4")- , ("$4", "2::int4")- , ("$3", "1::int4")- , ("$2", "'performance'::text")- , ("$1", "15000::bigint")- ]- expectIndex "ix_stream_events_all_by_category" plan- expectNoNodeType "Sort" plan-- it "category caught-up poll on 20000 idle streams reads at most 32 buffers" $ \store -> do- let cursor = T.pack (show categoryScalingHead) <> "::bigint"- plainAt =- [ ("$3", "100::int4")- , ("$2", "'idle'::text")- , ("$1", cursor)- ]- groupAt member =- [ ("$5", "100::int4")- , ("$4", "2::int4")- , ("$3", T.pack (show member) <> "::int4")- , ("$2", "'idle'::text")- , ("$1", cursor)- ]- groupMembers = [0, 1 :: Int]-- plainIdle <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt- expectBufferBudget "plain caught-up poll" plainIdle- snd plainIdle `shouldBe` 0- groupIdle <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers- mapM_ (expectBufferBudget "group caught-up poll") groupIdle- map snd groupIdle `shouldBe` [0, 0]-- appended <- runStoreIO store $ appendToStream (StreamName "idle-1") AnyVersion [makeEvent "IdleWake" Null]- appended `shouldSatisfy` either (const False) (const True)-- plainOne <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt- expectBufferBudget "plain poll after one append" plainOne- snd plainOne `shouldBe` 1- groupOne <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers- mapM_ (expectBufferBudget "group poll after one append") groupOne- -- Exactly one member owns idle-1.- sum (map snd groupOne) `shouldBe` 1--withCategoryScalingStore :: (KirokuStore -> IO ()) -> IO ()-withCategoryScalingStore action =- withTestStore $ \store -> do- result <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)- case result of- Left err -> expectationFailure ("failed to seed category-scaling fixture: " <> show err)- Right () -> action store---- | The G1 budget: a category poll that returns at most one row.-expectBufferBudget :: String -> (Int64, Int64) -> Expectation-expectBufferBudget label (buffers, _) =- unless (buffers <= 32) $- expectationFailure $- label- <> ": expected at most 32 shared buffers, but the plan read "- <> show buffers--{- | Execute a production statement under @EXPLAIN (ANALYZE, BUFFERS)@ and-return the top plan node's shared buffers (hit plus read) and actual rows.-Planning buffers are excluded; they are not paid by a prepared statement.--}-explainAnalyzeBuffers ::- KirokuStore ->- Statement params result ->- [(Text, Text)] ->- IO (Int64, Int64)-explainAnalyzeBuffers store productionStatement replacements = do- plan <-- explainWith- "EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, FORMAT JSON)\n"- store- productionStatement- replacements- case plan of- Array entries- | Just (Object entry) <- headMay entries- , Just (Object top) <- KeyMap.lookup "Plan" entry ->- pure- ( numberField "Shared Hit Blocks" top + numberField "Shared Read Blocks" top- , numberField "Actual Rows" top- )- _ -> expectationFailure ("unexpected EXPLAIN shape: " <> show plan) >> fail "unreachable"- where- headMay values = case foldr (:) [] values of- value : _ -> Just value- [] -> Nothing- numberField key object = case KeyMap.lookup key object of- Just value | Aeson.Success (number :: Double) <- Aeson.fromJSON value -> round number- _ -> 0--withObservedStore :: IORef Int -> (KirokuStore -> IO ()) -> IO ()-withObservedStore checkouts =- withTestStoreSettings $ \settings ->- settings- { observationHandler =- Just $ \case- ConnectionObservation _ InUseConnectionStatus -> modifyIORef' checkouts (+ 1)- _ -> pure ()- }--withQueryPlanStore :: (KirokuStore -> IO ()) -> IO ()-withQueryPlanStore action =- withTestStore $ \store -> do- result <- Pool.use (store ^. #pool) (Session.script queryPlanFixture)- case result of- Left err -> expectationFailure ("failed to seed performance query-plan fixture: " <> show err)- Right () -> action store--queryPlanFixture :: Text-queryPlanFixture =- """- BEGIN;-- WITH new_streams AS (- INSERT INTO streams (stream_name, stream_version)- SELECT 'performance-' || n::text, 100- FROM generate_series(1, 200) AS n- RETURNING stream_id- ), fixture_events AS MATERIALIZED (- SELECT uuidv7() AS event_id,- s.stream_id,- per_stream_position::bigint AS stream_version,- row_number() OVER (ORDER BY per_stream_position, s.stream_id)::bigint AS global_position- FROM new_streams AS s- CROSS JOIN generate_series(1, 100) AS per_stream_position- ), inserted_events AS (- INSERT INTO events (event_id, event_type, data)- SELECT event_id, 'PerformanceFixture', '{}'::jsonb- FROM fixture_events- RETURNING event_id- ), source_links AS (- INSERT INTO stream_events- (event_id, stream_id, stream_version, original_stream_id, original_stream_version)- SELECT fixture.event_id,- fixture.stream_id,- fixture.stream_version,- fixture.stream_id,- fixture.stream_version- FROM fixture_events AS fixture- JOIN inserted_events USING (event_id)- RETURNING event_id- ), all_links AS (- INSERT INTO stream_events- (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)- SELECT fixture.event_id,- 0,- fixture.global_position,- fixture.stream_id,- fixture.stream_version,- 'performance'- FROM fixture_events AS fixture- JOIN inserted_events USING (event_id)- RETURNING event_id- ), advanced_all AS (- UPDATE streams- SET stream_version = (SELECT max(global_position) FROM fixture_events)- WHERE stream_id = 0- RETURNING stream_id- ), inserted_dead_letters AS (- INSERT INTO dead_letters- (subscription_name, consumer_group_member, global_position, event_id,- reason, reason_summary, attempt_count)- SELECT CASE- WHEN fixture.global_position % 10 = 0 THEN 'performance-read'- ELSE 'performance-other-' || (fixture.global_position % 9)::text- END,- 0,- fixture.global_position,- fixture.event_id,- '{}'::jsonb,- 'performance fixture',- 1- FROM fixture_events AS fixture- JOIN inserted_events USING (event_id)- RETURNING dead_letter_id- )- SELECT (SELECT count(*) FROM source_links),- (SELECT count(*) FROM all_links),- (SELECT count(*) FROM advanced_all),- (SELECT count(*) FROM inserted_dead_letters);-- COMMIT;- ANALYZE streams;- ANALYZE events;- ANALYZE stream_events;- ANALYZE dead_letters;- INSERT INTO history_retention_leases- (owner, reason, protected_through, created_at, renewed_at, expires_at, released_at)- SELECT 'performance-owner',- 'performance-reason',- 0,- clock_timestamp() - interval '1 day',- clock_timestamp() - interval '1 day',- clock_timestamp() + interval '1 hour',- CASE WHEN n <= 9900 THEN clock_timestamp() ELSE NULL END- FROM generate_series(1, 10000) AS n;- ANALYZE history_retention_leases;- """--explainProductionStatement ::- KirokuStore ->- Statement params result ->- [(Text, Text)] ->- IO Value-explainProductionStatement = explainWith "EXPLAIN (FORMAT JSON, COSTS OFF)\n"--explainWith ::- Text ->- KirokuStore ->- Statement params result ->- [(Text, Text)] ->- IO Value-explainWith explainPrefix store productionStatement replacements = do- let productionSql = Statement.toSql productionStatement- explainedSql =- explainPrefix- <> foldl' (\sql (placeholder, literal) -> T.replace placeholder literal sql) productionSql replacements- explainStatement :: Statement () ByteString- explainStatement =- unpreparable- explainedSql- E.noParams- (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))- result <- Pool.use (store ^. #pool) (Session.statement () explainStatement)- bytes <- case result of- Left err -> expectationFailure ("EXPLAIN failed: " <> show err) >> fail "unreachable"- Right value -> pure value- case Aeson.eitherDecodeStrict' bytes of- Left err -> expectationFailure ("could not decode EXPLAIN JSON: " <> err) >> fail "unreachable"- Right value -> pure value--data PlanFacts = PlanFacts- { nodeTypes :: [Text]- , indexNames :: [Text]- }- deriving stock (Show)--instance Semigroup PlanFacts where- PlanFacts nodeTypesA indexNamesA <> PlanFacts nodeTypesB indexNamesB =- PlanFacts (nodeTypesA <> nodeTypesB) (indexNamesA <> indexNamesB)--instance Monoid PlanFacts where- mempty = PlanFacts [] []--collectPlanFacts :: Value -> PlanFacts-collectPlanFacts (Object object) =- PlanFacts- { nodeTypes = maybe [] pure (textField "Node Type" object)- , indexNames = maybe [] pure (textField "Index Name" object)- }- <> foldMap collectPlanFacts (KeyMap.elems object)-collectPlanFacts (Array values) = foldMap collectPlanFacts values-collectPlanFacts _ = mempty--textField :: Aeson.Key -> Aeson.Object -> Maybe Text-textField key object = case KeyMap.lookup key object of- Just (String value) -> Just value- _ -> Nothing--expectIndex :: Text -> Value -> Expectation-expectIndex expected plan = do- let facts = collectPlanFacts plan- unless (expected `elem` indexNames facts) $- expectationFailure $- "expected plan to use index "- <> T.unpack expected- <> ", but collected "- <> show facts- <> " from:\n"- <> show plan--expectNoNodeType :: Text -> Value -> Expectation-expectNoNodeType forbidden plan = do- let facts = collectPlanFacts plan- unless (forbidden `notElem` nodeTypes facts) $- expectationFailure $- "expected plan not to contain node type "- <> T.unpack forbidden- <> ", but collected "- <> show facts- <> " from:\n"- <> show plan--activeLeaseLookupStmt :: Statement () ()-activeLeaseLookupStmt =- Statement.preparable- """- SELECT count(*), min(expires_at)- FROM history_retention_leases- WHERE released_at IS NULL- AND expires_at > clock_timestamp()- HAVING count(*) > 0- """- E.noParams- D.noResult--retentionTriggerShapeStmt :: Statement () (Int64, Int64)-retentionTriggerShapeStmt =- Statement.preparable- """- SELECT count(*),- count(*) FILTER (WHERE (trigger.tgtype & 4) <> 0 OR (trigger.tgtype & 16) <> 0)- FROM pg_catalog.pg_trigger AS trigger- JOIN pg_catalog.pg_class AS relation ON relation.oid = trigger.tgrelid- JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace- WHERE namespace.nspname = 'kiroku'- AND relation.relname IN ('events', 'stream_events', 'streams')- AND trigger.tgname IN ('protect_replay_history_delete', 'protect_replay_history_truncate')- AND NOT trigger.tgisinternal- """- E.noParams- ( D.singleRow- ( (,)- <$> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.int8)- )- )--streamHeadQuerySpec :: Spec-streamHeadQuerySpec = describe "stream head query work" $- aroundAll withStreamHeadStore $- forM_ ["bench-1", "long-1", "empty-1", "missing-1"] $ \name ->- it ("bounds literal and warmed prepared probes for " <> T.unpack name) $ \store -> do- let literal = "'" <> name <> "'"- prefix = "EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) "- direct <- explainWith prefix store SQL.getStreamWithHeadStmt [("$1", literal)]- prepared <- explainStreamHeadPrepared store literal- forM_ [("literal", direct), ("prepared", prepared)] $ \(kind, plan) -> do- -- Emit complete JSON into retained test logs, including buffers and loops.- putStrLn ("STREAM_HEAD_PLAN " <> T.unpack name <> " " <> kind <> " " <> show (Aeson.encode plan))- expectIndex "ix_stream_events_all_by_origin" plan- expectNoNodeType "Sort" plan- let nodes = planObjects plan- probes = filter ((== Just "stream_events") . textField "Relation Name") nodes- limits = filter ((== Just "Limit") . textField "Node Type") nodes- length probes `shouldBe` 1- length limits `shouldBe` 1- forM_ probes $ \probe -> do- textField "Node Type" probe `shouldSatisfy` (`elem` [Just "Index Scan", Just "Index Only Scan"])- textField "Scan Direction" probe `shouldBe` Just "Backward"- planNumber "Actual Rows" probe `shouldSatisfy` (<= 1)- if name == "missing-1" then planNumber "Actual Loops" probe `shouldBe` 0 else pure ()- forM_ limits $ \limit -> do- expectIndex "ix_stream_events_all_by_origin" (Object limit)- planNumber "Actual Rows" limit `shouldSatisfy` (<= 1)- map (textField "Relation Name") nodes `shouldNotContain` [Just "events"]- case plan of- Array entries- | Object entry : _ <- foldr (:) [] entries- , Just (Object top) <- KeyMap.lookup "Plan" entry -> do- planNumber "Shared Hit Blocks" top + planNumber "Shared Read Blocks" top `shouldSatisfy` (<= 32)- planNumber "Actual Rows" top `shouldBe` (if name == "missing-1" then 0 else 1)- _ -> expectationFailure (show plan)- actual <- runStoreIO store (getStreamWithHead (StreamName name))- case name of- "missing-1" -> actual `shouldBe` Right Nothing- "empty-1" -> case actual of- Right (Just (_, headPosition)) -> headPosition `shouldBe` Nothing- _ -> expectationFailure (show actual)- _ -> case actual of- Right (Just (_, Just _)) -> pure ()- _ -> expectationFailure (show actual)--withStreamHeadStore :: (KirokuStore -> IO ()) -> IO ()-withStreamHeadStore action = withTestStore $ \store -> do- forM_ [streamHeadFixtureSql, "VACUUM (ANALYZE) stream_events"] $ \command ->- Pool.use (store ^. #pool) (Session.script command) >>= either (fail . show) pure- action store--explainStreamHeadPrepared :: KirokuStore -> Text -> IO Value-explainStreamHeadPrepared store literal = do- result <- Pool.use (store ^. #pool) $ do- Session.script ("PREPARE ep97_head(text) AS " <> Statement.toSql SQL.getStreamWithHeadStmt)- -- Default auto policy, past PostgreSQL's first five custom executions.- Session.script (T.replicate 6 ("EXECUTE ep97_head(" <> literal <> ");"))- bytes <-- Session.statement () $- unpreparable- ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE ep97_head(" <> literal <> ")")- E.noParams- (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))- Session.script "DEALLOCATE ep97_head"- pure bytes- bytes <- either (fail . show) pure result- either fail pure (Aeson.eitherDecodeStrict' bytes)--planObjects :: Value -> [Aeson.Object]-planObjects (Object fields) = fields : concatMap planObjects (KeyMap.elems fields)-planObjects (Array values) = concatMap planObjects (foldr (:) [] values)-planObjects _ = []--planNumber :: Aeson.Key -> Aeson.Object -> Double-planNumber key fields = case KeyMap.lookup key fields of- Just (Number n) -> realToFrac n- _ -> 0+ noOpAppendSpec+ queryPlanSpec+ DeadLetterQueryPlans.spec+ categoryReadCostSpec+ streamHeadQuerySpec++noOpAppendSpec :: Spec+noOpAppendSpec =+ describe "no-op paths use no pooled connection" $ do+ it "rejects invalid dead-letter page sizes before pool checkout" $ do+ checkouts <- newIORef (0 :: Int)+ withObservedStore checkouts $ \_ -> do+ before <- readIORef checkouts+ forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)+ after <- readIORef checkouts+ after - before `shouldBe` 0++ it "rejects invalid batch and buffer sizes before pool checkout" $ do+ checkouts <- newIORef (0 :: Int)+ withObservedStore checkouts $ \_ -> do+ before <- readIORef checkouts+ mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)+ mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))+ Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)+ after <- readIORef checkouts+ after - before `shouldBe` 0++ it "rejects invalid consumer-group values before pool checkout" $ do+ checkouts <- newIORef (0 :: Int)+ withObservedStore checkouts $ \_ -> do+ before <- readIORef checkouts+ mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)+ let Right n = mkConsumerGroupSize 2+ mkConsumerGroup 2 n `shouldBe` Left (InvalidConsumerGroup 2 2)+ after <- readIORef checkouts+ after - before `shouldBe` 0++ it "refuses mismatched topology in exactly one initialization checkout with no handler" $ do+ workerThread <- newIORef Nothing+ checkouts <- newIORef (0 :: Int)+ delivered <- newIORef False+ let observe (ConnectionObservation _ InUseConnectionStatus) = do+ thread <- myThreadId+ selected <- readIORef workerThread+ if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()+ observe _ = pure ()+ withTestStoreSettings (\settings -> settings {observationHandler = Just observe}) $ \store -> do+ seeded <- Pool.use (store ^. #pool) (Session.statement ("topology-checkout", 0, 0, 2, "unbound", Nothing) SQL.saveCheckpointMemberStmt)+ seeded `shouldBe` Right ()+ withLoadCheckpointHookForTest+ ( \_ -> do+ thread <- myThreadId+ writeIORef workerThread (Just thread)+ pure Nothing+ )+ $ do+ let cfg =+ (defaultSubscriptionConfig (SubscriptionName "topology-checkout") AllStreams (\_ -> writeIORef delivered True >> pure Continue))+ { consumerGroup = Just (validConsumerGroup 0 3)+ }+ handle <- subscribe store cfg+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Left exception) ->+ (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)+ other -> expectationFailure ("expected mismatch, got " <> show other)+ readIORef checkouts `shouldReturn` 1+ readIORef delivered `shouldReturn` False++ it "refuses mismatched target in exactly one initialization checkout with no handler" $ do+ workerThread <- newIORef Nothing+ checkouts <- newIORef (0 :: Int)+ delivered <- newIORef False+ let observe (ConnectionObservation _ InUseConnectionStatus) = do+ thread <- myThreadId+ selected <- readIORef workerThread+ if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()+ observe _ = pure ()+ withTestStoreSettings (\settings -> settings {observationHandler = Just observe}) $ \store -> do+ seeded <- Pool.use (store ^. #pool) (Session.statement ("target-checkout", 0, 0, 1, "all", Nothing) SQL.saveCheckpointMemberStmt)+ seeded `shouldBe` Right ()+ withLoadCheckpointHookForTest+ ( \_ -> do+ thread <- myThreadId+ writeIORef workerThread (Just thread)+ pure Nothing+ )+ $ do+ let cfg =+ (defaultSubscriptionConfig (SubscriptionName "target-checkout") (Category (CategoryName "other")) (\_ -> writeIORef delivered True >> pure Continue))++ handle <- subscribe store cfg+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Left exception) ->+ (fromException exception :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)+ other -> expectationFailure ("expected mismatch, got " <> show other)+ readIORef checkouts `shouldReturn` 1+ readIORef delivered `shouldReturn` False++ it "keeps ordinary checkpoint saves as one unconditional monotonic upsert" $ do+ forM_ [Statement.toSql SQL.saveCheckpointMemberStmt, Statement.toSql SQL.saveAllCheckpointMemberStmt, Statement.toSql SQL.saveCategoryCheckpointMemberStmt] $ \statement -> do+ let sql = T.toLower statement+ T.count "insert into" sql `shouldBe` 1+ sql `shouldSatisfy` T.isInfixOf "greatest(subscriptions.last_seen, excluded.last_seen)"+ sql `shouldNotSatisfy` T.isInfixOf "where"+ sql `shouldNotSatisfy` T.isInfixOf "returning"++ it "rejects an empty appendToStream batch before pool checkout" $ do+ checkouts <- newIORef (0 :: Int)+ withObservedStore checkouts $ \store -> do+ checkoutsBefore <- readIORef checkouts+ result <- runStoreIO store $ appendToStream (StreamName "performance-empty-append") AnyVersion []+ checkoutsAfter <- readIORef checkouts+ result `shouldBe` Left (EmptyAppendBatch (StreamName "performance-empty-append"))+ checkoutsAfter - checkoutsBefore `shouldBe` 0++ it "returns an empty appendMultiStream result before pool checkout" $ do+ checkouts <- newIORef (0 :: Int)+ withObservedStore checkouts $ \store -> do+ checkoutsBefore <- readIORef checkouts+ result <- runStoreIO store $ appendMultiStream []+ checkoutsAfter <- readIORef checkouts+ result `shouldBe` Right []+ checkoutsAfter - checkoutsBefore `shouldBe` 0++ it "rejects invalid retention requests before pool checkout" $ do+ checkouts <- newIORef (0 :: Int)+ withObservedStore checkouts $ \_store -> do+ checkoutsBefore <- readIORef checkouts+ mkHistoryRetentionLeaseOwner "" `shouldSatisfy` either (const True) (const False)+ mkHistoryRetentionLeaseReason "" `shouldSatisfy` either (const True) (const False)+ mkHistoryRetentionInventoryLimit 0 `shouldSatisfy` either (const True) (const False)+ checkoutsAfter <- readIORef checkouts+ checkoutsAfter - checkoutsBefore `shouldBe` 0++ it "keeps every ordinary statement free of retention coordination" $ do+ let ordinarySql =+ [ Statement.toSql SQL.appendExpectedVersion,+ Statement.toSql SQL.appendStreamExists,+ Statement.toSql SQL.appendNoStream,+ Statement.toSql SQL.appendAnyVersion,+ Statement.toSql SQL.readStreamForwardStmt,+ Statement.toSql SQL.readAllForwardStmt,+ Statement.toSql SQL.softDeleteStreamStmt,+ Statement.toSql SQL.undeleteStreamStmt,+ Statement.toSql SQL.setStreamTruncateBeforeStmt+ ]+ forM_ ordinarySql $ \sql ->+ T.toLower sql `shouldNotSatisfy` T.isInfixOf "history_retention"++queryPlanSpec :: Spec+queryPlanSpec =+ describe "production query plans" $+ aroundAll withQueryPlanStore $ do+ it "visible global head lookup uses ux_stream_events_stream_version without Sort" $ \store -> do+ plan <-+ explainProductionStatement+ store+ SQL.visibleGlobalHeadPositionStmt+ []+ expectIndex "ux_stream_events_stream_version" plan+ expectNoNodeType "Sort" plan++ it "dead-letter reads use ix_dead_letters_subscription_position without Sort" $ \store -> do+ plan <-+ explainProductionStatement+ store+ SQL.readDeadLettersStmt+ [ ("$2", "0::int4"),+ ("$1", "'performance-read'::text")+ ]+ expectIndex "ix_dead_letters_subscription_position" plan+ expectNoNodeType "Sort" plan++ it "orphan dead-letter cleanup uses ix_dead_letters_event_id" $ \store -> do+ plan <-+ explainProductionStatement+ store+ SQL.deleteDeadLettersForOrphanedEventsStmt+ [("$1", "ARRAY['00000000-0000-0000-0000-000000000001'::uuid]::uuid[]")]+ expectIndex "ix_dead_letters_event_id" plan++ it "active retention lookup uses ix_history_retention_leases_unreleased_expiry" $ \store -> do+ plan <- explainProductionStatement store activeLeaseLookupStmt []+ expectIndex "ix_history_retention_leases_unreleased_expiry" plan++ it "installs no retention trigger for INSERT or UPDATE" $ \store -> do+ result <- Pool.use (store ^. #pool) (Session.statement () retentionTriggerShapeStmt)+ case result of+ Left err -> expectationFailure ("could not inspect retention triggers: " <> show err)+ Right shape -> shape `shouldBe` (6, 0)++-- | BUG-2. A category read's cost must follow the rows it returns, not the+-- number of streams in the category. The store holds the category-scaling+-- fixture: @performance@ with 200 streams, @idle@ with 20,000 one-event streams,+-- and @noise@, head at 'categoryScalingHead'.+categoryReadCostSpec :: Spec+categoryReadCostSpec =+ describe "category read cost" $+ aroundAll withCategoryScalingStore $ do+ it "category high-cursor reads use ix_stream_events_all_by_category without Sort" $ \store -> do+ plan <-+ explainProductionStatement+ store+ SQL.readCategoryForwardStmt+ [ ("$3", "100::int4"),+ ("$2", "'performance'::text"),+ ("$1", "15000::bigint")+ ]+ expectIndex "ix_stream_events_all_by_category" plan+ expectNoNodeType "Sort" plan++ it "consumer-group category reads use ix_stream_events_all_by_category without Sort" $ \store -> do+ plan <-+ explainProductionStatement+ store+ SQL.readCategoryForwardConsumerGroupStmt+ [ ("$5", "100::int4"),+ ("$4", "2::int4"),+ ("$3", "1::int4"),+ ("$2", "'performance'::text"),+ ("$1", "15000::bigint")+ ]+ expectIndex "ix_stream_events_all_by_category" plan+ expectNoNodeType "Sort" plan++ it "category caught-up poll on 20000 idle streams reads at most 32 buffers" $ \store -> do+ let cursor = T.pack (show categoryScalingHead) <> "::bigint"+ plainAt =+ [ ("$3", "100::int4"),+ ("$2", "'idle'::text"),+ ("$1", cursor)+ ]+ groupAt member =+ [ ("$5", "100::int4"),+ ("$4", "2::int4"),+ ("$3", T.pack (show member) <> "::int4"),+ ("$2", "'idle'::text"),+ ("$1", cursor)+ ]+ groupMembers = [0, 1 :: Int]++ plainIdle <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt+ expectBufferBudget "plain caught-up poll" plainIdle+ snd plainIdle `shouldBe` 0+ groupIdle <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers+ mapM_ (expectBufferBudget "group caught-up poll") groupIdle+ map snd groupIdle `shouldBe` [0, 0]++ appended <- runStoreIO store $ appendToStream (StreamName "idle-1") AnyVersion [makeEvent "IdleWake" Null]+ appended `shouldSatisfy` either (const False) (const True)++ plainOne <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt+ expectBufferBudget "plain poll after one append" plainOne+ snd plainOne `shouldBe` 1+ groupOne <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers+ mapM_ (expectBufferBudget "group poll after one append") groupOne+ -- Exactly one member owns idle-1.+ sum (map snd groupOne) `shouldBe` 1++withCategoryScalingStore :: (KirokuStore -> IO ()) -> IO ()+withCategoryScalingStore action =+ withTestStore $ \store -> do+ result <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)+ case result of+ Left err -> expectationFailure ("failed to seed category-scaling fixture: " <> show err)+ Right () -> action store++-- | The G1 budget: a category poll that returns at most one row.+expectBufferBudget :: String -> (Int64, Int64) -> Expectation+expectBufferBudget label (buffers, _) =+ unless (buffers <= 32) $+ expectationFailure $+ label+ <> ": expected at most 32 shared buffers, but the plan read "+ <> show buffers++-- | Execute a production statement under @EXPLAIN (ANALYZE, BUFFERS)@ and+-- return the top plan node's shared buffers (hit plus read) and actual rows.+-- Planning buffers are excluded; they are not paid by a prepared statement.+explainAnalyzeBuffers ::+ KirokuStore ->+ Statement params result ->+ [(Text, Text)] ->+ IO (Int64, Int64)+explainAnalyzeBuffers store productionStatement replacements = do+ plan <-+ explainWith+ "EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, FORMAT JSON)\n"+ store+ productionStatement+ replacements+ case plan of+ Array entries+ | Just (Object entry) <- headMay entries,+ Just (Object top) <- KeyMap.lookup "Plan" entry ->+ pure+ ( numberField "Shared Hit Blocks" top + numberField "Shared Read Blocks" top,+ numberField "Actual Rows" top+ )+ _ -> expectationFailure ("unexpected EXPLAIN shape: " <> show plan) >> fail "unreachable"+ where+ headMay values = case foldr (:) [] values of+ value : _ -> Just value+ [] -> Nothing+ numberField key object = case KeyMap.lookup key object of+ Just value | Aeson.Success (number :: Double) <- Aeson.fromJSON value -> round number+ _ -> 0++withObservedStore :: IORef Int -> (KirokuStore -> IO ()) -> IO ()+withObservedStore checkouts =+ withTestStoreSettings $ \settings ->+ settings+ { observationHandler =+ Just $ \case+ ConnectionObservation _ InUseConnectionStatus -> modifyIORef' checkouts (+ 1)+ _ -> pure ()+ }++withQueryPlanStore :: (KirokuStore -> IO ()) -> IO ()+withQueryPlanStore action =+ withTestStore $ \store -> do+ result <- Pool.use (store ^. #pool) (Session.script queryPlanFixture)+ case result of+ Left err -> expectationFailure ("failed to seed performance query-plan fixture: " <> show err)+ Right () -> action store++queryPlanFixture :: Text+queryPlanFixture =+ """+ BEGIN;++ WITH new_streams AS (+ INSERT INTO streams (stream_name, stream_version)+ SELECT 'performance-' || n::text, 100+ FROM generate_series(1, 200) AS n+ RETURNING stream_id+ ), fixture_events AS MATERIALIZED (+ SELECT uuidv7() AS event_id,+ s.stream_id,+ per_stream_position::bigint AS stream_version,+ row_number() OVER (ORDER BY per_stream_position, s.stream_id)::bigint AS global_position+ FROM new_streams AS s+ CROSS JOIN generate_series(1, 100) AS per_stream_position+ ), inserted_events AS (+ INSERT INTO events (event_id, event_type, data)+ SELECT event_id, 'PerformanceFixture', '{}'::jsonb+ FROM fixture_events+ RETURNING event_id+ ), source_links AS (+ INSERT INTO stream_events+ (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+ SELECT fixture.event_id,+ fixture.stream_id,+ fixture.stream_version,+ fixture.stream_id,+ fixture.stream_version+ FROM fixture_events AS fixture+ JOIN inserted_events USING (event_id)+ RETURNING event_id+ ), all_links AS (+ INSERT INTO stream_events+ (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+ SELECT fixture.event_id,+ 0,+ fixture.global_position,+ fixture.stream_id,+ fixture.stream_version,+ 'performance'+ FROM fixture_events AS fixture+ JOIN inserted_events USING (event_id)+ RETURNING event_id+ ), advanced_all AS (+ UPDATE streams+ SET stream_version = (SELECT max(global_position) FROM fixture_events)+ WHERE stream_id = 0+ RETURNING stream_id+ ), inserted_dead_letters AS (+ INSERT INTO dead_letters+ (subscription_name, consumer_group_member, global_position, event_id,+ reason, reason_summary, attempt_count)+ SELECT CASE+ WHEN fixture.global_position % 10 = 0 THEN 'performance-read'+ ELSE 'performance-other-' || (fixture.global_position % 9)::text+ END,+ 0,+ fixture.global_position,+ fixture.event_id,+ '{}'::jsonb,+ 'performance fixture',+ 1+ FROM fixture_events AS fixture+ JOIN inserted_events USING (event_id)+ RETURNING dead_letter_id+ )+ SELECT (SELECT count(*) FROM source_links),+ (SELECT count(*) FROM all_links),+ (SELECT count(*) FROM advanced_all),+ (SELECT count(*) FROM inserted_dead_letters);++ COMMIT;+ ANALYZE streams;+ ANALYZE events;+ ANALYZE stream_events;+ ANALYZE dead_letters;+ INSERT INTO history_retention_leases+ (owner, reason, protected_through, created_at, renewed_at, expires_at, released_at)+ SELECT 'performance-owner',+ 'performance-reason',+ 0,+ clock_timestamp() - interval '1 day',+ clock_timestamp() - interval '1 day',+ clock_timestamp() + interval '1 hour',+ CASE WHEN n <= 9900 THEN clock_timestamp() ELSE NULL END+ FROM generate_series(1, 10000) AS n;+ ANALYZE history_retention_leases;+ """++explainProductionStatement ::+ KirokuStore ->+ Statement params result ->+ [(Text, Text)] ->+ IO Value+explainProductionStatement = explainWith "EXPLAIN (FORMAT JSON, COSTS OFF)\n"++explainWith ::+ Text ->+ KirokuStore ->+ Statement params result ->+ [(Text, Text)] ->+ IO Value+explainWith explainPrefix store productionStatement replacements = do+ let productionSql = Statement.toSql productionStatement+ explainedSql =+ explainPrefix+ <> foldl' (\sql (placeholder, literal) -> T.replace placeholder literal sql) productionSql replacements+ explainStatement :: Statement () ByteString+ explainStatement =+ unpreparable+ explainedSql+ E.noParams+ (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))+ result <- Pool.use (store ^. #pool) (Session.statement () explainStatement)+ bytes <- case result of+ Left err -> expectationFailure ("EXPLAIN failed: " <> show err) >> fail "unreachable"+ Right value -> pure value+ case Aeson.eitherDecodeStrict' bytes of+ Left err -> expectationFailure ("could not decode EXPLAIN JSON: " <> err) >> fail "unreachable"+ Right value -> pure value++data PlanFacts = PlanFacts+ { nodeTypes :: [Text],+ indexNames :: [Text]+ }+ deriving stock (Show)++instance Semigroup PlanFacts where+ PlanFacts nodeTypesA indexNamesA <> PlanFacts nodeTypesB indexNamesB =+ PlanFacts (nodeTypesA <> nodeTypesB) (indexNamesA <> indexNamesB)++instance Monoid PlanFacts where+ mempty = PlanFacts [] []++collectPlanFacts :: Value -> PlanFacts+collectPlanFacts (Object object) =+ PlanFacts+ { nodeTypes = maybe [] pure (textField "Node Type" object),+ indexNames = maybe [] pure (textField "Index Name" object)+ }+ <> foldMap collectPlanFacts (KeyMap.elems object)+collectPlanFacts (Array values) = foldMap collectPlanFacts values+collectPlanFacts _ = mempty++textField :: Aeson.Key -> Aeson.Object -> Maybe Text+textField key object = case KeyMap.lookup key object of+ Just (String value) -> Just value+ _ -> Nothing++expectIndex :: Text -> Value -> Expectation+expectIndex expected plan = do+ let facts = collectPlanFacts plan+ unless (expected `elem` indexNames facts) $+ expectationFailure $+ "expected plan to use index "+ <> T.unpack expected+ <> ", but collected "+ <> show facts+ <> " from:\n"+ <> show plan++expectNoNodeType :: Text -> Value -> Expectation+expectNoNodeType forbidden plan = do+ let facts = collectPlanFacts plan+ unless (forbidden `notElem` nodeTypes facts) $+ expectationFailure $+ "expected plan not to contain node type "+ <> T.unpack forbidden+ <> ", but collected "+ <> show facts+ <> " from:\n"+ <> show plan++activeLeaseLookupStmt :: Statement () ()+activeLeaseLookupStmt =+ Statement.preparable+ """+ SELECT count(*), min(expires_at)+ FROM history_retention_leases+ WHERE released_at IS NULL+ AND expires_at > clock_timestamp()+ HAVING count(*) > 0+ """+ E.noParams+ D.noResult++retentionTriggerShapeStmt :: Statement () (Int64, Int64)+retentionTriggerShapeStmt =+ Statement.preparable+ """+ SELECT count(*),+ count(*) FILTER (WHERE (trigger.tgtype & 4) <> 0 OR (trigger.tgtype & 16) <> 0)+ FROM pg_catalog.pg_trigger AS trigger+ JOIN pg_catalog.pg_class AS relation ON relation.oid = trigger.tgrelid+ JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace+ WHERE namespace.nspname = 'kiroku'+ AND relation.relname IN ('events', 'stream_events', 'streams')+ AND trigger.tgname IN ('protect_replay_history_delete', 'protect_replay_history_truncate')+ AND NOT trigger.tgisinternal+ """+ E.noParams+ ( D.singleRow+ ( (,)+ <$> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.int8)+ )+ )++streamHeadQuerySpec :: Spec+streamHeadQuerySpec = describe "stream head query work" $+ aroundAll withStreamHeadStore $+ forM_ ["bench-1", "long-1", "empty-1", "missing-1"] $ \name ->+ it ("bounds literal and warmed prepared probes for " <> T.unpack name) $ \store -> do+ let literal = "'" <> name <> "'"+ prefix = "EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) "+ direct <- explainWith prefix store SQL.getStreamWithHeadStmt [("$1", literal)]+ prepared <- explainStreamHeadPrepared store literal+ forM_ [("literal", direct), ("prepared", prepared)] $ \(kind, plan) -> do+ -- Emit complete JSON into retained test logs, including buffers and loops.+ putStrLn ("STREAM_HEAD_PLAN " <> T.unpack name <> " " <> kind <> " " <> show (Aeson.encode plan))+ expectIndex "ix_stream_events_all_by_origin" plan+ expectNoNodeType "Sort" plan+ let nodes = planObjects plan+ probes = filter ((== Just "stream_events") . textField "Relation Name") nodes+ limits = filter ((== Just "Limit") . textField "Node Type") nodes+ length probes `shouldBe` 1+ length limits `shouldBe` 1+ forM_ probes $ \probe -> do+ textField "Node Type" probe `shouldSatisfy` (`elem` [Just "Index Scan", Just "Index Only Scan"])+ textField "Scan Direction" probe `shouldBe` Just "Backward"+ planNumber "Actual Rows" probe `shouldSatisfy` (<= 1)+ if name == "missing-1" then planNumber "Actual Loops" probe `shouldBe` 0 else pure ()+ forM_ limits $ \limit -> do+ expectIndex "ix_stream_events_all_by_origin" (Object limit)+ planNumber "Actual Rows" limit `shouldSatisfy` (<= 1)+ map (textField "Relation Name") nodes `shouldNotContain` [Just "events"]+ case plan of+ Array entries+ | Object entry : _ <- foldr (:) [] entries,+ Just (Object top) <- KeyMap.lookup "Plan" entry -> do+ planNumber "Shared Hit Blocks" top + planNumber "Shared Read Blocks" top `shouldSatisfy` (<= 32)+ planNumber "Actual Rows" top `shouldBe` (if name == "missing-1" then 0 else 1)+ _ -> expectationFailure (show plan)+ actual <- runStoreIO store (getStreamWithHead (StreamName name))+ case name of+ "missing-1" -> actual `shouldBe` Right Nothing+ "empty-1" -> case actual of+ Right (Just (_, headPosition)) -> headPosition `shouldBe` Nothing+ _ -> expectationFailure (show actual)+ _ -> case actual of+ Right (Just (_, Just _)) -> pure ()+ _ -> expectationFailure (show actual)++withStreamHeadStore :: (KirokuStore -> IO ()) -> IO ()+withStreamHeadStore action = withTestStore $ \store -> do+ forM_ [streamHeadFixtureSql, "VACUUM (ANALYZE) stream_events"] $ \command ->+ Pool.use (store ^. #pool) (Session.script command) >>= either (fail . show) pure+ action store++explainStreamHeadPrepared :: KirokuStore -> Text -> IO Value+explainStreamHeadPrepared store literal = do+ result <- Pool.use (store ^. #pool) $ do+ Session.script ("PREPARE ep97_head(text) AS " <> Statement.toSql SQL.getStreamWithHeadStmt)+ -- Default auto policy, past PostgreSQL's first five custom executions.+ Session.script (T.replicate 6 ("EXECUTE ep97_head(" <> literal <> ");"))+ bytes <-+ Session.statement () $+ unpreparable+ ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE ep97_head(" <> literal <> ")")+ E.noParams+ (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))+ Session.script "DEALLOCATE ep97_head"+ pure bytes+ bytes <- either (fail . show) pure result+ either fail pure (Aeson.eitherDecodeStrict' bytes)++planObjects :: Value -> [Aeson.Object]+planObjects (Object fields) = fields : concatMap planObjects (KeyMap.elems fields)+planObjects (Array values) = concatMap planObjects (foldr (:) [] values)+planObjects _ = []++planNumber :: Aeson.Key -> Aeson.Object -> Double+planNumber key fields = case KeyMap.lookup key fields of+ Just (Number n) -> realToFrac n+ _ -> 0
test/Test/Properties.hs view
@@ -1,21 +1,20 @@-{- | Property-based tests for invariants identified by EP-1 through EP-5.--Each property generates a random sequence of operations and asserts that-some invariant holds after the sequence executes. The properties target-the cross-cutting invariants enumerated in EP-6 M1's Invariant List-(F2–F8 of @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@):-- * F2 — global position contiguity across appends, multi-stream appends,- and links.- * F3 — no orphan event payloads after lifecycle operations.- * F4 — soft-delete write barrier under arbitrary append constructors.- * F5 — caller-supplied event-id idempotence.--These properties run inside hspec via 'hspec-hedgehog'. Each property-acquires a fresh ephemeral PostgreSQL via 'withTestStore' inside the-hedgehog test action; the operation count per property is small (10-20-ops) to keep total runtime bounded.--}+-- | Property-based tests for invariants identified by EP-1 through EP-5.+--+-- Each property generates a random sequence of operations and asserts that+-- some invariant holds after the sequence executes. The properties target+-- the cross-cutting invariants enumerated in EP-6 M1's Invariant List+-- (F2–F8 of @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@):+--+-- * F2 — global position contiguity across appends, multi-stream appends,+-- and links.+-- * F3 — no orphan event payloads after lifecycle operations.+-- * F4 — soft-delete write barrier under arbitrary append constructors.+-- * F5 — caller-supplied event-id idempotence.+--+-- These properties run inside hspec via 'hspec-hedgehog'. Each property+-- acquires a fresh ephemeral PostgreSQL via 'withTestStore' inside the+-- hedgehog test action; the operation count per property is small (10-20+-- ops) to keep total runtime bounded. module Test.Properties (spec) where import Control.Lens ((^.))@@ -39,188 +38,184 @@ -- | Operations the property generators can produce. data Op- = OpAppend !StreamName ![Text]- | OpAppendMulti ![(StreamName, [Text])]- | OpSoftDelete !StreamName- | OpUndelete !StreamName- | OpHardDelete !StreamName- deriving stock (Show)+ = OpAppend !StreamName ![Text]+ | OpAppendMulti ![(StreamName, [Text])]+ | OpSoftDelete !StreamName+ | OpUndelete !StreamName+ | OpHardDelete !StreamName+ deriving stock (Show) genStreamName :: Gen StreamName genStreamName = do- -- Bounded universe (5 streams) so generated sequences exercise reuse.- n <- Gen.int (Range.linear 0 4)- pure (StreamName ("prop-" <> T.pack (show n)))+ -- Bounded universe (5 streams) so generated sequences exercise reuse.+ n <- Gen.int (Range.linear 0 4)+ pure (StreamName ("prop-" <> T.pack (show n))) genEventTypes :: Gen [Text] genEventTypes = do- n <- Gen.int (Range.linear 1 3)- pure (map (\i -> "Type" <> T.pack (show i)) [1 .. n])+ n <- Gen.int (Range.linear 1 3)+ pure (map (\i -> "Type" <> T.pack (show i)) [1 .. n]) genOp :: Gen Op genOp =- Gen.frequency- [ (60, OpAppend <$> genStreamName <*> genEventTypes)- ,- ( 10- , OpAppendMulti- <$> Gen.list- (Range.linear 2 3)- ((,) <$> genStreamName <*> genEventTypes)- )- , (10, OpSoftDelete <$> genStreamName)- , (10, OpUndelete <$> genStreamName)- , (10, OpHardDelete <$> genStreamName)- ]+ Gen.frequency+ [ (60, OpAppend <$> genStreamName <*> genEventTypes),+ ( 10,+ OpAppendMulti+ <$> Gen.list+ (Range.linear 2 3)+ ((,) <$> genStreamName <*> genEventTypes)+ ),+ (10, OpSoftDelete <$> genStreamName),+ (10, OpUndelete <$> genStreamName),+ (10, OpHardDelete <$> genStreamName)+ ] genOps :: Gen [Op] genOps = Gen.list (Range.linear 1 12) genOp -{- | Run a generated operation, ignoring any 'StoreError' (the property-is about the system's invariants surviving any sequence of attempts,-including ones that fail).--}+-- | Run a generated operation, ignoring any 'StoreError' (the property+-- is about the system's invariants surviving any sequence of attempts,+-- including ones that fail). runOp :: KirokuStore -> Op -> IO () runOp store = \case- OpAppend sn types -> do- let evts = map (\t -> makeEvent t (Aeson.object [])) types- void $ runStoreIO store $ appendToStream sn AnyVersion evts- OpAppendMulti ops -> do- let triples = map (\(sn, types) -> (sn, AnyVersion, map (\t -> makeEvent t (Aeson.object [])) types)) ops- void $ runStoreIO store $ appendMultiStream triples- OpSoftDelete sn -> void $ runStoreIO store $ softDeleteStream sn- OpUndelete sn -> void $ runStoreIO store $ undeleteStream sn- OpHardDelete sn -> void $ runStoreIO store $ hardDeleteStream sn+ OpAppend sn types -> do+ let evts = map (\t -> makeEvent t (Aeson.object [])) types+ void $ runStoreIO store $ appendToStream sn AnyVersion evts+ OpAppendMulti ops -> do+ let triples = map (\(sn, types) -> (sn, AnyVersion, map (\t -> makeEvent t (Aeson.object [])) types)) ops+ void $ runStoreIO store $ appendMultiStream triples+ OpSoftDelete sn -> void $ runStoreIO store $ softDeleteStream sn+ OpUndelete sn -> void $ runStoreIO store $ undeleteStream sn+ OpHardDelete sn -> void $ runStoreIO store $ hardDeleteStream sn -- | Read the global position cursor by counting events on `$all`. readAllCount :: KirokuStore -> IO Int readAllCount store = do- Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000- pure (V.length v)+ Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000+ pure (V.length v) -{- | Read all events on `$all` and return their global positions in-the order returned.--}+-- | Read all events on `$all` and return their global positions in+-- the order returned. readAllPositions :: KirokuStore -> IO [Int] readAllPositions store = do- Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000- pure (V.toList (V.map (\e -> case e ^. #globalPosition of GlobalPosition n -> fromIntegral n) v))+ Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000+ pure (V.toList (V.map (\e -> case e ^. #globalPosition of GlobalPosition n -> fromIntegral n) v)) -{- | Each property creates a fresh ephemeral PostgreSQL per test case,-which dominates wall-clock time. Cap iterations at 15 per property —-enough to exercise generator coverage without blowing the suite past-two minutes.--}+-- | Each property creates a fresh ephemeral PostgreSQL per test case,+-- which dominates wall-clock time. Cap iterations at 15 per property —+-- enough to exercise generator coverage without blowing the suite past+-- two minutes. spec :: Spec spec = modifyMaxSuccess (const 15) $ do- describe "kiroku-store invariants (property-based, hedgehog)" $ do- -- F2 — Global position contiguity. After any sequence of- -- appends, multi-appends, soft/undelete/hard-delete operations,- -- the global positions on $all (excluding hard-deleted events)- -- form a strictly ascending sequence with no duplicates and no- -- gaps relative to the events that actually exist on $all.- it "$all global positions are strictly ascending and unique (F2)" $- hedgehog $- propAllPositionsAscending+ describe "kiroku-store invariants (property-based, hedgehog)" $ do+ -- F2 — Global position contiguity. After any sequence of+ -- appends, multi-appends, soft/undelete/hard-delete operations,+ -- the global positions on $all (excluding hard-deleted events)+ -- form a strictly ascending sequence with no duplicates and no+ -- gaps relative to the events that actually exist on $all.+ it "$all global positions are strictly ascending and unique (F2)" $+ hedgehog $+ propAllPositionsAscending - -- F4 — Soft-delete write barrier. After softDeleteStream, no- -- subsequent appendToStream of any ExpectedVersion succeeds- -- (until undeleteStream). Generates a random append, soft-deletes,- -- attempts further appends with each ExpectedVersion constructor,- -- asserts each one fails.- it "soft-deleted streams reject appends of every ExpectedVersion (F4)" $- hedgehog $- propSoftDeleteBarrier+ -- F4 — Soft-delete write barrier. After softDeleteStream, no+ -- subsequent appendToStream of any ExpectedVersion succeeds+ -- (until undeleteStream). Generates a random append, soft-deletes,+ -- attempts further appends with each ExpectedVersion constructor,+ -- asserts each one fails.+ it "soft-deleted streams reject appends of every ExpectedVersion (F4)" $+ hedgehog $+ propSoftDeleteBarrier - -- F5 — Idempotent caller-supplied event ids. A batch of appends- -- with all-unique event ids succeeds; a batch where any id is a- -- repeat of a previously committed id fails with DuplicateEvent.- it "duplicate caller-supplied event ids are rejected (F5)" $- hedgehog $- propDuplicateEventIds+ -- F5 — Idempotent caller-supplied event ids. A batch of appends+ -- with all-unique event ids succeeds; a batch where any id is a+ -- repeat of a previously committed id fails with DuplicateEvent.+ it "duplicate caller-supplied event ids are rejected (F5)" $+ hedgehog $+ propDuplicateEventIds propAllPositionsAscending :: PropertyT IO () propAllPositionsAscending = do- ops <- forAll genOps- positions <- evalIO $ withTestStoreReturn $ \store -> do- forM_ ops (runOp store)- readAllPositions store- annotateShow positions- -- Strictly ascending: each position greater than the previous.- sort positions === positions- -- Unique: no duplicates.- length positions === length (Set.fromList positions)- -- The count returned by $all matches the position list length.- cnt <- evalIO $ withTestStoreReturn $ \store -> do- forM_ ops (runOp store)- readAllCount store- cnt === length positions+ ops <- forAll genOps+ positions <- evalIO $ withTestStoreReturn $ \store -> do+ forM_ ops (runOp store)+ readAllPositions store+ annotateShow positions+ -- Strictly ascending: each position greater than the previous.+ sort positions === positions+ -- Unique: no duplicates.+ length positions === length (Set.fromList positions)+ -- The count returned by $all matches the position list length.+ cnt <- evalIO $ withTestStoreReturn $ \store -> do+ forM_ ops (runOp store)+ readAllCount store+ cnt === length positions propSoftDeleteBarrier :: PropertyT IO () propSoftDeleteBarrier = do- streamSeed <- forAll (Gen.int (Range.linear 0 9))- nEvents <- forAll (Gen.int (Range.linear 1 4))- evalIO $ withTestStoreReturn $ \store -> do- let sn = StreamName ("barrier-" <> T.pack (show streamSeed))- let evts = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. nEvents]- Right _ <- runStoreIO store $ appendToStream sn NoStream evts- Right _ <- runStoreIO store $ softDeleteStream sn- -- Every subsequent append constructor must be rejected.- rNo <- runStoreIO store $ appendToStream sn NoStream [makeEvent "X" (Aeson.object [])]- case rNo of- Left (StreamAlreadyExists _) -> pure ()- other -> error ("F4 violated: NoStream against soft-deleted should be StreamAlreadyExists, got: " <> show other)- rExact <- runStoreIO store $ appendToStream sn (ExactVersion (StreamVersion (fromIntegral nEvents))) [makeEvent "X" (Aeson.object [])]- case rExact of- Left (WrongExpectedVersion _ _ _) -> pure ()- Left (StreamNotFound _) -> pure ()- other -> error ("F4 violated: ExactVersion against soft-deleted should fail, got: " <> show other)- rExists <- runStoreIO store $ appendToStream sn StreamExists [makeEvent "X" (Aeson.object [])]- case rExists of- Left (StreamNotFound _) -> pure ()- other -> error ("F4 violated: StreamExists against soft-deleted should be StreamNotFound, got: " <> show other)- rAny <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "X" (Aeson.object [])]- case rAny of- Left (StreamNotFound _) -> pure ()- other -> error ("F4 violated: AnyVersion against soft-deleted should be StreamNotFound, got: " <> show other)+ streamSeed <- forAll (Gen.int (Range.linear 0 9))+ nEvents <- forAll (Gen.int (Range.linear 1 4))+ evalIO $ withTestStoreReturn $ \store -> do+ let sn = StreamName ("barrier-" <> T.pack (show streamSeed))+ let evts = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. nEvents]+ Right _ <- runStoreIO store $ appendToStream sn NoStream evts+ Right _ <- runStoreIO store $ softDeleteStream sn+ -- Every subsequent append constructor must be rejected.+ rNo <- runStoreIO store $ appendToStream sn NoStream [makeEvent "X" (Aeson.object [])]+ case rNo of+ Left (StreamAlreadyExists _) -> pure ()+ other -> error ("F4 violated: NoStream against soft-deleted should be StreamAlreadyExists, got: " <> show other)+ rExact <- runStoreIO store $ appendToStream sn (ExactVersion (StreamVersion (fromIntegral nEvents))) [makeEvent "X" (Aeson.object [])]+ case rExact of+ Left (WrongExpectedVersion _ _ _) -> pure ()+ Left (StreamNotFound _) -> pure ()+ other -> error ("F4 violated: ExactVersion against soft-deleted should fail, got: " <> show other)+ rExists <- runStoreIO store $ appendToStream sn StreamExists [makeEvent "X" (Aeson.object [])]+ case rExists of+ Left (StreamNotFound _) -> pure ()+ other -> error ("F4 violated: StreamExists against soft-deleted should be StreamNotFound, got: " <> show other)+ rAny <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "X" (Aeson.object [])]+ case rAny of+ Left (StreamNotFound _) -> pure ()+ other -> error ("F4 violated: AnyVersion against soft-deleted should be StreamNotFound, got: " <> show other) propDuplicateEventIds :: PropertyT IO () propDuplicateEventIds = do- nFirst <- forAll (Gen.int (Range.linear 1 4))- evalIO $ withTestStoreReturn $ \store -> do- -- Generate a batch of unique caller-supplied event ids.- ids <- mapM (const UUIDv4.nextRandom) [1 .. nFirst]- let mkEvent uid =- EventData- { eventId = Just (EventId uid)- , eventType = EventType "Dup"- , payload = Aeson.object []- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }- let evts1 = map mkEvent ids- r1 <- runStoreIO store $ appendToStream (StreamName "dup-prop-1") NoStream evts1- case r1 of- Right _ -> pure ()- other -> error ("Initial append with unique ids should succeed: " <> show other)- -- Re-issue any one of those ids in a fresh stream — must be rejected.- let dupId = case ids of- (i : _) -> i- [] -> error "propDuplicateEventIds: nFirst >= 1 by Range, ids must be non-empty"- let evts2 = [mkEvent dupId]- r2 <- runStoreIO store $ appendToStream (StreamName "dup-prop-2") NoStream evts2- case r2 of- Left (DuplicateEvent _) -> pure ()- other -> error ("F5 violated: duplicate event id should be rejected, got: " <> show other)+ nFirst <- forAll (Gen.int (Range.linear 1 4))+ evalIO $ withTestStoreReturn $ \store -> do+ -- Generate a batch of unique caller-supplied event ids.+ ids <- mapM (const UUIDv4.nextRandom) [1 .. nFirst]+ let mkEvent uid =+ EventData+ { eventId = Just (EventId uid),+ eventType = EventType "Dup",+ payload = Aeson.object [],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }+ let evts1 = map mkEvent ids+ r1 <- runStoreIO store $ appendToStream (StreamName "dup-prop-1") NoStream evts1+ case r1 of+ Right _ -> pure ()+ other -> error ("Initial append with unique ids should succeed: " <> show other)+ -- Re-issue any one of those ids in a fresh stream — must be rejected.+ let dupId = case ids of+ (i : _) -> i+ [] -> error "propDuplicateEventIds: nFirst >= 1 by Range, ids must be non-empty"+ let evts2 = [mkEvent dupId]+ r2 <- runStoreIO store $ appendToStream (StreamName "dup-prop-2") NoStream evts2+ case r2 of+ Left (DuplicateEvent _) -> pure ()+ other -> error ("F5 violated: duplicate event id should be rejected, got: " <> show other) -- | Variant of 'withTestStore' that returns the action's result. withTestStoreReturn :: (KirokuStore -> IO a) -> IO a withTestStoreReturn action = do- ref <- newIORef (Nothing :: Maybe a)- withTestStore $ \store -> do- r <- action store- atomicModifyIORef' ref (\_ -> (Just r, ()))- readIORef ref >>= \case- Just r -> pure r- Nothing -> error "withTestStoreReturn: action did not produce a result"+ ref <- newIORef (Nothing :: Maybe a)+ withTestStore $ \store -> do+ r <- action store+ atomicModifyIORef' ref (\_ -> (Just r, ()))+ readIORef ref >>= \case+ Just r -> pure r+ Nothing -> error "withTestStoreReturn: action did not produce a result"
test/Test/PublisherCallbackResilience.hs view
@@ -33,484 +33,550 @@ import Test.Hspec data CallbackBoom = CallbackBoom- deriving stock (Show)- deriving anyclass (Exception)+ deriving stock (Show)+ 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+ result <- Async.race (threadDelay timeoutMicros) action+ case result of+ Left () -> fail ("timed out waiting for " <> label)+ Right a -> pure a failureOf :: RecordedEvent -> DecodeFailure failureOf event = DecodeFailure (event ^. #eventId) "cannot decrypt" poisonHook :: RecordedEvent -> IO (Either DecodeFailure RecordedEvent) poisonHook event =- pure $- if event ^. #eventType == EventType "Boom" then Left (failureOf event) else Right event+ pure $+ if event ^. #eventType == EventType "Boom" then Left (failureOf event) else Right event appendTypes :: KirokuStore -> Text -> [Text] -> IO () appendTypes store stream types = do- Right _ <- runStoreIO store $ appendToStream (StreamName stream) NoStream (map (\typ -> makeEvent typ (Aeson.object [])) types)- pure ()+ Right _ <- runStoreIO store $ appendToStream (StreamName stream) NoStream (map (\typ -> makeEvent typ (Aeson.object [])) types)+ pure () expectCleanStop :: SubscriptionHandle -> IO () expectCleanStop handle =- within "clean subscription stop" (wait handle) >>= \case- Right () -> pure ()- Left err -> expectationFailure ("expected clean stop, got " <> show err)+ within "clean subscription stop" (wait handle) >>= \case+ Right () -> pure ()+ Left err -> expectationFailure ("expected clean stop, got " <> show err) expectDecodeStop :: SubscriptionHandle -> IO DecodeFailure expectDecodeStop handle =- within "typed decode stop" (wait handle) >>= \case- Left err | Just (SubscriptionUndecodable failure) <- fromException err -> pure failure- other -> fail ("expected SubscriptionUndecodable, got " <> show other)+ within "typed decode stop" (wait handle) >>= \case+ Left err | Just (SubscriptionUndecodable failure) <- fromException err -> pure failure+ other -> fail ("expected SubscriptionUndecodable, got " <> show other) readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64) readCheckpoint store name' = do- Right value <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.getCheckpointMemberStmt)- pure value+ Right value <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.getCheckpointMemberStmt)+ pure value readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord] readDeadLetters store name' = do- Right values <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.readDeadLettersStmt)- pure (V.toList values)+ Right values <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.readDeadLettersStmt)+ pure (V.toList values) awaitCheckpoint :: KirokuStore -> Text -> Int64 -> IO () awaitCheckpoint store name' expected = within "durable checkpoint" loop where loop = do- value <- readCheckpoint store name'- if value == Just expected then pure () else threadDelay 1_000 >> loop+ value <- readCheckpoint store name'+ if value == Just expected then pure () else threadDelay 1_000 >> loop spec :: Spec spec = describe "publisher callback resilience" $ do- it "stops only the default subscriber while a sibling dead-letters and continues live" $ do- defaultLive <- newEmptyMVar- siblingLive <- newEmptyMVar- siblingDone <- newEmptyMVar- attempts <- newIORef []- defaultSeen <- newIORef ([] :: [EventType])- siblingSeen <- newIORef ([] :: [EventType])- publisherFailures <- newTVarIO (0 :: Int)- retryEvents <- newTVarIO (0 :: Int)- stopped <- newIORef Nothing- let defaultName = SubscriptionName "decode-default-live"- siblingName = SubscriptionName "decode-sibling-live"- hook event- | event ^. #eventType == EventType "Boom" = do- now <- getMonotonicTimeNSec- modifyIORef' attempts (now :)- pure (Left (failureOf event))- | otherwise = pure (Right event)- observe event = do- caughtUpEventHandler defaultName defaultLive Nothing event- caughtUpEventHandler siblingName siblingLive Nothing event- case event of- KirokuEventPublisherDecodeFailed{} -> atomically $ modifyTVar' publisherFailures (+ 1)- KirokuEventSubscriptionRetrying name' _ _ _ | name' == defaultName -> atomically $ modifyTVar' retryEvents (+ 1)- KirokuEventSubscriptionStopped name' _ (StopUndecodable failure) _ | name' == defaultName -> modifyIORef' stopped (const (Just failure))- _ -> pure ()- tweak settings =- settings- & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}- & #eventHandler .~ Just observe- record ref event = modifyIORef' ref ((event ^. #eventType) :) >> pure Continue- siblingHandler event = do- result <- record siblingSeen event- when (event ^. #eventType == EventType "After") $ () <$ tryPutMVar siblingDone ()- pure result- withTestStoreSettings tweak $ \store -> do- defaultHandle <- subscribe store ((defaultSubscriptionConfig defaultName AllStreams (record defaultSeen)){retryPolicy = RetryPolicy 3})- sibling <-- subscribe- store- ( (defaultSubscriptionConfig siblingName AllStreams siblingHandler)- { undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))- }- )- waitForSubscriptionLive defaultLive- waitForSubscriptionLive siblingLive- appendTypes store "decode-live" ["Before", "Boom", "After"]- failure <- expectDecodeStop defaultHandle- decodeFailureReason failure `shouldBe` "cannot decrypt"- readIORef stopped `shouldReturn` Just failure- atomically (readTVar retryEvents) `shouldReturn` 2- within "healthy sibling delivery" (waitForSubscriptionLive siblingDone)- awaitCheckpoint store "decode-sibling-live" 3- waitForPublisher store (GlobalPosition 3)- atomically (readTVar publisherFailures) `shouldReturn` 1- reverse <$> readIORef defaultSeen `shouldReturn` [EventType "Before"]- reverse <$> readIORef siblingSeen `shouldReturn` [EventType "Before", EventType "After"]- readCheckpoint store "decode-default-live" >>= (`shouldSatisfy` maybe False (< 2))- (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-default-live") `shouldReturn` []- letters <- readDeadLetters store "decode-sibling-live"- map SQL.deadLetterGlobalPosition letters `shouldBe` [2]- map SQL.deadLetterReasonSummary letters `shouldBe` ["decode failure: cannot decrypt"]- map SQL.deadLetterReason letters `shouldBe` [Aeson.object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= ("cannot decrypt" :: Text)]]- times <- reverse <$> readIORef attempts- length times `shouldBe` 3- zipWith (-) (drop 1 times) times `shouldSatisfy` all (>= 900_000_000)- currentState sibling >>= \case- Just Live{} -> pure ()- other -> expectationFailure ("expected healthy sibling, got " <> show other)- currentState defaultHandle >>= (`shouldSatisfy` isNothing)- cancel sibling+ it "stops only the default subscriber while a sibling dead-letters and continues live" $ do+ defaultLive <- newEmptyMVar+ siblingLive <- newEmptyMVar+ siblingDone <- newEmptyMVar+ attempts <- newIORef []+ defaultSeen <- newIORef ([] :: [EventType])+ siblingSeen <- newIORef ([] :: [EventType])+ publisherFailures <- newTVarIO (0 :: Int)+ retryEvents <- newTVarIO (0 :: Int)+ stopped <- newIORef Nothing+ let defaultName = SubscriptionName "decode-default-live"+ siblingName = SubscriptionName "decode-sibling-live"+ hook event+ | event ^. #eventType == EventType "Boom" = do+ now <- getMonotonicTimeNSec+ modifyIORef' attempts (now :)+ pure (Left (failureOf event))+ | otherwise = pure (Right event)+ observe event = do+ caughtUpEventHandler defaultName defaultLive Nothing event+ caughtUpEventHandler siblingName siblingLive Nothing event+ case event of+ KirokuEventPublisherDecodeFailed {} -> atomically $ modifyTVar' publisherFailures (+ 1)+ KirokuEventSubscriptionRetrying name' _ _ _ | name' == defaultName -> atomically $ modifyTVar' retryEvents (+ 1)+ KirokuEventSubscriptionStopped name' _ (StopUndecodable failure) _ | name' == defaultName -> modifyIORef' stopped (const (Just failure))+ _ -> pure ()+ tweak settings =+ settings+ & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ & #eventHandler .~ Just observe+ record ref event = modifyIORef' ref ((event ^. #eventType) :) >> pure Continue+ siblingHandler event = do+ result <- record siblingSeen event+ when (event ^. #eventType == EventType "After") $ () <$ tryPutMVar siblingDone ()+ pure result+ withTestStoreSettings tweak $ \store -> do+ defaultHandle <- subscribe store ((defaultSubscriptionConfig defaultName AllStreams (record defaultSeen)) {retryPolicy = RetryPolicy 3})+ sibling <-+ subscribe+ store+ ( (defaultSubscriptionConfig siblingName AllStreams siblingHandler)+ { undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))+ }+ )+ waitForSubscriptionLive defaultLive+ waitForSubscriptionLive siblingLive+ appendTypes store "decode-live" ["Before", "Boom", "After"]+ failure <- expectDecodeStop defaultHandle+ decodeFailureReason failure `shouldBe` "cannot decrypt"+ readIORef stopped `shouldReturn` Just failure+ atomically (readTVar retryEvents) `shouldReturn` 2+ within "healthy sibling delivery" (waitForSubscriptionLive siblingDone)+ awaitCheckpoint store "decode-sibling-live" 3+ waitForPublisher store (GlobalPosition 3)+ atomically (readTVar publisherFailures) `shouldReturn` 1+ reverse <$> readIORef defaultSeen `shouldReturn` [EventType "Before"]+ reverse <$> readIORef siblingSeen `shouldReturn` [EventType "Before", EventType "After"]+ readCheckpoint store "decode-default-live" >>= (`shouldSatisfy` maybe False (< 2))+ (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-default-live") `shouldReturn` []+ letters <- readDeadLetters store "decode-sibling-live"+ map SQL.deadLetterGlobalPosition letters `shouldBe` [2]+ map SQL.deadLetterReasonSummary letters `shouldBe` ["decode failure: cannot decrypt"]+ map SQL.deadLetterReason letters `shouldBe` [Aeson.object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= ("cannot decrypt" :: Text)]]+ times <- reverse <$> readIORef attempts+ length times `shouldBe` 3+ zipWith (-) (drop 1 times) times `shouldSatisfy` all (>= 900_000_000)+ currentState sibling >>= \case+ Just Live {} -> pure ()+ other -> expectationFailure ("expected healthy sibling, got " <> show other)+ currentState defaultHandle >>= (`shouldSatisfy` isNothing)+ cancel sibling - it "shares successful live decoding across subscribers" $ do- firstLive <- newEmptyMVar- secondLive <- newEmptyMVar- calls <- newIORef (0 :: Int)- let firstName = SubscriptionName "decode-fanout-first"- secondName = SubscriptionName "decode-fanout-second"- hook event = modifyIORef' calls (+ 1) >> pure (Right event)- observe event = do- caughtUpEventHandler firstName firstLive Nothing event- caughtUpEventHandler secondName secondLive Nothing event- tweak settings =- settings- & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}- & #eventHandler .~ Just observe- withTestStoreSettings tweak $ \store -> do- first <- subscribe store (defaultSubscriptionConfig firstName AllStreams (\_ -> pure Stop))- second <- subscribe store (defaultSubscriptionConfig secondName AllStreams (\_ -> pure Stop))- waitForSubscriptionLive firstLive- waitForSubscriptionLive secondLive- appendTypes store "decode-fanout" ["Good"]- expectCleanStop first- expectCleanStop second- readIORef calls `shouldReturn` 1+ it "shares successful live decoding across subscribers" $ do+ firstLive <- newEmptyMVar+ secondLive <- newEmptyMVar+ calls <- newIORef (0 :: Int)+ let firstName = SubscriptionName "decode-fanout-first"+ secondName = SubscriptionName "decode-fanout-second"+ hook event = modifyIORef' calls (+ 1) >> pure (Right event)+ observe event = do+ caughtUpEventHandler firstName firstLive Nothing event+ caughtUpEventHandler secondName secondLive Nothing event+ tweak settings =+ settings+ & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ & #eventHandler .~ Just observe+ withTestStoreSettings tweak $ \store -> do+ first <- subscribe store (defaultSubscriptionConfig firstName AllStreams (\_ -> pure Stop))+ second <- subscribe store (defaultSubscriptionConfig secondName AllStreams (\_ -> pure Stop))+ waitForSubscriptionLive firstLive+ waitForSubscriptionLive secondLive+ appendTypes store "decode-fanout" ["Good"]+ expectCleanStop first+ expectCleanStop second+ readIORef calls `shouldReturn` 1 - forM_ [(Category (CategoryName "decode"), Nothing), (AllStreams, Just (validConsumerGroup 0 1))] $ \(target', group) ->- it ("handles an undecodable event after confirmed DB-driven live transition for " <> show (target', group)) $ do- caughtUp <- newEmptyMVar- seen <- newIORef ([] :: [EventType])- let subName = SubscriptionName "decode-db-live"- tweak settings =- settings- & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)- handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop- withTestStoreSettings tweak $ \store -> do- handle <-- subscribe- store- ( (defaultSubscriptionConfig subName target' handler)- { consumerGroup = group- , undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))- }- )- waitForSubscriptionLive caughtUp- appendTypes store "decode-db-live" ["Boom", "After"]- expectCleanStop handle- readIORef seen `shouldReturn` [EventType "After"]- readCheckpoint store "decode-db-live" `shouldReturn` Just 2- map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-db-live" `shouldReturn` [1]+ forM_ [(Category (CategoryName "decode"), Nothing), (AllStreams, Just (validConsumerGroup 0 1))] $ \(target', group) ->+ it ("handles an undecodable event after confirmed DB-driven live transition for " <> show (target', group)) $ do+ caughtUp <- newEmptyMVar+ seen <- newIORef ([] :: [EventType])+ let subName = SubscriptionName "decode-db-live"+ tweak settings =+ settings+ & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+ handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+ withTestStoreSettings tweak $ \store -> do+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig subName target' handler)+ { consumerGroup = group,+ undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))+ }+ )+ waitForSubscriptionLive caughtUp+ appendTypes store "decode-db-live" ["Boom", "After"]+ expectCleanStop handle+ readIORef seen `shouldReturn` [EventType "After"]+ readCheckpoint store "decode-db-live" `shouldReturn` Just 2+ map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-db-live" `shouldReturn` [1] - it "does not apply an undecodable disposition to an explicitly filtered-out event" $ do- let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-filter" ["Boom", "After"]- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( ( defaultSubscriptionConfig- (SubscriptionName "decode-filter")- AllStreams- ( \event -> do- event ^. #eventType `shouldBe` EventType "After"- pure Stop- )- )- { eventTypeFilter = OnlyEventTypes (Set.singleton (EventType "After"))- , retryPolicy = RetryPolicy 1- }- )- expectCleanStop handle- readCheckpoint store "decode-filter" `shouldReturn` Just 2- map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-filter" `shouldReturn` []+ it "does not apply an undecodable disposition to an explicitly filtered-out event" $ do+ let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-filter" ["Boom", "After"]+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( ( defaultSubscriptionConfig+ (SubscriptionName "decode-filter")+ AllStreams+ ( \event -> do+ event ^. #eventType `shouldBe` EventType "After"+ pure Stop+ )+ )+ { eventTypeFilter = OnlyEventTypes (Set.singleton (EventType "After")),+ retryPolicy = RetryPolicy 1+ }+ )+ expectCleanStop handle+ readCheckpoint store "decode-filter" `shouldReturn` Just 2+ map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-filter" `shouldReturn` [] - it "recovers a typed one-shot failure on the default retry without a callback" $ do- failedOnce <- newIORef False- caughtUp <- newEmptyMVar- seen <- newIORef ([] :: [EventType])- let subName = SubscriptionName "decode-one-shot"- hook event- | event ^. #eventType == EventType "Boom" = do- already <- atomicModifyIORef' failedOnce (\old -> (True, old))- pure (if already then Right event else Left (failureOf event))- | otherwise = pure (Right event)- tweak settings =- settings- & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}- & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)- handler event = do- modifyIORef' seen ((event ^. #eventType) :)- pure (if event ^. #eventType == EventType "After" then Stop else Continue)- withTestStoreSettings tweak $ \store -> do- handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)- waitForSubscriptionLive caughtUp- appendTypes store "decode-recovery" ["Boom", "After"]- expectCleanStop handle- reverse <$> readIORef seen `shouldReturn` [EventType "Boom", EventType "After"]- readCheckpoint store "decode-one-shot" `shouldReturn` Just 2- (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-one-shot") `shouldReturn` []+ forM_ [False, True] $ \fails ->+ it ("filters undecodable type rewrites on the initial outcome, failure=" <> show fails) $ do+ callbacks <- newIORef (0 :: Int)+ seen <- newIORef []+ let v1 = EventType "OrderCreatedV1"+ v2 = EventType "OrderCreatedV2"+ hook event+ | event ^. #eventType == v1 = pure (if fails then Left (failureOf event) else Right (event & #eventType .~ v2))+ | otherwise = pure (Right event)+ tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-rewrite" ["OrderCreatedV1", "OrderCreatedV2"]+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "decode-rewrite") AllStreams handler)+ { eventTypeFilter = OnlyEventTypes (Set.singleton v2),+ undecodableHandler = Just (\_ _ -> modifyIORef' callbacks (+ 1) >> pure Stop)+ }+ )+ expectCleanStop handle+ readIORef callbacks `shouldReturn` 0+ readIORef seen `shouldReturn` [v2]+ readCheckpoint store "decode-rewrite" `shouldReturn` Just (if fails then 2 else 1) - forM_ [AllStreams, Category (CategoryName "decode")] $ \target' ->- forM_ [Nothing, Just (validConsumerGroup 0 1)] $ \group ->- it ("stops a persistent undecodable event during catch-up for " <> show (target', group)) $ do- let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- handler _ = expectationFailure "undecodable event reached ordinary handler" >> pure Stop- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-catchup" ["Boom"]- waitForPublisher store (GlobalPosition 1)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "decode-catchup") target' handler)- { consumerGroup = group- , retryPolicy = RetryPolicy 1- }- )- _ <- expectDecodeStop handle- readCheckpoint store "decode-catchup" `shouldReturn` Just 0- (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-catchup") `shouldReturn` []+ it "retains the undecodable raw filter decision when retry recovers to an excluded type" $ do+ recover <- newIORef False+ callbacks <- newIORef (0 :: Int)+ seen <- newIORef []+ rawSeen <- newIORef []+ let v1 = EventType "OrderCreatedV1"+ v2 = EventType "OrderCreatedV2"+ hook event = do+ enabled <- readIORef recover+ pure (if enabled then Right (event & #eventType .~ v2) else Left (failureOf event))+ tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ callback raw _ = do+ modifyIORef' callbacks (+ 1)+ modifyIORef' rawSeen (raw :)+ writeIORef recover True+ pure (Retry (RetryDelay 0))+ handler event = modifyIORef' seen (event :) >> pure Stop+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-recovered-filter" ["OrderCreatedV1"]+ waitForPublisher store (GlobalPosition 1)+ withSubscription+ store+ ( (defaultSubscriptionConfig (SubscriptionName "decode-recovered-filter") AllStreams handler)+ { eventTypeFilter = OnlyEventTypes (Set.singleton v1),+ undecodableHandler = Just callback+ }+ )+ $ \handle -> do+ expectCleanStop handle+ readIORef callbacks `shouldReturn` 1+ delivered <- readIORef seen+ raw <- readIORef rawSeen+ map (\e -> e ^. #eventType) delivered `shouldBe` [v2]+ map (\e -> e ^. #eventType) raw `shouldBe` [v1]+ map (\e -> (e ^. #eventId, e ^. #globalPosition)) delivered+ `shouldBe` map (\e -> (e ^. #eventId, e ^. #globalPosition)) raw+ readCheckpoint store "decode-recovered-filter" `shouldReturn` Just 1+ map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-recovered-filter" `shouldReturn` [] - forM_ [Continue, Stop, Retry (RetryDelay 0)] $ \disposition ->- it ("honors an explicit undecodable disposition " <> show disposition) $ do- callbacks <- newIORef (0 :: Int)- seen <- newIORef ([] :: [EventType])- let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop- callback _ _ = modifyIORef' callbacks (+ 1) >> pure disposition- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-disposition" ["Boom", "After"]- waitForPublisher store (GlobalPosition 2)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "decode-disposition") AllStreams handler)- { undecodableHandler = Just callback- , retryPolicy = RetryPolicy 3- }- )- expectCleanStop handle- case disposition of- Stop -> do- readIORef seen `shouldReturn` []- readCheckpoint store "decode-disposition" `shouldReturn` Just 1- (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` []- Retry _ -> do- readIORef callbacks `shouldReturn` 3- letters <- readDeadLetters store "decode-disposition"- map SQL.deadLetterReasonSummary letters `shouldBe` ["max retry attempts exceeded (3)"]- readCheckpoint store "decode-disposition" `shouldReturn` Just 2- _ -> do- readIORef callbacks `shouldReturn` 1- readIORef seen `shouldReturn` [EventType "After"]- (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` []+ it "recovers a typed one-shot failure on the default retry without a callback" $ do+ failedOnce <- newIORef False+ caughtUp <- newEmptyMVar+ seen <- newIORef ([] :: [EventType])+ let subName = SubscriptionName "decode-one-shot"+ hook event+ | event ^. #eventType == EventType "Boom" = do+ already <- atomicModifyIORef' failedOnce (\old -> (True, old))+ pure (if already then Right event else Left (failureOf event))+ | otherwise = pure (Right event)+ tweak settings =+ settings+ & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+ handler event = do+ modifyIORef' seen ((event ^. #eventType) :)+ pure (if event ^. #eventType == EventType "After" then Stop else Continue)+ withTestStoreSettings tweak $ \store -> do+ handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)+ waitForSubscriptionLive caughtUp+ appendTypes store "decode-recovery" ["Boom", "After"]+ expectCleanStop handle+ reverse <$> readIORef seen `shouldReturn` [EventType "Boom", EventType "After"]+ readCheckpoint store "decode-one-shot" `shouldReturn` Just 2+ (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-one-shot") `shouldReturn` [] - it "re-applies the hook on a callback Retry and then calls the ordinary handler" $ do- failedOnce <- newIORef False- callbacks <- newIORef (0 :: Int)- seen <- newIORef ([] :: [EventType])- let hook event = do- already <- atomicModifyIORef' failedOnce (\old -> (True, old))- pure (if already then Right event else Left (failureOf event))- tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}- handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+ forM_ [AllStreams, Category (CategoryName "decode")] $ \target' ->+ forM_ [Nothing, Just (validConsumerGroup 0 1)] $ \group ->+ it ("stops a persistent undecodable event during catch-up for " <> show (target', group)) $ do+ let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ handler _ = expectationFailure "undecodable event reached ordinary handler" >> pure Stop withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-callback-retry" ["Boom"]- waitForPublisher store (GlobalPosition 1)- handle <-- subscribe- store- ( (defaultSubscriptionConfig (SubscriptionName "decode-callback-retry") AllStreams handler)- { undecodableHandler = Just (\_ _ -> modifyIORef' callbacks (+ 1) >> pure (Retry (RetryDelay 0)))- }- )- expectCleanStop handle+ appendTypes store "decode-catchup" ["Boom"]+ waitForPublisher store (GlobalPosition 1)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "decode-catchup") target' handler)+ { consumerGroup = group,+ retryPolicy = RetryPolicy 1+ }+ )+ _ <- expectDecodeStop handle+ readCheckpoint store "decode-catchup" `shouldReturn` Just 0+ (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-catchup") `shouldReturn` []++ forM_ [Continue, Stop, Retry (RetryDelay 0)] $ \disposition ->+ it ("honors an explicit undecodable disposition " <> show disposition) $ do+ callbacks <- newIORef (0 :: Int)+ seen <- newIORef ([] :: [EventType])+ let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+ callback _ _ = modifyIORef' callbacks (+ 1) >> pure disposition+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-disposition" ["Boom", "After"]+ waitForPublisher store (GlobalPosition 2)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "decode-disposition") AllStreams handler)+ { undecodableHandler = Just callback,+ retryPolicy = RetryPolicy 3+ }+ )+ expectCleanStop handle+ case disposition of+ Stop -> do+ readIORef seen `shouldReturn` []+ readCheckpoint store "decode-disposition" `shouldReturn` Just 1+ (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` []+ Retry _ -> do+ readIORef callbacks `shouldReturn` 3+ letters <- readDeadLetters store "decode-disposition"+ map SQL.deadLetterReasonSummary letters `shouldBe` ["max retry attempts exceeded (3)"]+ readCheckpoint store "decode-disposition" `shouldReturn` Just 2+ _ -> do readIORef callbacks `shouldReturn` 1- readIORef seen `shouldReturn` [EventType "Boom"]+ readIORef seen `shouldReturn` [EventType "After"]+ (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` [] - it "fails reads with EventDecodeFailed instead of returning a partial vector" $ do- let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-read" ["Before", "Boom", "After"]- forM_- [ readAllForward (GlobalPosition 0) 10- , readAllBackward (GlobalPosition 10) 10- , readStreamForward (StreamName "decode-read") (StreamVersion 0) 10- , readStreamBackward (StreamName "decode-read") (StreamVersion 10) 10- , readCategory (CategoryName "decode") (GlobalPosition 0) 10- ]- $ \readEvents ->- runStoreIO store readEvents >>= \case- Left (EventDecodeFailed failure) -> decodeFailureReason failure `shouldBe` "cannot decrypt"- other -> expectationFailure ("expected typed read failure, got " <> show other)+ it "re-applies the hook on a callback Retry and then calls the ordinary handler" $ do+ failedOnce <- newIORef False+ callbacks <- newIORef (0 :: Int)+ seen <- newIORef ([] :: [EventType])+ let hook event = do+ already <- atomicModifyIORef' failedOnce (\old -> (True, old))+ pure (if already then Right event else Left (failureOf event))+ tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-callback-retry" ["Boom"]+ waitForPublisher store (GlobalPosition 1)+ handle <-+ subscribe+ store+ ( (defaultSubscriptionConfig (SubscriptionName "decode-callback-retry") AllStreams handler)+ { undecodableHandler = Just (\_ _ -> modifyIORef' callbacks (+ 1) >> pure (Retry (RetryDelay 0)))+ }+ )+ expectCleanStop handle+ readIORef callbacks `shouldReturn` 1+ readIORef seen `shouldReturn` [EventType "Boom"] - it "cancels promptly while waiting for the default decode retry" $ do- retrying <- newEmptyMVar- let subName = SubscriptionName "decode-cancel"- observe event = case event of- KirokuEventSubscriptionRetrying{} -> () <$ tryPutMVar retrying ()- _ -> pure ()- tweak settings =- settings- & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- & #eventHandler .~ Just observe- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-cancel" ["Boom"]- waitForPublisher store (GlobalPosition 1)- handle <- subscribe store (defaultSubscriptionConfig subName AllStreams (\_ -> pure Continue))- waitForSubscriptionLive retrying- startedAt <- getMonotonicTimeNSec- cancel handle- finishedAt <- getMonotonicTimeNSec- finishedAt - startedAt `shouldSatisfy` (< 500_000_000)- currentState handle >>= (`shouldSatisfy` isNothing)- readCheckpoint store "decode-cancel" `shouldReturn` Just 0+ it "fails reads with EventDecodeFailed instead of returning a partial vector" $ do+ let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-read" ["Before", "Boom", "After"]+ forM_+ [ readAllForward (GlobalPosition 0) 10,+ readAllBackward (GlobalPosition 10) 10,+ readStreamForward (StreamName "decode-read") (StreamVersion 0) 10,+ readStreamBackward (StreamName "decode-read") (StreamVersion 10) 10,+ readCategory (CategoryName "decode") (GlobalPosition 0) 10+ ]+ $ \readEvents ->+ runStoreIO store readEvents >>= \case+ Left (EventDecodeFailed failure) -> decodeFailureReason failure `shouldBe` "cannot decrypt"+ other -> expectationFailure ("expected typed read failure, got " <> show other) - it "replays the failed event from its durable checkpoint after the hook is fixed" $ do- repaired <- newIORef False- seen <- newIORef ([] :: [EventType])- let subName = SubscriptionName "decode-restart"- hook event = do- healthy <- readIORef repaired- pure (if healthy then Right event else Left (failureOf event))- tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}- handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-restart" ["Boom"]- waitForPublisher store (GlobalPosition 1)- failed <- subscribe store ((defaultSubscriptionConfig subName AllStreams handler){retryPolicy = RetryPolicy 1})- _ <- expectDecodeStop failed- readCheckpoint store "decode-restart" `shouldReturn` Just 0- writeIORef repaired True- recovered <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)- expectCleanStop recovered- readIORef seen `shouldReturn` [EventType "Boom"]- readCheckpoint store "decode-restart" `shouldReturn` Just 1+ it "cancels promptly while waiting for the default decode retry" $ do+ retrying <- newEmptyMVar+ let subName = SubscriptionName "decode-cancel"+ observe event = case event of+ KirokuEventSubscriptionRetrying {} -> () <$ tryPutMVar retrying ()+ _ -> pure ()+ tweak settings =+ settings+ & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ & #eventHandler .~ Just observe+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-cancel" ["Boom"]+ waitForPublisher store (GlobalPosition 1)+ handle <- subscribe store (defaultSubscriptionConfig subName AllStreams (\_ -> pure Continue))+ waitForSubscriptionLive retrying+ startedAt <- getMonotonicTimeNSec+ cancel handle+ finishedAt <- getMonotonicTimeNSec+ finishedAt - startedAt `shouldSatisfy` (< 500_000_000)+ currentState handle >>= (`shouldSatisfy` isNothing)+ readCheckpoint store "decode-cancel" `shouldReturn` Just 0 - it "releases a bracketed subscriber during a pending default decode retry" $ do- retrying <- newEmptyMVar- let observe event = case event of- KirokuEventSubscriptionRetrying{} -> () <$ tryPutMVar retrying ()- _ -> pure ()- tweak settings =- settings- & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- & #eventHandler .~ Just observe- within "bracketed store/subscription shutdown" $ withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-shutdown" ["Boom"]- waitForPublisher store (GlobalPosition 1)- handle <-- withSubscription- store- ((defaultSubscriptionConfig (SubscriptionName "decode-shutdown") AllStreams (\_ -> pure Continue)){retryPolicy = RetryPolicy 100})- (\h -> waitForSubscriptionLive retrying >> pure h)- currentState handle >>= (`shouldSatisfy` isNothing)- within "cancelled worker completion" (wait handle) >>= \case- Left err | Just Async.AsyncCancelled <- fromException err -> pure ()- other -> expectationFailure ("expected bracket cancellation, got " <> show other)+ it "replays the failed event from its durable checkpoint after the hook is fixed" $ do+ repaired <- newIORef False+ seen <- newIORef ([] :: [EventType])+ let subName = SubscriptionName "decode-restart"+ hook event = do+ healthy <- readIORef repaired+ pure (if healthy then Right event else Left (failureOf event))+ tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+ handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-restart" ["Boom"]+ waitForPublisher store (GlobalPosition 1)+ failed <- subscribe store ((defaultSubscriptionConfig subName AllStreams handler) {retryPolicy = RetryPolicy 1})+ _ <- expectDecodeStop failed+ readCheckpoint store "decode-restart" `shouldReturn` Just 0+ writeIORef repaired True+ recovered <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)+ expectCleanStop recovered+ readIORef seen `shouldReturn` [EventType "Boom"]+ readCheckpoint store "decode-restart" `shouldReturn` Just 1 - it "unlifts the undecodable callback in the same persistent effect environment as the handler" $ do- seen <- newIORef ([] :: [Int])- let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}- withTestStoreSettings tweak $ \store -> do- appendTypes store "decode-effectful" ["Boom", "After"]- waitForPublisher store (GlobalPosition 2)- runEff $ SubEff.runSubscription store $ State.evalState (0 :: Int) $ do- let config =- ( defaultSubscriptionConfig- (SubscriptionName "decode-effectful")- AllStreams- ( \_ -> do- State.modify @Int (+ 1)- count <- State.get @Int- liftIO (modifyIORef' seen (count :))- pure Stop- )- )- { undecodableHandler = Just (\_ _ -> State.modify @Int (+ 1) >> pure Continue)- }- handle <- SubEff.subscribe config- liftIO (expectCleanStop handle)- readIORef seen `shouldReturn` [2]+ it "releases a bracketed subscriber during a pending default decode retry" $ do+ retrying <- newEmptyMVar+ let observe event = case event of+ KirokuEventSubscriptionRetrying {} -> () <$ tryPutMVar retrying ()+ _ -> pure ()+ tweak settings =+ settings+ & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ & #eventHandler .~ Just observe+ within "bracketed store/subscription shutdown" $ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-shutdown" ["Boom"]+ waitForPublisher store (GlobalPosition 1)+ handle <-+ withSubscription+ store+ ((defaultSubscriptionConfig (SubscriptionName "decode-shutdown") AllStreams (\_ -> pure Continue)) {retryPolicy = RetryPolicy 100})+ (\h -> waitForSubscriptionLive retrying >> pure h)+ currentState handle >>= (`shouldSatisfy` isNothing)+ within "cancelled worker completion" (wait handle) >>= \case+ Left err | Just Async.AsyncCancelled <- fromException err -> pure ()+ other -> expectationFailure ("expected bracket cancellation, got " <> show other) - it "keeps the publisher alive when decodeHook throws once" $ do- failedOnce <- newIORef False- loopErrorSeen <- newEmptyMVar- caughtUp <- newEmptyMVar- delivered <- newIORef ([] :: [EventType])- deliveredCount <- newTVarIO (0 :: Int)+ it "unlifts the undecodable callback in the same persistent effect environment as the handler" $ do+ seen <- newIORef ([] :: [Int])+ let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+ withTestStoreSettings tweak $ \store -> do+ appendTypes store "decode-effectful" ["Boom", "After"]+ waitForPublisher store (GlobalPosition 2)+ runEff $ SubEff.runSubscription store $ State.evalState (0 :: Int) $ do+ let config =+ ( defaultSubscriptionConfig+ (SubscriptionName "decode-effectful")+ AllStreams+ ( \_ -> do+ State.modify @Int (+ 1)+ count <- State.get @Int+ liftIO (modifyIORef' seen (count :))+ pure Stop+ )+ )+ { undecodableHandler = Just (\_ _ -> State.modify @Int (+ 1) >> pure Continue)+ }+ handle <- SubEff.subscribe config+ liftIO (expectCleanStop handle)+ readIORef seen `shouldReturn` [2] - let subName = SubscriptionName "publisher-decode-hook-resilience"- decodeOnce event- | event ^. #eventType == EventType "Boom" = do- alreadyFailed <- atomicModifyIORef' failedOnce (\old -> (True, old))- if alreadyFailed- then pure (Right event)- else throwIO CallbackBoom- | otherwise = pure (Right 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+ it "keeps the publisher alive when decodeHook throws once" $ do+ failedOnce <- newIORef False+ loopErrorSeen <- newEmptyMVar+ caughtUp <- newEmptyMVar+ delivered <- newIORef ([] :: [EventType])+ deliveredCount <- newTVarIO (0 :: Int) - 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)+ let subName = SubscriptionName "publisher-decode-hook-resilience"+ decodeOnce event+ | event ^. #eventType == EventType "Boom" = do+ alreadyFailed <- atomicModifyIORef' failedOnce (\old -> (True, old))+ if alreadyFailed+ then pure (Right event)+ else throwIO CallbackBoom+ | otherwise = pure (Right 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 - seen <- reverse <$> readIORef delivered- seen `shouldBe` [EventType "Boom", EventType "Ok"]+ 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) - 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+ seen <- reverse <$> readIORef delivered+ seen `shouldBe` [EventType "Boom", EventType "Ok"] - 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)+ 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 - finalCount <- atomically (readTVar deliveredCount)- finalCount `shouldBe` 3+ 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/PublisherDropCounter.hs view
@@ -16,44 +16,44 @@ spec :: Spec spec = describe "publisher drop counter" $ do- it "counts drops, keeps the newest batch and preserves the idempotent wrapper" $ withTestStore $ \store -> do- baseline <- IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)- bracket (atomically (Pub.subscribePublisherWith store.publisher 1 DropOldest)) Pub.unsubscribe $ \sub -> do- appendAndWait store "drop-a" 1- readTVarIO sub.subscriptionDropped `shouldReturn` 0- appendAndWait store "drop-b" 2- readTVarIO sub.subscriptionDropped `shouldReturn` 1- readTVarIO sub.subscriptionStatus `shouldReturn` Pub.Active- newest sub.subscriptionQueue `shouldReturn` [GlobalPosition 2]- bracket (atomically (Pub.subscribePublisher store.publisher 1 DropOldest)) (\(_, _, stop) -> stop) $ \(queue, _, stop) -> do- appendAndWait store "drop-c" 3- appendAndWait store "drop-d" 4- newest queue `shouldReturn` [GlobalPosition 4]- stop >> stop- Pub.unsubscribe sub >> Pub.unsubscribe sub- (IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)) `shouldReturn` baseline- it "does not count PauseAndResume or DropSubscription overflow" $ withTestStore $ \store ->- mapM_- ( \policy -> bracket (atomically (Pub.subscribePublisherWith store.publisher 1 policy)) Pub.unsubscribe $ \sub -> do- Right pos <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])- bounded (waitForPublisher store pos.globalPosition)- Right pos2 <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])- bounded (waitForPublisher store pos2.globalPosition)- readTVarIO sub.subscriptionDropped `shouldReturn` 0- readTVarIO sub.subscriptionStatus `shouldReturn` (if policy == PauseAndResume then Pub.Paused else Pub.Overflowed)- )- [PauseAndResume, DropSubscription]+ it "counts drops, keeps the newest batch and preserves the idempotent wrapper" $ withTestStore $ \store -> do+ baseline <- IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)+ bracket (atomically (Pub.subscribePublisherWith store.publisher 1 DropOldest)) Pub.unsubscribe $ \sub -> do+ appendAndWait store "drop-a" 1+ readTVarIO sub.subscriptionDropped `shouldReturn` 0+ appendAndWait store "drop-b" 2+ readTVarIO sub.subscriptionDropped `shouldReturn` 1+ readTVarIO sub.subscriptionStatus `shouldReturn` Pub.Active+ newest sub.subscriptionQueue `shouldReturn` [GlobalPosition 2]+ bracket (atomically (Pub.subscribePublisher store.publisher 1 DropOldest)) (\(_, _, stop) -> stop) $ \(queue, _, stop) -> do+ appendAndWait store "drop-c" 3+ appendAndWait store "drop-d" 4+ newest queue `shouldReturn` [GlobalPosition 4]+ stop >> stop+ Pub.unsubscribe sub >> Pub.unsubscribe sub+ (IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)) `shouldReturn` baseline+ it "does not count PauseAndResume or DropSubscription overflow" $ withTestStore $ \store ->+ mapM_+ ( \policy -> bracket (atomically (Pub.subscribePublisherWith store.publisher 1 policy)) Pub.unsubscribe $ \sub -> do+ Right pos <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])+ bounded (waitForPublisher store pos.globalPosition)+ Right pos2 <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])+ bounded (waitForPublisher store pos2.globalPosition)+ readTVarIO sub.subscriptionDropped `shouldReturn` 0+ readTVarIO sub.subscriptionStatus `shouldReturn` (if policy == PauseAndResume then Pub.Paused else Pub.Overflowed)+ )+ [PauseAndResume, DropSubscription] appendAndWait :: KirokuStore -> Text -> Int -> IO () appendAndWait store name n = do- Right _ <- runStoreIO store (appendToStream (StreamName name) NoStream [makeEvent "E" (object [])])- bounded (waitForPublisher store (GlobalPosition (fromIntegral n)))+ Right _ <- runStoreIO store (appendToStream (StreamName name) NoStream [makeEvent "E" (object [])])+ bounded (waitForPublisher store (GlobalPosition (fromIntegral n))) bounded :: IO a -> IO a bounded action = timeout 5_000_000 action >>= maybe (fail "publisher timeout") pure newest :: TBQueue DecodedBatch -> IO [GlobalPosition] newest queue = do- Just (UnchangedBatch events) <- atomically (tryReadTBQueue queue)- atomically (isEmptyTBQueue queue) `shouldReturn` True- pure (map (.globalPosition) (V.toList events))+ Just (UnchangedBatch events) <- atomically (tryReadTBQueue queue)+ atomically (isEmptyTBQueue queue) `shouldReturn` True+ pure (map (.globalPosition) (V.toList events))
test/Test/PublisherIdleAdvance.hs view
@@ -27,149 +27,149 @@ 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+ 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 (Right event)- }+ settings+ & #storeSettings+ .~ defaultStoreSettings+ { decodeHook =+ Just $ \event -> do+ atomicModifyIORef' counter (\n -> (n + 1, ()))+ pure (Right 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)+ 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))+ IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher)) sampleLargeObjects :: IO Integer sampleLargeObjects = do- enabled <- getRTSStatsEnabled- when (not enabled) (fail "publisher retention test requires RTS statistics")- performMajorGC- fromIntegral . gcdetails_large_objects_bytes . gc <$> getRTSStats+ enabled <- getRTSStatsEnabled+ when (not enabled) (fail "publisher retention test requires RTS statistics")+ performMajorGC+ fromIntegral . gcdetails_large_objects_bytes . gc <$> getRTSStats spec :: Spec spec = describe "publisher idle advance" $ do- it "does not retain append results while advancing with no queue subscribers" $ do- withTestStore $ \store -> do- let blockSize :: Int- blockSize = 2_000- blocks :: Int- blocks = 6- event = makeEvent "Retention" (Aeson.object [])- appendOne = do- result <- runStoreIO store $ appendToStream (StreamName "pubidle-retention") AnyVersion [event]- case result of- Left err -> fail ("append failed: " <> show err)- Right _ -> pure ()- samples <- forM [1 .. blocks] $ \_ -> do- sequence_ (replicate blockSize appendOne)- -- Allow the notification-driven publisher to finish its tail- -- query without reading (and thereby forcing) its position.- threadDelay 100_000- sampleLargeObjects- publisherSubscriberCount store `shouldReturn` 0- case samples of- firstSample : _ -> do- let growth = last samples - firstSample- when (growth >= 3 * 1024 * 1024) $- expectationFailure ("large-object bytes grew by " <> show growth <> "; samples: " <> show samples)- [] -> expectationFailure "publisher retention test collected no heap samples"+ it "does not retain append results while advancing with no queue subscribers" $ do+ withTestStore $ \store -> do+ let blockSize :: Int+ blockSize = 2_000+ blocks :: Int+ blocks = 6+ event = makeEvent "Retention" (Aeson.object [])+ appendOne = do+ result <- runStoreIO store $ appendToStream (StreamName "pubidle-retention") AnyVersion [event]+ case result of+ Left err -> fail ("append failed: " <> show err)+ Right _ -> pure ()+ samples <- forM [1 .. blocks] $ \_ -> do+ sequence_ (replicate blockSize appendOne)+ -- Allow the notification-driven publisher to finish its tail+ -- query without reading (and thereby forcing) its position.+ threadDelay 100_000+ sampleLargeObjects+ publisherSubscriberCount store `shouldReturn` 0+ case samples of+ firstSample : _ -> do+ let growth = last samples - firstSample+ when (growth >= 3 * 1024 * 1024) $+ expectationFailure ("large-object bytes grew by " <> show growth <> "; samples: " <> show samples)+ [] -> expectationFailure "publisher retention test collected no heap samples" - 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 "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)- countBefore <- readIORef counter- tailPos <- appendEvents store 30 "pubidleb"- waitForPublisher store tailPos- countAfter <- readIORef counter- (countAfter - countBefore) `shouldBe` 0- publisherSubscriberCount store `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)+ countBefore <- readIORef counter+ tailPos <- appendEvents store 30 "pubidleb"+ waitForPublisher store tailPos+ countAfter <- readIORef counter+ (countAfter - countBefore) `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)+ 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]+ 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/PublisherRestartNoRebroadcast.hs view
@@ -17,204 +17,204 @@ spec :: Spec spec =- describe "publisher restart tail initialization" $ do- it "does not rebroadcast restart history into a blocked all-stream subscriber" $- withMigratedTestDatabase $ \connStr -> do- let settings = defaultConnectionSettings connStr- withStore settings $ \store ->- appendBatch store "tail-init-overflow-seed" "TailInitSeed" 1001+ describe "publisher restart tail initialization" $ do+ it "does not rebroadcast restart history into a blocked all-stream subscriber" $+ withMigratedTestDatabase $ \connStr -> do+ let settings = defaultConnectionSettings connStr+ withStore settings $ \store ->+ appendBatch store "tail-init-overflow-seed" "TailInitSeed" 1001 - firstSeen <- newEmptyMVar- releaseFirst <- newEmptyMVar- caughtUp <- newEmptyMVar- blockedFirst <- newTVarIO True- ref <- newIORef ([] :: [GlobalPosition])+ firstSeen <- newEmptyMVar+ releaseFirst <- newEmptyMVar+ caughtUp <- newEmptyMVar+ blockedFirst <- newTVarIO True+ ref <- newIORef ([] :: [GlobalPosition]) - let subName = SubscriptionName "tail-init-no-rebroadcast"- stopType = EventType "TailInitStop"- handler' evt = do- modifyIORef' ref ((evt ^. #globalPosition) :)- shouldBlock <- atomically $ do- block <- readTVar blockedFirst- when block (writeTVar blockedFirst False)- pure block- when shouldBlock $ do- putMVar firstSeen ()- takeMVar releaseFirst- if evt ^. #eventType == stopType- then pure Stop- else pure Continue- cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = handler'- , batchSize = either (error . show) Prelude.id (mkBatchSize 10)- , queueCapacity = 1- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- liveSettings =- settings- & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+ let subName = SubscriptionName "tail-init-no-rebroadcast"+ stopType = EventType "TailInitStop"+ handler' evt = do+ modifyIORef' ref ((evt ^. #globalPosition) :)+ shouldBlock <- atomically $ do+ block <- readTVar blockedFirst+ when block (writeTVar blockedFirst False)+ pure block+ when shouldBlock $ do+ putMVar firstSeen ()+ takeMVar releaseFirst+ if evt ^. #eventType == stopType+ then pure Stop+ else pure Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = handler',+ batchSize = either (error . show) Prelude.id (mkBatchSize 10),+ queueCapacity = 1,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ liveSettings =+ settings+ & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing) - withStore liveSettings $ \store -> do- handle <- subscribe store cfg- takeMVar firstSeen+ withStore liveSettings $ \store -> do+ handle <- subscribe store cfg+ takeMVar firstSeen - Right _ <-- runStoreIO store $- appendToStream- (StreamName "tail-init-overflow-live")- NoStream- [makeEvent "TailInitDuringCatchUp" (Aeson.object [])]- waitForPublisher store (GlobalPosition 1002)+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "tail-init-overflow-live")+ NoStream+ [makeEvent "TailInitDuringCatchUp" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 1002) - putMVar releaseFirst ()- waitForSubscriptionLive caughtUp+ putMVar releaseFirst ()+ waitForSubscriptionLive caughtUp - Right _ <-- runStoreIO store $- appendToStream- (StreamName "tail-init-overflow-stop")- NoStream- [makeEvent "TailInitStop" (Aeson.object [])]+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "tail-init-overflow-stop")+ NoStream+ [makeEvent "TailInitStop" (Aeson.object [])] - result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure () - collected <- reverse <$> readIORef ref- collected `shouldBe` map GlobalPosition [1 .. 1003]+ collected <- reverse <$> readIORef ref+ collected `shouldBe` map GlobalPosition [1 .. 1003] - it "delivers a live event appended after a restarted subscriber catches up" $- withMigratedTestDatabase $ \connStr -> do- let settings = defaultConnectionSettings connStr- subName = SubscriptionName "tail-init-live-after-catchup"- withStore settings $ \store ->- appendBatch store "tail-init-live-seed" "TailInitLiveSeed" 3+ it "delivers a live event appended after a restarted subscriber catches up" $+ withMigratedTestDatabase $ \connStr -> do+ let settings = defaultConnectionSettings connStr+ subName = SubscriptionName "tail-init-live-after-catchup"+ withStore settings $ \store ->+ appendBatch store "tail-init-live-seed" "TailInitLiveSeed" 3 - caughtUp <- newEmptyMVar- ref <- newIORef ([] :: [GlobalPosition])- let cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = \evt -> do- modifyIORef' ref ((evt ^. #globalPosition) :)- if evt ^. #globalPosition == GlobalPosition 4- then pure Stop- else pure Continue- , batchSize = either (error . show) Prelude.id (mkBatchSize 10)- , queueCapacity = 1- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- liveSettings =- settings- & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+ caughtUp <- newEmptyMVar+ ref <- newIORef ([] :: [GlobalPosition])+ let cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = \evt -> do+ modifyIORef' ref ((evt ^. #globalPosition) :)+ if evt ^. #globalPosition == GlobalPosition 4+ then pure Stop+ else pure Continue,+ batchSize = either (error . show) Prelude.id (mkBatchSize 10),+ queueCapacity = 1,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ liveSettings =+ settings+ & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing) - withStore liveSettings $ \store -> do- handle <- subscribe store cfg- waitForSubscriptionLive caughtUp- Right _ <-- runStoreIO store $- appendToStream- (StreamName "tail-init-live-after-catchup")- NoStream- [makeEvent "TailInitLiveAfterCatchUp" (Aeson.object [])]- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()+ withStore liveSettings $ \store -> do+ handle <- subscribe store cfg+ waitForSubscriptionLive caughtUp+ Right _ <-+ runStoreIO store $+ appendToStream+ (StreamName "tail-init-live-after-catchup")+ NoStream+ [makeEvent "TailInitLiveAfterCatchUp" (Aeson.object [])]+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure () - collected <- reverse <$> readIORef ref- collected `shouldBe` map GlobalPosition [1 .. 4]+ collected <- reverse <$> readIORef ref+ collected `shouldBe` map GlobalPosition [1 .. 4] - it "keeps per-subscription checkpoints independent from the publisher tail" $- withMigratedTestDatabase $ \connStr -> do- let settings = defaultConnectionSettings connStr- withStore settings $ \store ->- appendBatch store "tail-init-checkpoint-seed" "TailInitCheckpointSeed" 10+ it "keeps per-subscription checkpoints independent from the publisher tail" $+ withMigratedTestDatabase $ \connStr -> do+ let settings = defaultConnectionSettings connStr+ withStore settings $ \store ->+ appendBatch store "tail-init-checkpoint-seed" "TailInitCheckpointSeed" 10 - withStore settings $ \store -> do- runUntil store (SubscriptionName "tail-init-sub-a") 3- `shouldReturn` map GlobalPosition [1 .. 3]- runUntil store (SubscriptionName "tail-init-sub-b") 7- `shouldReturn` map GlobalPosition [1 .. 7]+ withStore settings $ \store -> do+ runUntil store (SubscriptionName "tail-init-sub-a") 3+ `shouldReturn` map GlobalPosition [1 .. 3]+ runUntil store (SubscriptionName "tail-init-sub-b") 7+ `shouldReturn` map GlobalPosition [1 .. 7] - withStore settings $ \store -> do- runUntil store (SubscriptionName "tail-init-sub-a") 7- `shouldReturn` map GlobalPosition [4 .. 10]- runUntil store (SubscriptionName "tail-init-sub-b") 3- `shouldReturn` map GlobalPosition [8 .. 10]+ withStore settings $ \store -> do+ runUntil store (SubscriptionName "tail-init-sub-a") 7+ `shouldReturn` map GlobalPosition [4 .. 10]+ runUntil store (SubscriptionName "tail-init-sub-b") 3+ `shouldReturn` map GlobalPosition [8 .. 10] appendBatch :: KirokuStore -> T.Text -> T.Text -> Int -> IO () appendBatch store streamName typePrefix count = do- let events =- [ makeEvent (typePrefix <> T.pack (show i)) (Aeson.object [])- | i <- [1 .. count]- ]- result <- runStoreIO store $ appendToStream (StreamName streamName) NoStream events- case result of- Left err -> expectationFailure ("append failed: " <> show err)- Right _ -> pure ()+ let events =+ [ makeEvent (typePrefix <> T.pack (show i)) (Aeson.object [])+ | i <- [1 .. count]+ ]+ result <- runStoreIO store $ appendToStream (StreamName streamName) NoStream events+ case result of+ Left err -> expectationFailure ("append failed: " <> show err)+ Right _ -> pure () runUntil :: KirokuStore -> SubscriptionName -> Int -> IO [GlobalPosition] runUntil store subName targetCount = do- ref <- newIORef ([] :: [GlobalPosition])- countRef <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' ref ((evt ^. #globalPosition) :)- n <- atomically $ do- count <- readTVar countRef- let count' = count + 1- writeTVar countRef count'- pure count'- if n >= targetCount- then pure Stop- else pure Continue- cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = handler'- , batchSize = either (error . show) Prelude.id (mkBatchSize 10)- , queueCapacity = 1- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- result <- waitWithTimeout 10_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)- Right (Right ()) -> pure ()- reverse <$> readIORef ref+ ref <- newIORef ([] :: [GlobalPosition])+ countRef <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' ref ((evt ^. #globalPosition) :)+ n <- atomically $ do+ count <- readTVar countRef+ let count' = count + 1+ writeTVar countRef count'+ pure count'+ if n >= targetCount+ then pure Stop+ else pure Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = handler',+ batchSize = either (error . show) Prelude.id (mkBatchSize 10),+ queueCapacity = 1,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+ Right (Right ()) -> pure ()+ reverse <$> readIORef ref
test/Test/ReadStream.hs view
@@ -1,9 +1,8 @@-{- | Tests for "Kiroku.Store.Read".'readStreamForwardStream' — the-Streamly-shaped sibling of 'readStreamForward'. The streaming wrapper-must yield the same events in the same order as the @Vector@-returning-form, page transparently across the configured @pageSize@, terminate on-empty / nonexistent streams, and honor a non-zero starting cursor.--}+-- | Tests for "Kiroku.Store.Read".'readStreamForwardStream' — the+-- Streamly-shaped sibling of 'readStreamForward'. The streaming wrapper+-- must yield the same events in the same order as the @Vector@-returning+-- form, page transparently across the configured @pageSize@, terminate on+-- empty / nonexistent streams, and honor a non-zero starting cursor. module Test.ReadStream (spec) where import Control.Lens ((^.))@@ -22,132 +21,132 @@ spec :: Spec spec = do- describe "readStreamForwardStream" $ do- 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)+ describe "readStreamForwardStream" $ do+ 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)- countBefore <- readIORef ref- streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 2)- countAfter <- readIORef ref- case streamed of- Right xs -> do- map (^. #streamVersion) xs- `shouldBe` [StreamVersion 1, StreamVersion 2, StreamVersion 3, StreamVersion 4, StreamVersion 5]- countAfter - countBefore `shouldBe` 3- 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)+ countBefore <- readIORef ref+ streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 2)+ countAfter <- readIORef ref+ case streamed of+ Right xs -> do+ map (^. #streamVersion) xs+ `shouldBe` [StreamVersion 1, StreamVersion 2, StreamVersion 3, StreamVersion 4, StreamVersion 5]+ countAfter - countBefore `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+ 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 "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+ 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- }+ EventData+ { eventId = Just eid,+ eventType = EventType "Created",+ payload = Aeson.object [],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }
test/Test/ScriptSessionErrors.hs view
@@ -25,89 +25,89 @@ spec :: Spec spec = describe "structured script session errors" $ do- around withTestStore $ do- it "preserves missing-relation SQLSTATE through runTransaction" $ \store -> do- result <- runStoreIO store (runTransaction (Tx.sql (TE.encodeUtf8 missingRelation)))- shouldBeMissingRelation result- it "preserves missing-relation SQLSTATE without retry" $ \store -> do- result <- runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 missingRelation)))- shouldBeMissingRelation result- forM_ ["40001", "40P01"] $ \code -> do- it ("preserves script SQLSTATE and message " <> show code) $ \store -> do- result <- timeout 5_000_000 $ runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 (raiseCode code))))- result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))- it ("preserves prepared-statement SQLSTATE and message " <> show code) $ \store -> do- result <-- timeout 5_000_000 $- runStoreIO store $- runTransactionNoRetry $- Tx.statement () (preparable (raiseCode code) E.noParams D.noResult)- result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))- it "rolls back application writes before a script failure" $ \store -> do- createSideTable store- result <- runStoreIO store $ runTransactionNoRetry $ do- Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"- Tx.sql (TE.encodeUtf8 missingRelation)- shouldBeMissingRelation result- countSideRows store `shouldReturn` Right 0- it "rolls back composed appends and application writes on script failure" $ \store -> do- createSideTable store- result <- runStoreIO store $- runTransactionAppendingNoRetry (StreamName "script-rollback") NoStream [makeEvent "Created" (Aeson.object [])] $ \_ -> do- Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"- Tx.sql (TE.encodeUtf8 missingRelation)- shouldBeMissingRelation result- countSideRows store `shouldReturn` Right 0- runStoreIO store (getStream (StreamName "script-rollback")) `shouldReturn` Right Nothing- countEvents store `shouldReturn` 0+ around withTestStore $ do+ it "preserves missing-relation SQLSTATE through runTransaction" $ \store -> do+ result <- runStoreIO store (runTransaction (Tx.sql (TE.encodeUtf8 missingRelation)))+ shouldBeMissingRelation result+ it "preserves missing-relation SQLSTATE without retry" $ \store -> do+ result <- runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 missingRelation)))+ shouldBeMissingRelation result+ forM_ ["40001", "40P01"] $ \code -> do+ it ("preserves script SQLSTATE and message " <> show code) $ \store -> do+ result <- timeout 5_000_000 $ runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 (raiseCode code))))+ result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))+ it ("preserves prepared-statement SQLSTATE and message " <> show code) $ \store -> do+ result <-+ timeout 5_000_000 $+ runStoreIO store $+ runTransactionNoRetry $+ Tx.statement () (preparable (raiseCode code) E.noParams D.noResult)+ result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))+ it "rolls back application writes before a script failure" $ \store -> do+ createSideTable store+ result <- runStoreIO store $ runTransactionNoRetry $ do+ Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"+ Tx.sql (TE.encodeUtf8 missingRelation)+ shouldBeMissingRelation result+ countSideRows store `shouldReturn` Right 0+ it "rolls back composed appends and application writes on script failure" $ \store -> do+ createSideTable store+ result <- runStoreIO store $+ runTransactionAppendingNoRetry (StreamName "script-rollback") NoStream [makeEvent "Created" (Aeson.object [])] $ \_ -> do+ Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"+ Tx.sql (TE.encodeUtf8 missingRelation)+ shouldBeMissingRelation result+ countSideRows store `shouldReturn` Right 0+ runStoreIO store (getStream (StreamName "script-rollback")) `shouldReturn` Right Nothing+ countEvents store `shouldReturn` 0 - it "preserves missing-relation SQLSTATE through the public pool-backed read" $- withMigratedTestDatabase $ \connStr ->- withStore (defaultConnectionSettings connStr) $ \store ->- withPool connStr [Config.initSession (Session.script "SET search_path TO pg_catalog")] $ \pool -> do- result <- runStoreIO (store & #pool .~ pool) (getStream (StreamName "missing"))- shouldBeMissingRelation result+ it "preserves missing-relation SQLSTATE through the public pool-backed read" $+ withMigratedTestDatabase $ \connStr ->+ withStore (defaultConnectionSettings connStr) $ \store ->+ withPool connStr [Config.initSession (Session.script "SET search_path TO pg_catalog")] $ \pool -> do+ result <- runStoreIO (store & #pool .~ pool) (getStream (StreamName "missing"))+ shouldBeMissingRelation result - it "keeps genuine connection establishment failures typed for transactions and reads" $- withTestStore $ \store ->- withPool "host=/nonexistent/kiroku-script-errors connect_timeout=1" [] $ \pool -> do- let broken = store & #pool .~ pool- tx <- timeout 5_000_000 $ runStoreIO broken (runTransactionNoRetry (Tx.sql "SELECT 1"))- tx `shouldSatisfy` isConnectionLost- readResult <- timeout 5_000_000 $ runStoreIO broken (getStream (StreamName "missing"))- readResult `shouldSatisfy` isConnectionLost+ it "keeps genuine connection establishment failures typed for transactions and reads" $+ withTestStore $ \store ->+ withPool "host=/nonexistent/kiroku-script-errors connect_timeout=1" [] $ \pool -> do+ let broken = store & #pool .~ pool+ tx <- timeout 5_000_000 $ runStoreIO broken (runTransactionNoRetry (Tx.sql "SELECT 1"))+ tx `shouldSatisfy` isConnectionLost+ readResult <- timeout 5_000_000 $ runStoreIO broken (getStream (StreamName "missing"))+ readResult `shouldSatisfy` isConnectionLost - it "routes script server errors through every contextual mapper" $ do- forM_ ["40001", "40P01", "42P01", "40003", "23503"] $ \code -> do- let usage = scriptUsage code fixtureMessage Nothing- expected = if code `elem` ["40001", "40P01"] then TransientTransactionFailure code fixtureMessage else UnexpectedServerError code fixtureMessage- mapGenericUsageError usage `shouldBe` expected- mapTransactionUsageError usage `shouldBe` expected- mapLinkUsageError (StreamName "target") usage `shouldBe` expected- isTransientSerializationError usage `shouldBe` (code `elem` ["40001", "40P01"])- if code == "23503"- then mapUsageError "target" AnyVersion usage `shouldBe` StreamNotFound (StreamName "target")- else do- mapUsageError "target" AnyVersion usage `shouldBe` expected- attributeMultiStreamError [(StreamName "target", AnyVersion)] usage `shouldBe` expected+ it "routes script server errors through every contextual mapper" $ do+ forM_ ["40001", "40P01", "42P01", "40003", "23503"] $ \code -> do+ let usage = scriptUsage code fixtureMessage Nothing+ expected = if code `elem` ["40001", "40P01"] then TransientTransactionFailure code fixtureMessage else UnexpectedServerError code fixtureMessage+ mapGenericUsageError usage `shouldBe` expected+ mapTransactionUsageError usage `shouldBe` expected+ mapLinkUsageError (StreamName "target") usage `shouldBe` expected+ isTransientSerializationError usage `shouldBe` (code `elem` ["40001", "40P01"])+ if code == "23503"+ then mapUsageError "target" AnyVersion usage `shouldBe` StreamNotFound (StreamName "target")+ else do+ mapUsageError "target" AnyVersion usage `shouldBe` expected+ attributeMultiStreamError [(StreamName "target", AnyVersion)] usage `shouldBe` expected - it "retains script append and link constraint attribution" $ do- let usage = scriptUsage "23505" "duplicate key value violates unique constraint \"stream_events_pkey\"" Nothing- mapTransactionUsageError usage `shouldBe` DuplicateEvent Nothing- mapUsageError "target" AnyVersion usage `shouldBe` DuplicateEvent Nothing- mapLinkUsageError (StreamName "target") usage `shouldBe` EventAlreadyLinked (StreamName "target") Nothing- let named = scriptUsage "23505" "duplicate key value violates unique constraint \"ix_streams_stream_name\"" (Just "Key (stream_name)=(second) already exists.")- attributeMultiStreamError [(StreamName "first", AnyVersion), (StreamName "second", NoStream)] named- `shouldBe` StreamAlreadyExists (StreamName "second")+ it "retains script append and link constraint attribution" $ do+ let usage = scriptUsage "23505" "duplicate key value violates unique constraint \"stream_events_pkey\"" Nothing+ mapTransactionUsageError usage `shouldBe` DuplicateEvent Nothing+ mapUsageError "target" AnyVersion usage `shouldBe` DuplicateEvent Nothing+ mapLinkUsageError (StreamName "target") usage `shouldBe` EventAlreadyLinked (StreamName "target") Nothing+ let named = scriptUsage "23505" "duplicate key value violates unique constraint \"ix_streams_stream_name\"" (Just "Key (stream_name)=(second) already exists.")+ attributeMultiStreamError [(StreamName "first", AnyVersion), (StreamName "second", NoStream)] named+ `shouldBe` StreamAlreadyExists (StreamName "second") - it "distinguishes session connection loss, acquisition timeout and unsupported errors" $ do- let lost = Pool.SessionUsageError (Errors.ConnectionSessionError "connection detail")- fallback = Pool.SessionUsageError (Errors.DriverSessionError "driver detail")- forM_ [mapGenericUsageError, mapTransactionUsageError, mapUsageError "target" AnyVersion, mapLinkUsageError (StreamName "target")] $ \mapper -> do- mapper lost `shouldBe` ConnectionLost "connection detail"- mapper Pool.AcquisitionTimeoutUsageError `shouldBe` PoolAcquisitionTimeout- mapper fallback `shouldSatisfy` (\case ConnectionError _ -> True; _ -> False)- isTransientSerializationError lost `shouldBe` False+ it "distinguishes session connection loss, acquisition timeout and unsupported errors" $ do+ let lost = Pool.SessionUsageError (Errors.ConnectionSessionError "connection detail")+ fallback = Pool.SessionUsageError (Errors.DriverSessionError "driver detail")+ forM_ [mapGenericUsageError, mapTransactionUsageError, mapUsageError "target" AnyVersion, mapLinkUsageError (StreamName "target")] $ \mapper -> do+ mapper lost `shouldBe` ConnectionLost "connection detail"+ mapper Pool.AcquisitionTimeoutUsageError `shouldBe` PoolAcquisitionTimeout+ mapper fallback `shouldSatisfy` (\case ConnectionError _ -> True; _ -> False)+ isTransientSerializationError lost `shouldBe` False missingRelation :: Text missingRelation = "INSERT INTO public.kiroku_script_missing_relation VALUES (1)"@@ -123,8 +123,8 @@ shouldBeMissingRelation :: (Show a) => Either StoreError a -> Expectation shouldBeMissingRelation result = case result of- Left (UnexpectedServerError "42P01" _) -> pure ()- other -> expectationFailure ("Expected structured 42P01, got " <> show other)+ Left (UnexpectedServerError "42P01" _) -> pure ()+ other -> expectationFailure ("Expected structured 42P01, got " <> show other) isConnectionLost :: Maybe (Either StoreError a) -> Bool isConnectionLost (Just (Left (ConnectionLost _))) = True@@ -138,6 +138,6 @@ countSideRows :: KirokuStore -> IO (Either StoreError Int64) countSideRows store =- runStoreIO store $- runTransactionNoRetry $- Tx.statement () (preparable "SELECT count(*) FROM public.script_error_rows" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))+ runStoreIO store $+ runTransactionNoRetry $+ Tx.statement () (preparable "SELECT count(*) FROM public.script_error_rows" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))
test/Test/StartupFailureSurfacing.hs view
@@ -20,105 +20,105 @@ 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+ 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)+ 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+ wasCalled <- readTVarIO handlerCalled+ wasCalled `shouldBe` False+ observed <- reverse <$> readIORef observedRef+ any (isLoadCheckpointError subName) observed `shouldBe` True+ any (isCrashedStop subName) observed `shouldBe` True - it "refuses a missing checkpoint before Started or handler delivery" $ do- handlerCalled <- newTVarIO False- observedRef <- newIORef ([] :: [KirokuEvent])- let subName = SubscriptionName "fail-if-checkpoint-missing"- key = SubscriptionCheckpointKey subName 0- cfg =- ( defaultSubscriptionConfig subName AllStreams $ \_ -> do- atomically (writeTVar handlerCalled True)- pure Continue- )- { missingCheckpointPolicy = FailIfMissing- }- observe evt = modifyIORef' observedRef (evt :)- tweak settings = settings & #eventHandler .~ Just observe+ it "refuses a missing checkpoint before Started or handler delivery" $ do+ handlerCalled <- newTVarIO False+ observedRef <- newIORef ([] :: [KirokuEvent])+ let subName = SubscriptionName "fail-if-checkpoint-missing"+ key = SubscriptionCheckpointKey subName 0+ cfg =+ ( defaultSubscriptionConfig subName AllStreams $ \_ -> do+ atomically (writeTVar handlerCalled True)+ pure Continue+ )+ { missingCheckpointPolicy = FailIfMissing+ }+ observe evt = modifyIORef' observedRef (evt :)+ tweak settings = settings & #eventHandler .~ Just observe - withTestStoreSettings tweak $ \store -> do- handle <- subscribe store cfg- result <- waitWithTimeout 5_000_000 handle- case result of- Right (Left e)- | Just (SubscriptionCheckpointMissing actualKey) <- fromException e ->- actualKey `shouldBe` key- Left timeout -> expectationFailure timeout- Right other -> expectationFailure ("expected missing-checkpoint refusal, got: " <> show other)+ withTestStoreSettings tweak $ \store -> do+ handle <- subscribe store cfg+ result <- waitWithTimeout 5_000_000 handle+ case result of+ Right (Left e)+ | Just (SubscriptionCheckpointMissing actualKey) <- fromException e ->+ actualKey `shouldBe` key+ Left timeout -> expectationFailure timeout+ Right other -> expectationFailure ("expected missing-checkpoint refusal, got: " <> show other) - runStoreIO store subscriptionCheckpointInventory- `shouldReturn` Right (SubscriptionCheckpointInventory (GlobalPosition 0) mempty)+ runStoreIO store subscriptionCheckpointInventory+ `shouldReturn` Right (SubscriptionCheckpointInventory (GlobalPosition 0) mempty) - readTVarIO handlerCalled `shouldReturn` False- observed <- reverse <$> readIORef observedRef- observed `shouldSatisfy` any (isMissingCheckpoint key)- observed `shouldSatisfy` all (not . isStarted subName)+ readTVarIO handlerCalled `shouldReturn` False+ observed <- reverse <$> readIORef observedRef+ observed `shouldSatisfy` any (isMissingCheckpoint key)+ observed `shouldSatisfy` all (not . isStarted subName) - 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- pendingAsync <- Async.async (subscribe store cfg)- Async.cancel pendingAsync- outcome <- Async.waitCatch pendingAsync- case outcome of- Right handle -> cancel handle >> (() <$ wait handle)- Left (_ :: SomeException) -> pure ()+ 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+ pendingAsync <- Async.async (subscribe store cfg)+ Async.cancel pendingAsync+ outcome <- Async.waitCatch pendingAsync+ 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+ 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+ 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+ KirokuEventSubscriptionStopped actual _ (StopWorkerCrashed e) _+ | actual == expected,+ Just Pool.AcquisitionTimeoutUsageError <- fromException e ->+ True+ _ -> False isMissingCheckpoint :: SubscriptionCheckpointKey -> KirokuEvent -> Bool isMissingCheckpoint expected = \case- KirokuEventSubscriptionCheckpointMissing (SubscriptionCheckpointMissing actual) _ -> actual == expected- _ -> False+ KirokuEventSubscriptionCheckpointMissing (SubscriptionCheckpointMissing actual) _ -> actual == expected+ _ -> False isStarted :: SubscriptionName -> KirokuEvent -> Bool isStarted expected = \case- KirokuEventSubscriptionStarted actual _ _ -> actual == expected- _ -> False+ KirokuEventSubscriptionStarted actual _ _ -> actual == expected+ _ -> False
test/Test/StreamBridgeTermination.hs view
@@ -20,97 +20,97 @@ import Test.Hspec data TestBoom = TestBoom- deriving stock (Show)- deriving anyclass (Exception)+ deriving stock (Show)+ 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+ 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)+ 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 at construction" $- Buffer.mkStreamBufferSize 0 `shouldBe` Left (InvalidStreamBufferSize 0)+ it "rejects a zero-sized bridge buffer at construction" $+ Buffer.mkStreamBufferSize 0 `shouldBe` Left (InvalidStreamBufferSize 0) - 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 (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 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 "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 (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 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 (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 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 "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 (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 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 (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))- _pos1 <- appendOne store (StreamName "bridge-full-cancel-1") (EventType "BridgeFullCancel1")- pos2 <- appendOne store (StreamName "bridge-full-cancel-2") (EventType "BridgeFullCancel2")- waitForPublisher store pos2+ 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 (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 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+ 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"+ 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" - it "cancelAction is idempotent and leaves no subscription thread registered" $- withTestStore $ \store -> do- let subscriptionName = SubscriptionName "bridge-idempotent-cancel-sub"- cfg = defaultSubscriptionConfig subscriptionName AllStreams (\_ -> pure Continue)- key = (subscriptionName, 0)- (_stream, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))+ it "cancelAction is idempotent and leaves no subscription thread registered" $+ withTestStore $ \store -> do+ let subscriptionName = SubscriptionName "bridge-idempotent-cancel-sub"+ cfg = defaultSubscriptionConfig subscriptionName AllStreams (\_ -> pure Continue)+ key = (subscriptionName, 0)+ (_stream, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1)) - statesBefore <- subscriptionStates store- Map.member key statesBefore `shouldBe` True+ statesBefore <- subscriptionStates store+ Map.member key statesBefore `shouldBe` True - within "first bridge cancellation" cancelStream- within "repeated bridge cancellation" cancelStream+ within "first bridge cancellation" cancelStream+ within "repeated bridge cancellation" cancelStream - statesAfter <- subscriptionStates store- Map.member key statesAfter `shouldBe` False+ statesAfter <- subscriptionStates store+ Map.member key statesAfter `shouldBe` False
test/Test/StreamHead.hs view
@@ -20,89 +20,89 @@ spec :: Spec spec = describe "stream head" $ do- forM_ [False, True] $ \resource -> describe (if resource then "resource runner" else "direct runner") $ do- let readHead store name =- if resource- then runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ getStreamWithHead name- else runStoreIO store (getStreamWithHead name)- check store name version headPosition = do- Right (Just metadata) <- runStoreIO store (getStream name)- metadata ^. #version `shouldBe` StreamVersion version- readHead store name `shouldReturn` Right (Just (metadata, headPosition))- it "distinguishes absent, empty and reserved $all in empty and populated stores" $ withTestStore $ \store -> do- readHead store (StreamName "missing") `shouldReturn` Right Nothing- raw store "INSERT INTO streams(stream_name) VALUES ('empty')"- check store (StreamName "empty") 0 Nothing- check store (StreamName "$all") 0 Nothing- appendOne store (StreamName "origin")- check store (StreamName "$all") 1 Nothing- it "captures interleaved heads, logical lifecycle and a fresh identity after hard deletion" $ withTestStore $ \store -> do- let a = StreamName "a"; b = StreamName "b"- mapM_ (appendOne store) [a, b, a, b]- originated <- appendPosition store a- originated `shouldBe` GlobalPosition 5- check store a 3 (Just originated)- appendOne store b- check store a 3 (Just originated)- Right (Just _) <- runStoreIO store (setStreamTruncateBefore a (StreamVersion 3))- check store a 3 (Just originated)- Right (Just _) <- runStoreIO store (softDeleteStream a)- check store a 3 (Just originated)- Right (Just (old, _)) <- readHead store a- Right (Just _) <- runStoreIO store (hardDeleteStream a)- readHead store a `shouldReturn` Right Nothing- raw store "INSERT INTO streams(stream_name) VALUES ('a')"- check store a 0 Nothing- Right (Just (fresh, _)) <- readHead store a- fresh ^. #id `shouldNotBe` (old ^. #id)- recreated <- appendPosition store a- recreated `shouldBe` GlobalPosition 7- check store a 1 (Just recreated)- it "ignores links while tracking subsequent originated appends" $ withTestStore $ \store -> do- let mixed = StreamName "mixed"; source = StreamName "source"; linked = StreamName "linked"- appendOne store mixed- appendOne store source- Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)- let eid = (events V.! 1) ^. #eventId- Right _ <- runStoreIO store (linkToStream linked [eid])- check store linked 1 Nothing- Right _ <- runStoreIO store (linkToStream mixed [eid])- check store mixed 2 (Just ((events V.! 0) ^. #globalPosition))- next <- appendPosition store mixed- next `shouldBe` GlobalPosition 3- check store mixed 3 (Just next)- it "never invokes the event decode hook" $ do- calls <- newIORef (0 :: Int)- let hook _ = modifyIORef' calls (+ 1) >> error "unexpected event decode"- withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}) $ \store -> do- expected <- appendPosition store (StreamName "hook")- Right (Just (_, actual)) <- runStoreIO store (getStreamWithHead (StreamName "hook"))- actual `shouldBe` Just expected- readIORef calls `shouldReturn` 0- it "observes version and head from one snapshot while appends commit" $ withTestStore $ \store -> do- let name = StreamName "concurrent"- appendOne store name- start <- newEmptyMVar- let writer = takeMVar start >> replicateM_ 100 (appendOne store name)- reader = replicateM_ 200 $ do- Right (Just (info, Just (GlobalPosition position))) <- runStoreIO store (getStreamWithHead name)- info ^. #version `shouldBe` StreamVersion position- outcome <- timeout 20_000_000 $ Async.withAsync writer $ \worker -> do- putMVar start ()- reader- Async.wait worker- outcome `shouldBe` Just ()- Right (Just (info, headPosition)) <- runStoreIO store (getStreamWithHead name)- info ^. #version `shouldBe` StreamVersion 101- headPosition `shouldBe` Just (GlobalPosition 101)+ forM_ [False, True] $ \resource -> describe (if resource then "resource runner" else "direct runner") $ do+ let readHead store name =+ if resource+ then runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ getStreamWithHead name+ else runStoreIO store (getStreamWithHead name)+ check store name version headPosition = do+ Right (Just metadata) <- runStoreIO store (getStream name)+ metadata ^. #version `shouldBe` StreamVersion version+ readHead store name `shouldReturn` Right (Just (metadata, headPosition))+ it "distinguishes absent, empty and reserved $all in empty and populated stores" $ withTestStore $ \store -> do+ readHead store (StreamName "missing") `shouldReturn` Right Nothing+ raw store "INSERT INTO streams(stream_name) VALUES ('empty')"+ check store (StreamName "empty") 0 Nothing+ check store (StreamName "$all") 0 Nothing+ appendOne store (StreamName "origin")+ check store (StreamName "$all") 1 Nothing+ it "captures interleaved heads, logical lifecycle and a fresh identity after hard deletion" $ withTestStore $ \store -> do+ let a = StreamName "a"; b = StreamName "b"+ mapM_ (appendOne store) [a, b, a, b]+ originated <- appendPosition store a+ originated `shouldBe` GlobalPosition 5+ check store a 3 (Just originated)+ appendOne store b+ check store a 3 (Just originated)+ Right (Just _) <- runStoreIO store (setStreamTruncateBefore a (StreamVersion 3))+ check store a 3 (Just originated)+ Right (Just _) <- runStoreIO store (softDeleteStream a)+ check store a 3 (Just originated)+ Right (Just (old, _)) <- readHead store a+ Right (Just _) <- runStoreIO store (hardDeleteStream a)+ readHead store a `shouldReturn` Right Nothing+ raw store "INSERT INTO streams(stream_name) VALUES ('a')"+ check store a 0 Nothing+ Right (Just (fresh, _)) <- readHead store a+ fresh ^. #id `shouldNotBe` (old ^. #id)+ recreated <- appendPosition store a+ recreated `shouldBe` GlobalPosition 7+ check store a 1 (Just recreated)+ it "ignores links while tracking subsequent originated appends" $ withTestStore $ \store -> do+ let mixed = StreamName "mixed"; source = StreamName "source"; linked = StreamName "linked"+ appendOne store mixed+ appendOne store source+ Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)+ let eid = (events V.! 1) ^. #eventId+ Right _ <- runStoreIO store (linkToStream linked [eid])+ check store linked 1 Nothing+ Right _ <- runStoreIO store (linkToStream mixed [eid])+ check store mixed 2 (Just ((events V.! 0) ^. #globalPosition))+ next <- appendPosition store mixed+ next `shouldBe` GlobalPosition 3+ check store mixed 3 (Just next)+ it "never invokes the event decode hook" $ do+ calls <- newIORef (0 :: Int)+ let hook _ = modifyIORef' calls (+ 1) >> error "unexpected event decode"+ withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}) $ \store -> do+ expected <- appendPosition store (StreamName "hook")+ Right (Just (_, actual)) <- runStoreIO store (getStreamWithHead (StreamName "hook"))+ actual `shouldBe` Just expected+ readIORef calls `shouldReturn` 0+ it "observes version and head from one snapshot while appends commit" $ withTestStore $ \store -> do+ let name = StreamName "concurrent"+ appendOne store name+ start <- newEmptyMVar+ let writer = takeMVar start >> replicateM_ 100 (appendOne store name)+ reader = replicateM_ 200 $ do+ Right (Just (info, Just (GlobalPosition position))) <- runStoreIO store (getStreamWithHead name)+ info ^. #version `shouldBe` StreamVersion position+ outcome <- timeout 20_000_000 $ Async.withAsync writer $ \worker -> do+ putMVar start ()+ reader+ Async.wait worker+ outcome `shouldBe` Just ()+ Right (Just (info, headPosition)) <- runStoreIO store (getStreamWithHead name)+ info ^. #version `shouldBe` StreamVersion 101+ headPosition `shouldBe` Just (GlobalPosition 101) appendOne :: KirokuStore -> StreamName -> IO () appendOne store name = void (appendPosition store name) appendPosition :: KirokuStore -> StreamName -> IO GlobalPosition appendPosition store name = do- result <- runStoreIO store (appendToStream name AnyVersion [makeEvent "StreamHeadEvent" Null])- either (fail . show) (pure . (^. #globalPosition)) result+ result <- runStoreIO store (appendToStream name AnyVersion [makeEvent "StreamHeadEvent" Null])+ either (fail . show) (pure . (^. #globalPosition)) result raw :: KirokuStore -> Text -> IO () raw store command = Pool.use (store ^. #pool) (Session.script command) >>= either (expectationFailure . show) pure
test/Test/StreamHeadIsolation.hs view
@@ -10,27 +10,27 @@ spec :: Spec spec = describe "stream head legacy isolation" $ do- forM_ statements $ \(name, actual) ->- it ("preserves " <> name <> " byte for byte") $ do- path <- getDataFileName ("test/fixtures/stream-head-isolation/" <> name <> ".sql")- expected <- T.readFile path- actual `shouldBe` expected- it "keeps the six metadata and eleven event result columns" $ do- columnCount (Statement.toSql SQL.getStreamStmt) `shouldBe` 6- columnCount (Statement.toSql SQL.readAllForwardStmt) `shouldBe` 11+ forM_ statements $ \(name, actual) ->+ it ("preserves " <> name <> " byte for byte") $ do+ path <- getDataFileName ("test/fixtures/stream-head-isolation/" <> name <> ".sql")+ expected <- T.readFile path+ actual `shouldBe` expected+ it "keeps the six metadata and eleven event result columns" $ do+ columnCount (Statement.toSql SQL.getStreamStmt) `shouldBe` 6+ columnCount (Statement.toSql SQL.readAllForwardStmt) `shouldBe` 11 where columnCount = length . T.splitOn "," . fst . T.breakOn "FROM" . snd . T.breakOn "SELECT" statements =- [ ("getStreamStmt", Statement.toSql SQL.getStreamStmt)- , ("readStreamForwardStmt", Statement.toSql SQL.readStreamForwardStmt)- , ("readStreamBackwardStmt", Statement.toSql SQL.readStreamBackwardStmt)- , ("readAllForwardStmt", Statement.toSql SQL.readAllForwardStmt)- , ("readAllBackwardStmt", Statement.toSql SQL.readAllBackwardStmt)- , ("readCategoryForwardStmt", Statement.toSql SQL.readCategoryForwardStmt)- , ("readCategoryForwardConsumerGroupStmt", Statement.toSql SQL.readCategoryForwardConsumerGroupStmt)- , ("appendExpectedVersion", Statement.toSql SQL.appendExpectedVersion)- , ("appendStreamExists", Statement.toSql SQL.appendStreamExists)- , ("appendNoStream", Statement.toSql SQL.appendNoStream)- , ("appendAnyVersion", Statement.toSql SQL.appendAnyVersion)- , ("linkToStreamStmt", Statement.toSql SQL.linkToStreamStmt)- ]+ [ ("getStreamStmt", Statement.toSql SQL.getStreamStmt),+ ("readStreamForwardStmt", Statement.toSql SQL.readStreamForwardStmt),+ ("readStreamBackwardStmt", Statement.toSql SQL.readStreamBackwardStmt),+ ("readAllForwardStmt", Statement.toSql SQL.readAllForwardStmt),+ ("readAllBackwardStmt", Statement.toSql SQL.readAllBackwardStmt),+ ("readCategoryForwardStmt", Statement.toSql SQL.readCategoryForwardStmt),+ ("readCategoryForwardConsumerGroupStmt", Statement.toSql SQL.readCategoryForwardConsumerGroupStmt),+ ("appendExpectedVersion", Statement.toSql SQL.appendExpectedVersion),+ ("appendStreamExists", Statement.toSql SQL.appendStreamExists),+ ("appendNoStream", Statement.toSql SQL.appendNoStream),+ ("appendAnyVersion", Statement.toSql SQL.appendAnyVersion),+ ("linkToStreamStmt", Statement.toSql SQL.linkToStreamStmt)+ ]
test/Test/StreamHeadMock.hs view
@@ -10,19 +10,19 @@ spec :: Spec spec = describe "stream head mock" $ it "sends exactly one dedicated constructor and preserves all nested-Maybe outcomes" $ do- now <- getCurrentTime- let name = StreamName "mock"- info = StreamInfo (StreamId 42) name (StreamVersion 3) now Nothing (StreamVersion 0)- forM_ [Nothing, Just (info, Nothing), Just (info, Just (GlobalPosition 5))] $ \expected -> do- calls <- newIORef (0 :: Int)- actual <- runEff $ runMock calls name expected (getStreamWithHead name)- actual `shouldBe` expected- readIORef calls `shouldReturn` 1+ now <- getCurrentTime+ let name = StreamName "mock"+ info = StreamInfo (StreamId 42) name (StreamVersion 3) now Nothing (StreamVersion 0)+ forM_ [Nothing, Just (info, Nothing), Just (info, Just (GlobalPosition 5))] $ \expected -> do+ calls <- newIORef (0 :: Int)+ actual <- runEff $ runMock calls name expected (getStreamWithHead name)+ actual `shouldBe` expected+ readIORef calls `shouldReturn` 1 runMock :: (IOE :> es) => IORef Int -> StreamName -> Maybe (StreamInfo, Maybe GlobalPosition) -> Eff (Store : es) a -> Eff es a runMock calls name expected = interpret_ $ \case- GetStreamWithHead actual -> do- liftIO $ actual `shouldBe` name- liftIO $ modifyIORef' calls (+ 1)- pure expected- _ -> error "unexpected Store operation in stream-head mock"+ GetStreamWithHead actual -> do+ liftIO $ actual `shouldBe` name+ liftIO $ modifyIORef' calls (+ 1)+ pure expected+ _ -> error "unexpected Store operation in stream-head mock"
test/Test/StreamHistoryGuard.hs view
@@ -22,184 +22,184 @@ spec :: Spec spec = describe "stream history guard" $ do- it "returns exact metadata and pages with production cursor semantics" $- withTestStore $ \store -> do- let stream = StreamName "guard-metadata"- Right _ <- runStoreIO store $ appendToStream stream NoStream (events 3)- Right _ <- runStoreIO store $ setStreamTruncateBefore stream (StreamVersion 2)- result <- runTx store $ do- locked <- lockStreamHistoryForReplayTx stream- page <- readStreamForwardTx stream (StreamVersion 0) 10- pure (locked, page)- case result of- (Right info, page) -> do- info ^. #version `shouldBe` StreamVersion 3- info ^. #truncateBefore `shouldBe` StreamVersion 2- info ^. #deletedAt `shouldBe` Nothing- fmap (^. #streamVersion) (Vector.toList page)- `shouldBe` [StreamVersion 2, StreamVersion 3]- (Left unavailable, _) -> expectationFailure ("guard unexpectedly unavailable: " <> show unavailable)+ it "returns exact metadata and pages with production cursor semantics" $+ withTestStore $ \store -> do+ let stream = StreamName "guard-metadata"+ Right _ <- runStoreIO store $ appendToStream stream NoStream (events 3)+ Right _ <- runStoreIO store $ setStreamTruncateBefore stream (StreamVersion 2)+ result <- runTx store $ do+ locked <- lockStreamHistoryForReplayTx stream+ page <- readStreamForwardTx stream (StreamVersion 0) 10+ pure (locked, page)+ case result of+ (Right info, page) -> do+ info ^. #version `shouldBe` StreamVersion 3+ info ^. #truncateBefore `shouldBe` StreamVersion 2+ info ^. #deletedAt `shouldBe` Nothing+ fmap (^. #streamVersion) (Vector.toList page)+ `shouldBe` [StreamVersion 2, StreamVersion 3]+ (Left unavailable, _) -> expectationFailure ("guard unexpectedly unavailable: " <> show unavailable) - it "returns typed unavailable results for missing and reserved streams" $- withTestStore $ \store -> do- missing <- runTx store (lockStreamHistoryForReplayTx (StreamName "guard-missing"))- missing `shouldBe` Left (StreamHistoryNotFound (StreamName "guard-missing"))- reserved <- runTx store (lockStreamHistoryForReplayTx (StreamName "$all"))- reserved `shouldBe` Left (StreamHistoryReserved (StreamName "$all"))+ it "returns typed unavailable results for missing and reserved streams" $+ withTestStore $ \store -> do+ missing <- runTx store (lockStreamHistoryForReplayTx (StreamName "guard-missing"))+ missing `shouldBe` Left (StreamHistoryNotFound (StreamName "guard-missing"))+ reserved <- runTx store (lockStreamHistoryForReplayTx (StreamName "$all"))+ reserved `shouldBe` Left (StreamHistoryReserved (StreamName "$all")) - it "returns soft-deleted metadata and blocks undelete until guard completion" $- withTestStore $ \store -> do- let stream = StreamName "guard-undelete"- Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)- Right _ <- runStoreIO store $ softDeleteStream stream- assertBlockedByGuard store stream $ do- info <- runTx store (lockStreamHistoryForReplayTx stream)- info `shouldSatisfy` \case Right StreamInfo{deletedAt = Just _} -> True; _ -> False- runStoreIO store (undeleteStream stream)+ it "returns soft-deleted metadata and blocks undelete until guard completion" $+ withTestStore $ \store -> do+ let stream = StreamName "guard-undelete"+ Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+ Right _ <- runStoreIO store $ softDeleteStream stream+ assertBlockedByGuard store stream $ do+ info <- runTx store (lockStreamHistoryForReplayTx stream)+ info `shouldSatisfy` \case Right StreamInfo {deletedAt = Just _} -> True; _ -> False+ runStoreIO store (undeleteStream stream) - it "blocks append until guard completion" $- withTestStore $ \store -> do- let stream = StreamName "guard-append"- Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)- assertBlockedByGuard store stream $- runStoreIO store (appendToStream stream AnyVersion (events 1))+ it "blocks append until guard completion" $+ withTestStore $ \store -> do+ let stream = StreamName "guard-append"+ Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+ assertBlockedByGuard store stream $+ runStoreIO store (appendToStream stream AnyVersion (events 1)) - it "releases the guard on rollback and lets the blocked append complete" $- withTestStore $ \store -> do- let stream = StreamName "guard-rollback"- Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)- assertBlockedByGuardEnding True store stream $- runStoreIO store (appendToStream stream AnyVersion (events 1))+ it "releases the guard on rollback and lets the blocked append complete" $+ withTestStore $ \store -> do+ let stream = StreamName "guard-rollback"+ Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+ assertBlockedByGuardEnding True store stream $+ runStoreIO store (appendToStream stream AnyVersion (events 1)) - it "blocks link into the guarded stream until guard completion" $- withTestStore $ \store -> do- let source = StreamName "guard-link-source"- target = StreamName "guard-link-target"- Right _ <- runStoreIO store $ appendToStream source NoStream (events 1)- Right _ <- runStoreIO store $ appendToStream target NoStream (events 1)- Right sourceRows <- runStoreIO store $ readStreamForward source (StreamVersion 0) 10- let eventId = Vector.head sourceRows ^. #eventId- assertBlockedByGuard store target $- runStoreIO store (linkToStream target [eventId])+ it "blocks link into the guarded stream until guard completion" $+ withTestStore $ \store -> do+ let source = StreamName "guard-link-source"+ target = StreamName "guard-link-target"+ Right _ <- runStoreIO store $ appendToStream source NoStream (events 1)+ Right _ <- runStoreIO store $ appendToStream target NoStream (events 1)+ Right sourceRows <- runStoreIO store $ readStreamForward source (StreamVersion 0) 10+ let eventId = Vector.head sourceRows ^. #eventId+ assertBlockedByGuard store target $+ runStoreIO store (linkToStream target [eventId]) - it "blocks soft delete and logical truncate until guard completion" $- withTestStore $ \store -> do- let soft = StreamName "guard-soft-delete"- truncated = StreamName "guard-truncate"- Right _ <- runStoreIO store $ appendToStream soft NoStream (events 1)- Right _ <- runStoreIO store $ appendToStream truncated NoStream (events 2)- assertBlockedByGuard store soft $- runStoreIO store (softDeleteStream soft)- assertBlockedByGuard store truncated $- runStoreIO store (setStreamTruncateBefore truncated (StreamVersion 2))+ it "blocks soft delete and logical truncate until guard completion" $+ withTestStore $ \store -> do+ let soft = StreamName "guard-soft-delete"+ truncated = StreamName "guard-truncate"+ Right _ <- runStoreIO store $ appendToStream soft NoStream (events 1)+ Right _ <- runStoreIO store $ appendToStream truncated NoStream (events 2)+ assertBlockedByGuard store soft $+ runStoreIO store (softDeleteStream soft)+ assertBlockedByGuard store truncated $+ runStoreIO store (setStreamTruncateBefore truncated (StreamVersion 2)) - it "blocks hard delete of the guarded origin until guard completion" $- withTestStore $ \store -> do- let stream = StreamName "guard-hard-delete"- Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)- assertBlockedByGuard store stream $- runStoreIO store (hardDeleteStream stream)+ it "blocks hard delete of the guarded origin until guard completion" $+ withTestStore $ \store -> do+ let stream = StreamName "guard-hard-delete"+ Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+ assertBlockedByGuard store stream $+ runStoreIO store (hardDeleteStream stream) - it "blocks hard delete of another origin linked into the guarded stream" $- withTestStore $ \store -> do- let origin = StreamName "guard-linked-origin"- guarded = StreamName "guard-linked-target"- Right _ <- runStoreIO store $ appendToStream origin NoStream (events 1)- Right _ <- runStoreIO store $ appendToStream guarded NoStream (events 1)- Right sourceRows <- runStoreIO store $ readStreamForward origin (StreamVersion 0) 10- Right _ <- runStoreIO store $ linkToStream guarded [Vector.head sourceRows ^. #eventId]- assertBlockedByGuard store guarded $- runStoreIO store (hardDeleteStream origin)+ it "blocks hard delete of another origin linked into the guarded stream" $+ withTestStore $ \store -> do+ let origin = StreamName "guard-linked-origin"+ guarded = StreamName "guard-linked-target"+ Right _ <- runStoreIO store $ appendToStream origin NoStream (events 1)+ Right _ <- runStoreIO store $ appendToStream guarded NoStream (events 1)+ Right sourceRows <- runStoreIO store $ readStreamForward origin (StreamVersion 0) 10+ Right _ <- runStoreIO store $ linkToStream guarded [Vector.head sourceRows ^. #eventId]+ assertBlockedByGuard store guarded $+ runStoreIO store (hardDeleteStream origin) - it "keeps opposing hard deletes and multi-stream appends deadlock-free" $- withTestStore $ \store ->- forM_ [1 .. 5 :: Int] $ \index -> do- let suffix = Text.pack (show index)- first = StreamName ("guard-race-a-" <> suffix)- second = StreamName ("guard-race-b-" <> suffix)- Right _ <- runStoreIO store $ appendToStream first NoStream (events 1)- Right _ <- runStoreIO store $ appendToStream second NoStream (events 1)- operations <-- Async.async $- Async.concurrently- (runStoreIO store $ hardDeleteStream first)- ( runStoreIO store $- appendMultiStream- [ (first, AnyVersion, events 1)- , (second, AnyVersion, events 1)- ]- )- _ <- waitWithin "hard-delete/multi-append race" operations- pure ()+ it "keeps opposing hard deletes and multi-stream appends deadlock-free" $+ withTestStore $ \store ->+ forM_ [1 .. 5 :: Int] $ \index -> do+ let suffix = Text.pack (show index)+ first = StreamName ("guard-race-a-" <> suffix)+ second = StreamName ("guard-race-b-" <> suffix)+ Right _ <- runStoreIO store $ appendToStream first NoStream (events 1)+ Right _ <- runStoreIO store $ appendToStream second NoStream (events 1)+ operations <-+ Async.async $+ Async.concurrently+ (runStoreIO store $ hardDeleteStream first)+ ( runStoreIO store $+ appendMultiStream+ [ (first, AnyVersion, events 1),+ (second, AnyVersion, events 1)+ ]+ )+ _ <- waitWithin "hard-delete/multi-append race" operations+ pure () assertBlockedByGuard :: KirokuStore -> StreamName -> IO result -> IO () assertBlockedByGuard = assertBlockedByGuardEnding False assertBlockedByGuardEnding :: Bool -> KirokuStore -> StreamName -> IO result -> IO () assertBlockedByGuardEnding rollBack store stream mutation = do- guard <- Async.async $ runStoreIO store $ runTransaction $ do- locked <- lockStreamHistoryForReplayTx stream- _ <- Tx.statement () holdGuardStmt- when rollBack Tx.condemn- pure locked- waitForGuardPhase store 100- waiter <- Async.async mutation- threadDelay 50_000- Async.poll waiter >>= \case- Nothing -> pure ()- Just _ -> expectationFailure "mutation completed while the stream-history guard was held"- guardResult <- waitWithin "guard transaction" guard- guardResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False- _ <- waitWithin "blocked mutation" waiter- pure ()+ guard <- Async.async $ runStoreIO store $ runTransaction $ do+ locked <- lockStreamHistoryForReplayTx stream+ _ <- Tx.statement () holdGuardStmt+ when rollBack Tx.condemn+ pure locked+ waitForGuardPhase store 100+ waiter <- Async.async mutation+ threadDelay 50_000+ Async.poll waiter >>= \case+ Nothing -> pure ()+ Just _ -> expectationFailure "mutation completed while the stream-history guard was held"+ guardResult <- waitWithin "guard transaction" guard+ guardResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False+ _ <- waitWithin "blocked mutation" waiter+ pure () waitForGuardPhase :: KirokuStore -> Int -> IO () waitForGuardPhase _ 0 = expectationFailure "guard never reached its held phase" waitForGuardPhase store attempts = do- result <- Pool.use (store ^. #pool) (Session.statement () guardActiveStmt)- case result of- Right True -> pure ()- Right False -> threadDelay 10_000 >> waitForGuardPhase store (attempts - 1)- Left err -> expectationFailure ("could not observe guard phase: " <> show err)+ result <- Pool.use (store ^. #pool) (Session.statement () guardActiveStmt)+ case result of+ Right True -> pure ()+ Right False -> threadDelay 10_000 >> waitForGuardPhase store (attempts - 1)+ Left err -> expectationFailure ("could not observe guard phase: " <> show err) waitWithin :: String -> Async.Async value -> IO value waitWithin label action = do- result <- Async.race (threadDelay 2_000_000) (Async.wait action)- case result of- Left () -> do- Async.cancel action- expectationFailure (label <> " timed out")- error "unreachable"- Right value -> pure value+ result <- Async.race (threadDelay 2_000_000) (Async.wait action)+ case result of+ Left () -> do+ Async.cancel action+ expectationFailure (label <> " timed out")+ error "unreachable"+ Right value -> pure value runTx :: KirokuStore -> Tx.Transaction value -> IO value runTx store transaction = do- result <- runStoreIO store (runTransaction transaction)- case result of- Left err -> expectationFailure ("guard transaction failed: " <> show err) >> error "unreachable"- Right value -> pure value+ result <- runStoreIO store (runTransaction transaction)+ case result of+ Left err -> expectationFailure ("guard transaction failed: " <> show err) >> error "unreachable"+ Right value -> pure value events :: Int -> [EventData] events count =- [makeEvent "Guarded" (Aeson.object [("n", Aeson.toJSON n)]) | n <- [1 .. count]]+ [makeEvent "Guarded" (Aeson.object [("n", Aeson.toJSON n)]) | n <- [1 .. count]] holdGuardStmt :: Statement () Bool holdGuardStmt =- preparable- "SELECT pg_sleep(0.4) IS NULL"- E.noParams- (D.singleRow (D.column (D.nonNullable D.bool)))+ preparable+ "SELECT pg_sleep(0.4) IS NULL"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.bool))) guardActiveStmt :: Statement () Bool guardActiveStmt =- preparable- """- SELECT EXISTS (- SELECT 1- FROM pg_stat_activity- WHERE state = 'active'- AND query = 'SELECT pg_sleep(0.4) IS NULL'- )- """- E.noParams- (D.singleRow (D.column (D.nonNullable D.bool)))+ preparable+ """+ SELECT EXISTS (+ SELECT 1+ FROM pg_stat_activity+ WHERE state = 'active'+ AND query = 'SELECT pg_sleep(0.4) IS NULL'+ )+ """+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.bool)))
test/Test/StreamNameLookup.hs view
@@ -1,8 +1,7 @@-{- | Tests for 'Kiroku.Store.Read.lookupStreamNames' and 'lookupStreamName'-(plan 36). These resolve the surrogate 'RecordedEvent.originalStreamId' carried-by fan-in reads back to a human-readable 'StreamName', without every read having-to return the name (which a benchmark showed costs ~13% on @$all@ pages).--}+-- | Tests for 'Kiroku.Store.Read.lookupStreamNames' and 'lookupStreamName'+-- (plan 36). These resolve the surrogate 'RecordedEvent.originalStreamId' carried+-- by fan-in reads back to a human-readable 'StreamName', without every read having+-- to return the name (which a benchmark showed costs ~13% on @$all@ pages). module Test.StreamNameLookup (spec, noOpSpec) where import Control.Lens ((^.))@@ -17,65 +16,65 @@ spec :: Spec spec = describe "lookupStreamNames / lookupStreamName" $ do- around withTestStore $ do- it "resolves originalStreamId back to the source stream for $all reads" $ \store -> do- Right _ <-- runStoreIO store $- appendToStream (StreamName "orders-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]- Right _ <-- runStoreIO store $- appendToStream (StreamName "shipments-1") NoStream [makeEvent "ShipmentDispatched" (Aeson.object [])]- Right evs <- runStoreIO store $ readAllForward (GlobalPosition 0) 100- Right names <- runStoreIO store $ lookupStreamNames (map (^. #originalStreamId) (V.toList evs))- let nameOf typ =- case V.find ((== EventType typ) . (^. #eventType)) evs of- Just e -> Map.lookup (e ^. #originalStreamId) names- Nothing -> Nothing- nameOf "OrderCreated" `shouldBe` Just (StreamName "orders-1")- nameOf "ShipmentDispatched" `shouldBe` Just (StreamName "shipments-1")+ around withTestStore $ do+ it "resolves originalStreamId back to the source stream for $all reads" $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "orders-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "shipments-1") NoStream [makeEvent "ShipmentDispatched" (Aeson.object [])]+ Right evs <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+ Right names <- runStoreIO store $ lookupStreamNames (map (^. #originalStreamId) (V.toList evs))+ let nameOf typ =+ case V.find ((== EventType typ) . (^. #eventType)) evs of+ Just e -> Map.lookup (e ^. #originalStreamId) names+ Nothing -> Nothing+ nameOf "OrderCreated" `shouldBe` Just (StreamName "orders-1")+ nameOf "ShipmentDispatched" `shouldBe` Just (StreamName "shipments-1") - it "omits ids that name no existing stream" $ \store -> do- Right names <- runStoreIO store $ lookupStreamNames [StreamId 999999]- Map.size names `shouldBe` 0+ it "omits ids that name no existing stream" $ \store -> do+ Right names <- runStoreIO store $ lookupStreamNames [StreamId 999999]+ Map.size names `shouldBe` 0 - it "returns an empty map for an empty id list" $ \store -> do- Right names <- runStoreIO store $ lookupStreamNames []- Map.null names `shouldBe` True+ it "returns an empty map for an empty id list" $ \store -> do+ Right names <- runStoreIO store $ lookupStreamNames []+ Map.null names `shouldBe` True - it "lookupStreamName resolves a single id, and Nothing for an unknown one" $ \store -> do- Right _ <-- runStoreIO store $- appendToStream (StreamName "widgets-7") NoStream [makeEvent "WidgetMade" (Aeson.object [])]- Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "widgets-7")- Right found <- runStoreIO store $ lookupStreamName sid- found `shouldBe` Just (StreamName "widgets-7")- Right missing <- runStoreIO store $ lookupStreamName (StreamId 888888)- missing `shouldBe` Nothing+ it "lookupStreamName resolves a single id, and Nothing for an unknown one" $ \store -> do+ Right _ <-+ runStoreIO store $+ appendToStream (StreamName "widgets-7") NoStream [makeEvent "WidgetMade" (Aeson.object [])]+ Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "widgets-7")+ Right found <- runStoreIO store $ lookupStreamName sid+ found `shouldBe` Just (StreamName "widgets-7")+ Right missing <- runStoreIO store $ lookupStreamName (StreamId 888888)+ missing `shouldBe` Nothing noOpSpec :: Spec noOpSpec =- describe "empty stream-name lookup" $- 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")+ describe "empty stream-name lookup" $+ 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+ 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+ 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/SubscriptionCheckpointInitialization.hs view
@@ -3,134 +3,265 @@ module Test.SubscriptionCheckpointInitialization (spec) where +import Control.Concurrent (threadDelay) import Control.Concurrent.Async qualified as Async+import Control.Exception (bracket, finally)+import Control.Lens ((^.))+import Control.Monad (forM_) import Data.Aeson qualified as Aeson+import Data.Generics.Labels () import Data.Int (Int32) import Data.Text (Text) import Data.Text qualified as T import Data.Vector qualified as V import Effectful (runEff) import Effectful.Error.Static (runErrorNoCallStack)+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Conn+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store-import Test.Helpers (makeEvent, withTestStore)+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import System.Timeout (timeout)+import Test.Helpers (makeEvent, validConsumerGroup, withTestStore) import Test.Hspec spec :: Spec spec = describe "subscription checkpoint initialization" $ do- it "materializes position zero for FromBeginning" $- withTestStore $ \store -> do- let name = SubscriptionName "initialize-from-beginning"- key = SubscriptionCheckpointKey name 0- result <- initialize store name 0 FromBeginning- result `shouldBe` Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0))- inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 0)]+ it "materializes position zero for FromBeginning" $+ withTestStore $ \store -> do+ let name = SubscriptionName "initialize-from-beginning"+ key = SubscriptionCheckpointKey name 0+ result <- initialize store name 0 FromBeginning+ result `shouldBe` Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0))+ inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 0)]+ topology store `shouldReturn` [(0, 1, "unbound", Nothing)] - it "atomically materializes the current store head for FromCurrentHead" $- withTestStore $ \store -> do- appendEvents store "initialize-current-head-events" 3- let name = SubscriptionName "initialize-current-head"- key = SubscriptionCheckpointKey name 0- result <- initialize store name 0 FromCurrentHead- result `shouldBe` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 3))- inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 3)]+ it "atomically materializes the current store head for FromCurrentHead" $+ withTestStore $ \store -> do+ appendEvents store "initialize-current-head-events" 3+ let name = SubscriptionName "initialize-current-head"+ key = SubscriptionCheckpointKey name 0+ result <- initialize store name 0 FromCurrentHead+ result `shouldBe` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 3))+ inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 3)] - it "returns a typed missing result without creating a row for FailIfMissing" $- withTestStore $ \store -> do- let name = SubscriptionName "initialize-fail-if-missing"- key = SubscriptionCheckpointKey name 0- result <- initialize store name 0 FailIfMissing- result `shouldBe` Left (SubscriptionCheckpointMissing key)- inventoryKeys <$> inventory store `shouldReturn` []+ it "returns a typed missing result without creating a row for FailIfMissing" $+ withTestStore $ \store -> do+ let name = SubscriptionName "initialize-fail-if-missing"+ key = SubscriptionCheckpointKey name 0+ result <- initialize store name 0 FailIfMissing+ result `shouldBe` Left (SubscriptionCheckpointMissing key)+ inventoryKeys <$> inventory store `shouldReturn` [] - it "preserves an existing row for every configured policy" $- withTestStore $ \store -> do- appendEvents store "initialize-existing-events" 5- let name = SubscriptionName "initialize-existing"- key = SubscriptionCheckpointKey name 0- initialize store name 0 FromCurrentHead- `shouldReturn` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 5))- mapM_ (assertExisting store key) [FromBeginning, FromCurrentHead, FailIfMissing]- inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 5)]+ it "preserves an existing row for every configured policy" $+ withTestStore $ \store -> do+ appendEvents store "initialize-existing-events" 5+ let name = SubscriptionName "initialize-existing"+ key = SubscriptionCheckpointKey name 0+ initialize store name 0 FromCurrentHead+ `shouldReturn` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 5))+ mapM_ (assertExisting store key) [FromBeginning, FromCurrentHead, FailIfMissing]+ inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 5)] - it "isolates checkpoint initialization by consumer-group member" $- withTestStore $ \store -> do- appendEvents store "initialize-member-events" 4- let name = SubscriptionName "initialize-members"- initialize store name 0 FromBeginning- `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))- initialize store name 1 FromCurrentHead- `shouldReturn` Right (InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 4))- inventoryKeys <$> inventory store- `shouldReturn` [ (name, 0, GlobalPosition 0)- , (name, 1, GlobalPosition 4)- ]+ it "isolates checkpoint initialization by consumer-group member" $+ withTestStore $ \store -> do+ appendEvents store "initialize-member-events" 4+ let name = SubscriptionName "initialize-members"+ initialize store name 0 FromBeginning+ `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))+ initialize store name 1 FromCurrentHead+ `shouldReturn` Right (InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 4))+ inventoryKeys <$> inventory store+ `shouldReturn` [ (name, 0, GlobalPosition 0),+ (name, 1, GlobalPosition 4)+ ] - it "converges concurrent initializers on one durable winner" $- withTestStore $ \store -> do- appendEvents store "initialize-race-events" 7- let name = SubscriptionName "initialize-race"- policies = take 20 (cycle [FromBeginning, FromCurrentHead])- results <- Async.mapConcurrently (initialize store name 3) policies- let successes = [initialization | Right initialization <- results]- positions = fmap checkpointInitializationPosition successes- initializedCount = length [() | InitializedCheckpoint{} <- successes]- length successes `shouldBe` length policies- initializedCount `shouldBe` 1- positions `shouldSatisfy` \case- [] -> False- first : rest -> all (== first) rest- case positions of- [] -> expectationFailure "expected concurrent initialization results"- winner : _ ->- inventoryKeys <$> inventory store- `shouldReturn` [(name, 3, winner)]+ it "converges concurrent initializers on one durable winner" $+ withTestStore $ \store -> do+ appendEvents store "initialize-race-events" 7+ let name = SubscriptionName "initialize-race"+ policies = take 20 (cycle [FromBeginning, FromCurrentHead])+ results <- Async.mapConcurrently (initialize store name 3) policies+ let successes = [initialization | Right initialization <- results]+ positions = fmap checkpointInitializationPosition successes+ initializedCount = length [() | InitializedCheckpoint {} <- successes]+ length successes `shouldBe` length policies+ initializedCount `shouldBe` 1+ positions `shouldSatisfy` \case+ [] -> False+ first : rest -> all (== first) rest+ case positions of+ [] -> expectationFailure "expected concurrent initialization results"+ winner : _ ->+ inventoryKeys <$> inventory store+ `shouldReturn` [(name, 3, winner)] - it "runs through the resource-backed Store interpreter" $- withTestStore $ \store -> do- let name = SubscriptionName "initialize-resource"- key = SubscriptionCheckpointKey name 2- result <-- runEff- . runErrorNoCallStack @StoreError- . runKirokuStoreWith store- . runStoreResource- $ initializeSubscriptionCheckpoint name 2 FromBeginning- result `shouldBe` Right (Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0)))+ it "initializes an absent member with sibling size and category binding, then starts under RequireBound" $+ withTestStore $ \store -> do+ let name = SubscriptionName "initialize-bound"+ target = Category (CategoryName "orders")+ config memberIndex =+ (defaultSubscriptionConfig name target (\_ -> pure Continue))+ { consumerGroup = Just (validConsumerGroup memberIndex 2),+ targetBindingPolicy = RequireBound+ }+ withSubscription store (config 0) $ \handle -> awaitLive handle+ topology store `shouldReturn` [(0, 2, "category", Just "orders")]+ initialize store name 1 FromBeginning+ `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 1) (GlobalPosition 0))+ topology store `shouldReturn` [(0, 2, "category", Just "orders"), (1, 2, "category", Just "orders")]+ withSubscription store (config 1) $ \handle -> awaitLive handle + forM_ (["uniform-unbound", "mixed-size", "mixed-binding"] :: [Text]) $ \variant ->+ it ("initializes with uniform topology or legacy defaults for " <> show variant) $+ withTestStore $ \store -> do+ let secondSize = if variant == "mixed-size" then "3" else "2"+ secondBinding = if variant == "mixed-binding" then "all" else "unbound"+ runSession+ store+ ( Session.script+ ("INSERT INTO subscriptions (subscription_name, consumer_group_member, consumer_group_size, last_seen, target_kind) VALUES ('initialize-fallback', 0, 2, 7, 'unbound'), ('initialize-fallback', 1, " <> secondSize <> ", 9, '" <> secondBinding <> "')")+ )+ originalRows <- topology store+ initialize store (SubscriptionName "initialize-fallback") 2 FromBeginning+ `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey (SubscriptionName "initialize-fallback") 2) (GlobalPosition 0))+ initializedRows <- topology store+ take 2 initializedRows `shouldBe` originalRows+ drop 2 initializedRows `shouldBe` [(2, if variant == "uniform-unbound" then 2 else 1, "unbound", Nothing)]++ it "initializes after a concurrent rebind commits, waiting on the name lock before reading topology" $+ withMigratedTestDatabase $ \connStr -> withStore (defaultConnectionSettings connStr) $ \store ->+ bracket+ (Connection.acquire (Conn.connectionString connStr) >>= either (fail . show) pure)+ Connection.release+ $ \gate -> do+ let name = SubscriptionName "initialize-rebind"+ newTarget = Category (CategoryName "new-orders")+ useGate session = Connection.use gate session >>= either (fail . show) pure+ initialize store name 0 FromBeginning+ `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))+ -- The session lock is a test gate. Rebind blocks at the gate after+ -- changing its rows, while still owning the transaction's name lock.+ useGate (Session.script "SELECT pg_advisory_lock(103)")+ ( Async.withAsync+ ( runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ Tx.sql "SET LOCAL statement_timeout = '5s'"+ report <- rebindSubscriptionTargetTx name newTarget (GlobalPosition 0)+ Tx.sql "SELECT pg_advisory_xact_lock(103)"+ pure report+ )+ $ \rebinder -> do+ within $ awaitQuery store gateWaitStmt+ Async.withAsync (initialize store name 1 FromBeginning) $ \initializer -> do+ ( do+ within $ awaitQuery store topologyWaitStmt+ useGate (Session.script "SELECT pg_advisory_unlock(103)")+ _ <- within (Async.wait rebinder)+ within (Async.wait initializer)+ `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 1) (GlobalPosition 0))+ topology store `shouldReturn` [(0, 1, "category", Just "new-orders"), (1, 1, "category", Just "new-orders")]+ )+ `finally` useGate (Session.script "SELECT pg_advisory_unlock_all()")+ )+ `finally` useGate (Session.script "SELECT pg_advisory_unlock_all()")++ it "runs through the resource-backed Store interpreter" $+ withTestStore $ \store -> do+ let name = SubscriptionName "initialize-resource"+ key = SubscriptionCheckpointKey name 2+ result <-+ runEff+ . runErrorNoCallStack @StoreError+ . runKirokuStoreWith store+ . runStoreResource+ $ initializeSubscriptionCheckpoint name 2 FromBeginning+ result `shouldBe` Right (Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0)))+ assertExisting :: KirokuStore -> SubscriptionCheckpointKey -> MissingCheckpointPolicy -> IO () assertExisting store key@(SubscriptionCheckpointKey name member) policy =- initialize store name member policy- `shouldReturn` Right (ExistingCheckpoint key (GlobalPosition 5))+ initialize store name member policy+ `shouldReturn` Right (ExistingCheckpoint key (GlobalPosition 5)) initialize ::- KirokuStore ->- SubscriptionName ->- Int32 ->- MissingCheckpointPolicy ->- IO (Either SubscriptionCheckpointMissing CheckpointInitialization)+ KirokuStore ->+ SubscriptionName ->+ Int32 ->+ MissingCheckpointPolicy ->+ IO (Either SubscriptionCheckpointMissing CheckpointInitialization) initialize store name member policy = do- result <- runStoreIO store (initializeSubscriptionCheckpoint name member policy)- case result of- Left err -> expectationFailure ("checkpoint initialization failed: " <> show err) >> error "unreachable"- Right initialized -> pure initialized+ result <- runStoreIO store (initializeSubscriptionCheckpoint name member policy)+ case result of+ Left err -> expectationFailure ("checkpoint initialization failed: " <> show err) >> error "unreachable"+ Right initialized -> pure initialized inventory :: KirokuStore -> IO SubscriptionCheckpointInventory inventory store = do- result <- runStoreIO store subscriptionCheckpointInventory- case result of- Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"- Right rows -> pure rows+ result <- runStoreIO store subscriptionCheckpointInventory+ case result of+ Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"+ Right rows -> pure rows inventoryKeys :: SubscriptionCheckpointInventory -> [(SubscriptionName, Int32, GlobalPosition)] inventoryKeys (SubscriptionCheckpointInventory _ rows) =- [ (name, member, position)- | SubscriptionCheckpoint name member position _ <- V.toList rows- ]+ [ (name, member, position)+ | SubscriptionCheckpoint name member position _ <- V.toList rows+ ] appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store stream count = do- let events = [makeEvent ("Initialize" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]- result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events- case result of- Left err -> expectationFailure ("append failed: " <> show err)- Right _ -> pure ()+ let events = [makeEvent ("Initialize" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+ result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+ case result of+ Left err -> expectationFailure ("append failed: " <> show err)+ Right _ -> pure ()++within :: IO a -> IO a+within action = timeout 5_000_000 action >>= maybe (fail "checkpoint initialization test timed out") pure++awaitLive :: SubscriptionHandle -> IO ()+awaitLive handle = within loop+ where+ loop =+ currentState handle >>= \case+ Just state | stateName state == "live" -> pure ()+ _ -> threadDelay 1_000 >> loop++runSession :: KirokuStore -> Session.Session a -> IO a+runSession store session = Pool.use (store ^. #pool) session >>= either (fail . show) pure++topology :: KirokuStore -> IO [(Int32, Int32, Text, Maybe Text)]+topology store = V.toList <$> runSession store (Session.statement () stmt)+ where+ stmt =+ preparable+ "SELECT consumer_group_member, consumer_group_size, target_kind, target_category FROM subscriptions ORDER BY consumer_group_member"+ E.noParams+ (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))++awaitQuery :: KirokuStore -> Statement () Bool -> IO ()+awaitQuery store stmt = do+ ready <- runSession store (Session.statement () stmt)+ if ready then pure () else threadDelay 1_000 >> awaitQuery store stmt++-- Only this test database participates; unrelated advisory locks cannot satisfy+-- either barrier. The second query observes the initializer blocked on a lock+-- held by the rebind transaction, which is itself waiting at the test gate.+gateWaitStmt :: Statement () Bool+gateWaitStmt =+ preparable+ "SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted AND classid = 0 AND objid = 103 AND objsubid = 1 AND database = (SELECT oid FROM pg_database WHERE datname = current_database()))"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.bool)))++topologyWaitStmt :: Statement () Bool+topologyWaitStmt =+ preparable+ "SELECT EXISTS (SELECT 1 FROM pg_locks waiting JOIN pg_locks held USING (locktype, database, classid, objid, objsubid) JOIN pg_locks gate ON gate.pid = held.pid WHERE waiting.locktype = 'advisory' AND NOT waiting.granted AND held.granted AND gate.locktype = 'advisory' AND NOT gate.granted AND gate.classid = 0 AND gate.objid = 103 AND gate.objsubid = 1 AND waiting.database = (SELECT oid FROM pg_database WHERE datname = current_database()))"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.bool)))
test/Test/SubscriptionCheckpointInitializationMock.hs view
@@ -12,33 +12,33 @@ spec :: Spec spec = describe "subscription checkpoint initialization mock interpreter" $ do- it "carries the closed policy and typed result through one Store effect call" $ do- calls <- newIORef (0 :: Int)- let name = SubscriptionName "mock-initialization"- key = SubscriptionCheckpointKey name 4- expected = Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 23))- actual <-- runEff $- runInitializationMock calls name 4 FromCurrentHead expected $- initializeSubscriptionCheckpoint name 4 FromCurrentHead- actual `shouldBe` expected- readIORef calls `shouldReturn` 1+ it "carries the closed policy and typed result through one Store effect call" $ do+ calls <- newIORef (0 :: Int)+ let name = SubscriptionName "mock-initialization"+ key = SubscriptionCheckpointKey name 4+ expected = Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 23))+ actual <-+ runEff $+ runInitializationMock calls name 4 FromCurrentHead expected $+ initializeSubscriptionCheckpoint name 4 FromCurrentHead+ actual `shouldBe` expected+ readIORef calls `shouldReturn` 1 runInitializationMock ::- (IOE :> es) =>- IORef Int ->- SubscriptionName ->- Int ->- MissingCheckpointPolicy ->- Either SubscriptionCheckpointMissing CheckpointInitialization ->- Eff (Store : es) a ->- Eff es a+ (IOE :> es) =>+ IORef Int ->+ SubscriptionName ->+ Int ->+ MissingCheckpointPolicy ->+ Either SubscriptionCheckpointMissing CheckpointInitialization ->+ Eff (Store : es) a ->+ Eff es a runInitializationMock calls expectedName expectedMember expectedPolicy expected = interpret_ $ \case- InitializeSubscriptionCheckpoint actualName actualMember actualPolicy -> do- liftIO $ do- actualName `shouldBe` expectedName- fromIntegral actualMember `shouldBe` expectedMember- actualPolicy `shouldBe` expectedPolicy- modifyIORef' calls (+ 1)- pure expected- _ -> error "unexpected Store operation in checkpoint initialization mock"+ InitializeSubscriptionCheckpoint actualName actualMember actualPolicy -> do+ liftIO $ do+ actualName `shouldBe` expectedName+ fromIntegral actualMember `shouldBe` expectedMember+ actualPolicy `shouldBe` expectedPolicy+ modifyIORef' calls (+ 1)+ pure expected+ _ -> error "unexpected Store operation in checkpoint initialization mock"
test/Test/SubscriptionCheckpointInventory.hs view
@@ -29,216 +29,216 @@ spec :: Spec spec = describe "SubscriptionCheckpointInventory" $ do- it "returns position zero and no rows for an empty migrated store" $- withTestStore $ \store -> do- inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store- captured `shouldBe` GlobalPosition 0- rows `shouldBe` V.empty- assertSqlRelationMatchesInventory store inventory+ it "returns position zero and no rows for an empty migrated store" $+ withTestStore $ \store -> do+ inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store+ captured `shouldBe` GlobalPosition 0+ rows `shouldBe` V.empty+ assertSqlRelationMatchesInventory store inventory - it "runs through the resource-backed Store interpreter" $- withTestStore $ \store -> do- result <-- runEff- . runErrorNoCallStack @StoreError- . runKirokuStoreWith store- . runStoreResource- $ subscriptionCheckpointInventory- result `shouldBe` Right (SubscriptionCheckpointInventory (GlobalPosition 0) V.empty)+ it "runs through the resource-backed Store interpreter" $+ withTestStore $ \store -> do+ result <-+ runEff+ . runErrorNoCallStack @StoreError+ . runKirokuStoreWith store+ . runStoreResource+ $ subscriptionCheckpointInventory+ result `shouldBe` Right (SubscriptionCheckpointInventory (GlobalPosition 0) V.empty) - it "returns the exact store position and a member-zero checkpoint" $- withTestStore $ \store -> do- appendEvents store "inventory-single" 3- saveCheckpoint store "single" 0 2+ it "returns the exact store position and a member-zero checkpoint" $+ withTestStore $ \store -> do+ appendEvents store "inventory-single" 3+ saveCheckpoint store "single" 0 2 - SubscriptionCheckpointInventory captured rows <- readInventory store- captured `shouldBe` GlobalPosition 3- checkpointKeys rows `shouldBe` [("single", 0, 2)]- now <- getCurrentTime- case V.toList rows of- [SubscriptionCheckpoint _ _ _ updatedAt] -> updatedAt `shouldSatisfy` (<= now)- _ -> expectationFailure "expected exactly one checkpoint"+ SubscriptionCheckpointInventory captured rows <- readInventory store+ captured `shouldBe` GlobalPosition 3+ checkpointKeys rows `shouldBe` [("single", 0, 2)]+ now <- getCurrentTime+ case V.toList rows of+ [SubscriptionCheckpoint _ _ _ updatedAt] -> updatedAt `shouldSatisfy` (<= now)+ _ -> expectationFailure "expected exactly one checkpoint" - it "returns multiple names and members in deterministic key order" $- withTestStore $ \store -> do- appendEvents store "inventory-many" 20- saveCheckpoint store "zeta" 2 7- saveCheckpoint store "alpha" 10 3- saveCheckpoint store "alpha" 2 5+ it "returns multiple names and members in deterministic key order" $+ withTestStore $ \store -> do+ appendEvents store "inventory-many" 20+ saveCheckpoint store "zeta" 2 7+ saveCheckpoint store "alpha" 10 3+ saveCheckpoint store "alpha" 2 5 - inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store- captured `shouldBe` GlobalPosition 20- checkpointKeys rows- `shouldBe` [ ("alpha", 2, 5)- , ("alpha", 10, 3)- , ("zeta", 2, 7)- ]- assertSqlRelationMatchesInventory store inventory+ inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store+ captured `shouldBe` GlobalPosition 20+ checkpointKeys rows+ `shouldBe` [ ("alpha", 2, 5),+ ("alpha", 10, 3),+ ("zeta", 2, 7)+ ]+ assertSqlRelationMatchesInventory store inventory - it "preserves monotonic checkpoints and observes later commits on a fresh read" $- withTestStore $ \store -> do- appendEvents store "inventory-monotonic" 10- saveCheckpoint store "monotonic" 0 8- saveCheckpoint store "monotonic" 0 4+ it "preserves monotonic checkpoints and observes later commits on a fresh read" $+ withTestStore $ \store -> do+ appendEvents store "inventory-monotonic" 10+ saveCheckpoint store "monotonic" 0 8+ saveCheckpoint store "monotonic" 0 4 - first <- readInventory store- inventoryKeys first `shouldBe` [("monotonic", 0, 8)]+ first <- readInventory store+ inventoryKeys first `shouldBe` [("monotonic", 0, 8)] - saveCheckpoint store "monotonic" 0 9- second <- readInventory store- inventoryKeys second `shouldBe` [("monotonic", 0, 9)]+ saveCheckpoint store "monotonic" 0 9+ second <- readInventory store+ inventoryKeys second `shouldBe` [("monotonic", 0, 9)] - it "retains a durable row after the worker stops and leaves live state" $- withTestStore $ \store -> do- appendEvents store "inventory-stopped" 1- waitForPublisher store (GlobalPosition 1)- let name = SubscriptionName "stopped"- handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Stop))- waitClean handle+ it "retains a durable row after the worker stops and leaves live state" $+ withTestStore $ \store -> do+ appendEvents store "inventory-stopped" 1+ waitForPublisher store (GlobalPosition 1)+ let name = SubscriptionName "stopped"+ handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Stop))+ waitClean handle - states <- subscriptionStates store- Map.member (name, 0) states `shouldBe` False- inventory <- readInventory store- inventoryKeys inventory `shouldBe` [("stopped", 0, 1)]- assertSqlRelationMatchesInventory store inventory+ states <- subscriptionStates store+ Map.member (name, 0) states `shouldBe` False+ inventory <- readInventory store+ inventoryKeys inventory `shouldBe` [("stopped", 0, 1)]+ assertSqlRelationMatchesInventory store inventory - it "does not expose in-flight live handler progress before checkpoint commit" $ do- caughtUp <- newEmptyMVar- enteredHandler <- newEmptyMVar- releaseHandler <- newEmptyMVar- let name = SubscriptionName "in-flight"- observe = caughtUpEventHandler name caughtUp Nothing- handler _ = do- putMVar enteredHandler ()- takeMVar releaseHandler- pure Stop- config = defaultSubscriptionConfig name AllStreams handler- withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do- appendEvents store "inventory-live" 1- saveCheckpoint store "in-flight" 0 1- waitForPublisher store (GlobalPosition 1)- handle <- subscribe store config- waitForSubscriptionLive caughtUp+ it "does not expose in-flight live handler progress before checkpoint commit" $ do+ caughtUp <- newEmptyMVar+ enteredHandler <- newEmptyMVar+ releaseHandler <- newEmptyMVar+ let name = SubscriptionName "in-flight"+ observe = caughtUpEventHandler name caughtUp Nothing+ handler _ = do+ putMVar enteredHandler ()+ takeMVar releaseHandler+ pure Stop+ config = defaultSubscriptionConfig name AllStreams handler+ withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+ appendEvents store "inventory-live" 1+ saveCheckpoint store "in-flight" 0 1+ waitForPublisher store (GlobalPosition 1)+ handle <- subscribe store config+ waitForSubscriptionLive caughtUp - appendEventsExisting store "inventory-live" 1- waitForMVar "live handler did not receive the event" enteredHandler- beforeCommit <- readInventory store- inventoryKeys beforeCommit `shouldBe` [("in-flight", 0, 1)]- assertSqlRelationMatchesInventory store beforeCommit+ appendEventsExisting store "inventory-live" 1+ waitForMVar "live handler did not receive the event" enteredHandler+ beforeCommit <- readInventory store+ inventoryKeys beforeCommit `shouldBe` [("in-flight", 0, 1)]+ assertSqlRelationMatchesInventory store beforeCommit - putMVar releaseHandler ()- waitClean handle- afterCommit <- readInventory store- inventoryKeys afterCommit `shouldBe` [("in-flight", 0, 2)]- assertSqlRelationMatchesInventory store afterCommit+ putMVar releaseHandler ()+ waitClean handle+ afterCommit <- readInventory store+ inventoryKeys afterCommit `shouldBe` [("in-flight", 0, 2)]+ assertSqlRelationMatchesInventory store afterCommit - it "observes the checkpoint advanced by a dead-letter transaction" $- withTestStore $ \store -> do- appendEvents store "inventory-dead-letter" 1- Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10- let event = V.head events- insertDeadLetterForEvent store "dead-lettered" event+ it "observes the checkpoint advanced by a dead-letter transaction" $+ withTestStore $ \store -> do+ appendEvents store "inventory-dead-letter" 1+ Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+ let event = V.head events+ insertDeadLetterForEvent store "dead-lettered" event - inventory <- readInventory store- inventoryKeys inventory `shouldBe` [("dead-lettered", 0, 1)]+ inventory <- readInventory store+ inventoryKeys inventory `shouldBe` [("dead-lettered", 0, 1)] - it "captures a head at or beyond every normally written checkpoint" $- withTestStore $ \store -> do- appendEvents store "inventory-bounds" 6- saveCheckpoint store "bounds-a" 0 2- saveCheckpoint store "bounds-b" 1 6+ it "captures a head at or beyond every normally written checkpoint" $+ withTestStore $ \store -> do+ appendEvents store "inventory-bounds" 6+ saveCheckpoint store "bounds-a" 0 2+ saveCheckpoint store "bounds-b" 1 6 - SubscriptionCheckpointInventory (GlobalPosition captured) rows <- readInventory store- let positions = [position | SubscriptionCheckpoint _ _ (GlobalPosition position) _ <- V.toList rows]- positions `shouldSatisfy` all (<= captured)+ SubscriptionCheckpointInventory (GlobalPosition captured) rows <- readInventory store+ let positions = [position | SubscriptionCheckpoint _ _ (GlobalPosition position) _ <- V.toList rows]+ positions `shouldSatisfy` all (<= captured) readInventory :: KirokuStore -> IO SubscriptionCheckpointInventory readInventory store = do- result <- runStoreIO store subscriptionCheckpointInventory- case result of- Left err -> error ("subscriptionCheckpointInventory failed: " <> show err)- Right inventory -> pure inventory+ result <- runStoreIO store subscriptionCheckpointInventory+ case result of+ Left err -> error ("subscriptionCheckpointInventory failed: " <> show err)+ Right inventory -> pure inventory saveCheckpoint :: KirokuStore -> Text -> Int32 -> Int64 -> IO () saveCheckpoint store name member position = do- result <- Pool.use (store ^. #pool) $ Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt- case result of- Left err -> error ("saveCheckpoint failed: " <> show err)- Right () -> pure ()+ result <- Pool.use (store ^. #pool) $ Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt+ case result of+ Left err -> error ("saveCheckpoint failed: " <> show err)+ Right () -> pure () appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store stream count = do- let events = [makeEvent ("E" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]- result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events- case result of- Left err -> error ("appendEvents failed: " <> show err)- Right _ -> pure ()+ let events = [makeEvent ("E" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+ result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+ case result of+ Left err -> error ("appendEvents failed: " <> show err)+ Right _ -> pure () appendEventsExisting :: KirokuStore -> Text -> Int -> IO () appendEventsExisting store stream count = do- let events = [makeEvent ("Live" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]- result <- runStoreIO store $ appendToStream (StreamName stream) StreamExists events- case result of- Left err -> error ("appendEventsExisting failed: " <> show err)- Right _ -> pure ()+ let events = [makeEvent ("Live" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+ result <- runStoreIO store $ appendToStream (StreamName stream) StreamExists events+ case result of+ Left err -> error ("appendEventsExisting failed: " <> show err)+ Right _ -> pure () waitClean :: SubscriptionHandle -> IO () waitClean handle = do- result <- waitWithTimeout 20_000_000 handle- case result of- Left message -> expectationFailure message- Right (Left err) -> expectationFailure ("subscription failed: " <> show err)- Right (Right ()) -> pure ()+ result <- waitWithTimeout 20_000_000 handle+ case result of+ Left message -> expectationFailure message+ Right (Left err) -> expectationFailure ("subscription failed: " <> show err)+ Right (Right ()) -> pure () waitForMVar :: String -> MVar () -> IO () waitForMVar failureMessage var = do- result <- timeout 5_000_000 (takeMVar var)- case result of- Nothing -> expectationFailure failureMessage- Just () -> pure ()+ result <- timeout 5_000_000 (takeMVar var)+ case result of+ Nothing -> expectationFailure failureMessage+ Just () -> pure () inventoryKeys :: SubscriptionCheckpointInventory -> [(Text, Int32, Int64)] inventoryKeys (SubscriptionCheckpointInventory _ rows) = checkpointKeys rows checkpointKeys :: V.Vector SubscriptionCheckpoint -> [(Text, Int32, Int64)] checkpointKeys rows =- [ (name, member, position)- | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <- V.toList rows- ]+ [ (name, member, position)+ | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <- V.toList rows+ ] assertSqlRelationMatchesInventory :: KirokuStore -> SubscriptionCheckpointInventory -> Expectation assertSqlRelationMatchesInventory store (SubscriptionCheckpointInventory _ inventoryRows) = do- result <- Pool.use (store ^. #pool) $ Session.statement () subscriptionCheckpointRelationStmt- case result of- Left err -> expectationFailure ("subscription checkpoint relation failed: " <> show err)- Right sqlRows ->- sqlRows- `shouldBe` [ (name, member, position, updatedAt)- | SubscriptionCheckpoint- (SubscriptionName name)- member- (GlobalPosition position)- updatedAt <-- V.toList inventoryRows- ]+ result <- Pool.use (store ^. #pool) $ Session.statement () subscriptionCheckpointRelationStmt+ case result of+ Left err -> expectationFailure ("subscription checkpoint relation failed: " <> show err)+ Right sqlRows ->+ sqlRows+ `shouldBe` [ (name, member, position, updatedAt)+ | SubscriptionCheckpoint+ (SubscriptionName name)+ member+ (GlobalPosition position)+ updatedAt <-+ V.toList inventoryRows+ ] subscriptionCheckpointRelationStmt :: Statement () [(Text, Int32, Int64, UTCTime)] subscriptionCheckpointRelationStmt =- preparable- """- SELECT subscription_name,- consumer_group_member,- checkpoint_position,- checkpoint_updated_at- FROM kiroku.subscription_checkpoints_v1- ORDER BY subscription_name, consumer_group_member- """- E.noParams- ( D.rowList- ( (,,,)- <$> D.column (D.nonNullable D.text)- <*> D.column (D.nonNullable D.int4)- <*> D.column (D.nonNullable D.int8)- <*> D.column (D.nonNullable D.timestamptz)- )+ preparable+ """+ SELECT subscription_name,+ consumer_group_member,+ checkpoint_position,+ checkpoint_updated_at+ FROM kiroku.subscription_checkpoints_v1+ ORDER BY subscription_name, consumer_group_member+ """+ E.noParams+ ( D.rowList+ ( (,,,)+ <$> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.int4)+ <*> D.column (D.nonNullable D.int8)+ <*> D.column (D.nonNullable D.timestamptz) )+ )
test/Test/SubscriptionCheckpointInventoryMock.hs view
@@ -15,31 +15,31 @@ spec :: Spec spec = describe "SubscriptionCheckpointInventory mock interpreter" $ do- it "returns a public inventory through one Store effect call" $ do- calls <- newIORef (0 :: Int)- let updatedAt = UTCTime (fromGregorian 2026 8 9) 0- expected =- SubscriptionCheckpointInventory- (GlobalPosition 17)- ( V.singleton $- SubscriptionCheckpoint- (SubscriptionName "mock")- 3- (GlobalPosition 11)- updatedAt- )- actual <- runEff $ runInventoryMock calls expected subscriptionCheckpointInventory- actual `shouldBe` expected- readIORef calls `shouldReturn` 1+ it "returns a public inventory through one Store effect call" $ do+ calls <- newIORef (0 :: Int)+ let updatedAt = UTCTime (fromGregorian 2026 8 9) 0+ expected =+ SubscriptionCheckpointInventory+ (GlobalPosition 17)+ ( V.singleton $+ SubscriptionCheckpoint+ (SubscriptionName "mock")+ 3+ (GlobalPosition 11)+ updatedAt+ )+ actual <- runEff $ runInventoryMock calls expected subscriptionCheckpointInventory+ actual `shouldBe` expected+ readIORef calls `shouldReturn` 1 runInventoryMock ::- (IOE :> es) =>- IORef Int ->- SubscriptionCheckpointInventory ->- Eff (Store : es) a ->- Eff es a+ (IOE :> es) =>+ IORef Int ->+ SubscriptionCheckpointInventory ->+ Eff (Store : es) a ->+ Eff es a runInventoryMock calls expected = interpret_ $ \case- GetSubscriptionCheckpointInventory -> do- liftIO $ modifyIORef' calls (+ 1)- pure expected- _ -> error "unexpected Store operation in inventory mock"+ GetSubscriptionCheckpointInventory -> do+ liftIO $ modifyIORef' calls (+ 1)+ pure expected+ _ -> error "unexpected Store operation in inventory mock"
test/Test/SubscriptionCheckpointReset.hs view
@@ -24,190 +24,190 @@ spec :: Spec spec = describe "subscription checkpoint reset" $ do- it "commits every persisted member and reports sorted affected and missing names exactly" $- withTestStore $ \store -> do- appendEvents store "reset-commit-events" 10- initializeRows store [("zeta", 1), ("alpha", 2), ("alpha", 0)]- createSentinelTable store+ it "commits every persisted member and reports sorted affected and missing names exactly" $+ withTestStore $ \store -> do+ appendEvents store "reset-commit-events" 10+ initializeRows store [("zeta", 1), ("alpha", 2), ("alpha", 0)]+ createSentinelTable store - result <- runStoreIO store $ runTransaction $ do- Tx.statement (1, "committed") insertSentinelStmt- resetSubscriptionCheckpointsTx- ( SubscriptionName "zeta"- :| [ SubscriptionName "missing"- , SubscriptionName "alpha"- , SubscriptionName "alpha"- ]- )- (GlobalPosition 7)+ result <- runStoreIO store $ runTransaction $ do+ Tx.statement (1, "committed") insertSentinelStmt+ resetSubscriptionCheckpointsTx+ ( SubscriptionName "zeta"+ :| [ SubscriptionName "missing",+ SubscriptionName "alpha",+ SubscriptionName "alpha"+ ]+ )+ (GlobalPosition 7) - result- `shouldBe` Right- SubscriptionCheckpointResetReport- { resetCheckpointKeys =- Vector.fromList- [ SubscriptionCheckpointKey (SubscriptionName "alpha") 0- , SubscriptionCheckpointKey (SubscriptionName "alpha") 2- , SubscriptionCheckpointKey (SubscriptionName "zeta") 1- ]- , missingSubscriptionNames = Vector.singleton (SubscriptionName "missing")- }- countSentinels store `shouldReturn` 1- inventoryKeys <$> inventory store- `shouldReturn` [ ("alpha", 0, 7)- , ("alpha", 2, 7)- , ("zeta", 1, 7)- ]+ result+ `shouldBe` Right+ SubscriptionCheckpointResetReport+ { resetCheckpointKeys =+ Vector.fromList+ [ SubscriptionCheckpointKey (SubscriptionName "alpha") 0,+ SubscriptionCheckpointKey (SubscriptionName "alpha") 2,+ SubscriptionCheckpointKey (SubscriptionName "zeta") 1+ ],+ missingSubscriptionNames = Vector.singleton (SubscriptionName "missing")+ }+ countSentinels store `shouldReturn` 1+ inventoryKeys <$> inventory store+ `shouldReturn` [ ("alpha", 0, 7),+ ("alpha", 2, 7),+ ("zeta", 1, 7)+ ] - it "rolls back the reset and an application-table write when the transaction is condemned" $- withTestStore $ \store -> do- appendEvents store "reset-rollback-events" 10- initializeRows store [("rollback", 0)]- _ <- reset store (SubscriptionName "rollback" :| []) (GlobalPosition 9)- createSentinelTable store+ it "rolls back the reset and an application-table write when the transaction is condemned" $+ withTestStore $ \store -> do+ appendEvents store "reset-rollback-events" 10+ initializeRows store [("rollback", 0)]+ _ <- reset store (SubscriptionName "rollback" :| []) (GlobalPosition 9)+ createSentinelTable store - result <- runStoreIO store $ runTransaction $ do- Tx.statement (2, "rolled back") insertSentinelStmt- report <-- resetSubscriptionCheckpointsTx- (SubscriptionName "rollback" :| [])- (GlobalPosition 3)- Tx.condemn- pure report+ result <- runStoreIO store $ runTransaction $ do+ Tx.statement (2, "rolled back") insertSentinelStmt+ report <-+ resetSubscriptionCheckpointsTx+ (SubscriptionName "rollback" :| [])+ (GlobalPosition 3)+ Tx.condemn+ pure report - result- `shouldBe` Right- SubscriptionCheckpointResetReport- { resetCheckpointKeys =- Vector.singleton- (SubscriptionCheckpointKey (SubscriptionName "rollback") 0)- , missingSubscriptionNames = Vector.empty- }- countSentinels store `shouldReturn` 0- inventoryKeys <$> inventory store `shouldReturn` [("rollback", 0, 9)]+ result+ `shouldBe` Right+ SubscriptionCheckpointResetReport+ { resetCheckpointKeys =+ Vector.singleton+ (SubscriptionCheckpointKey (SubscriptionName "rollback") 0),+ missingSubscriptionNames = Vector.empty+ }+ countSentinels store `shouldReturn` 0+ inventoryKeys <$> inventory store `shouldReturn` [("rollback", 0, 9)] - it "can rewind while later ordinary saves remain monotonic" $- withTestStore $ \store -> do- appendEvents store "reset-rewind-events" 10- initializeRows store [("rewind", 0)]- _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 8)- _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 4)- inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)]+ it "can rewind while later ordinary saves remain monotonic" $+ withTestStore $ \store -> do+ appendEvents store "reset-rewind-events" 10+ initializeRows store [("rewind", 0)]+ _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 8)+ _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 4)+ inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)] - saveCheckpoint store "rewind" 0 2- inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)]- saveCheckpoint store "rewind" 0 6- inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 6)]+ saveCheckpoint store "rewind" 0 2+ inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)]+ saveCheckpoint store "rewind" 0 6+ inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 6)] - it "reports missing names without manufacturing checkpoint rows" $- withTestStore $ \store -> do- result <-- reset- store- (SubscriptionName "absent-b" :| [SubscriptionName "absent-a"])- (GlobalPosition 5)- result- `shouldBe` SubscriptionCheckpointResetReport- { resetCheckpointKeys = Vector.empty- , missingSubscriptionNames =- Vector.fromList- [SubscriptionName "absent-a", SubscriptionName "absent-b"]- }- inventoryKeys <$> inventory store `shouldReturn` []+ it "reports missing names without manufacturing checkpoint rows" $+ withTestStore $ \store -> do+ result <-+ reset+ store+ (SubscriptionName "absent-b" :| [SubscriptionName "absent-a"])+ (GlobalPosition 5)+ result+ `shouldBe` SubscriptionCheckpointResetReport+ { resetCheckpointKeys = Vector.empty,+ missingSubscriptionNames =+ Vector.fromList+ [SubscriptionName "absent-a", SubscriptionName "absent-b"]+ }+ inventoryKeys <$> inventory store `shouldReturn` [] initializeRows :: KirokuStore -> [(Text, Int32)] -> IO () initializeRows store rows =- mapM_ initialize rows+ mapM_ initialize rows where initialize (name, member) = do- result <-- runStoreIO store $- initializeSubscriptionCheckpoint- (SubscriptionName name)- member- FromBeginning- case result of- Right (Right _) -> pure ()- other -> expectationFailure ("checkpoint initialization failed: " <> show other)+ result <-+ runStoreIO store $+ initializeSubscriptionCheckpoint+ (SubscriptionName name)+ member+ FromBeginning+ case result of+ Right (Right _) -> pure ()+ other -> expectationFailure ("checkpoint initialization failed: " <> show other) reset ::- KirokuStore ->- NonEmpty SubscriptionName ->- GlobalPosition ->- IO SubscriptionCheckpointResetReport+ KirokuStore ->+ NonEmpty SubscriptionName ->+ GlobalPosition ->+ IO SubscriptionCheckpointResetReport reset store names position = do- result <- runStoreIO store $ runTransaction $ resetSubscriptionCheckpointsTx names position- case result of- Left err -> expectationFailure ("checkpoint reset failed: " <> show err) >> error "unreachable"- Right report -> pure report+ result <- runStoreIO store $ runTransaction $ resetSubscriptionCheckpointsTx names position+ case result of+ Left err -> expectationFailure ("checkpoint reset failed: " <> show err) >> error "unreachable"+ Right report -> pure report inventory :: KirokuStore -> IO SubscriptionCheckpointInventory inventory store = do- result <- runStoreIO store subscriptionCheckpointInventory- case result of- Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"- Right rows -> pure rows+ result <- runStoreIO store subscriptionCheckpointInventory+ case result of+ Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"+ Right rows -> pure rows inventoryKeys :: SubscriptionCheckpointInventory -> [(Text, Int32, Int64)] inventoryKeys (SubscriptionCheckpointInventory _ rows) =- [ (name, member, position)- | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <-- Vector.toList rows- ]+ [ (name, member, position)+ | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <-+ Vector.toList rows+ ] appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store stream count = do- let events =- [makeEvent ("Reset" <> Text.pack (show i)) (Aeson.object []) | i <- [1 .. count]]- result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events- case result of- Left err -> expectationFailure ("append failed: " <> show err)- Right _ -> pure ()+ let events =+ [makeEvent ("Reset" <> Text.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+ result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+ case result of+ Left err -> expectationFailure ("append failed: " <> show err)+ Right _ -> pure () saveCheckpoint :: KirokuStore -> Text -> Int32 -> Int64 -> IO () saveCheckpoint store name member position = do- result <-- Pool.use (store ^. #pool) $- Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt- case result of- Left err -> expectationFailure ("ordinary checkpoint save failed: " <> show err)- Right () -> pure ()+ result <-+ Pool.use (store ^. #pool) $+ Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt+ case result of+ Left err -> expectationFailure ("ordinary checkpoint save failed: " <> show err)+ Right () -> pure () createSentinelTable :: KirokuStore -> IO () createSentinelTable store = do- result <- Pool.use (store ^. #pool) (Session.statement () createSentinelTableStmt)- case result of- Left err -> expectationFailure ("sentinel table creation failed: " <> show err)- Right () -> pure ()+ result <- Pool.use (store ^. #pool) (Session.statement () createSentinelTableStmt)+ case result of+ Left err -> expectationFailure ("sentinel table creation failed: " <> show err)+ Right () -> pure () countSentinels :: KirokuStore -> IO Int64 countSentinels store = do- result <- Pool.use (store ^. #pool) (Session.statement () countSentinelsStmt)- case result of- Left err -> expectationFailure ("sentinel count failed: " <> show err) >> error "unreachable"- Right count -> pure count+ result <- Pool.use (store ^. #pool) (Session.statement () countSentinelsStmt)+ case result of+ Left err -> expectationFailure ("sentinel count failed: " <> show err) >> error "unreachable"+ Right count -> pure count createSentinelTableStmt :: Statement () () createSentinelTableStmt =- unpreparable- "CREATE TABLE public.checkpoint_reset_sentinel \- \(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"- E.noParams- D.noResult+ unpreparable+ "CREATE TABLE public.checkpoint_reset_sentinel \+ \(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"+ E.noParams+ D.noResult insertSentinelStmt :: Statement (Int64, Text) () insertSentinelStmt =- preparable- "INSERT INTO public.checkpoint_reset_sentinel (id, value) VALUES ($1, $2)"- ( contrazip2- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.text))- )- D.noResult+ preparable+ "INSERT INTO public.checkpoint_reset_sentinel (id, value) VALUES ($1, $2)"+ ( contrazip2+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.text))+ )+ D.noResult countSentinelsStmt :: Statement () Int64 countSentinelsStmt =- preparable- "SELECT COUNT(*) FROM public.checkpoint_reset_sentinel"- E.noParams- (D.singleRow (D.column (D.nonNullable D.int8)))+ preparable+ "SELECT COUNT(*) FROM public.checkpoint_reset_sentinel"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.int8)))
test/Test/SubscriptionCheckpointWorker.hs view
@@ -28,224 +28,224 @@ spec :: Spec spec = describe "subscription checkpoint worker policies" $ do- it "starts a non-group worker FromBeginning and reports the durable seed" $ do- eventsRef <- newIORef []- resolutionsRef <- newIORef []- let name = SubscriptionName "worker-from-beginning"- observe evt = case evt of- KirokuEventSubscriptionCheckpointResolved initialization NonGroup ->- modifyIORef' resolutionsRef (initialization :)- _ -> pure ()- tweak settings = settings & #eventHandler .~ Just observe- withTestStoreSettings tweak $ \store -> do- _ <- appendBatch store "worker-from-beginning-events" 3- waitForPublisher store (GlobalPosition 3)- let handler event = do- modifyIORef' eventsRef (event ^. #globalPosition :)- seen <- length <$> readIORef eventsRef- pure (if seen >= 3 then Stop else Continue)- config =- (defaultSubscriptionConfig name AllStreams handler)- { missingCheckpointPolicy = FromBeginning- }- handle <- subscribe store config- expectClean handle+ it "starts a non-group worker FromBeginning and reports the durable seed" $ do+ eventsRef <- newIORef []+ resolutionsRef <- newIORef []+ let name = SubscriptionName "worker-from-beginning"+ observe evt = case evt of+ KirokuEventSubscriptionCheckpointResolved initialization NonGroup ->+ modifyIORef' resolutionsRef (initialization :)+ _ -> pure ()+ tweak settings = settings & #eventHandler .~ Just observe+ withTestStoreSettings tweak $ \store -> do+ _ <- appendBatch store "worker-from-beginning-events" 3+ waitForPublisher store (GlobalPosition 3)+ let handler event = do+ modifyIORef' eventsRef (event ^. #globalPosition :)+ seen <- length <$> readIORef eventsRef+ pure (if seen >= 3 then Stop else Continue)+ config =+ (defaultSubscriptionConfig name AllStreams handler)+ { missingCheckpointPolicy = FromBeginning+ }+ handle <- subscribe store config+ expectClean handle - reverse <$> readIORef eventsRef- `shouldReturn` fmap GlobalPosition [1, 2, 3]- inventoryPositions store- `shouldReturn` [(SubscriptionCheckpointKey name 0, GlobalPosition 3)]+ reverse <$> readIORef eventsRef+ `shouldReturn` fmap GlobalPosition [1, 2, 3]+ inventoryPositions store+ `shouldReturn` [(SubscriptionCheckpointKey name 0, GlobalPosition 3)] - readIORef resolutionsRef- `shouldReturn` [InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0)]+ readIORef resolutionsRef+ `shouldReturn` [InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0)] - it "forms a clean FromCurrentHead cut while appends race startup" $ do- initializationReady <- newEmptyMVar- deliveredRef <- newIORef []- let name = SubscriptionName "worker-current-head-race"- key = SubscriptionCheckpointKey name 0- observe evt = case evt of- KirokuEventSubscriptionCheckpointResolved initialization NonGroup- | initializationKey initialization == key ->- void (tryPutMVar initializationReady initialization)- _ -> pure ()- tweak settings = settings & #eventHandler .~ Just observe- withTestStoreSettings tweak $ \store -> do- _ <- appendBatch store "worker-current-head-race-events" 10- waitForPublisher store (GlobalPosition 10)- gate <- newEmptyMVar- let handler event = do- modifyIORef' deliveredRef (event ^. #globalPosition :)- pure $ if event ^. #eventType == EventType "RaceSentinel" then Stop else Continue- config =- (defaultSubscriptionConfig name AllStreams handler)- { missingCheckpointPolicy = FromCurrentHead- , batchSize = either (error . show) Prelude.id (mkBatchSize 3)- }- subscribeThread <- Async.async (takeMVar gate >> subscribe store config)- appendThread <- Async.async $ do- takeMVar gate- forM [1 .. 20 :: Int] $ \i ->- appendExisting store "worker-current-head-race-events" ("Racing" <> T.pack (show i))- putMVar gate ()- putMVar gate ()- handle <- Async.wait subscribeThread- initialization <- waitMVar "checkpoint resolution" initializationReady- racePositions <- Async.wait appendThread- raceTail <- case reverse racePositions of- [] -> expectationFailure "expected racing appends" >> error "unreachable"- position : _ -> pure position- let seed = checkpointInitializationPosition initialization- waitForPublisher store raceTail- finalPosition <- appendExisting store "worker-current-head-race-events" "RaceSentinel"- waitForPublisher store finalPosition- expectClean handle+ it "forms a clean FromCurrentHead cut while appends race startup" $ do+ initializationReady <- newEmptyMVar+ deliveredRef <- newIORef []+ let name = SubscriptionName "worker-current-head-race"+ key = SubscriptionCheckpointKey name 0+ observe evt = case evt of+ KirokuEventSubscriptionCheckpointResolved initialization NonGroup+ | initializationKey initialization == key ->+ void (tryPutMVar initializationReady initialization)+ _ -> pure ()+ tweak settings = settings & #eventHandler .~ Just observe+ withTestStoreSettings tweak $ \store -> do+ _ <- appendBatch store "worker-current-head-race-events" 10+ waitForPublisher store (GlobalPosition 10)+ gate <- newEmptyMVar+ let handler event = do+ modifyIORef' deliveredRef (event ^. #globalPosition :)+ pure $ if event ^. #eventType == EventType "RaceSentinel" then Stop else Continue+ config =+ (defaultSubscriptionConfig name AllStreams handler)+ { missingCheckpointPolicy = FromCurrentHead,+ batchSize = either (error . show) Prelude.id (mkBatchSize 3)+ }+ subscribeThread <- Async.async (takeMVar gate >> subscribe store config)+ appendThread <- Async.async $ do+ takeMVar gate+ forM [1 .. 20 :: Int] $ \i ->+ appendExisting store "worker-current-head-race-events" ("Racing" <> T.pack (show i))+ putMVar gate ()+ putMVar gate ()+ handle <- Async.wait subscribeThread+ initialization <- waitMVar "checkpoint resolution" initializationReady+ racePositions <- Async.wait appendThread+ raceTail <- case reverse racePositions of+ [] -> expectationFailure "expected racing appends" >> error "unreachable"+ position : _ -> pure position+ let seed = checkpointInitializationPosition initialization+ waitForPublisher store raceTail+ finalPosition <- appendExisting store "worker-current-head-race-events" "RaceSentinel"+ waitForPublisher store finalPosition+ expectClean handle - initialization `shouldBe` InitializedCheckpoint FromCurrentHead key seed- delivered <- reverse <$> readIORef deliveredRef- delivered `shouldBe` positionsAfter seed finalPosition+ initialization `shouldBe` InitializedCheckpoint FromCurrentHead key seed+ delivered <- reverse <$> readIORef deliveredRef+ delivered `shouldBe` positionsAfter seed finalPosition - it "initializes each consumer-group member independently at the current head" $ do- resolutionsVar <- newTVarIO []- handlerCalled <- newTVarIO False- let name = SubscriptionName "worker-current-head-members"- observe evt = case evt of- KirokuEventSubscriptionCheckpointResolved initialization GroupMember{} ->- atomically (modifyTVar' resolutionsVar (initialization :))- _ -> pure ()- tweak settings = settings & #eventHandler .~ Just observe- withTestStoreSettings tweak $ \store -> do- _ <- appendBatch store "worker-current-head-members-events" 6- waitForPublisher store (GlobalPosition 6)- let config member =- ( defaultSubscriptionConfig name AllStreams $ \_ -> do- atomically (writeTVar handlerCalled True)- pure Continue- )- { consumerGroup = Just (validConsumerGroup member 2)- , missingCheckpointPolicy = FromCurrentHead- }- handles <- mapM (subscribe store . config) [0, 1]- atomically $ do- resolutions <- readTVar resolutionsVar- check (length resolutions >= 2)- mapM_ cancel handles- mapM_ wait handles+ it "initializes each consumer-group member independently at the current head" $ do+ resolutionsVar <- newTVarIO []+ handlerCalled <- newTVarIO False+ let name = SubscriptionName "worker-current-head-members"+ observe evt = case evt of+ KirokuEventSubscriptionCheckpointResolved initialization GroupMember {} ->+ atomically (modifyTVar' resolutionsVar (initialization :))+ _ -> pure ()+ tweak settings = settings & #eventHandler .~ Just observe+ withTestStoreSettings tweak $ \store -> do+ _ <- appendBatch store "worker-current-head-members-events" 6+ waitForPublisher store (GlobalPosition 6)+ let config member =+ ( defaultSubscriptionConfig name AllStreams $ \_ -> do+ atomically (writeTVar handlerCalled True)+ pure Continue+ )+ { consumerGroup = Just (validConsumerGroup member 2),+ missingCheckpointPolicy = FromCurrentHead+ }+ handles <- mapM (subscribe store . config) [0, 1]+ atomically $ do+ resolutions <- readTVar resolutionsVar+ check (length resolutions >= 2)+ mapM_ cancel handles+ mapM_ wait handles - resolutions <- sortOn initializationKey <$> readTVarIO resolutionsVar- resolutions- `shouldBe` [ InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 0) (GlobalPosition 6)- , InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 6)- ]- readTVarIO handlerCalled `shouldReturn` False- inventoryPositions store- `shouldReturn` [ (SubscriptionCheckpointKey name 0, GlobalPosition 6)- , (SubscriptionCheckpointKey name 1, GlobalPosition 6)- ]+ resolutions <- sortOn initializationKey <$> readTVarIO resolutionsVar+ resolutions+ `shouldBe` [ InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 0) (GlobalPosition 6),+ InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 6)+ ]+ readTVarIO handlerCalled `shouldReturn` False+ inventoryPositions store+ `shouldReturn` [ (SubscriptionCheckpointKey name 0, GlobalPosition 6),+ (SubscriptionCheckpointKey name 1, GlobalPosition 6)+ ] - it "preserves FailIfMissing through the bracketed plain-IO entry point" $- withTestStore $ \store -> do- let name = SubscriptionName "worker-bracketed-missing"- key = SubscriptionCheckpointKey name 0- config =- (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))- { missingCheckpointPolicy = FailIfMissing- }- outcome <- withSubscription store config wait- case outcome of- Left err- | Just (SubscriptionCheckpointMissing actual) <- fromException err ->- actual `shouldBe` key- other -> expectationFailure ("expected bracketed missing refusal, got: " <> show other)+ it "preserves FailIfMissing through the bracketed plain-IO entry point" $+ withTestStore $ \store -> do+ let name = SubscriptionName "worker-bracketed-missing"+ key = SubscriptionCheckpointKey name 0+ config =+ (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))+ { missingCheckpointPolicy = FailIfMissing+ }+ outcome <- withSubscription store config wait+ case outcome of+ Left err+ | Just (SubscriptionCheckpointMissing actual) <- fromException err ->+ actual `shouldBe` key+ other -> expectationFailure ("expected bracketed missing refusal, got: " <> show other) - it "preserves FromCurrentHead through the higher-order effect entry point" $ do- initializationReady <- newEmptyMVar- deliveredRef <- newIORef []- let name = SubscriptionName "worker-effect-current-head"- key = SubscriptionCheckpointKey name 0- observe evt = case evt of- KirokuEventSubscriptionCheckpointResolved initialization NonGroup- | initializationKey initialization == key ->- void (tryPutMVar initializationReady initialization)- _ -> pure ()- tweak settings = settings & #eventHandler .~ Just observe- withTestStoreSettings tweak $ \store -> do- _ <- appendBatch store "worker-effect-current-head-events" 5- waitForPublisher store (GlobalPosition 5)- runEff $ SubEff.runSubscription store $ do- let config =- ( defaultSubscriptionConfig name AllStreams $ \event -> do- liftIO (modifyIORef' deliveredRef (event ^. #globalPosition :))- pure Stop- )- { missingCheckpointPolicy = FromCurrentHead- }- handle <- SubEff.subscribe config- initialization <- liftIO (waitMVar "effect checkpoint resolution" initializationReady)- liftIO $ initialization `shouldBe` InitializedCheckpoint FromCurrentHead key (GlobalPosition 5)- position <- liftIO $ appendExisting store "worker-effect-current-head-events" "EffectFuture"- liftIO (waitForPublisher store position)- liftIO (expectClean handle)- reverse <$> readIORef deliveredRef `shouldReturn` [GlobalPosition 6]+ it "preserves FromCurrentHead through the higher-order effect entry point" $ do+ initializationReady <- newEmptyMVar+ deliveredRef <- newIORef []+ let name = SubscriptionName "worker-effect-current-head"+ key = SubscriptionCheckpointKey name 0+ observe evt = case evt of+ KirokuEventSubscriptionCheckpointResolved initialization NonGroup+ | initializationKey initialization == key ->+ void (tryPutMVar initializationReady initialization)+ _ -> pure ()+ tweak settings = settings & #eventHandler .~ Just observe+ withTestStoreSettings tweak $ \store -> do+ _ <- appendBatch store "worker-effect-current-head-events" 5+ waitForPublisher store (GlobalPosition 5)+ runEff $ SubEff.runSubscription store $ do+ let config =+ ( defaultSubscriptionConfig name AllStreams $ \event -> do+ liftIO (modifyIORef' deliveredRef (event ^. #globalPosition :))+ pure Stop+ )+ { missingCheckpointPolicy = FromCurrentHead+ }+ handle <- SubEff.subscribe config+ initialization <- liftIO (waitMVar "effect checkpoint resolution" initializationReady)+ liftIO $ initialization `shouldBe` InitializedCheckpoint FromCurrentHead key (GlobalPosition 5)+ position <- liftIO $ appendExisting store "worker-effect-current-head-events" "EffectFuture"+ liftIO (waitForPublisher store position)+ liftIO (expectClean handle)+ reverse <$> readIORef deliveredRef `shouldReturn` [GlobalPosition 6] - it "preserves FailIfMissing through the Streamly bridge" $- withTestStore $ \store -> do- let name = SubscriptionName "worker-streamly-missing"- key = SubscriptionCheckpointKey name 0- config =- (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))- { missingCheckpointPolicy = FailIfMissing- }- (stream, cancelStream) <- subscriptionAckStream store config (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))- pulled <- finally (try (Stream.uncons stream)) cancelStream- case pulled of- Left err- | Just (SubscriptionCheckpointMissing actual) <- fromException (err :: SomeException) ->- actual `shouldBe` key- Left err -> expectationFailure ("expected typed Streamly refusal, got: " <> show err)- Right _ -> expectationFailure "expected Streamly bridge startup to fail"+ it "preserves FailIfMissing through the Streamly bridge" $+ withTestStore $ \store -> do+ let name = SubscriptionName "worker-streamly-missing"+ key = SubscriptionCheckpointKey name 0+ config =+ (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))+ { missingCheckpointPolicy = FailIfMissing+ }+ (stream, cancelStream) <- subscriptionAckStream store config (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))+ pulled <- finally (try (Stream.uncons stream)) cancelStream+ case pulled of+ Left err+ | Just (SubscriptionCheckpointMissing actual) <- fromException (err :: SomeException) ->+ actual `shouldBe` key+ Left err -> expectationFailure ("expected typed Streamly refusal, got: " <> show err)+ Right _ -> expectationFailure "expected Streamly bridge startup to fail" initializationKey :: CheckpointInitialization -> SubscriptionCheckpointKey initializationKey = \case- ExistingCheckpoint key _ -> key- InitializedCheckpoint _ key _ -> key+ ExistingCheckpoint key _ -> key+ InitializedCheckpoint _ key _ -> key positionsAfter :: GlobalPosition -> GlobalPosition -> [GlobalPosition] positionsAfter (GlobalPosition start) (GlobalPosition end) =- fmap GlobalPosition [start + 1 .. end]+ fmap GlobalPosition [start + 1 .. end] inventoryPositions :: KirokuStore -> IO [(SubscriptionCheckpointKey, GlobalPosition)] inventoryPositions store = do- Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory- pure- [ (SubscriptionCheckpointKey name member, position)- | SubscriptionCheckpoint name member position _ <- V.toList rows- ]+ Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory+ pure+ [ (SubscriptionCheckpointKey name member, position)+ | SubscriptionCheckpoint name member position _ <- V.toList rows+ ] appendBatch :: KirokuStore -> Text -> Int -> IO GlobalPosition appendBatch store stream count = do- let events = [makeEvent ("History" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]- Right result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events- pure (result ^. #globalPosition)+ let events = [makeEvent ("History" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+ Right result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+ pure (result ^. #globalPosition) appendExisting :: KirokuStore -> Text -> Text -> IO GlobalPosition appendExisting store stream typ = do- Right result <-- runStoreIO store $- appendToStream (StreamName stream) StreamExists [makeEvent typ (Aeson.object [])]- pure (result ^. #globalPosition)+ Right result <-+ runStoreIO store $+ appendToStream (StreamName stream) StreamExists [makeEvent typ (Aeson.object [])]+ pure (result ^. #globalPosition) expectClean :: SubscriptionHandle -> IO () expectClean handle = do- result <- waitWithTimeout 15_000_000 handle- case result of- Left message -> expectationFailure message- Right (Left err) -> expectationFailure ("subscription failed: " <> show err)- Right (Right ()) -> pure ()+ result <- waitWithTimeout 15_000_000 handle+ case result of+ Left message -> expectationFailure message+ Right (Left err) -> expectationFailure ("subscription failed: " <> show err)+ Right (Right ()) -> pure () waitMVar :: String -> MVar a -> IO a waitMVar label var = do- result <- Async.race (threadDelay 10_000_000) (takeMVar var)- case result of- Left () -> expectationFailure ("timed out waiting for " <> label) >> error "unreachable"- Right value -> pure value+ result <- Async.race (threadDelay 10_000_000) (takeMVar var)+ case result of+ Left () -> expectationFailure ("timed out waiting for " <> label) >> error "unreachable"+ Right value -> pure value
test/Test/SubscriptionDeadLetters.hs view
@@ -21,106 +21,109 @@ spec :: Spec spec = describe "SubscriptionDeadLetters" $ do- it "validates page sizes including both boundaries" $ do- forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)- forM_ [1, 1000] $ \n -> fmap subscriptionDeadLetterLimitValue (mkSubscriptionDeadLetterLimit n) `shouldBe` Right n- subscriptionDeadLetterLimitValue defaultSubscriptionDeadLetterLimit `shouldBe` 100- around withTestStore $ do- it "returns empty pages through both Store interpreters" $ \store -> do- let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "absent")- runStoreIO store (subscriptionDeadLetters query) `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)- (runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ subscriptionDeadLetters query)- `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)- it "pages without omissions, isolates names and matches the internal member read" $ \store -> do- events <- seedEvents store 7- mapM_ (insertDeadLetterForEvent store "page") (take 5 events)- mapM_ (insertDeadLetterForEvent store "other") (drop 5 events)- let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "page")){limit = validLimit 2}- first <- page store query- second <- page store query{after = first ^. #nextCursor}- third <- page store query{after = second ^. #nextCursor}- map positions [first, second, third] `shouldBe` [[5, 4], [3, 2], [1]]- third ^. #nextCursor `shouldBe` Nothing- Right internal <- Pool.use (store ^. #pool) (Session.statement ("page", 0) SQL.readDeadLettersStmt)- map SQL.deadLetterGlobalPosition (V.toList internal) `shouldBe` [5, 4, 3, 2, 1]- forM_ [5, 1000] $ \n -> do- final <- page store query{limit = validLimit n}- positions final `shouldBe` [5, 4, 3, 2, 1]- final ^. #nextCursor `shouldBe` Nothing- it "merges historical members and resolves same-position ties with an exclusive cursor" $ \store -> do- [event] <- seedEvents store 1- forM_ [0, 1, 9] $ \member -> insertDeadLetterWith store "ties" member (object []) "tie" 2 event- let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "ties")){limit = validLimit 1}- first <- page store query- second <- page store query{after = first ^. #nextCursor}- third <- page store query{after = second ^. #nextCursor}- map (\p -> map (\row -> row ^. #consumerGroupMember) (V.toList (p ^. #deadLetters))) [first, second, third] `shouldBe` [[9], [1], [0]]- third ^. #nextCursor `shouldBe` Nothing- forM_ [0, 1, 9, 7] $ \member -> do- selected <- page store (query & #consumerGroupMember .~ Just member)- positions selected `shouldBe` if member == 7 then [] else [1]- it "preserves arbitrary and decode-failure JSON unchanged" $ \store -> do- events <- seedEvents store 2- let reasons = [object ["kind" .= ("other" :: T.Text), "detail" .= object ["code" .= (42 :: Int)]], object ["kind" .= ("decode_failure" :: T.Text), "detail" .= ("bad version" :: T.Text)]]- forM_ (zip events reasons) $ \(event, reason) -> insertDeadLetterWith store "json" 0 reason "summary" 3 event- result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "json"))- map (\row -> row ^. #reason) (V.toList (result ^. #deadLetters)) `shouldBe` reverse reasons- now <- getCurrentTime- forM_ (V.toList (result ^. #deadLetters)) $ \row -> do- row ^. #attemptCount `shouldBe` 3- row ^. #createdAt `shouldSatisfy` (<= now)- it "never invokes a configured event decode hook for dead-letter reads" $ \_ ->- withTestStoreSettings (\settings -> (settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just (\_ -> fail "dead-letter read invoked decode hook")})) $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "hook-1") NoStream [makeEvent "E" (object [])]- Right () <- Pool.use (store ^. #pool) (Session.script "INSERT INTO dead_letters(subscription_name,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'hook',1,event_id,'{}','fixture',1 FROM events")- result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "hook"))- positions result `shouldBe` [1]+ it "validates page sizes including both boundaries" $ do+ forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)+ forM_ [1, 1000] $ \n -> fmap subscriptionDeadLetterLimitValue (mkSubscriptionDeadLetterLimit n) `shouldBe` Right n+ subscriptionDeadLetterLimitValue defaultSubscriptionDeadLetterLimit `shouldBe` 100+ around withTestStore $ do+ it "returns empty pages through both Store interpreters" $ \store -> do+ let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "absent")+ runStoreIO store (subscriptionDeadLetters query) `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)+ (runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ subscriptionDeadLetters query)+ `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)+ it "pages without omissions, isolates names and matches the internal member read" $ \store -> do+ events <- seedEvents store 7+ mapM_ (insertDeadLetterForEvent store "page") (take 5 events)+ mapM_ (insertDeadLetterForEvent store "other") (drop 5 events)+ let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "page")) {limit = validLimit 2}+ first <- page store query+ second <- page store query {after = first ^. #nextCursor}+ third <- page store query {after = second ^. #nextCursor}+ map positions [first, second, third] `shouldBe` [[5, 4], [3, 2], [1]]+ third ^. #nextCursor `shouldBe` Nothing+ Right internal <- Pool.use (store ^. #pool) (Session.statement ("page", 0) SQL.readDeadLettersStmt)+ map SQL.deadLetterGlobalPosition (V.toList internal) `shouldBe` [5, 4, 3, 2, 1]+ forM_ [5, 1000] $ \n -> do+ final <- page store query {limit = validLimit n}+ positions final `shouldBe` [5, 4, 3, 2, 1]+ final ^. #nextCursor `shouldBe` Nothing+ it "merges historical members and resolves same-position ties with an exclusive cursor" $ \store -> do+ [event] <- seedEvents store 1+ forM_ [0, 1, 9] $ \member -> insertDeadLetterWith store "ties" member (object []) "tie" 2 event+ let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "ties")) {limit = validLimit 1}+ first <- page store query+ second <- page store query {after = first ^. #nextCursor}+ third <- page store query {after = second ^. #nextCursor}+ map (\p -> map (\row -> row ^. #consumerGroupMember) (V.toList (p ^. #deadLetters))) [first, second, third] `shouldBe` [[9], [1], [0]]+ third ^. #nextCursor `shouldBe` Nothing+ forM_ [0, 1, 9, 7] $ \member -> do+ selected <- page store (query & #consumerGroupMember .~ Just member)+ positions selected `shouldBe` if member == 7 then [] else [1]+ it "preserves arbitrary and decode-failure JSON unchanged" $ \store -> do+ events <- seedEvents store 2+ let reasons = [object ["kind" .= ("other" :: T.Text), "detail" .= object ["code" .= (42 :: Int)]], object ["kind" .= ("decode_failure" :: T.Text), "detail" .= ("bad version" :: T.Text)]]+ forM_ (zip events reasons) $ \(event, reason) -> insertDeadLetterWith store "json" 0 reason "summary" 3 event+ result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "json"))+ map (\row -> row ^. #reason) (V.toList (result ^. #deadLetters)) `shouldBe` reverse reasons+ now <- getCurrentTime+ forM_ (V.toList (result ^. #deadLetters)) $ \row -> do+ row ^. #attemptCount `shouldBe` 3+ row ^. #createdAt `shouldSatisfy` (<= now)+ it "never invokes a configured event decode hook for dead-letter reads" $ \_ ->+ withTestStoreSettings (\settings -> (settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just (\_ -> fail "dead-letter read invoked decode hook")})) $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "hook-1") NoStream [makeEvent "E" (object [])]+ Right () <- Pool.use (store ^. #pool) (Session.script "INSERT INTO dead_letters(subscription_name,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'hook',1,event_id,'{}','fixture',1 FROM events")+ result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "hook"))+ positions result `shouldBe` [1] - it "keeps a cursor valid after its event and row are hard-deleted" $ \store -> do- events <- seedEvents store 3- mapM_ (insertDeadLetterForEvent store "delete") events- let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "delete")){limit = validLimit 1}- first <- page store query- Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dead-3")- older <- page store query{after = first ^. #nextCursor, limit = validLimit 10}- positions older `shouldBe` [2, 1]- it "includes legal maxBound pairs on first pages" $ \store -> do- [event] <- seedEvents store 1- insertDeadLetterForEvent store "boundary" event- Right () <- Pool.use (store ^. #pool) (Session.script "UPDATE dead_letters SET global_position=9223372036854775807, dead_letter_id=9223372036854775807 WHERE subscription_name='boundary'")- let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "boundary")- forM_ [Nothing, Just 0] $ \member -> do- result <- page store (query & #consumerGroupMember .~ member)- positions result `shouldBe` [maxBound]- older <- page store (query & #consumerGroupMember .~ member & #after .~ Just (SubscriptionDeadLetterCursor (GlobalPosition maxBound) maxBound))- positions older `shouldBe` []- it "reads a real worker-produced poison reason without changing its checkpoint" $ \store -> do- events <- seedEvents store 3- waitForPublisher store (GlobalPosition 3)- handle <- subscribe store $ defaultSubscriptionConfig (SubscriptionName "worker") AllStreams $ \event -> pure $ case event ^. #globalPosition of- GlobalPosition 2 -> DeadLetter (DeadLetterPoison "boom")- GlobalPosition 3 -> Stop- _ -> Continue- waitWithTimeout 10_000_000 handle >>= \case- Right (Right ()) -> pure ()- other -> expectationFailure (show other)- result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "worker"))- case V.toList (result ^. #deadLetters) of- [row] -> do- row ^. #eventId `shouldBe` (events !! 1) ^. #eventId- row ^. #reason `shouldBe` object ["kind" .= ("poison" :: T.Text), "detail" .= ("boom" :: T.Text)]- row ^. #reasonSummary `shouldBe` "poison: boom"- _ -> expectationFailure (show result)- Pool.use (store ^. #pool) (Session.statement ("worker", 0) SQL.getCheckpointMemberStmt) `shouldReturn` Right (Just 3)+ it "keeps a cursor valid after its event and row are hard-deleted" $ \store -> do+ events <- seedEvents store 3+ mapM_ (insertDeadLetterForEvent store "delete") events+ let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "delete")) {limit = validLimit 1}+ first <- page store query+ Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dead-3")+ older <- page store query {after = first ^. #nextCursor, limit = validLimit 10}+ positions older `shouldBe` [2, 1]+ it "includes legal maxBound pairs on first pages" $ \store -> do+ [event] <- seedEvents store 1+ insertDeadLetterForEvent store "boundary" event+ Right () <- Pool.use (store ^. #pool) (Session.script "UPDATE dead_letters SET global_position=9223372036854775807, dead_letter_id=9223372036854775807 WHERE subscription_name='boundary'")+ let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "boundary")+ forM_ [Nothing, Just 0] $ \member -> do+ result <- page store (query & #consumerGroupMember .~ member)+ positions result `shouldBe` [maxBound]+ older <- page store (query & #consumerGroupMember .~ member & #after .~ Just (SubscriptionDeadLetterCursor (GlobalPosition maxBound) maxBound))+ positions older `shouldBe` []+ it "reads a real worker-produced poison reason without changing its checkpoint" $ \store -> do+ events <- seedEvents store 3+ waitForPublisher store (GlobalPosition 3)+ handle <- subscribe store $ defaultSubscriptionConfig (SubscriptionName "worker") AllStreams $ \event -> pure $ case event ^. #globalPosition of+ GlobalPosition 2 -> DeadLetter (DeadLetterPoison "boom")+ GlobalPosition 3 -> Stop+ _ -> Continue+ waitWithTimeout 10_000_000 handle >>= \case+ Right (Right ()) -> pure ()+ other -> expectationFailure (show other)+ result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "worker"))+ case V.toList (result ^. #deadLetters) of+ [row] -> do+ row ^. #eventId `shouldBe` (events !! 1) ^. #eventId+ row ^. #reason `shouldBe` object ["kind" .= ("poison" :: T.Text), "detail" .= ("boom" :: T.Text)]+ row ^. #reasonSummary `shouldBe` "poison: boom"+ _ -> expectationFailure (show result)+ Pool.use (store ^. #pool) (Session.statement ("worker", 0) SQL.getCheckpointMemberStmt) `shouldReturn` Right (Just 3) validLimit :: Int32 -> SubscriptionDeadLetterLimit validLimit = either (error . show) (\value -> value) . mkSubscriptionDeadLetterLimit+ page :: KirokuStore -> SubscriptionDeadLetterQuery -> IO SubscriptionDeadLetterPage page store query = runStoreIO store (subscriptionDeadLetters query) >>= either (fail . show) pure+ positions :: SubscriptionDeadLetterPage -> [Int64] positions result = map (\row -> let GlobalPosition p = row ^. #globalPosition in p) (V.toList (result ^. #deadLetters))+ seedEvents :: KirokuStore -> Int -> IO [RecordedEvent] seedEvents store count = do- forM_ [1 .. count] $ \n -> void $ runStoreIO store $ appendToStream (StreamName ("dead-" <> T.pack (show n))) NoStream [makeEvent "E" (object [])]- Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) (fromIntegral count)- pure (V.toList events)+ forM_ [1 .. count] $ \n -> void $ runStoreIO store $ appendToStream (StreamName ("dead-" <> T.pack (show n))) NoStream [makeEvent "E" (object [])]+ Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) (fromIntegral count)+ pure (V.toList events)
test/Test/SubscriptionDeadLettersMock.hs view
@@ -10,14 +10,14 @@ spec :: Spec spec = describe "SubscriptionDeadLetters mock interpreter" $ it "dispatches the public wrapper once with the entire query and returns the interpreter page" $ do- calls <- newIORef (0 :: Int)- let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "mock")){consumerGroupMember = Just 9, after = Just (SubscriptionDeadLetterCursor (GlobalPosition 21) 7)}- expected = SubscriptionDeadLetterPage V.empty (Just (SubscriptionDeadLetterCursor (GlobalPosition 10) 3))- runner = interpret_ $ \case- ListSubscriptionDeadLetters actual -> do- liftIO $ actual `shouldBe` query- liftIO $ modifyIORef' calls (+ 1)- pure expected- _ -> error "unexpected Store operation"- runEff (runner (subscriptionDeadLetters query)) `shouldReturn` expected- readIORef calls `shouldReturn` 1+ calls <- newIORef (0 :: Int)+ let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "mock")) {consumerGroupMember = Just 9, after = Just (SubscriptionDeadLetterCursor (GlobalPosition 21) 7)}+ expected = SubscriptionDeadLetterPage V.empty (Just (SubscriptionDeadLetterCursor (GlobalPosition 10) 3))+ runner = interpret_ $ \case+ ListSubscriptionDeadLetters actual -> do+ liftIO $ actual `shouldBe` query+ liftIO $ modifyIORef' calls (+ 1)+ pure expected+ _ -> error "unexpected Store operation"+ runEff (runner (subscriptionDeadLetters query)) `shouldReturn` expected+ readIORef calls `shouldReturn` 1
+ test/Test/SubscriptionNamespace.hs view
@@ -0,0 +1,375 @@+module Test.SubscriptionNamespace (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Concurrent.STM (atomically, newTVarIO, readTVarIO)+import Control.Exception (bracket, fromException)+import Control.Lens ((^.))+import Control.Monad (forM_, replicateM_, when)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int32, Int64)+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (preparable)+import Hasql.Transaction qualified as Transaction+import Hasql.Transaction.Sessions qualified as Tx+import Kiroku.Store+import Kiroku.Store.Notification qualified as Notify+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Subscription.Fsm qualified as Fsm+import Kiroku.Store.Subscription.Worker (withNamespaceSafetyPollForTest, withNamespaceWindowHookForTest, withSaveCheckpointHookForTest)+import System.Timeout (timeout)+import Test.Helpers+import Test.Hspec++spec :: Spec+spec = describe "namespace subscriptions" $ do+ forM_ [1, 3] $ \width ->+ it ("catches up across empty windows, includes future categories, and restarts (W=" <> show width <> ")") $+ withTestStore $ \store -> do+ replicateM_ 9 (append store "noise-1")+ append store "pm-1"+ append store "pm:fulfillment-1"+ append store "pm:billing-1"+ append store "orders-1"+ seen <- newIORef []+ let config = (cfg "restart" "pm" (record seen)) {batchSize = batch width}+ withSubscription store config $ \handle -> do+ awaitLive handle+ positions seen `shouldReturn` [10, 11, 12]+ checkpoint store "restart" 0 `shouldReturn` Just 13+ append store "pm:new-1"+ await (positions seen) (== [10, 11, 12, 14])+ await (checkpoint store "restart" 0) (== Just 14)+ Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True+ restarted <- subscribe store ((cfg "restart" "pm" (\event -> record seen event >> pure Stop)) {batchSize = batch width})+ append store "pm:newer-1"+ clean restarted+ positions seen `shouldReturn` [10, 11, 12, 14, 15]+ binding store "restart" `shouldReturn` ("category_namespace", Just "pm")++ forM_+ [ ("pm", ["pm", "pm:a", "pm:a:b"], ["pmm", "pmish", "orders"]),+ ("pm:a", ["pm:a", "pm:a:b"], ["pm", "pm:ab"]),+ ("%_\\,雪", ["%_\\,雪", "%_\\,雪:子"], ["xx雪", "%X\\,雪"]),+ ("", ["", ":", ":a"], ["pm", "orders"]),+ ("pm:", ["pm:", "pm::child"], ["pm", "pm:child"]),+ ("pm-x", [], ["pm", "pm:x", "pm-x"])+ ]+ $ \(namespace, included, excluded) ->+ it ("matches literal category boundaries for " <> show namespace) $+ withTestStore $ \store -> do+ forM_ (included <> excluded) $ \category -> append store (category <> "-1")+ seen <- newIORef []+ withSubscription store (cfg "literal" namespace (record seen)) $ \handle -> do+ awaitLive handle+ positions seen `shouldReturn` [1 .. fromIntegral (length included)]++ it "keeps Stop at the stopping event, then replays the rest of a prefetched window" $+ withTestStore $ \store -> do+ forM_ ["pm-1", "pm:a-1", "pm:b-1", "noise-1"] (append store)+ first <- subscribe store (cfg "stop" "pm" (\_ -> pure Stop))+ clean first+ checkpoint store "stop" 0 `shouldReturn` Just 1+ seen <- newIORef []+ withSubscription store (cfg "stop" "pm" (record seen)) $ \handle -> do+ awaitLive handle+ positions seen `shouldReturn` [2, 3]+ checkpoint store "stop" 0 `shouldReturn` Just 4++ it "does not publish speculative completion while a handler is blocked or cancelled" $+ withTestStore $ \store -> do+ append store "pm-1"+ append store "noise-1"+ entered <- newEmptyMVar+ blocked <- newEmptyMVar+ withSubscription store (cfg "cancel" "pm" (\_ -> putMVar entered () >> takeMVar blocked >> pure Continue)) $ \handle -> do+ bounded (takeMVar entered)+ checkpoint store "cancel" 0 `shouldReturn` Just 0+ currentState handle >>= \case+ Just (Fsm.CatchingUp (GlobalPosition 0) _) -> pure ()+ other -> expectationFailure (show other)+ seen <- newIORef []+ withSubscription store (cfg "cancel" "pm" (record seen)) $ \handle -> do+ awaitLive handle+ positions seen `shouldReturn` [1]+ checkpoint store "cancel" 0 `shouldReturn` Just 2++ it "resolves Retry, DeadLetter, filtering and completed trailing rows with one tail save" $+ withTestStore $ \store -> do+ append store "pm-1"+ append store "pm:a-1"+ append store "noise-1"+ attempts <- newIORef (0 :: Int)+ saves <- newIORef []+ let handler' event+ | event ^. #globalPosition == GlobalPosition 1 = do+ attempt <- atomicModifyIORef' attempts (\n -> (n + 1, n))+ pure (if attempt == 0 then Retry (RetryDelay 0) else Continue)+ | otherwise = pure (DeadLetter (DeadLetterPoison "test"))+ withSaveCheckpointHookForTest (\_ p -> atomicModifyIORef' saves (\ps -> (p : ps, ()))) $+ withSubscription store (cfg "dispositions" "pm" handler') awaitLive+ readIORef attempts `shouldReturn` 2+ readIORef saves `shouldReturn` [GlobalPosition 3]+ checkpoint store "dispositions" 0 `shouldReturn` Just 3+ binding store "dispositions" `shouldReturn` ("category_namespace", Just "pm")+ let filtered = (cfg "filter" "pm" (\_ -> expectationFailure "filtered handler ran" >> pure Continue)) {selector = Just (const False)}+ withSubscription store filtered awaitLive+ checkpoint store "filter" 0 `shouldReturn` Just 3++ it "coalesces empty-window saves at 32 windows and the head" $+ withTestStore $ \store -> do+ replicateM_ 70 (append store "noise-1")+ saves <- newIORef []+ withSaveCheckpointHookForTest (\_ p -> atomicModifyIORef' saves (\ps -> (ps <> [p], ()))) $+ withSubscription store ((cfg "coalesce" "pm" (\_ -> pure Continue)) {batchSize = batch 1}) awaitLive+ readIORef saves `shouldReturn` map GlobalPosition [32, 64, 70]++ it "does not mistake hard-deleted tail gaps or an empty window for pending delivery" $+ withTestStore $ \store -> do+ append store "pm-1"+ append store "gone-1"+ Right _ <- runStoreIO store (hardDeleteStream (StreamName "gone-1"))+ seen <- newIORef []+ withSubscription store ((cfg "gaps" "pm" (record seen)) {batchSize = batch 1}) awaitLive+ positions seen `shouldReturn` [1]+ checkpoint store "gaps" 0 `shouldReturn` Just 2++ it "partitions plain and grouped namespaces completely with per-member empty windows" $+ withTestStore $ \store -> do+ forM_ [1 .. 25 :: Int] $ \n -> do+ append store "noise-1"+ append store ("pm:" <> T.pack (show n) <> "-1")+ members <- mapM (const (newIORef [])) [0 .. 2 :: Int32]+ forM_ (zip [0 ..] members) $ \(index, seen) ->+ withSubscription store ((cfg "group" "pm" (record seen)) {consumerGroup = Just (validConsumerGroup index 3), batchSize = batch 1}) $ \handle -> do+ awaitLive handle+ checkpoint store "group" index `shouldReturn` Just 50+ delivered <- concat <$> mapM positions members+ sort delivered `shouldBe` [2, 4 .. 50]++ it "retries catch-up and live fetch failures without skipping matching rows" $+ withTestStore $ \store -> do+ append store "pm-1"+ remaining <- newIORef True+ seen <- newIORef []+ let inject _ _ _ = do+ failNow <- atomicModifyIORef' remaining (\b -> (False, b))+ pure (if failNow then Just (Left Pool.AcquisitionTimeoutUsageError) else Nothing)+ withNamespaceWindowHookForTest inject $+ withSubscription store (cfg "fetch-fault" "pm" (record seen)) $ \handle -> do+ awaitLive handle+ writeIORef remaining True+ append store "pm:new-1"+ await (positions seen) (== [1, 2])+ await (checkpoint store "fetch-fault" 0) (== Just 2)++ it "replays after ordinary save failure and never persists an unfinished atomic dead letter" $+ withTestStore $ \store -> do+ append store "pm-1"+ append store "noise-1"+ -- A database trigger injects actual ordinary/atomic SQL failures, rather+ -- than throwing from a seam before attempting a save.+ sql store "CREATE FUNCTION fail_namespace_save() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.last_seen > 0 THEN RAISE EXCEPTION 'injected save failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER fail_namespace_save BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION fail_namespace_save()"+ withSubscription store (cfg "save-fault" "pm" (\_ -> pure Continue)) awaitLive+ checkpoint store "save-fault" 0 `shouldReturn` Just 0+ failed <- subscribe store (cfg "atomic-fault" "pm" (\_ -> pure (DeadLetter (DeadLetterPoison "fail"))))+ waitWithTimeout 5_000_000 failed >>= \case+ Right (Left _) -> pure ()+ other -> expectationFailure (show other)+ checkpoint store "atomic-fault" 0 `shouldReturn` Just 0+ sql store "DROP TRIGGER fail_namespace_save ON subscriptions"+ seen <- newIORef []+ withSubscription store (cfg "save-fault" "pm" (record seen)) awaitLive+ positions seen `shouldReturn` [1]+ checkpoint store "save-fault" 0 `shouldReturn` Just 2++ it "preserves namespace identity through rebind, resize, adoption and strict startup" $+ withTestStore $ \store -> do+ withSubscription store (cfg "binding" "pm" (\_ -> pure Continue)) awaitLive+ mismatch <- subscribe store (defaultSubscriptionConfig (SubscriptionName "binding") (Category (CategoryName "pm")) (\_ -> pure Stop))+ waitWithTimeout 5_000_000 mismatch >>= \case+ Right (Left e) -> (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)+ other -> expectationFailure (show other)+ let tx :: Transaction.Transaction a -> IO a+ tx action = Pool.use (store ^. #pool) (Tx.transaction Tx.ReadCommitted Tx.Write action) >>= either (fail . show) pure+ report <- tx (rebindSubscriptionTargetTx (SubscriptionName "binding") (CategoryNamespace "pm:a") (GlobalPosition 0))+ previousBindings report `shouldBe` V.singleton (Just (CategoryNamespace "pm"))+ _ <- tx (resizeConsumerGroupTx (SubscriptionName "binding") (either (error . show) Prelude.id (mkConsumerGroupSize 2)))+ withSubscription store ((cfg "binding" "pm:a" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}) awaitLive+ _ <- tx (resizeConsumerGroupTx (SubscriptionName "adopt") (either (error . show) Prelude.id (mkConsumerGroupSize 2)))+ withSubscription store ((cfg "adopt" "pm" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 0 2)}) awaitLive+ withSubscription store ((cfg "adopt" "pm" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}) awaitLive++ it "routes only exact/ancestor namespace cells and releases shared registrations idempotently" $ do+ registry <- newTVarIO Map.empty+ (pm, release1) <- atomically (Notify.registerNamespace registry "pm")+ (same, release2) <- atomically (Notify.registerNamespace registry "pm")+ (nested, release3) <- atomically (Notify.registerNamespace registry "pm:a")+ (empty, release4) <- atomically (Notify.registerNamespace registry "")+ (colon, release5) <- atomically (Notify.registerNamespace registry "pm:")+ (pm == same) `shouldBe` True+ atomically (Notify.notifyNamespace registry "pm:a:b")+ mapM readTVarIO [pm, nested, empty, colon] `shouldReturn` [1, 1, 0, 0]+ atomically (Notify.notifyNamespace registry "pm::child" >> Notify.notifyNamespace registry ":child")+ mapM readTVarIO [pm, nested, empty, colon] `shouldReturn` [2, 1, 1, 1]+ atomically (release1 >> release1)+ Map.size <$> readTVarIO registry `shouldReturn` 4+ atomically (release2 >> release3 >> release4 >> release5)+ Map.null <$> readTVarIO registry `shouldReturn` True+ (replacement, release6) <- atomically (Notify.registerNamespace registry "pm")+ atomically (release2 >> Notify.notifyNamespace registry "pm")+ readTVarIO replacement `shouldReturn` 1+ atomically release6+ -- Empty registry path must not even evaluate a category.+ atomically (Notify.notifyNamespace registry (error "empty registry evaluated category"))++ it "suppresses unrelated live fetches and recovers a lost notification at the safety tick" $+ withNamespaceSafetyPollForTest 200_000 $+ withTestStore $ \store -> do+ fetches <- newIORef (0 :: Int)+ seen <- newIORef []+ withNamespaceWindowHookForTest (\_ _ _ -> atomicModifyIORef' fetches (\n -> (n + 1, Nothing))) $+ withSubscription store (cfg "idle" "pm" (record seen)) $ \handle -> do+ awaitLive handle+ append store "noise-1"+ -- Wait for the actual listener to process the unrelated notification.+ await (readTVarIO (Notify.categoryGenerations (store ^. #notifier))) (Map.member "noise")+ readIORef fetches `shouldReturn` 0+ await (readIORef fetches) (> 0)+ -- Temporarily remove the sole registry entry to simulate a lost wake.+ sql store "ALTER TABLE streams DISABLE TRIGGER stream_events_notify_insert; ALTER TABLE streams DISABLE TRIGGER stream_events_notify_update"+ append store "pm:new-1"+ sql store "ALTER TABLE streams ENABLE TRIGGER stream_events_notify_insert; ALTER TABLE streams ENABLE TRIGGER stream_events_notify_update"+ await (positions seen) (== [2])++ it "keeps a captured head finite and does not lose a notification during its drain" $+ withTestStore $ \store -> do+ append store "pm-1"+ captured <- newIORef False+ seen <- newIORef []+ let inject _ _ headPosition = do+ first <- atomicModifyIORef' captured (\done -> (True, not done))+ when first $ do+ headPosition `shouldBe` GlobalPosition 1+ append store "pm:new-1"+ await (readTVarIO (Notify.categoryGenerations (store ^. #notifier))) (Map.member "pm:new")+ pure Nothing+ withNamespaceWindowHookForTest inject $+ withSubscription store ((cfg "race" "pm" (record seen)) {batchSize = batch 1}) $ \handle -> do+ awaitLive handle+ await (positions seen) (== [1, 2])+ await (checkpoint store "race" 0) (== Just 2)++ it "never advances past an unhandled decode failure, and ignores unrelated decode failures" $ do+ let reject event = pure (Left (DecodeFailure (event ^. #eventId) "test refusal"))+ broken :: ConnectionSettings -> ConnectionSettings+ broken settings = settings {storeSettings = defaultStoreSettings {decodeHook = Just reject}}+ withTestStoreSettings broken $ \store -> do+ append store "noise-1"+ withSubscription store (cfg "unrelated-decode" "pm" (\_ -> expectationFailure "unrelated handler" >> pure Stop)) awaitLive+ checkpoint store "unrelated-decode" 0 `shouldReturn` Just 1+ append store "pm-1"+ append store "noise-1"+ handle <- subscribe store ((cfg "decode" "pm" (\_ -> expectationFailure "undecodable handler" >> pure Stop)) {retryPolicy = RetryPolicy 1})+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Left err) -> (fromException err :: Maybe SubscriptionUndecodable) `shouldSatisfy` maybe False (const True)+ other -> expectationFailure (show other)+ checkpoint store "decode" 0 `shouldReturn` Just 0+ -- The alternate callback resolves only matching undecodable events.+ withSubscription store ((cfg "decode" "pm" (\_ -> pure Stop)) {undecodableHandler = Just (\_ _ -> pure Continue)}) awaitLive+ checkpoint store "decode" 0 `shouldReturn` Just 3++ it "shares live namespace wakes across grouped members and reconnects the listener" $+ withNamespaceSafetyPollForTest 100_000 $+ withTestStore $ \store -> do+ members <- mapM (const (newIORef [])) [0 .. 2 :: Int32]+ let configs = [(cfg "live-group" "pm" (record seen)) {consumerGroup = Just (validConsumerGroup index 3), batchSize = batch 1} | (index, seen) <- zip [0 ..] members]+ bracket (mapM (subscribe store) configs) (mapM_ cancel) $ \handles -> do+ mapM_ awaitLive handles+ forM_ [1 .. 6 :: Int] $ \n -> append store ("pm:new:" <> T.pack (show n) <> "-1")+ await (concat <$> mapM positions members) ((== [1 .. 6]) . sort)+ pid <- waitForListenerPid (store ^. #pool) 5_000_000+ terminateBackend (store ^. #pool) pid+ append store "pm:reconnect-1"+ await (concat <$> mapM positions members) ((== [1 .. 7]) . sort)+ _ <- waitForListenerPidNotEqual (store ^. #pool) pid 5_000_000+ pure ()+ Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True++ it "retains a failed empty frontier and retries its flush without another append" $+ withNamespaceSafetyPollForTest 50_000 $+ withTestStore $ \store -> do+ append store "noise-1"+ sql store "CREATE FUNCTION fail_empty_save() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected flush failure'; END $$; CREATE TRIGGER fail_empty_save BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION fail_empty_save()"+ withSubscription store (cfg "flush-fault" "pm" (\_ -> pure Continue)) $ \handle -> do+ awaitLive handle+ checkpoint store "flush-fault" 0 `shouldReturn` Just 0+ sql store "DROP TRIGGER fail_empty_save ON subscriptions"+ await (checkpoint store "flush-fault" 0) (== Just 1)++ it "releases namespace registrations when startup fails" $+ withTestStore $ \store -> do+ handle <- subscribe store ((cfg "missing" "pm" (\_ -> pure Continue)) {missingCheckpointPolicy = FailIfMissing})+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Left _) -> pure ()+ other -> expectationFailure (show other)+ Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True++cfg :: Text -> Text -> (RecordedEvent -> IO SubscriptionResult) -> SubscriptionConfig+cfg name namespace = defaultSubscriptionConfig (SubscriptionName name) (CategoryNamespace namespace)++batch :: Int32 -> BatchSize+batch = either (error . show) Prelude.id . mkBatchSize++record :: IORef [Int64] -> RecordedEvent -> IO SubscriptionResult+record ref event = do+ let GlobalPosition pos = event ^. #globalPosition+ atomicModifyIORef' ref (\xs -> (xs <> [pos], ()))+ pure Continue++positions :: IORef [Int64] -> IO [Int64]+positions = readIORef++append :: KirokuStore -> Text -> IO ()+append store stream = do+ Right _ <- runStoreIO store (appendToStream (StreamName stream) AnyVersion [makeEvent "E" (Aeson.object [])])+ pure ()++await :: IO a -> (a -> Bool) -> IO ()+await action predicate = bounded go+ where+ go = action >>= \value -> if predicate value then pure () else threadDelay 1000 >> go++bounded :: IO a -> IO a+bounded action = timeout 5_000_000 action >>= maybe (fail "namespace test timed out") pure++awaitLive :: SubscriptionHandle -> IO ()+awaitLive handle = await (currentState handle) (\case Just Fsm.Live {} -> True; _ -> False)++clean :: SubscriptionHandle -> IO ()+clean handle =+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Right ()) -> pure ()+ other -> expectationFailure (show other)++checkpoint :: KirokuStore -> Text -> Int32 -> IO (Maybe Int64)+checkpoint store name index = session store (Session.statement (name, index) SQL.getCheckpointMemberStmt)++binding :: KirokuStore -> Text -> IO (Text, Maybe Text)+binding store name = session store (Session.statement name (preparable "SELECT target_kind,target_category FROM subscriptions WHERE subscription_name=$1 AND consumer_group_member=0" (E.param (E.nonNullable E.text)) (D.singleRow ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))))++sql :: KirokuStore -> Text -> IO ()+sql store = session store . Session.script++session :: KirokuStore -> Session.Session a -> IO a+session store action = Pool.use (store ^. #pool) action >>= either (fail . show) pure
test/Test/SubscriptionPauseResume.hs view
@@ -1,33 +1,32 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for ExecPlan 41 M2 — recoverable backpressure.--Before M2, a slow AllStreams consumer that filled its bounded queue was-__terminal__: the publisher marked it @Overflowed@ and the worker threw-'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' and died (overflow-policy 'Kiroku.Store.Subscription.Types.DropSubscription'). M2 adds the-recoverable 'Kiroku.Store.Subscription.Types.PauseAndResume' policy (now the-default): when the queue fills the publisher /pauses/ delivery rather than-killing the subscriber, and the worker — once its slow handler catches up —-drains the stale queue and re-reads the events it missed directly from the-database from its checkpoint. No event is lost and the checkpoint advances-monotonically.--The first spec proves the recovery: a handler that blocks long enough to fill a-@queueCapacity = 1@ subscriber while five events are appended still delivers all-five in order, stops cleanly (not via 'SubscriptionOverflowed'), advances the-checkpoint to the last position, and emits a-'Kiroku.Store.Observability.KirokuEventSubscriptionPaused' followed by a-'Kiroku.Store.Observability.KirokuEventSubscriptionResumed'.--The second spec proves the fail-fast path is preserved: the identical scenario-under 'DropSubscription' still surfaces 'SubscriptionOverflowed' on @wait@.--To confirm the first spec actually pins the new behavior: temporarily set its-@overflowPolicy@ to 'DropSubscription' and it fails with-@Left (SubscriptionOverflowed ...)@ where it expected @Right ()@.--}+-- | Regression tests for ExecPlan 41 M2 — recoverable backpressure.+--+-- Before M2, a slow AllStreams consumer that filled its bounded queue was+-- __terminal__: the publisher marked it @Overflowed@ and the worker threw+-- 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' and died (overflow+-- policy 'Kiroku.Store.Subscription.Types.DropSubscription'). M2 adds the+-- recoverable 'Kiroku.Store.Subscription.Types.PauseAndResume' policy (now the+-- default): when the queue fills the publisher /pauses/ delivery rather than+-- killing the subscriber, and the worker — once its slow handler catches up —+-- drains the stale queue and re-reads the events it missed directly from the+-- database from its checkpoint. No event is lost and the checkpoint advances+-- monotonically.+--+-- The first spec proves the recovery: a handler that blocks long enough to fill a+-- @queueCapacity = 1@ subscriber while five events are appended still delivers all+-- five in order, stops cleanly (not via 'SubscriptionOverflowed'), advances the+-- checkpoint to the last position, and emits a+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionPaused' followed by a+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionResumed'.+--+-- The second spec proves the fail-fast path is preserved: the identical scenario+-- under 'DropSubscription' still surfaces 'SubscriptionOverflowed' on @wait@.+--+-- To confirm the first spec actually pins the new behavior: temporarily set its+-- @overflowPolicy@ to 'DropSubscription' and it fails with+-- @Left (SubscriptionOverflowed ...)@ where it expected @Right ()@. module Test.SubscriptionPauseResume (spec) where import Control.Concurrent (threadDelay)@@ -53,148 +52,148 @@ -- subscription (member 0), or 'Nothing' if no row exists yet. readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int) readCheckpoint store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)- case result of- Left err -> error ("readCheckpoint failed: " <> show err)- Right mPos -> pure (fmap fromIntegral mPos)+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+ case result of+ Left err -> error ("readCheckpoint failed: " <> show err)+ Right mPos -> pure (fmap fromIntegral mPos) spec :: Spec spec = describe "subscription FSM — recoverable backpressure (EP-41 M2)" $ do- it "pauses a slow AllStreams consumer and resumes, delivering all events" $ do- evtRef <- newIORef ([] :: [KirokuEvent])- -- Publisher and worker callbacks can arrive concurrently.- let evtHandler e = atomicModifyIORef' evtRef (\events -> (e : events, ()))- withTestStoreSettings (& #eventHandler .~ Just evtHandler) $ \store -> do- firstSeen <- newEmptyMVar- release <- newEmptyMVar- delivered <- newIORef ([] :: [GlobalPosition])- seenCount <- newTVarIO (0 :: Int)- let handler' evt = do- modifyIORef' delivered ((evt ^. #globalPosition) :)- n <- atomically $ do- c <- readTVar seenCount- writeTVar seenCount (c + 1)- pure (c + 1)- if n == 1- then putMVar firstSeen () >> takeMVar release- else pure ()- -- Stop once all five have been seen, so `wait` resolves Right ().- if n >= 5 then pure Stop else pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "pause-resume-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 1- , overflowPolicy = PauseAndResume- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- waitForLive handle- -- First append: the worker reads it from the queue and the handler- -- blocks inside it on `release`, so the worker stops draining.- Right _ <- runStoreIO store $ appendToStream (StreamName "pr-1") NoStream [makeEvent "E1" (Aeson.object [])]- takeMVar firstSeen- -- With the worker stuck, append four more one at a time. Capacity is 1,- -- so the second of these fills the queue and the publisher pauses the- -- subscriber for the rest (skipping, not dropping).- let appendOne i = do- let sn = StreamName ("pr-" <> T.pack (show (i :: Int)))- Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]- waitForPublisher store (GlobalPosition (fromIntegral i))- appendOne 2- appendOne 3- appendOne 4- appendOne 5- -- Release the slow handler; the worker drains, resumes, re-catches-up- -- from its checkpoint, and delivers the skipped events.- putMVar release ()- result <- waitWithTimeout 15_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)- Right (Right ()) -> pure ()- ds <- reverse <$> readIORef delivered- ds `shouldBe` map GlobalPosition [1 .. 5]- cp <- readCheckpoint store "pause-resume-test"- cp `shouldBe` Just 5- evts <- readIORef evtRef- let isPaused (KirokuEventSubscriptionPaused (SubscriptionName "pause-resume-test") _ _) = True- isPaused _ = False- isResumed (KirokuEventSubscriptionResumed (SubscriptionName "pause-resume-test") _ _) = True- isResumed _ = False- any isPaused evts `shouldBe` True- any isResumed evts `shouldBe` True+ it "pauses a slow AllStreams consumer and resumes, delivering all events" $ do+ evtRef <- newIORef ([] :: [KirokuEvent])+ -- Publisher and worker callbacks can arrive concurrently.+ let evtHandler e = atomicModifyIORef' evtRef (\events -> (e : events, ()))+ withTestStoreSettings (& #eventHandler .~ Just evtHandler) $ \store -> do+ firstSeen <- newEmptyMVar+ release <- newEmptyMVar+ delivered <- newIORef ([] :: [GlobalPosition])+ seenCount <- newTVarIO (0 :: Int)+ let handler' evt = do+ modifyIORef' delivered ((evt ^. #globalPosition) :)+ n <- atomically $ do+ c <- readTVar seenCount+ writeTVar seenCount (c + 1)+ pure (c + 1)+ if n == 1+ then putMVar firstSeen () >> takeMVar release+ else pure ()+ -- Stop once all five have been seen, so `wait` resolves Right ().+ if n >= 5 then pure Stop else pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "pause-resume-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 1,+ overflowPolicy = PauseAndResume,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ waitForLive handle+ -- First append: the worker reads it from the queue and the handler+ -- blocks inside it on `release`, so the worker stops draining.+ Right _ <- runStoreIO store $ appendToStream (StreamName "pr-1") NoStream [makeEvent "E1" (Aeson.object [])]+ takeMVar firstSeen+ -- With the worker stuck, append four more one at a time. Capacity is 1,+ -- so the second of these fills the queue and the publisher pauses the+ -- subscriber for the rest (skipping, not dropping).+ let appendOne i = do+ let sn = StreamName ("pr-" <> T.pack (show (i :: Int)))+ Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]+ waitForPublisher store (GlobalPosition (fromIntegral i))+ appendOne 2+ appendOne 3+ appendOne 4+ appendOne 5+ -- Release the slow handler; the worker drains, resumes, re-catches-up+ -- from its checkpoint, and delivers the skipped events.+ putMVar release ()+ result <- waitWithTimeout 15_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+ Right (Right ()) -> pure ()+ ds <- reverse <$> readIORef delivered+ ds `shouldBe` map GlobalPosition [1 .. 5]+ cp <- readCheckpoint store "pause-resume-test"+ cp `shouldBe` Just 5+ evts <- readIORef evtRef+ let isPaused (KirokuEventSubscriptionPaused (SubscriptionName "pause-resume-test") _ _) = True+ isPaused _ = False+ isResumed (KirokuEventSubscriptionResumed (SubscriptionName "pause-resume-test") _ _) = True+ isResumed _ = False+ any isPaused evts `shouldBe` True+ any isResumed evts `shouldBe` True - it "still fails fast under DropSubscription" $ do- withTestStoreSettings Prelude.id $ \store -> do- firstSeen <- newEmptyMVar- release <- newEmptyMVar- seenCount <- newTVarIO (0 :: Int)- let handler' _evt = do- n <- atomically $ do- c <- readTVar seenCount- writeTVar seenCount (c + 1)- pure (c + 1)- if n == 1- then putMVar firstSeen () >> takeMVar release- else pure ()- pure Continue- let cfg =- SubscriptionConfig- { name = SubscriptionName "dropsub-overflow-test"- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 1- , overflowPolicy = DropSubscription- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- handle <- subscribe store cfg- waitForLive handle- Right _ <- runStoreIO store $ appendToStream (StreamName "ds-1") NoStream [makeEvent "E1" (Aeson.object [])]- takeMVar firstSeen- let appendOne i = do- let sn = StreamName ("ds-" <> T.pack (show (i :: Int)))- Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]- waitForPublisher store (GlobalPosition (fromIntegral i))- appendOne 2- appendOne 3- appendOne 4- appendOne 5- putMVar release ()- result <- waitWithTimeout 15_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got a clean stop"- Right (Left e) ->- case Control.Exception.fromException e of- Just (SubscriptionOverflowed sn) -> sn `shouldBe` SubscriptionName "dropsub-overflow-test"- Nothing -> expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)+ it "still fails fast under DropSubscription" $ do+ withTestStoreSettings Prelude.id $ \store -> do+ firstSeen <- newEmptyMVar+ release <- newEmptyMVar+ seenCount <- newTVarIO (0 :: Int)+ let handler' _evt = do+ n <- atomically $ do+ c <- readTVar seenCount+ writeTVar seenCount (c + 1)+ pure (c + 1)+ if n == 1+ then putMVar firstSeen () >> takeMVar release+ else pure ()+ pure Continue+ let cfg =+ SubscriptionConfig+ { name = SubscriptionName "dropsub-overflow-test",+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 1,+ overflowPolicy = DropSubscription,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ handle <- subscribe store cfg+ waitForLive handle+ Right _ <- runStoreIO store $ appendToStream (StreamName "ds-1") NoStream [makeEvent "E1" (Aeson.object [])]+ takeMVar firstSeen+ let appendOne i = do+ let sn = StreamName ("ds-" <> T.pack (show (i :: Int)))+ Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]+ waitForPublisher store (GlobalPosition (fromIntegral i))+ appendOne 2+ appendOne 3+ appendOne 4+ appendOne 5+ putMVar release ()+ result <- waitWithTimeout 15_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got a clean stop"+ Right (Left e) ->+ case Control.Exception.fromException e of+ Just (SubscriptionOverflowed sn) -> sn `shouldBe` SubscriptionName "dropsub-overflow-test"+ Nothing -> expectationFailure ("expected SubscriptionOverflowed, got: " <> show e) -- These fixtures must block live delivery, not the initial catch-up read. waitForLive :: SubscriptionHandle -> IO () waitForLive handle = do- let go = do- st <- currentState handle- case st of- Just Fsm.Live{} -> pure ()- _ -> threadDelay 1_000 >> go- Timeout.timeout 5_000_000 go >>= (`shouldBe` Just ())+ let go = do+ st <- currentState handle+ case st of+ Just Fsm.Live {} -> pure ()+ _ -> threadDelay 1_000 >> go+ Timeout.timeout 5_000_000 go >>= (`shouldBe` Just ())
test/Test/SubscriptionReconnect.hs view
@@ -1,29 +1,28 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression test for ExecPlan 41 M3 — worker-level live reconnect.--Before M3, a live database fetch error in the Category / consumer-group live-loops was retried in place, forever and invisibly. M3 lifts recovery into the-FSM: a live-mode 'Hasql.Pool.UsageError' drives the worker into the-'Kiroku.Store.Subscription.Fsm.Reconnecting' state — it emits-'Kiroku.Store.Observability.KirokuEventSubscriptionReconnecting', backs off, and-re-catches-up from its checkpoint — instead of silently spinning. The-subscription survives the fault and resumes from where it left off.--This spec uses a __Category__ subscription, not AllStreams: AllStreams live-delivery is fed by the shared publisher (which owns its own reconnect), so the-AllStreams worker performs no live-mode fetch and has nothing to reconnect. The-Category worker fetches in live mode, so 'withFetchBatchHookForTest' can inject-failures there. The hook fails the first @K@ fetches that occur __after__ the-subscription has caught up (a 'TVar' flag the @eventHandler@ sets on-'KirokuEventSubscriptionCaughtUp'), then falls through to the real database.--Asserted: at least one 'KirokuEventSubscriptionReconnecting' fired; @wait@ did-not surface the injected error (the worker recovered rather than died); every-appended event was delivered in increasing position; the checkpoint advanced to-the last position.--}+-- | Regression test for ExecPlan 41 M3 — worker-level live reconnect.+--+-- Before M3, a live database fetch error in the Category / consumer-group live+-- loops was retried in place, forever and invisibly. M3 lifts recovery into the+-- FSM: a live-mode 'Hasql.Pool.UsageError' drives the worker into the+-- 'Kiroku.Store.Subscription.Fsm.Reconnecting' state — it emits+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionReconnecting', backs off, and+-- re-catches-up from its checkpoint — instead of silently spinning. The+-- subscription survives the fault and resumes from where it left off.+--+-- This spec uses a __Category__ subscription, not AllStreams: AllStreams live+-- delivery is fed by the shared publisher (which owns its own reconnect), so the+-- AllStreams worker performs no live-mode fetch and has nothing to reconnect. The+-- Category worker fetches in live mode, so 'withFetchBatchHookForTest' can inject+-- failures there. The hook fails the first @K@ fetches that occur __after__ the+-- subscription has caught up (a 'TVar' flag the @eventHandler@ sets on+-- 'KirokuEventSubscriptionCaughtUp'), then falls through to the real database.+--+-- Asserted: at least one 'KirokuEventSubscriptionReconnecting' fired; @wait@ did+-- not surface the injected error (the worker recovered rather than died); every+-- appended event was delivered in increasing position; the checkpoint advanced to+-- the last position. module Test.SubscriptionReconnect (spec) where import Control.Concurrent.STM (atomically, check, newTVarIO, readTVar, readTVarIO, writeTVar)@@ -44,149 +43,149 @@ readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int) readCheckpoint store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)- case result of- Left err -> error ("readCheckpoint failed: " <> show err)- Right mPos -> pure (fmap fromIntegral mPos)+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+ case result of+ Left err -> error ("readCheckpoint failed: " <> show err)+ Right mPos -> pure (fmap fromIntegral mPos) spec :: Spec spec = describe "subscription FSM — worker-level live reconnect (EP-41 M3)" $ do- it "reconnects and resumes after transient live-mode database errors" $ do- evtRef <- newIORef ([] :: [KirokuEvent])- deliveredRef <- newIORef ([] :: [GlobalPosition])- caughtUpFlag <- newTVarIO False- failsLeft <- newTVarIO (2 :: Int)- seenCount <- newTVarIO (0 :: Int)+ it "reconnects and resumes after transient live-mode database errors" $ do+ evtRef <- newIORef ([] :: [KirokuEvent])+ deliveredRef <- newIORef ([] :: [GlobalPosition])+ caughtUpFlag <- newTVarIO False+ failsLeft <- newTVarIO (2 :: Int)+ seenCount <- newTVarIO (0 :: Int) - let subName = SubscriptionName "reconnect-test"- observe evt = do- modifyIORef' evtRef (evt :)- case evt of- KirokuEventSubscriptionCaughtUp n _ _- | n == subName -> atomically (writeTVar caughtUpFlag True)- _ -> pure ()- -- Fail the first K fetches that happen after catch-up, then fall- -- through to the real database. Catch-up fetches (flag still False)- -- are never failed, so the fault is purely a live-mode event.- injectHook config _cursor- | SubTypes.name config == subName = do- cu <- readTVarIO caughtUpFlag- if not cu- then pure Nothing- else atomically $ do- n <- readTVar failsLeft- if n > 0- then do- writeTVar failsLeft (n - 1)- pure (Just (Left Pool.AcquisitionTimeoutUsageError))- else pure Nothing- | otherwise = pure Nothing- handler' evt = do- modifyIORef' deliveredRef ((evt ^. #globalPosition) :)- n <- atomically $ do- c <- readTVar seenCount- writeTVar seenCount (c + 1)- pure (c + 1)- if n >= 3 then pure Stop else pure Continue- cfg =- SubscriptionConfig- { name = subName- , target = Category (CategoryName "rc")- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = PauseAndResume- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }+ let subName = SubscriptionName "reconnect-test"+ observe evt = do+ modifyIORef' evtRef (evt :)+ case evt of+ KirokuEventSubscriptionCaughtUp n _ _+ | n == subName -> atomically (writeTVar caughtUpFlag True)+ _ -> pure ()+ -- Fail the first K fetches that happen after catch-up, then fall+ -- through to the real database. Catch-up fetches (flag still False)+ -- are never failed, so the fault is purely a live-mode event.+ injectHook config _cursor+ | SubTypes.name config == subName = do+ cu <- readTVarIO caughtUpFlag+ if not cu+ then pure Nothing+ else atomically $ do+ n <- readTVar failsLeft+ if n > 0+ then do+ writeTVar failsLeft (n - 1)+ pure (Just (Left Pool.AcquisitionTimeoutUsageError))+ else pure Nothing+ | otherwise = pure Nothing+ handler' evt = do+ modifyIORef' deliveredRef ((evt ^. #globalPosition) :)+ n <- atomically $ do+ c <- readTVar seenCount+ writeTVar seenCount (c + 1)+ pure (c + 1)+ if n >= 3 then pure Stop else pure Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = Category (CategoryName "rc"),+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = PauseAndResume,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ } - withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do- withFetchBatchHookForTest injectHook $ do- -- Empty store: the worker catches up instantly (no fetch), sets the- -- flag, then its first live-mode fetch is the one the hook fails —- -- so the fault lands squarely in live mode.- handle <- subscribe store cfg- -- Wait until catch-up has completed and the reconnect failures have- -- been consumed, so the appends below are served by the real DB.- atomically $ do- cu <- readTVar caughtUpFlag- check cu- n <- readTVar failsLeft- check (n == 0)- Right _ <- runStoreIO store $ appendToStream (StreamName "rc-1") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "rc-2") NoStream [makeEvent "B" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "rc-3") NoStream [makeEvent "C" (Aeson.object [])]- result <- waitWithTimeout 20_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left e) -> expectationFailure ("worker did not survive the fault: " <> show e)- Right (Right ()) -> pure ()+ withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+ withFetchBatchHookForTest injectHook $ do+ -- Empty store: the worker catches up instantly (no fetch), sets the+ -- flag, then its first live-mode fetch is the one the hook fails —+ -- so the fault lands squarely in live mode.+ handle <- subscribe store cfg+ -- Wait until catch-up has completed and the reconnect failures have+ -- been consumed, so the appends below are served by the real DB.+ atomically $ do+ cu <- readTVar caughtUpFlag+ check cu+ n <- readTVar failsLeft+ check (n == 0)+ Right _ <- runStoreIO store $ appendToStream (StreamName "rc-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "rc-2") NoStream [makeEvent "B" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "rc-3") NoStream [makeEvent "C" (Aeson.object [])]+ result <- waitWithTimeout 20_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left e) -> expectationFailure ("worker did not survive the fault: " <> show e)+ Right (Right ()) -> pure () - delivered <- reverse <$> readIORef deliveredRef- delivered `shouldBe` map GlobalPosition [1 .. 3]- cp <- readCheckpoint store "reconnect-test"- cp `shouldBe` Just 3- evts <- readIORef evtRef- let isReconnecting (KirokuEventSubscriptionReconnecting n _ _) = n == subName- isReconnecting _ = False- any isReconnecting evts `shouldBe` True+ delivered <- reverse <$> readIORef deliveredRef+ delivered `shouldBe` map GlobalPosition [1 .. 3]+ cp <- readCheckpoint store "reconnect-test"+ cp `shouldBe` Just 3+ evts <- readIORef evtRef+ let isReconnecting (KirokuEventSubscriptionReconnecting n _ _) = n == subName+ isReconnecting _ = False+ any isReconnecting evts `shouldBe` True - it "resumes a database-driven live worker from processed progress" $ do- delivered <- newIORef []- caughtUp <- newTVarIO False- processed <- newTVarIO False- failed <- newTVarIO False- recoveryCursor <- newIORef Nothing- let subName = SubscriptionName "mid-live-progress"- observe = \case- KirokuEventSubscriptionCaughtUp n _ _ | n == subName -> atomically (writeTVar caughtUp True)- _ -> pure ()- inject config cursor- | SubTypes.name config /= subName = pure Nothing- | otherwise = do- live <- readTVarIO caughtUp- done <- readTVarIO processed- alreadyFailed <- readTVarIO failed- if live && done && not alreadyFailed- then do- cursor `shouldBe` GlobalPosition 1- atomically (writeTVar failed True)- pure (Just (Left Pool.AcquisitionTimeoutUsageError))- else do- if alreadyFailed- then do- previous <- readIORef recoveryCursor- case previous of- Nothing -> modifyIORef' recoveryCursor (const (Just cursor))- Just _ -> pure ()- else pure ()- pure Nothing- handler event = do- modifyIORef' delivered ((event ^. #globalPosition) :)- if event ^. #globalPosition == GlobalPosition 1- then atomically (writeTVar processed True) >> pure Continue- else pure Stop- cfg = defaultSubscriptionConfig subName (Category (CategoryName "midlive")) handler- withTestStoreSettings (& #eventHandler .~ Just observe) $ \store ->- withFetchBatchHookForTest inject $ do- handle <- subscribe store cfg- atomically (readTVar caughtUp >>= check)- Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-1") NoStream [makeEvent "A" (Aeson.object [])]- atomically (readTVar processed >>= check)- -- Wake the live fetch once more after its first successful batch.- Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-2") NoStream [makeEvent "B" (Aeson.object [])]- result <- waitWithTimeout 10_000_000 handle- case result of- Right (Right ()) -> pure ()- other -> expectationFailure (show other)- readTVarIO failed `shouldReturn` True- readIORef recoveryCursor `shouldReturn` Just (GlobalPosition 1)- reverse <$> readIORef delivered `shouldReturn` [GlobalPosition 1, GlobalPosition 2]- readCheckpoint store "mid-live-progress" `shouldReturn` Just 2+ it "resumes a database-driven live worker from processed progress" $ do+ delivered <- newIORef []+ caughtUp <- newTVarIO False+ processed <- newTVarIO False+ failed <- newTVarIO False+ recoveryCursor <- newIORef Nothing+ let subName = SubscriptionName "mid-live-progress"+ observe = \case+ KirokuEventSubscriptionCaughtUp n _ _ | n == subName -> atomically (writeTVar caughtUp True)+ _ -> pure ()+ inject config cursor+ | SubTypes.name config /= subName = pure Nothing+ | otherwise = do+ live <- readTVarIO caughtUp+ done <- readTVarIO processed+ alreadyFailed <- readTVarIO failed+ if live && done && not alreadyFailed+ then do+ cursor `shouldBe` GlobalPosition 1+ atomically (writeTVar failed True)+ pure (Just (Left Pool.AcquisitionTimeoutUsageError))+ else do+ if alreadyFailed+ then do+ previous <- readIORef recoveryCursor+ case previous of+ Nothing -> modifyIORef' recoveryCursor (const (Just cursor))+ Just _ -> pure ()+ else pure ()+ pure Nothing+ handler event = do+ modifyIORef' delivered ((event ^. #globalPosition) :)+ if event ^. #globalPosition == GlobalPosition 1+ then atomically (writeTVar processed True) >> pure Continue+ else pure Stop+ cfg = defaultSubscriptionConfig subName (Category (CategoryName "midlive")) handler+ withTestStoreSettings (& #eventHandler .~ Just observe) $ \store ->+ withFetchBatchHookForTest inject $ do+ handle <- subscribe store cfg+ atomically (readTVar caughtUp >>= check)+ Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-1") NoStream [makeEvent "A" (Aeson.object [])]+ atomically (readTVar processed >>= check)+ -- Wake the live fetch once more after its first successful batch.+ Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-2") NoStream [makeEvent "B" (Aeson.object [])]+ result <- waitWithTimeout 10_000_000 handle+ case result of+ Right (Right ()) -> pure ()+ other -> expectationFailure (show other)+ readTVarIO failed `shouldReturn` True+ readIORef recoveryCursor `shouldReturn` Just (GlobalPosition 1)+ reverse <$> readIORef delivered `shouldReturn` [GlobalPosition 1, GlobalPosition 2]+ readCheckpoint store "mid-live-progress" `shouldReturn` Just 2
test/Test/SubscriptionRegistry.hs view
@@ -1,23 +1,22 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Tests for the central subscription-state registry (ExecPlan 45 / EP-1 of-MasterPlan 7).--These prove the registry's observable behaviour against a real migrated-PostgreSQL database:-- * Several live subscriptions (including consumer-group members) appear in the- 'Kiroku.Store.Subscription.subscriptionStates' snapshot with sensible state- labels, matching keys, and a valid FSM cursor, and a live handle's- 'currentState' agrees with its snapshot entry (the single-source-of-truth- reshape).- * A subscription's key disappears from the snapshot when it stops cleanly, is- cancelled, or crashes — and its held handle's 'currentState' then returns- 'Nothing'.- * A stale duplicate-key worker's cleanup cannot delete a newer worker's- replacement entry (the per-worker token defends the active entry).--}+-- | Tests for the central subscription-state registry (ExecPlan 45 / EP-1 of+-- MasterPlan 7).+--+-- These prove the registry's observable behaviour against a real migrated+-- PostgreSQL database:+--+-- * Several live subscriptions (including consumer-group members) appear in the+-- 'Kiroku.Store.Subscription.subscriptionStates' snapshot with sensible state+-- labels, matching keys, and a valid FSM cursor, and a live handle's+-- 'currentState' agrees with its snapshot entry (the single-source-of-truth+-- reshape).+-- * A subscription's key disappears from the snapshot when it stops cleanly, is+-- cancelled, or crashes — and its held handle's 'currentState' then returns+-- 'Nothing'.+-- * A stale duplicate-key worker's cleanup cannot delete a newer worker's+-- replacement entry (the per-worker token defends the active entry). module Test.SubscriptionRegistry (spec) where import Control.Concurrent (threadDelay)@@ -44,174 +43,174 @@ -- | A size-@n@ category-group config for member @m@ whose handler never stops. groupCont :: Text -> Text -> Int32 -> Int32 -> SubscriptionConfig groupCont nm cat m n =- (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) (\_ -> pure Continue))- { consumerGroup = Just (validConsumerGroup m n)- }+ (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) (\_ -> pure Continue))+ { consumerGroup = Just (validConsumerGroup m 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 (validConsumerGroup m n)- }+ (defaultSubscriptionConfig (SubscriptionName nm) AllStreams (\_ -> pure Continue))+ { consumerGroup = Just (validConsumerGroup m n)+ } publisherSubscriberCount :: KirokuStore -> IO Int publisherSubscriberCount store =- IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))+ 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- | budget <= 0 = pure False- | otherwise = do- m <- subscriptionStates store- case Map.lookup key m of- Just v | (v ^. #statePhase) == phase -> pure True- _ -> threadDelay 20_000 >> waitUntilPhase (budget - 20_000) store key phase+ | budget <= 0 = pure False+ | otherwise = do+ m <- subscriptionStates store+ case Map.lookup key m of+ Just v | (v ^. #statePhase) == phase -> pure True+ _ -> threadDelay 20_000 >> waitUntilPhase (budget - 20_000) store key phase -- | Poll the snapshot until the keyed entry is present. waitUntilPresent :: Int -> KirokuStore -> (SubscriptionName, Int32) -> IO Bool waitUntilPresent budget store key- | budget <= 0 = pure False- | otherwise = do- m <- subscriptionStates store- if Map.member key m- then pure True- else threadDelay 20_000 >> waitUntilPresent (budget - 20_000) store key+ | budget <= 0 = pure False+ | otherwise = do+ m <- subscriptionStates store+ if Map.member key m+ then pure True+ else threadDelay 20_000 >> waitUntilPresent (budget - 20_000) store key -- | Poll the snapshot until the keyed entry is absent. waitUntilAbsent :: Int -> KirokuStore -> (SubscriptionName, Int32) -> IO Bool waitUntilAbsent budget store key- | budget <= 0 = pure False- | otherwise = do- m <- subscriptionStates store- if Map.member key m- then threadDelay 20_000 >> waitUntilAbsent (budget - 20_000) store key- else pure True+ | budget <= 0 = pure False+ | otherwise = do+ m <- subscriptionStates store+ if Map.member key m+ then threadDelay 20_000 >> waitUntilAbsent (budget - 20_000) store key+ else pure True spec :: Spec spec = describe "subscription registry (EP-1 / plan 45)" $ do- it "registers every live subscription with a sensible state and position" $- withTestStore $ \store -> do- let plainName = SubscriptionName "reg-plain"- groupName = SubscriptionName "reg-group"- Right _ <- runStoreIO store $ appendToStream (StreamName "reg-1") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "reg-2") NoStream [makeEvent "B" (Aeson.object [])]- waitForPublisher store (GlobalPosition 2)- hPlain <- subscribe store (plainCont "reg-plain")- hG0 <- subscribe store (groupCont "reg-group" "reg" 0 2)- hG1 <- subscribe store (groupCont "reg-group" "reg" 1 2)- okP <- waitUntilPhase 5_000_000 store (plainName, 0) "live"- ok0 <- waitUntilPhase 5_000_000 store (groupName, 0) "live"- ok1 <- waitUntilPhase 5_000_000 store (groupName, 1) "live"- (okP, ok0, ok1) `shouldBe` (True, True, True)- snap <- subscriptionStates store- Map.keys snap `shouldMatchList` [(plainName, 0), (groupName, 0), (groupName, 1)]- for_ (Map.toList snap) $ \((nm, mbr), v) -> do- (v ^. #subscriptionName) `shouldBe` nm- (v ^. #member) `shouldBe` mbr- (v ^. #statePhase) `shouldSatisfy` (`elem` (["catching_up", "live"] :: [Text]))- let GlobalPosition p = v ^. #cursor- p `shouldSatisfy` (>= 0)- -- currentState on a live handle agrees with its snapshot entry.- Just s <- currentState hPlain- case Map.lookup (plainName, 0) snap of- Just v -> stateName s `shouldBe` (v ^. #statePhase)- Nothing -> expectationFailure "plain subscription missing from snapshot"- mapM_ cancel [hPlain, hG0, hG1]+ it "registers every live subscription with a sensible state and position" $+ withTestStore $ \store -> do+ let plainName = SubscriptionName "reg-plain"+ groupName = SubscriptionName "reg-group"+ Right _ <- runStoreIO store $ appendToStream (StreamName "reg-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "reg-2") NoStream [makeEvent "B" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 2)+ hPlain <- subscribe store (plainCont "reg-plain")+ hG0 <- subscribe store (groupCont "reg-group" "reg" 0 2)+ hG1 <- subscribe store (groupCont "reg-group" "reg" 1 2)+ okP <- waitUntilPhase 5_000_000 store (plainName, 0) "live"+ ok0 <- waitUntilPhase 5_000_000 store (groupName, 0) "live"+ ok1 <- waitUntilPhase 5_000_000 store (groupName, 1) "live"+ (okP, ok0, ok1) `shouldBe` (True, True, True)+ snap <- subscriptionStates store+ Map.keys snap `shouldMatchList` [(plainName, 0), (groupName, 0), (groupName, 1)]+ for_ (Map.toList snap) $ \((nm, mbr), v) -> do+ (v ^. #subscriptionName) `shouldBe` nm+ (v ^. #member) `shouldBe` mbr+ (v ^. #statePhase) `shouldSatisfy` (`elem` (["catching_up", "live"] :: [Text]))+ let GlobalPosition p = v ^. #cursor+ p `shouldSatisfy` (>= 0)+ -- currentState on a live handle agrees with its snapshot entry.+ Just s <- currentState hPlain+ case Map.lookup (plainName, 0) snap of+ Just v -> stateName s `shouldBe` (v ^. #statePhase)+ Nothing -> expectationFailure "plain subscription missing from snapshot"+ mapM_ cancel [hPlain, hG0, hG1] - it "removes a subscription's entry when it stops cleanly" $- withTestStore $ \store -> do- let nm = SubscriptionName "reg-stop"- Right _ <- runStoreIO store $ appendToStream (StreamName "stop-1") NoStream [makeEvent "A" (Aeson.object [])]- waitForPublisher store (GlobalPosition 1)- handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> pure Stop))- _ <- wait handle- gone <- waitUntilAbsent 5_000_000 store (nm, 0)- gone `shouldBe` True- cs <- currentState handle- cs `shouldSatisfy` isNothing+ it "removes a subscription's entry when it stops cleanly" $+ withTestStore $ \store -> do+ let nm = SubscriptionName "reg-stop"+ Right _ <- runStoreIO store $ appendToStream (StreamName "stop-1") NoStream [makeEvent "A" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 1)+ handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> pure Stop))+ _ <- wait handle+ gone <- waitUntilAbsent 5_000_000 store (nm, 0)+ gone `shouldBe` True+ cs <- currentState handle+ cs `shouldSatisfy` isNothing - it "removes a subscription's entry when it is cancelled" $- withTestStore $ \store -> do- let nm = SubscriptionName "reg-cancel"- Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-1") NoStream [makeEvent "A" (Aeson.object [])]- waitForPublisher store (GlobalPosition 1)- handle <- subscribe store (plainCont "reg-cancel")- live <- waitUntilPhase 5_000_000 store (nm, 0) "live"- live `shouldBe` True- cancel handle- _ <- wait handle- gone <- waitUntilAbsent 5_000_000 store (nm, 0)- gone `shouldBe` True- cs <- currentState handle- cs `shouldSatisfy` isNothing+ it "removes a subscription's entry when it is cancelled" $+ withTestStore $ \store -> do+ let nm = SubscriptionName "reg-cancel"+ Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-1") NoStream [makeEvent "A" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 1)+ handle <- subscribe store (plainCont "reg-cancel")+ live <- waitUntilPhase 5_000_000 store (nm, 0) "live"+ live `shouldBe` True+ cancel handle+ _ <- wait handle+ gone <- waitUntilAbsent 5_000_000 store (nm, 0)+ gone `shouldBe` True+ cs <- currentState handle+ cs `shouldSatisfy` isNothing - it "removes a subscription's entry when its handler crashes" $- withTestStore $ \store -> do- let nm = SubscriptionName "reg-crash"- Right _ <- runStoreIO store $ appendToStream (StreamName "crash-1") NoStream [makeEvent "A" (Aeson.object [])]- waitForPublisher store (GlobalPosition 1)- handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> throwIO (userError "boom")))- res <- wait handle- case res of- Left _ -> pure ()- Right () -> expectationFailure "expected the crashing handler to fail the worker"- gone <- waitUntilAbsent 5_000_000 store (nm, 0)- gone `shouldBe` True- cs <- currentState handle- cs `shouldSatisfy` isNothing+ it "removes a subscription's entry when its handler crashes" $+ withTestStore $ \store -> do+ let nm = SubscriptionName "reg-crash"+ Right _ <- runStoreIO store $ appendToStream (StreamName "crash-1") NoStream [makeEvent "A" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 1)+ handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> throwIO (userError "boom")))+ res <- wait handle+ case res of+ Left _ -> pure ()+ Right () -> expectationFailure "expected the crashing handler to fail the worker"+ gone <- waitUntilAbsent 5_000_000 store (nm, 0)+ gone `shouldBe` True+ cs <- currentState handle+ cs `shouldSatisfy` isNothing - it "stale duplicate-key cleanup does not remove the replacement entry" $- withTestStore $ \store -> do- let nm = SubscriptionName "reg-dup"- h1 <- subscribe store (plainCont "reg-dup")- present1 <- waitUntilPresent 5_000_000 store (nm, 0)- present1 `shouldBe` True- -- A second subscribe with the same (name, member) supersedes h1's- -- entry in the registry (insert is synchronous, before the fork).- h2 <- subscribe store (plainCont "reg-dup")- -- Stopping the first worker must not delete the second's live entry.- cancel h1- _ <- wait h1- snap <- subscriptionStates store- Map.member (nm, 0) snap `shouldBe` True- cs1 <- currentState h1- cs1 `shouldSatisfy` isNothing- cs2 <- currentState h2- cs2 `shouldSatisfy` isJust- cancel h2+ it "stale duplicate-key cleanup does not remove the replacement entry" $+ withTestStore $ \store -> do+ let nm = SubscriptionName "reg-dup"+ h1 <- subscribe store (plainCont "reg-dup")+ present1 <- waitUntilPresent 5_000_000 store (nm, 0)+ present1 `shouldBe` True+ -- A second subscribe with the same (name, member) supersedes h1's+ -- entry in the registry (insert is synchronous, before the fork).+ h2 <- subscribe store (plainCont "reg-dup")+ -- Stopping the first worker must not delete the second's live entry.+ cancel h1+ _ <- wait h1+ snap <- subscriptionStates store+ Map.member (nm, 0) snap `shouldBe` True+ cs1 <- currentState h1+ cs1 `shouldSatisfy` isNothing+ 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+ 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+ 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+ 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+ 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+ hAll <- subscribe store (plainCont "reg-all-has-queue")+ publisherSubscriberCount store `shouldReturn` 1 - cancel hAll- _ <- wait hAll- publisherSubscriberCount store `shouldReturn` 0+ cancel hAll+ _ <- wait hAll+ publisherSubscriberCount store `shouldReturn` 0 - cancel hCategory- _ <- wait hCategory- publisherSubscriberCount store `shouldReturn` 0+ cancel hCategory+ _ <- wait hCategory+ publisherSubscriberCount store `shouldReturn` 0 - cancel hGroupCategory- _ <- wait hGroupCategory- publisherSubscriberCount store `shouldReturn` 0+ cancel hGroupCategory+ _ <- wait hGroupCategory+ publisherSubscriberCount store `shouldReturn` 0 - cancel hGroupAll- _ <- wait hGroupAll- publisherSubscriberCount store `shouldReturn` 0+ cancel hGroupAll+ _ <- wait hGroupAll+ publisherSubscriberCount store `shouldReturn` 0
test/Test/SubscriptionRetryDeadLetter.hs view
@@ -1,25 +1,24 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for EP-40 (MasterPlan 6, child plan 2) — per-event retry-and dead-letter dispositions in @kiroku-store@.--These pin the three behavioral acceptance criteria from the ExecPlan:-- * __dead-letter__: a handler returning 'DeadLetter' for one event records it in- @kiroku.dead_letters@, advances the checkpoint past it atomically, and keeps- delivering later events;- * __bounded retry__: a handler returning 'Retry' for one event sees that same- event redelivered (the checkpoint does not advance past it) until it- succeeds;- * __retry exhaustion__: once the configured 'retryMaxAttempts' is exceeded the- event is dead-lettered with 'DeadLetterMaxAttempts' and later events- continue.--All three use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery-path). Events are appended and the publisher is awaited before subscribing, so-delivery happens during catch-up.--}+-- | Regression tests for EP-40 (MasterPlan 6, child plan 2) — per-event retry+-- and dead-letter dispositions in @kiroku-store@.+--+-- These pin the three behavioral acceptance criteria from the ExecPlan:+--+-- * __dead-letter__: a handler returning 'DeadLetter' for one event records it in+-- @kiroku.dead_letters@, advances the checkpoint past it atomically, and keeps+-- delivering later events;+-- * __bounded retry__: a handler returning 'Retry' for one event sees that same+-- event redelivered (the checkpoint does not advance past it) until it+-- succeeds;+-- * __retry exhaustion__: once the configured 'retryMaxAttempts' is exceeded the+-- event is dead-lettered with 'DeadLetterMaxAttempts' and later events+-- continue.+--+-- All three use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery+-- path). Events are appended and the publisher is awaited before subscribing, so+-- delivery happens during catch-up. module Test.SubscriptionRetryDeadLetter (spec) where import Control.Lens ((^.))@@ -42,125 +41,125 @@ readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64) readCheckpoint store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)- case result of- Left err -> error ("readCheckpoint failed: " <> show err)- Right mPos -> pure mPos+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+ case result of+ Left err -> error ("readCheckpoint failed: " <> show err)+ Right mPos -> pure mPos readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord] readDeadLetters store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)- case result of- Left err -> error ("readDeadLetters failed: " <> show err)- Right v -> pure (V.toList v)+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)+ case result of+ Left err -> error ("readDeadLetters failed: " <> show err)+ Right v -> pure (V.toList v) -- Append three single-event streams so global positions are 1, 2, 3. appendThree :: KirokuStore -> IO () appendThree store =- mapM_- ( \i -> do- let sn = StreamName ("rdl-" <> T.pack (show (i :: Int)))- Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "E" (Aeson.object [])]- pure ()- )- [1 .. 3]+ mapM_+ ( \i -> do+ let sn = StreamName ("rdl-" <> T.pack (show (i :: Int)))+ Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "E" (Aeson.object [])]+ pure ()+ )+ [1 .. 3] spec :: Spec spec = describe "subscription dispositions — retry / dead-letter (EP-40)" $ do- it "dead-letters one event, advances the checkpoint, and keeps delivering" $ do- withTestStore $ \store -> do- appendThree store- waitForPublisher store (GlobalPosition 3)- delivered <- newIORef ([] :: [Int64])- let subName = SubscriptionName "rdl-deadletter"- handler' evt = do- let p = posOf evt- modifyIORef' delivered (p :)- case p of- 2 -> pure (DeadLetter (DeadLetterPoison "boom"))- 3 -> pure Stop- _ -> pure Continue- cfg = (defaultSubscriptionConfig subName AllStreams handler'){batchSize = defaultBatchSize}- handle <- subscribe store cfg- result <- waitWithTimeout 20_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)- Right (Right ()) -> pure ()- ds <- reverse <$> readIORef delivered- -- Each event delivered once; event 2 was dead-lettered (not retried).- ds `shouldBe` [1, 2, 3]- dls <- readDeadLetters store "rdl-deadletter"- map SQL.deadLetterGlobalPosition dls `shouldBe` [2]- -- Checkpoint advanced to the last processed event (the Stop at 3).- cp <- readCheckpoint store "rdl-deadletter"- cp `shouldBe` Just 3+ it "dead-letters one event, advances the checkpoint, and keeps delivering" $ do+ withTestStore $ \store -> do+ appendThree store+ waitForPublisher store (GlobalPosition 3)+ delivered <- newIORef ([] :: [Int64])+ let subName = SubscriptionName "rdl-deadletter"+ handler' evt = do+ let p = posOf evt+ modifyIORef' delivered (p :)+ case p of+ 2 -> pure (DeadLetter (DeadLetterPoison "boom"))+ 3 -> pure Stop+ _ -> pure Continue+ cfg = (defaultSubscriptionConfig subName AllStreams handler') {batchSize = defaultBatchSize}+ handle <- subscribe store cfg+ result <- waitWithTimeout 20_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+ Right (Right ()) -> pure ()+ ds <- reverse <$> readIORef delivered+ -- Each event delivered once; event 2 was dead-lettered (not retried).+ ds `shouldBe` [1, 2, 3]+ dls <- readDeadLetters store "rdl-deadletter"+ map SQL.deadLetterGlobalPosition dls `shouldBe` [2]+ -- Checkpoint advanced to the last processed event (the Stop at 3).+ cp <- readCheckpoint store "rdl-deadletter"+ cp `shouldBe` Just 3 - it "retries one event until it succeeds without advancing past it" $ do- withTestStore $ \store -> do- appendThree store- waitForPublisher store (GlobalPosition 3)- delivered <- newIORef ([] :: [Int64])- pos2Count <- newIORef (0 :: Int)- let subName = SubscriptionName "rdl-retry"- handler' evt = do- let p = posOf evt- modifyIORef' delivered (p :)- case p of- 2 -> do- c <- atomicModifyIORef' pos2Count (\x -> (x + 1, x + 1))- -- Retry the first two deliveries, succeed on the third.- if c <= 2 then pure (Retry (RetryDelay 0)) else pure Continue- 3 -> pure Stop- _ -> pure Continue- cfg = (defaultSubscriptionConfig subName AllStreams handler'){batchSize = defaultBatchSize}- handle <- subscribe store cfg- result <- waitWithTimeout 20_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)- Right (Right ()) -> pure ()- ds <- reverse <$> readIORef delivered- -- Event 2 delivered three times (two retries + success); 1 and 3 once each.- length (filter (== 2) ds) `shouldBe` 3- ds `shouldBe` [1, 2, 2, 2, 3]- -- No dead letters; the retry succeeded.- dls <- readDeadLetters store "rdl-retry"- dls `shouldSatisfy` null- cp <- readCheckpoint store "rdl-retry"- cp `shouldBe` Just 3+ it "retries one event until it succeeds without advancing past it" $ do+ withTestStore $ \store -> do+ appendThree store+ waitForPublisher store (GlobalPosition 3)+ delivered <- newIORef ([] :: [Int64])+ pos2Count <- newIORef (0 :: Int)+ let subName = SubscriptionName "rdl-retry"+ handler' evt = do+ let p = posOf evt+ modifyIORef' delivered (p :)+ case p of+ 2 -> do+ c <- atomicModifyIORef' pos2Count (\x -> (x + 1, x + 1))+ -- Retry the first two deliveries, succeed on the third.+ if c <= 2 then pure (Retry (RetryDelay 0)) else pure Continue+ 3 -> pure Stop+ _ -> pure Continue+ cfg = (defaultSubscriptionConfig subName AllStreams handler') {batchSize = defaultBatchSize}+ handle <- subscribe store cfg+ result <- waitWithTimeout 20_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+ Right (Right ()) -> pure ()+ ds <- reverse <$> readIORef delivered+ -- Event 2 delivered three times (two retries + success); 1 and 3 once each.+ length (filter (== 2) ds) `shouldBe` 3+ ds `shouldBe` [1, 2, 2, 2, 3]+ -- No dead letters; the retry succeeded.+ dls <- readDeadLetters store "rdl-retry"+ dls `shouldSatisfy` null+ cp <- readCheckpoint store "rdl-retry"+ cp `shouldBe` Just 3 - it "dead-letters after exhausting the retry budget and continues" $ do- withTestStore $ \store -> do- appendThree store- waitForPublisher store (GlobalPosition 3)- delivered <- newIORef ([] :: [Int64])- let subName = SubscriptionName "rdl-exhaust"- handler' evt = do- let p = posOf evt- modifyIORef' delivered (p :)- case p of- 2 -> pure (Retry (RetryDelay 0)) -- always retry → exhausts the budget- 3 -> pure Stop- _ -> pure Continue- cfg =- (defaultSubscriptionConfig subName AllStreams handler')- { batchSize = defaultBatchSize- , retryPolicy = RetryPolicy{retryMaxAttempts = 3}- }- handle <- subscribe store cfg- result <- waitWithTimeout 20_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)- Right (Right ()) -> pure ()- ds <- reverse <$> readIORef delivered- -- Event 2 delivered exactly retryMaxAttempts (3) times, then dead-lettered.- length (filter (== 2) ds) `shouldBe` 3- ds `shouldBe` [1, 2, 2, 2, 3]- dls <- readDeadLetters store "rdl-exhaust"- map SQL.deadLetterGlobalPosition dls `shouldBe` [2]- map SQL.deadLetterAttemptCount dls `shouldBe` [3]- -- Later events still processed; checkpoint advanced to the end.- cp <- readCheckpoint store "rdl-exhaust"- cp `shouldBe` Just 3+ it "dead-letters after exhausting the retry budget and continues" $ do+ withTestStore $ \store -> do+ appendThree store+ waitForPublisher store (GlobalPosition 3)+ delivered <- newIORef ([] :: [Int64])+ let subName = SubscriptionName "rdl-exhaust"+ handler' evt = do+ let p = posOf evt+ modifyIORef' delivered (p :)+ case p of+ 2 -> pure (Retry (RetryDelay 0)) -- always retry → exhausts the budget+ 3 -> pure Stop+ _ -> pure Continue+ cfg =+ (defaultSubscriptionConfig subName AllStreams handler')+ { batchSize = defaultBatchSize,+ retryPolicy = RetryPolicy {retryMaxAttempts = 3}+ }+ handle <- subscribe store cfg+ result <- waitWithTimeout 20_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+ Right (Right ()) -> pure ()+ ds <- reverse <$> readIORef delivered+ -- Event 2 delivered exactly retryMaxAttempts (3) times, then dead-lettered.+ length (filter (== 2) ds) `shouldBe` 3+ ds `shouldBe` [1, 2, 2, 2, 3]+ dls <- readDeadLetters store "rdl-exhaust"+ map SQL.deadLetterGlobalPosition dls `shouldBe` [2]+ map SQL.deadLetterAttemptCount dls `shouldBe` [3]+ -- Later events still processed; checkpoint advanced to the end.+ cp <- readCheckpoint store "rdl-exhaust"+ cp `shouldBe` Just 3
test/Test/SubscriptionState.hs view
@@ -1,20 +1,19 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for ExecPlan 41 M4 — observable FSM state and the-no-missed-events / monotonic-checkpoint invariants under the FSM driver.--The first spec reads the worker's current state through the new-'Kiroku.Store.Subscription.Types.currentState' handle accessor: while a handler-blocks on its first catch-up event the worker is observably @CatchingUp@, and-once it has caught up it is observably @Live@.--The second spec proves the FSM driver preserves the core delivery invariant: with-events appended both before and after the subscription starts, every event is-delivered exactly once in strictly increasing 'GlobalPosition' across the-catch-up → live boundary, and the durable checkpoint advances to the last-position (a monotonic, no-gap result).--}+-- | Regression tests for ExecPlan 41 M4 — observable FSM state and the+-- no-missed-events / monotonic-checkpoint invariants under the FSM driver.+--+-- The first spec reads the worker's current state through the new+-- 'Kiroku.Store.Subscription.Types.currentState' handle accessor: while a handler+-- blocks on its first catch-up event the worker is observably @CatchingUp@, and+-- once it has caught up it is observably @Live@.+--+-- The second spec proves the FSM driver preserves the core delivery invariant: with+-- events appended both before and after the subscription starts, every event is+-- delivered exactly once in strictly increasing 'GlobalPosition' across the+-- catch-up → live boundary, and the durable checkpoint advances to the last+-- position (a monotonic, no-gap result). module Test.SubscriptionState (spec) where import Control.Concurrent (threadDelay)@@ -35,11 +34,11 @@ import Test.Hspec isCatchingUp :: SubscriptionState -> Bool-isCatchingUp CatchingUp{} = True+isCatchingUp CatchingUp {} = True isCatchingUp _ = False isLive :: SubscriptionState -> Bool-isLive Live{} = True+isLive Live {} = True isLive _ = False -- Poll an FSM-state read until the predicate holds or the budget runs out.@@ -47,118 +46,118 @@ -- @Nothing@ (not currently live) is treated as "not yet in the wanted state". waitUntilState :: Int -> IO (Maybe SubscriptionState) -> (SubscriptionState -> Bool) -> IO Bool waitUntilState budget readSt p- | budget <= 0 = pure False- | otherwise = do- s <- readSt- if maybe False p s- then pure True- else threadDelay 20_000 >> waitUntilState (budget - 20_000) readSt p+ | budget <= 0 = pure False+ | otherwise = do+ s <- readSt+ if maybe False p s+ then pure True+ else threadDelay 20_000 >> waitUntilState (budget - 20_000) readSt p readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int) readCheckpoint store subName = do- result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)- case result of- Left err -> error ("readCheckpoint failed: " <> show err)- Right mPos -> pure (fmap fromIntegral mPos)+ result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+ case result of+ Left err -> error ("readCheckpoint failed: " <> show err)+ Right mPos -> pure (fmap fromIntegral mPos) spec :: Spec spec = describe "subscription FSM — observable state (EP-41 M4)" $ do- it "reports CatchingUp while blocked in catch-up and Live once caught up" $ do- caughtUp <- newEmptyMVar- firstSeen <- newEmptyMVar- release <- newEmptyMVar- seen <- newTVarIO (0 :: Int)- let subName = SubscriptionName "state-readout-test"- observe = caughtUpEventHandler subName caughtUp Nothing- handler' _evt = do- n <- atomically $ do- c <- readTVar seen- writeTVar seen (c + 1)- pure (c + 1)- if n == 1- then putMVar firstSeen () >> takeMVar release- else pure ()- pure Continue- cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = PauseAndResume- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "st-1") NoStream [makeEvent "A" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "st-2") NoStream [makeEvent "B" (Aeson.object [])]- Right _ <- runStoreIO store $ appendToStream (StreamName "st-3") NoStream [makeEvent "C" (Aeson.object [])]- waitForPublisher store (GlobalPosition 3)- handle <- subscribe store cfg- -- Worker is now blocked inside the handler on the first catch-up event.- takeMVar firstSeen- Just st <- currentState handle- st `shouldSatisfy` isCatchingUp- -- Release: catch-up completes, the worker goes live.- putMVar release ()- waitForSubscriptionLive caughtUp- becameLive <- waitUntilState 5_000_000 (currentState handle) isLive- becameLive `shouldBe` True- cancel handle+ it "reports CatchingUp while blocked in catch-up and Live once caught up" $ do+ caughtUp <- newEmptyMVar+ firstSeen <- newEmptyMVar+ release <- newEmptyMVar+ seen <- newTVarIO (0 :: Int)+ let subName = SubscriptionName "state-readout-test"+ observe = caughtUpEventHandler subName caughtUp Nothing+ handler' _evt = do+ n <- atomically $ do+ c <- readTVar seen+ writeTVar seen (c + 1)+ pure (c + 1)+ if n == 1+ then putMVar firstSeen () >> takeMVar release+ else pure ()+ pure Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = PauseAndResume,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "st-1") NoStream [makeEvent "A" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "st-2") NoStream [makeEvent "B" (Aeson.object [])]+ Right _ <- runStoreIO store $ appendToStream (StreamName "st-3") NoStream [makeEvent "C" (Aeson.object [])]+ waitForPublisher store (GlobalPosition 3)+ handle <- subscribe store cfg+ -- Worker is now blocked inside the handler on the first catch-up event.+ takeMVar firstSeen+ Just st <- currentState handle+ st `shouldSatisfy` isCatchingUp+ -- Release: catch-up completes, the worker goes live.+ putMVar release ()+ waitForSubscriptionLive caughtUp+ becameLive <- waitUntilState 5_000_000 (currentState handle) isLive+ becameLive `shouldBe` True+ cancel handle - it "delivers every event exactly once in order across the catch-up to live boundary" $ do- delivered <- newIORef ([] :: [GlobalPosition])- seen <- newTVarIO (0 :: Int)- let subName = SubscriptionName "no-missed-test"- handler' evt = do- modifyIORef' delivered ((evt ^. #globalPosition) :)- n <- atomically $ do- c <- readTVar seen- writeTVar seen (c + 1)- pure (c + 1)- if n >= 10 then pure Stop else pure Continue- cfg =- SubscriptionConfig- { name = subName- , target = AllStreams- , handler = handler'- , batchSize = defaultBatchSize- , queueCapacity = 16- , overflowPolicy = PauseAndResume- , consumerGroup = Nothing- , consumerGroupGuard = False- , missingCheckpointPolicy = FromBeginning- , targetBindingPolicy = AdoptUnbound- , undecodableHandler = Nothing- , handlerStallWarnAfter = Nothing- , retryPolicy = defaultRetryPolicy- , eventTypeFilter = AllEventTypes- , selector = Nothing- }- withTestStoreSettings Prelude.id $ \store -> do- -- Five events before subscribing (delivered during catch-up).- let appendAt i =- runStoreIO store $- appendToStream (StreamName ("nm-" <> T.pack (show (i :: Int)))) NoStream [makeEvent "E" (Aeson.object [])]- mapM_ (\i -> appendAt i >> pure ()) [1 .. 5]- waitForPublisher store (GlobalPosition 5)- handle <- subscribe store cfg- -- Five more after (delivered live).- mapM_ (\i -> appendAt i >> pure ()) [6 .. 10]- result <- waitWithTimeout 20_000_000 handle- case result of- Left timeout -> expectationFailure timeout- Right (Left e) -> expectationFailure ("subscription failed: " <> show e)- Right (Right ()) -> pure ()- ds <- reverse <$> readIORef delivered- ds `shouldBe` map GlobalPosition [1 .. 10]- cp <- readCheckpoint store "no-missed-test"- cp `shouldBe` Just 10+ it "delivers every event exactly once in order across the catch-up to live boundary" $ do+ delivered <- newIORef ([] :: [GlobalPosition])+ seen <- newTVarIO (0 :: Int)+ let subName = SubscriptionName "no-missed-test"+ handler' evt = do+ modifyIORef' delivered ((evt ^. #globalPosition) :)+ n <- atomically $ do+ c <- readTVar seen+ writeTVar seen (c + 1)+ pure (c + 1)+ if n >= 10 then pure Stop else pure Continue+ cfg =+ SubscriptionConfig+ { name = subName,+ target = AllStreams,+ handler = handler',+ batchSize = defaultBatchSize,+ queueCapacity = 16,+ overflowPolicy = PauseAndResume,+ consumerGroup = Nothing,+ consumerGroupGuard = False,+ missingCheckpointPolicy = FromBeginning,+ targetBindingPolicy = AdoptUnbound,+ undecodableHandler = Nothing,+ handlerStallWarnAfter = Nothing,+ retryPolicy = defaultRetryPolicy,+ eventTypeFilter = AllEventTypes,+ selector = Nothing+ }+ withTestStoreSettings Prelude.id $ \store -> do+ -- Five events before subscribing (delivered during catch-up).+ let appendAt i =+ runStoreIO store $+ appendToStream (StreamName ("nm-" <> T.pack (show (i :: Int)))) NoStream [makeEvent "E" (Aeson.object [])]+ mapM_ (\i -> appendAt i >> pure ()) [1 .. 5]+ waitForPublisher store (GlobalPosition 5)+ handle <- subscribe store cfg+ -- Five more after (delivered live).+ mapM_ (\i -> appendAt i >> pure ()) [6 .. 10]+ result <- waitWithTimeout 20_000_000 handle+ case result of+ Left timeout -> expectationFailure timeout+ Right (Left e) -> expectationFailure ("subscription failed: " <> show e)+ Right (Right ()) -> pure ()+ ds <- reverse <$> readIORef delivered+ ds `shouldBe` map GlobalPosition [1 .. 10]+ cp <- readCheckpoint store "no-missed-test"+ cp `shouldBe` Just 10
test/Test/SubscriptionTarget.hs view
@@ -9,7 +9,7 @@ import Control.Monad (void) import Data.Aeson qualified as Aeson import Data.Generics.Labels ()-import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef) import Data.Int (Int32, Int64) import Data.Text (Text) import Data.Vector qualified as V@@ -28,160 +28,189 @@ spec :: Spec spec = do- describe "subscription configuration" $ do- it "validates batch and bridge buffer boundaries as values" $ do- mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)- mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))- fmap batchSizeValue (mkBatchSize 1) `shouldBe` Right 1- batchSizeValue defaultBatchSize `shouldBe` 100- Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)- fmap Buffer.streamBufferSizeValue (Buffer.mkStreamBufferSize 1) `shouldBe` Right 1- it "exposes every runtime startup refusal through the parent and concrete catches" $ do- let name = SubscriptionName "hierarchy"- key = SubscriptionCheckpointKey name 0- assertHierarchy (SubscriptionCheckpointMissing key)- assertHierarchy (ConsumerGroupGuardConflict name 0)- assertHierarchy (ConsumerGroupSizeMismatch name 2 (V.singleton 1))- assertHierarchy (SubscriptionTargetMismatch name AllStreams (V.singleton Nothing))- describe "checkpoint target" $ do- it "refuses accidental target reuse before delivery, then resumes after explicit rebind" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "target-1") NoStream [makeEvent "E" (Aeson.object [])]- first <- subscribe store (cfg "retarget" AllStreams (\_ -> pure Stop))- clean first- called <- newIORef False- let newTarget = Category (CategoryName "target")- refused <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))- mismatch refused- readIORef called `shouldReturn` False- report <- rebind store "retarget" newTarget 0- reboundMemberCount report `shouldBe` 1- previousBindings report `shouldBe` V.singleton (Just AllStreams)- accepted <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))- clean accepted- readIORef called `shouldReturn` True- rows store "retarget" `shouldReturn` [(0, "category", Just "target", 1)]- it "adopts every legacy member once and emits one bound event for the name" $ do- events <- newIORef []- let observe e = modifyIORef' events (e :)- withTestStoreSettings (\s -> s{eventHandler = Just observe}) $ \store -> do- void $ resize store "adopt" 2- startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)){consumerGroup = Just (validConsumerGroup 0 2)})- startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)){consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound})- rows store "adopt" `shouldReturn` [(0, "all", Nothing, 0), (1, "all", Nothing, 0)]- seen <- readIORef events- length [() | KirokuEventSubscriptionTargetBound (SubscriptionName "adopt") AllStreams _ <- seen] `shouldBe` 1- it "refuses unbound rows under RequireBound without changing rows or invoking the handler" $- withTestStore $ \store -> do- void $ resize store "strict" 1- called <- newIORef False- handle <- subscribe store ((cfg "strict" AllStreams (\_ -> writeIORef called True >> pure Continue)){targetBindingPolicy = RequireBound})- mismatch handle- readIORef called `shouldReturn` False- rows store "strict" `shouldReturn` [(0, "unbound", Nothing, 0)]- it "refuses mixed bound/unbound siblings before inserting a missing member" $- withTestStore $ \store -> do- void $ resize store "mixed-target" 3- runSession store (Session.script "UPDATE subscriptions SET target_kind = 'all' WHERE subscription_name = 'mixed-target' AND consumer_group_member = 0; DELETE FROM subscriptions WHERE subscription_name = 'mixed-target' AND consumer_group_member = 2")- handle <- subscribe store ((cfg "mixed-target" AllStreams (\_ -> expectationFailure "handler ran" >> pure Stop)){consumerGroup = Just (validConsumerGroup 2 3)})- mismatch handle- rows store "mixed-target" `shouldReturn` [(0, "all", Nothing, 0), (1, "unbound", Nothing, 0)]- it "does not adopt siblings when FailIfMissing refuses an absent member" $- withTestStore $ \store -> do- void $ resize store "missing-member" 2- runSession store (Session.script "DELETE FROM subscriptions WHERE subscription_name = 'missing-member' AND consumer_group_member = 1")- handle <- subscribe store ((cfg "missing-member" AllStreams (\_ -> pure Stop)){consumerGroup = Just (validConsumerGroup 1 2), missingCheckpointPolicy = FailIfMissing})- waitWithTimeout 5_000_000 handle >>= \case- Right (Left e) -> (fromException e :: Maybe SubscriptionCheckpointMissing) `shouldSatisfy` maybe False (const True)- other -> expectationFailure (show other)- rows store "missing-member" `shouldReturn` [(0, "unbound", Nothing, 0)]- it "serializes conflicting targets on an initially absent name" $- withTestStore $ \store -> do- [a, b] <- Async.mapConcurrently (\target -> subscribe store (cfg "competing-targets" target (\_ -> pure Continue))) [AllStreams, Category (CategoryName "other")]- outcome <- timeout 5_000_000 (Async.race (wait a) (wait b))- case outcome of- Just (Left (Left e)) -> assertMismatch e- Just (Right (Left e)) -> assertMismatch e- other -> expectationFailure (show other)- mapM_ cancel [a, b]- length <$> rows store "competing-targets" `shouldReturn` 1- it "preserves category binding on resize and initializes fresh names under RequireBound" $- withTestStore $ \store -> do- let target = Category (CategoryName "preserved")- startLive store ((cfg "bound-resize" target (\_ -> pure Continue)){targetBindingPolicy = RequireBound})- void $ resize store "bound-resize" 3- rows store "bound-resize" `shouldReturn` [(m, "category", Just "preserved", 0) | m <- [0 .. 2]]- startLive store ((cfg "bound-resize" target (\_ -> pure Continue)){consumerGroup = Just (validConsumerGroup 2 3), targetBindingPolicy = RequireBound})- it "rebinds all members atomically, repeats idempotently, and rolls back with the caller" $- withTestStore $ \store -> do- void $ resize store "rebind-group" 3- report <- rebind store "rebind-group" AllStreams 7- reboundMemberCount report `shouldBe` 3- previousBindings report `shouldBe` V.singleton Nothing- rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]- again <- rebind store "rebind-group" AllStreams 7- previousBindings again `shouldBe` V.singleton (Just AllStreams)- void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do- result <- rebindSubscriptionTargetTx (SubscriptionName "rebind-group") (Category (CategoryName "rollback")) (GlobalPosition 0)- Tx.condemn- pure result- rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]- absent <- Pool.use (store ^. #pool) $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (rebindSubscriptionTargetTx (SubscriptionName "absent") AllStreams (GlobalPosition 0))- absent `shouldSatisfy` either (const True) (const False)- it "persists target binding through an atomic dead-letter save" $- withTestStore $ \store -> do- Right _ <- runStoreIO store $ appendToStream (StreamName "poison-target-1") NoStream [makeEvent "E" (Aeson.object [])]- withSubscription store (cfg "target-dead-letter" (Category (CategoryName "poison")) (\_ -> pure (DeadLetter (DeadLetterPoison "chosen by consumer")))) $ \_ -> do- let awaitDurable = do- persisted <- rows store "target-dead-letter"- if persisted == [(0, "category", Just "poison", 1)]- then pure ()- else threadDelay 1_000 >> awaitDurable- timeout 5_000_000 awaitDurable `shouldReturn` Just ()- rows store "target-dead-letter" `shouldReturn` [(0, "category", Just "poison", 1)]+ describe "subscription configuration" $ do+ it "validates batch and bridge buffer boundaries as values" $ do+ mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)+ mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))+ fmap batchSizeValue (mkBatchSize 1) `shouldBe` Right 1+ batchSizeValue defaultBatchSize `shouldBe` 100+ Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)+ fmap Buffer.streamBufferSizeValue (Buffer.mkStreamBufferSize 1) `shouldBe` Right 1+ it "exposes every runtime startup refusal through the parent and concrete catches" $ do+ let name = SubscriptionName "hierarchy"+ key = SubscriptionCheckpointKey name 0+ assertHierarchy (SubscriptionCheckpointMissing key)+ assertHierarchy (ConsumerGroupGuardConflict name 0)+ assertHierarchy (ConsumerGroupSizeMismatch name 2 (V.singleton 1))+ assertHierarchy (SubscriptionTargetMismatch name AllStreams (V.singleton Nothing))+ describe "checkpoint target" $ do+ it "refuses accidental target reuse before delivery, then resumes after explicit rebind" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "target-1") NoStream [makeEvent "E" (Aeson.object [])]+ first <- subscribe store (cfg "retarget" AllStreams (\_ -> pure Stop))+ clean first+ called <- newIORef False+ let newTarget = Category (CategoryName "target")+ refused <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))+ mismatch refused+ readIORef called `shouldReturn` False+ report <- rebind store "retarget" newTarget 0+ reboundMemberCount report `shouldBe` 1+ previousBindings report `shouldBe` V.singleton (Just AllStreams)+ accepted <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))+ clean accepted+ readIORef called `shouldReturn` True+ rows store "retarget" `shouldReturn` [(0, "category", Just "target", 1)]+ it "emits a target mismatch followed by exactly one crashed stop before wait returns" $ do+ events <- newIORef []+ let observe e = atomicModifyIORef' events (\seen -> (e : seen, ()))+ subName = SubscriptionName "observe-target"+ config = (cfg "observe-target" AllStreams (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}+ refusal = SubscriptionTargetMismatch subName AllStreams (V.singleton Nothing)+ withTestStoreSettings (\s -> s {eventHandler = Just observe}) $ \store -> do+ void $ resize store "observe-target" 2+ handle <- subscribe store config+ mismatch handle+ seen <- reverse <$> readIORef events+ let lifecycle =+ [ e+ | e <- seen,+ case e of+ KirokuEventSubscriptionTargetMismatch {} -> True+ KirokuEventSubscriptionStopped {} -> True+ _ -> False+ ]+ case lifecycle of+ [KirokuEventSubscriptionTargetMismatch actual grp, KirokuEventSubscriptionStopped n pos (StopWorkerCrashed err) stopGrp] -> do+ actual `shouldBe` refusal+ grp `shouldBe` GroupMember 1 2+ n `shouldBe` subName+ pos `shouldBe` GlobalPosition 0+ stopGrp `shouldBe` grp+ fromException err `shouldBe` Just refusal+ _ -> expectationFailure ("unexpected refusal lifecycle: " <> show lifecycle)+ it "adopts every legacy member once and emits one bound event for the name" $ do+ events <- newIORef []+ let observe e = modifyIORef' events (e :)+ withTestStoreSettings (\s -> s {eventHandler = Just observe}) $ \store -> do+ void $ resize store "adopt" 2+ startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 0 2)})+ startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound})+ rows store "adopt" `shouldReturn` [(0, "all", Nothing, 0), (1, "all", Nothing, 0)]+ seen <- readIORef events+ length [() | KirokuEventSubscriptionTargetBound (SubscriptionName "adopt") AllStreams _ <- seen] `shouldBe` 1+ it "refuses unbound rows under RequireBound without changing rows or invoking the handler" $+ withTestStore $ \store -> do+ void $ resize store "strict" 1+ called <- newIORef False+ handle <- subscribe store ((cfg "strict" AllStreams (\_ -> writeIORef called True >> pure Continue)) {targetBindingPolicy = RequireBound})+ mismatch handle+ readIORef called `shouldReturn` False+ rows store "strict" `shouldReturn` [(0, "unbound", Nothing, 0)]+ it "refuses mixed bound/unbound siblings before inserting a missing member" $+ withTestStore $ \store -> do+ void $ resize store "mixed-target" 3+ runSession store (Session.script "UPDATE subscriptions SET target_kind = 'all' WHERE subscription_name = 'mixed-target' AND consumer_group_member = 0; DELETE FROM subscriptions WHERE subscription_name = 'mixed-target' AND consumer_group_member = 2")+ handle <- subscribe store ((cfg "mixed-target" AllStreams (\_ -> expectationFailure "handler ran" >> pure Stop)) {consumerGroup = Just (validConsumerGroup 2 3)})+ mismatch handle+ rows store "mixed-target" `shouldReturn` [(0, "all", Nothing, 0), (1, "unbound", Nothing, 0)]+ it "does not adopt siblings when FailIfMissing refuses an absent member" $+ withTestStore $ \store -> do+ void $ resize store "missing-member" 2+ runSession store (Session.script "DELETE FROM subscriptions WHERE subscription_name = 'missing-member' AND consumer_group_member = 1")+ handle <- subscribe store ((cfg "missing-member" AllStreams (\_ -> pure Stop)) {consumerGroup = Just (validConsumerGroup 1 2), missingCheckpointPolicy = FailIfMissing})+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Left e) -> (fromException e :: Maybe SubscriptionCheckpointMissing) `shouldSatisfy` maybe False (const True)+ other -> expectationFailure (show other)+ rows store "missing-member" `shouldReturn` [(0, "unbound", Nothing, 0)]+ it "serializes conflicting targets on an initially absent name" $+ withTestStore $ \store -> do+ [a, b] <- Async.mapConcurrently (\target -> subscribe store (cfg "competing-targets" target (\_ -> pure Continue))) [AllStreams, Category (CategoryName "other")]+ outcome <- timeout 5_000_000 (Async.race (wait a) (wait b))+ case outcome of+ Just (Left (Left e)) -> assertMismatch e+ Just (Right (Left e)) -> assertMismatch e+ other -> expectationFailure (show other)+ mapM_ cancel [a, b]+ length <$> rows store "competing-targets" `shouldReturn` 1+ it "preserves category binding on resize and initializes fresh names under RequireBound" $+ withTestStore $ \store -> do+ let target = Category (CategoryName "preserved")+ startLive store ((cfg "bound-resize" target (\_ -> pure Continue)) {targetBindingPolicy = RequireBound})+ void $ resize store "bound-resize" 3+ rows store "bound-resize" `shouldReturn` [(m, "category", Just "preserved", 0) | m <- [0 .. 2]]+ startLive store ((cfg "bound-resize" target (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 2 3), targetBindingPolicy = RequireBound})+ it "rebinds all members atomically, repeats idempotently, and rolls back with the caller" $+ withTestStore $ \store -> do+ void $ resize store "rebind-group" 3+ report <- rebind store "rebind-group" AllStreams 7+ reboundMemberCount report `shouldBe` 3+ previousBindings report `shouldBe` V.singleton Nothing+ rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]+ again <- rebind store "rebind-group" AllStreams 7+ previousBindings again `shouldBe` V.singleton (Just AllStreams)+ void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+ result <- rebindSubscriptionTargetTx (SubscriptionName "rebind-group") (Category (CategoryName "rollback")) (GlobalPosition 0)+ Tx.condemn+ pure result+ rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]+ absent <- Pool.use (store ^. #pool) $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (rebindSubscriptionTargetTx (SubscriptionName "absent") AllStreams (GlobalPosition 0))+ absent `shouldSatisfy` either (const True) (const False)+ it "persists target binding through an atomic dead-letter save" $+ withTestStore $ \store -> do+ Right _ <- runStoreIO store $ appendToStream (StreamName "poison-target-1") NoStream [makeEvent "E" (Aeson.object [])]+ withSubscription store (cfg "target-dead-letter" (Category (CategoryName "poison")) (\_ -> pure (DeadLetter (DeadLetterPoison "chosen by consumer")))) $ \_ -> do+ let awaitDurable = do+ persisted <- rows store "target-dead-letter"+ if persisted == [(0, "category", Just "poison", 1)]+ then pure ()+ else threadDelay 1_000 >> awaitDurable+ timeout 5_000_000 awaitDurable `shouldReturn` Just ()+ rows store "target-dead-letter" `shouldReturn` [(0, "category", Just "poison", 1)] assertHierarchy :: (Exception e, Eq e) => e -> IO () assertHierarchy refusal = do- parent <- try (throwIO refusal) :: IO (Either SomeSubscriptionStartupFailure ())- case parent of- Left caught -> fromException (toException caught) `shouldBe` Just refusal- Right () -> expectationFailure "parent catch did not match"+ parent <- try (throwIO refusal) :: IO (Either SomeSubscriptionStartupFailure ())+ case parent of+ Left caught -> fromException (toException caught) `shouldBe` Just refusal+ Right () -> expectationFailure "parent catch did not match" cfg :: Text -> SubscriptionTarget -> EventHandler -> SubscriptionConfig cfg name = defaultSubscriptionConfig (SubscriptionName name) clean :: SubscriptionHandle -> IO () clean handle =- waitWithTimeout 5_000_000 handle >>= \case- Right (Right ()) -> pure ()- other -> expectationFailure (show other)+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Right ()) -> pure ()+ other -> expectationFailure (show other) assertMismatch :: SomeException -> IO () assertMismatch e = do- (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)- (fromException e :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` maybe False (const True)+ (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)+ (fromException e :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` maybe False (const True) mismatch :: SubscriptionHandle -> IO () mismatch handle =- waitWithTimeout 5_000_000 handle >>= \case- Right (Left e) -> assertMismatch e- other -> expectationFailure (show other)+ waitWithTimeout 5_000_000 handle >>= \case+ Right (Left e) -> assertMismatch e+ other -> expectationFailure (show other) startLive :: KirokuStore -> SubscriptionConfig -> IO () startLive store config = withSubscription store config $ \handle -> do- result <- timeout 5_000_000 (awaitLive handle)- result `shouldBe` Just ()+ result <- timeout 5_000_000 (awaitLive handle)+ result `shouldBe` Just () where awaitLive handle =- currentState handle >>= \case- Just state | stateName state == "live" -> pure ()- _ -> threadDelay 1_000 >> awaitLive handle+ currentState handle >>= \case+ Just state | stateName state == "live" -> pure ()+ _ -> threadDelay 1_000 >> awaitLive handle runSession :: KirokuStore -> Session.Session a -> IO a runSession store session = Pool.use (store ^. #pool) session >>= either (fail . show) pure resize :: KirokuStore -> Text -> Int32 -> IO ConsumerGroupResizeReport resize store name n = let Right size = mkConsumerGroupSize n in runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (resizeConsumerGroupTx (SubscriptionName name) size)+ rebind :: KirokuStore -> Text -> SubscriptionTarget -> Int64 -> IO SubscriptionTargetRebindReport rebind store name target position = runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (rebindSubscriptionTargetTx (SubscriptionName name) target (GlobalPosition position)) @@ -189,7 +218,7 @@ rows store name = V.toList <$> runSession store (Session.statement name stmt) where stmt =- preparable- "SELECT consumer_group_member, target_kind, target_category, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"- (E.param (E.nonNullable E.text))- (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text) <*> D.column (D.nonNullable D.int8)))+ preparable+ "SELECT consumer_group_member, target_kind, target_category, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"+ (E.param (E.nonNullable E.text))+ (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text) <*> D.column (D.nonNullable D.int8)))
test/Test/Transaction.hs view
@@ -1,8 +1,7 @@-{- | Tests for "Kiroku.Store.Transaction" — the transactional escape-hatch and append combinators that let callers compose store operations-with arbitrary 'Hasql.Transaction.Transaction' work in one ACID-transaction.--}+-- | Tests for "Kiroku.Store.Transaction" — the transactional escape+-- hatch and append combinators that let callers compose store operations+-- with arbitrary 'Hasql.Transaction.Transaction' work in one ACID+-- transaction. module Test.Transaction (spec) where import Contravariant.Extras (contrazip2)@@ -28,400 +27,398 @@ spec :: Spec spec = around withTestStore $ do- describe "runTransaction" $ do- it "executes a no-op Tx.statement and returns its result" $ \store -> do- result <-- runStoreIO store $- runTransaction $- Tx.statement () selectOneStmt- case result of- Right n -> n `shouldBe` (1 :: Int)- Left err -> expectationFailure ("Expected Right 1, got: " <> show err)+ describe "runTransaction" $ do+ it "executes a no-op Tx.statement and returns its result" $ \store -> do+ result <-+ runStoreIO store $+ runTransaction $+ Tx.statement () selectOneStmt+ case result of+ Right n -> n `shouldBe` (1 :: Int)+ Left err -> expectationFailure ("Expected Right 1, got: " <> show err) - it "preserves SQLSTATE and message for an opaque foreign-key failure" $ \store -> do- createForeignKeyTables (store ^. #pool)- result <-- runStoreIO store $- runTransaction $- Tx.statement () insertMissingForeignKeyStmt- case result of- Left (UnexpectedServerError code message) -> do- code `shouldBe` "23503"- T.toLower message `shouldSatisfy` T.isInfixOf "foreign key"- Left (StreamNotFound (StreamName "<transaction>")) ->- expectationFailure "foreign-key failure was misclassified as a missing <transaction> stream"- other ->- expectationFailure ("Expected UnexpectedServerError 23503, got: " <> show other)+ it "preserves SQLSTATE and message for an opaque foreign-key failure" $ \store -> do+ createForeignKeyTables (store ^. #pool)+ result <-+ runStoreIO store $+ runTransaction $+ Tx.statement () insertMissingForeignKeyStmt+ case result of+ Left (UnexpectedServerError code message) -> do+ code `shouldBe` "23503"+ T.toLower message `shouldSatisfy` T.isInfixOf "foreign key"+ Left (StreamNotFound (StreamName "<transaction>")) ->+ expectationFailure "foreign-key failure was misclassified as a missing <transaction> stream"+ other ->+ expectationFailure ("Expected UnexpectedServerError 23503, got: " <> show other) - describe "appendToStreamTx (driven inside runTransaction)" $ do- it "appends events and a side-table row in one atomic transaction" $ \store -> do- createSideTable (store ^. #pool)- let evt = makeEvent "Created" (Aeson.object [("x", Aeson.Number 1)])- prepared <- prepareEventsIO [evt]- now <- getCurrentTime- result <- runStoreIO store $ runTransaction $ do- outcome <- appendToStreamTx (StreamName "txn-success-1") NoStream prepared now- case outcome of- Left c -> pure (Left c)- Right ar -> do- let StreamId sid = ar ^. #streamId- Tx.statement (sid, "hello") insertSideRowStmt- pure (Right ar)- case result of- Right (Right ar) -> do- (ar ^. #streamVersion) `shouldBe` StreamVersion 1- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 1- other ->- expectationFailure- ("Expected Right (Right AppendResult), got: " <> show other)+ describe "appendToStreamTx (driven inside runTransaction)" $ do+ it "appends events and a side-table row in one atomic transaction" $ \store -> do+ createSideTable (store ^. #pool)+ let evt = makeEvent "Created" (Aeson.object [("x", Aeson.Number 1)])+ prepared <- prepareEventsIO [evt]+ now <- getCurrentTime+ result <- runStoreIO store $ runTransaction $ do+ outcome <- appendToStreamTx (StreamName "txn-success-1") NoStream prepared now+ case outcome of+ Left c -> pure (Left c)+ Right ar -> do+ let StreamId sid = ar ^. #streamId+ Tx.statement (sid, "hello") insertSideRowStmt+ pure (Right ar)+ case result of+ Right (Right ar) -> do+ (ar ^. #streamVersion) `shouldBe` StreamVersion 1+ rows <- countSideTable (store ^. #pool)+ rows `shouldBe` 1+ other ->+ expectationFailure+ ("Expected Right (Right AppendResult), got: " <> show other) - it "rolls back the append and the side row when the body condemns" $ \store -> do- createSideTable (store ^. #pool)- let evt = makeEvent "Created" (Aeson.object [])- prepared <- prepareEventsIO [evt]- now <- getCurrentTime- result <- runStoreIO store $ runTransaction $ do- outcome <- appendToStreamTx (StreamName "txn-condemn-1") NoStream prepared now- case outcome of- Right ar -> do- let StreamId sid = ar ^. #streamId- Tx.statement (sid, "should-not-persist") insertSideRowStmt- Tx.condemn- Left _ -> Tx.condemn- result `shouldBe` Right ()- mInfo <- runStoreIO store $ getStream (StreamName "txn-condemn-1")- mInfo `shouldBe` Right Nothing- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 0+ it "rolls back the append and the side row when the body condemns" $ \store -> do+ createSideTable (store ^. #pool)+ let evt = makeEvent "Created" (Aeson.object [])+ prepared <- prepareEventsIO [evt]+ now <- getCurrentTime+ result <- runStoreIO store $ runTransaction $ do+ outcome <- appendToStreamTx (StreamName "txn-condemn-1") NoStream prepared now+ case outcome of+ Right ar -> do+ let StreamId sid = ar ^. #streamId+ Tx.statement (sid, "should-not-persist") insertSideRowStmt+ Tx.condemn+ Left _ -> Tx.condemn+ result `shouldBe` Right ()+ mInfo <- runStoreIO store $ getStream (StreamName "txn-condemn-1")+ mInfo `shouldBe` Right Nothing+ rows <- countSideTable (store ^. #pool)+ rows `shouldBe` 0 - it "returns Left on version conflict; the caller's branch sees no AppendResult" $ \store -> do- createSideTable (store ^. #pool)- -- Pre-populate the stream so the ExactVersion check below mismatches.- _ <-- runStoreIO store $- appendToStream- (StreamName "txn-conflict-1")- NoStream- [makeEvent "Created" (Aeson.object [])]- prepared <- prepareEventsIO [makeEvent "Updated" (Aeson.object [])]- now <- getCurrentTime- result <- runStoreIO store $ runTransaction $ do- outcome <-- appendToStreamTx- (StreamName "txn-conflict-1")- (ExactVersion (StreamVersion 99))- prepared- now- case outcome of- Right _ -> do- Tx.statement (0 :: Int64, "should-not-run") insertSideRowStmt- pure outcome- Left _ -> pure outcome- case result of- Right (Left (WrongExpectedVersionConflict _ _ _)) -> pure ()- other ->- expectationFailure- ("Expected Right (Left WrongExpectedVersionConflict), got: " <> show other)- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 0+ it "returns Left on version conflict; the caller's branch sees no AppendResult" $ \store -> do+ createSideTable (store ^. #pool)+ -- Pre-populate the stream so the ExactVersion check below mismatches.+ _ <-+ runStoreIO store $+ appendToStream+ (StreamName "txn-conflict-1")+ NoStream+ [makeEvent "Created" (Aeson.object [])]+ prepared <- prepareEventsIO [makeEvent "Updated" (Aeson.object [])]+ now <- getCurrentTime+ result <- runStoreIO store $ runTransaction $ do+ outcome <-+ appendToStreamTx+ (StreamName "txn-conflict-1")+ (ExactVersion (StreamVersion 99))+ prepared+ now+ case outcome of+ Right _ -> do+ Tx.statement (0 :: Int64, "should-not-run") insertSideRowStmt+ pure outcome+ Left _ -> pure outcome+ case result of+ Right (Left (WrongExpectedVersionConflict _ _ _)) -> pure ()+ other ->+ expectationFailure+ ("Expected Right (Left WrongExpectedVersionConflict), got: " <> show other)+ 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+ 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+ 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)- let evts =- [ makeEvent "Created" (Aeson.object [("i", Aeson.Number 1)])- , makeEvent "Updated" (Aeson.object [("i", Aeson.Number 2)])- , makeEvent "Closed" (Aeson.object [("i", Aeson.Number 3)])- ]- result <-- runStoreIO store $- runTransactionAppending- (StreamName "txn-wrapper-success-1")- NoStream- evts- ( \ar -> do- let StreamId sid = ar ^. #streamId- Tx.statement (sid, "projection") insertSideRowStmt- pure ar- )- case result of- Right (Right ar) ->- (ar ^. #streamVersion) `shouldBe` StreamVersion 3- other ->- expectationFailure- ("Expected Right (Right AppendResult), got: " <> show other)- -- Verify both the events and the side row landed.- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 1- mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-success-1")- case mInfo of- Right (Just info) -> (info ^. #version) `shouldBe` StreamVersion 3- other -> expectationFailure ("Expected stream with version 3, got: " <> show other)+ describe "runTransactionAppending" $ do+ it "commits 3 events and a side-table row in one ACID transaction" $ \store -> do+ createSideTable (store ^. #pool)+ let evts =+ [ makeEvent "Created" (Aeson.object [("i", Aeson.Number 1)]),+ makeEvent "Updated" (Aeson.object [("i", Aeson.Number 2)]),+ makeEvent "Closed" (Aeson.object [("i", Aeson.Number 3)])+ ]+ result <-+ runStoreIO store $+ runTransactionAppending+ (StreamName "txn-wrapper-success-1")+ NoStream+ evts+ ( \ar -> do+ let StreamId sid = ar ^. #streamId+ Tx.statement (sid, "projection") insertSideRowStmt+ pure ar+ )+ case result of+ Right (Right ar) ->+ (ar ^. #streamVersion) `shouldBe` StreamVersion 3+ other ->+ expectationFailure+ ("Expected Right (Right AppendResult), got: " <> show other)+ -- Verify both the events and the side row landed.+ rows <- countSideTable (store ^. #pool)+ rows `shouldBe` 1+ mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-success-1")+ case mInfo of+ Right (Just info) -> (info ^. #version) `shouldBe` StreamVersion 3+ other -> expectationFailure ("Expected stream with version 3, got: " <> show other) - it "rolls back the append and the side row when the callback condemns" $ \store -> do- createSideTable (store ^. #pool)- result <-- runStoreIO store $- runTransactionAppending- (StreamName "txn-wrapper-condemn-1")- NoStream- [makeEvent "Created" (Aeson.object [])]- ( \ar -> do- let StreamId sid = ar ^. #streamId- Tx.statement (sid, "should-not-persist") insertSideRowStmt- Tx.condemn- pure ar- )- -- The callback ran and returned, so the wrapper sees Right; but the- -- transaction was condemned, so neither write committed.- case result of- Right (Right _) -> pure ()- other ->- expectationFailure- ("Expected Right (Right AppendResult), got: " <> show other)- mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-condemn-1")- mInfo `shouldBe` Right Nothing- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 0+ it "rolls back the append and the side row when the callback condemns" $ \store -> do+ createSideTable (store ^. #pool)+ result <-+ runStoreIO store $+ runTransactionAppending+ (StreamName "txn-wrapper-condemn-1")+ NoStream+ [makeEvent "Created" (Aeson.object [])]+ ( \ar -> do+ let StreamId sid = ar ^. #streamId+ Tx.statement (sid, "should-not-persist") insertSideRowStmt+ Tx.condemn+ pure ar+ )+ -- The callback ran and returned, so the wrapper sees Right; but the+ -- transaction was condemned, so neither write committed.+ case result of+ Right (Right _) -> pure ()+ other ->+ expectationFailure+ ("Expected Right (Right AppendResult), got: " <> show other)+ mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-condemn-1")+ mInfo `shouldBe` Right Nothing+ rows <- countSideTable (store ^. #pool)+ rows `shouldBe` 0 - it "skips the callback and surfaces the version conflict" $ \store -> do- createSideTable (store ^. #pool)- -- Pre-populate the stream so ExactVersion 99 will mismatch.- _ <-- runStoreIO store $- appendToStream- (StreamName "txn-wrapper-conflict-1")- NoStream- [makeEvent "Created" (Aeson.object [])]- calledRef <- newIORef (0 :: Int)- result <-- runStoreIO store $- runTransactionAppending- (StreamName "txn-wrapper-conflict-1")- (ExactVersion (StreamVersion 99))- [makeEvent "Updated" (Aeson.object [])]- ( \ar -> do- -- Tx.Transaction has no MonadIO; the IORef bump can't go- -- here. Instead, write a side row whose absence asserts- -- non-invocation.- let StreamId sid = ar ^. #streamId- Tx.statement (sid, "should-not-run") insertSideRowStmt- pure ar- )- -- Bump the ref outside the Tx; assertion below proves the guard- -- works: if the callback HAD run, the side row would exist.- modifyIORef' calledRef (+ 1)- n <- readIORef calledRef- n `shouldBe` 1- case result of- Right (Left (WrongExpectedVersion _ _ _)) -> pure ()- other ->- expectationFailure- ("Expected Right (Left WrongExpectedVersion), got: " <> show other)- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 0+ it "skips the callback and surfaces the version conflict" $ \store -> do+ createSideTable (store ^. #pool)+ -- Pre-populate the stream so ExactVersion 99 will mismatch.+ _ <-+ runStoreIO store $+ appendToStream+ (StreamName "txn-wrapper-conflict-1")+ NoStream+ [makeEvent "Created" (Aeson.object [])]+ calledRef <- newIORef (0 :: Int)+ result <-+ runStoreIO store $+ runTransactionAppending+ (StreamName "txn-wrapper-conflict-1")+ (ExactVersion (StreamVersion 99))+ [makeEvent "Updated" (Aeson.object [])]+ ( \ar -> do+ -- Tx.Transaction has no MonadIO; the IORef bump can't go+ -- here. Instead, write a side row whose absence asserts+ -- non-invocation.+ let StreamId sid = ar ^. #streamId+ Tx.statement (sid, "should-not-run") insertSideRowStmt+ pure ar+ )+ -- Bump the ref outside the Tx; assertion below proves the guard+ -- works: if the callback HAD run, the side row would exist.+ modifyIORef' calledRef (+ 1)+ n <- readIORef calledRef+ n `shouldBe` 1+ case result of+ Right (Left (WrongExpectedVersion _ _ _)) -> pure ()+ other ->+ expectationFailure+ ("Expected Right (Left WrongExpectedVersion), got: " <> show other)+ rows <- countSideTable (store ^. #pool)+ rows `shouldBe` 0 - it "rejects $all before opening any transaction" $ \store -> do- createSideTable (store ^. #pool)- result <-- runStoreIO store $- runTransactionAppending- (StreamName "$all")- AnyVersion- [makeEvent "Should" (Aeson.object [])]- ( \ar -> do- let StreamId sid = ar ^. #streamId- Tx.statement (sid, "should-never-run") insertSideRowStmt- pure ar- )- case result of- Right (Left (ReservedStreamName (StreamName "$all"))) -> pure ()- other ->- expectationFailure- ("Expected Right (Left (ReservedStreamName \"$all\")), got: " <> show other)- rows <- countSideTable (store ^. #pool)- rows `shouldBe` 0+ it "rejects $all before opening any transaction" $ \store -> do+ createSideTable (store ^. #pool)+ result <-+ runStoreIO store $+ runTransactionAppending+ (StreamName "$all")+ AnyVersion+ [makeEvent "Should" (Aeson.object [])]+ ( \ar -> do+ let StreamId sid = ar ^. #streamId+ Tx.statement (sid, "should-never-run") insertSideRowStmt+ pure ar+ )+ case result of+ Right (Left (ReservedStreamName (StreamName "$all"))) -> pure ()+ other ->+ expectationFailure+ ("Expected Right (Left (ReservedStreamName \"$all\")), got: " <> show other)+ 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 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 "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+ 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+ duplicate <-+ runStoreIO store $+ runTransactionAppending+ (StreamName "txn-wrapper-duplicate-target-1")+ NoStream+ [evt]+ pure+ duplicate `shouldSatisfy` isDuplicateEvent -- --------------------------------------------------------------------------- -- Local statements and pool-side helpers -- --------------------------------------------------------------------------- -{- | Trivial @SELECT 1@ used to verify 'runTransaction' wiring without-depending on any schema state.--}+-- | Trivial @SELECT 1@ used to verify 'runTransaction' wiring without+-- depending on any schema state. selectOneStmt :: Statement () Int selectOneStmt =- preparable- "SELECT 1::int4"- E.noParams- (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int4)))+ preparable+ "SELECT 1::int4"+ E.noParams+ (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int4))) -- | DDL for the projection-row side table used by transaction tests. createSideTableStmt :: Statement () () createSideTableStmt =- unpreparable- "CREATE TABLE IF NOT EXISTS test_side_table \- \(id BIGINT PRIMARY KEY, payload TEXT NOT NULL)"- E.noParams- D.noResult+ unpreparable+ "CREATE TABLE IF NOT EXISTS test_side_table \+ \(id BIGINT PRIMARY KEY, payload TEXT NOT NULL)"+ E.noParams+ D.noResult createForeignKeyParentStmt :: Statement () () createForeignKeyParentStmt =- unpreparable- "CREATE TABLE IF NOT EXISTS test_tx_parent (id BIGINT PRIMARY KEY)"- E.noParams- D.noResult+ unpreparable+ "CREATE TABLE IF NOT EXISTS test_tx_parent (id BIGINT PRIMARY KEY)"+ E.noParams+ D.noResult createForeignKeyChildStmt :: Statement () () createForeignKeyChildStmt =- unpreparable- "CREATE TABLE IF NOT EXISTS test_tx_child (parent_id BIGINT NOT NULL REFERENCES test_tx_parent(id))"- E.noParams- D.noResult+ unpreparable+ "CREATE TABLE IF NOT EXISTS test_tx_child (parent_id BIGINT NOT NULL REFERENCES test_tx_parent(id))"+ E.noParams+ D.noResult insertMissingForeignKeyStmt :: Statement () () insertMissingForeignKeyStmt =- preparable- "INSERT INTO test_tx_child (parent_id) VALUES (999999)"- E.noParams- D.noResult+ preparable+ "INSERT INTO test_tx_child (parent_id) VALUES (999999)"+ E.noParams+ D.noResult -{- | Insert a row into the side table. The @id@ doubles as the foreign-key onto the stream the transaction appended to.--}+-- | Insert a row into the side table. The @id@ doubles as the foreign+-- key onto the stream the transaction appended to. insertSideRowStmt :: Statement (Int64, Text) () insertSideRowStmt =- preparable- "INSERT INTO test_side_table (id, payload) VALUES ($1, $2)"- ( contrazip2- (E.param (E.nonNullable E.int8))- (E.param (E.nonNullable E.text))- )- D.noResult+ preparable+ "INSERT INTO test_side_table (id, payload) VALUES ($1, $2)"+ ( contrazip2+ (E.param (E.nonNullable E.int8))+ (E.param (E.nonNullable E.text))+ )+ D.noResult -- | Count rows in the side table. countSideTableStmt :: Statement () Int64 countSideTableStmt =- preparable- "SELECT COUNT(*) FROM test_side_table"- E.noParams- (D.singleRow (D.column (D.nonNullable D.int8)))+ preparable+ "SELECT COUNT(*) FROM test_side_table"+ E.noParams+ (D.singleRow (D.column (D.nonNullable D.int8))) createSideTable :: Pool -> IO () createSideTable pool = do- r <- Pool.use pool (Session.statement () createSideTableStmt)- case r of- Left err -> error ("createSideTable failed: " <> show err)- Right () -> pure ()+ r <- Pool.use pool (Session.statement () createSideTableStmt)+ case r of+ Left err -> error ("createSideTable failed: " <> show err)+ Right () -> pure () createForeignKeyTables :: Pool -> IO () createForeignKeyTables pool = do- parent <- Pool.use pool (Session.statement () createForeignKeyParentStmt)- child <- Pool.use pool (Session.statement () createForeignKeyChildStmt)- case (parent, child) of- (Right (), Right ()) -> pure ()- other -> error ("createForeignKeyTables failed: " <> show other)+ parent <- Pool.use pool (Session.statement () createForeignKeyParentStmt)+ child <- Pool.use pool (Session.statement () createForeignKeyChildStmt)+ case (parent, child) of+ (Right (), Right ()) -> pure ()+ other -> error ("createForeignKeyTables failed: " <> show other) countSideTable :: Pool -> IO Int64 countSideTable pool = do- r <- Pool.use pool (Session.statement () countSideTableStmt)- case r of- Left err -> error ("countSideTable failed: " <> show err)- Right n -> pure n+ r <- Pool.use pool (Session.statement () countSideTableStmt)+ 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+ Right _ -> True+ Left _ -> False isDuplicateEvent :: Either StoreError a -> Bool isDuplicateEvent = \case- Left (DuplicateEvent _) -> True- _ -> False+ 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- }+ EventData+ { eventId = Just eid,+ eventType = EventType "Created",+ payload = Aeson.object [],+ metadata = Nothing,+ causationId = Nothing,+ correlationId = Nothing+ }
test/Test/TruncateBefore.hs view
@@ -1,15 +1,14 @@-{- | 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.--}+-- | 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 ((^.))@@ -25,101 +24,101 @@ -- | 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 ()+ 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))+ 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)+ 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]+ forwardVersions store name `shouldReturn` [StreamVersion 6] - Right back <- runStoreIO store $ readStreamBackward name (StreamVersion 0) 100- map (^. #streamVersion) (V.toList back) `shouldBe` [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- countBefore <- countEvents store- Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)+ it "leaves the $all global log and category reads intact" $ \store -> do+ let name = StreamName "preference-abc"+ seedStream store name 6+ countBefore <- 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+ -- \$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+ -- 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.- countAfter <- countEvents store- countAfter `shouldBe` countBefore+ -- Nothing was physically deleted.+ countAfter <- countEvents store+ countAfter `shouldBe` countBefore - 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]+ 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]+ 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 "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 "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 "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+ 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+ 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)+ 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)
test/Test/UniqueViolationMapping.hs view
@@ -11,83 +11,83 @@ spec :: Spec spec = describe "unique violation mapping" $ do- let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)- scalar = "Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists."- composite = "Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists."- fallback = WrongExpectedVersion (StreamName "orders-1") AnyVersion (StreamVersion 0)- mapAppend = mapUsageError "orders-1" AnyVersion- cases =- [ ("events_pkey", scalar, DuplicateEvent (Just eid))- , ("stream_events_pkey", composite, DuplicateEvent (Just eid))- , ("ix_streams_stream_name", "Key (stream_name)=(orders-1) already exists.", StreamAlreadyExists (StreamName "orders-1"))- , ("ux_stream_events_stream_version", "Key (stream_id, stream_version)=(42, 1) already exists.", UnexpectedServerError "23505" "detail-only error")- , ("unknown_constraint", scalar, fallback)- ]- forM_ cases $ \(name, detail, expected) -> do- it ("classifies the exact message constraint " <> show name) $ do- let message = constraintMessage name- outcome = case expected of- UnexpectedServerError code _ -> UnexpectedServerError code message- other -> other- mapAppend (serverUsage message (Just detail)) `shouldBe` outcome- it ("classifies a detail-only constraint " <> show name) $- mapAppend (serverUsage "detail-only error" (Just ("constraint: " <> name <> "; " <> detail)))- `shouldBe` expected+ let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)+ scalar = "Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists."+ composite = "Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists."+ fallback = WrongExpectedVersion (StreamName "orders-1") AnyVersion (StreamVersion 0)+ mapAppend = mapUsageError "orders-1" AnyVersion+ cases =+ [ ("events_pkey", scalar, DuplicateEvent (Just eid)),+ ("stream_events_pkey", composite, DuplicateEvent (Just eid)),+ ("ix_streams_stream_name", "Key (stream_name)=(orders-1) already exists.", StreamAlreadyExists (StreamName "orders-1")),+ ("ux_stream_events_stream_version", "Key (stream_id, stream_version)=(42, 1) already exists.", UnexpectedServerError "23505" "detail-only error"),+ ("unknown_constraint", scalar, fallback)+ ]+ forM_ cases $ \(name, detail, expected) -> do+ it ("classifies the exact message constraint " <> show name) $ do+ let message = constraintMessage name+ outcome = case expected of+ UnexpectedServerError code _ -> UnexpectedServerError code message+ other -> other+ mapAppend (serverUsage message (Just detail)) `shouldBe` outcome+ it ("classifies a detail-only constraint " <> show name) $+ mapAppend (serverUsage "detail-only error" (Just ("constraint: " <> name <> "; " <> detail)))+ `shouldBe` expected - it "does not match stream_events_pkey as events_pkey" $- mapAppend (serverUsage (constraintMessage "stream_events_pkey") (Just composite))- `shouldBe` DuplicateEvent (Just eid)- forM_ ["events_pkey", "stream_events_pkey", "ix_streams_stream_name", "ux_stream_events_stream_version"] $ \name ->- forM_ ["prefix_" <> name, name <> "_suffix", name <> "$suffix"] $ \other -> do- it ("rejects the message lookalike " <> show other) $- mapAppend (serverUsage (constraintMessage other) (Just scalar)) `shouldBe` fallback- it ("rejects the detail lookalike " <> show other) $- mapAppend (serverUsage "detail-only error" (Just (other <> "; " <> scalar))) `shouldBe` fallback+ it "does not match stream_events_pkey as events_pkey" $+ mapAppend (serverUsage (constraintMessage "stream_events_pkey") (Just composite))+ `shouldBe` DuplicateEvent (Just eid)+ forM_ ["events_pkey", "stream_events_pkey", "ix_streams_stream_name", "ux_stream_events_stream_version"] $ \name ->+ forM_ ["prefix_" <> name, name <> "_suffix", name <> "$suffix"] $ \other -> do+ it ("rejects the message lookalike " <> show other) $+ mapAppend (serverUsage (constraintMessage other) (Just scalar)) `shouldBe` fallback+ it ("rejects the detail lookalike " <> show other) $+ mapAppend (serverUsage "detail-only error" (Just (other <> "; " <> scalar))) `shouldBe` fallback - it "uses the message constraint ahead of misleading detail tokens" $- mapAppend (serverUsage (constraintMessage "ux_stream_events_stream_version") (Just ("events_pkey " <> scalar)))- `shouldBe` UnexpectedServerError "23505" (constraintMessage "ux_stream_events_stream_version")- it "retains an unknown message constraint even when detail names a known constraint" $- mapAppend (serverUsage (constraintMessage "unknown_constraint") (Just ("events_pkey " <> scalar)))- `shouldBe` fallback- forM_ ["events_pkey", "stream_events_pkey"] $ \name ->- forM_ [Nothing, Just "unparseable localized detail"] $ \detail ->- it ("does not fabricate an event id for " <> show (name, detail)) $- mapAppend (serverUsage (constraintMessage name) detail) `shouldBe` DuplicateEvent Nothing- it "maps a composite duplicate inside a transaction with its event id" $- mapTransactionUsageError (serverUsage (constraintMessage "stream_events_pkey") (Just composite))- `shouldBe` DuplicateEvent (Just eid)- it "preserves the link-specific duplicate classification" $- mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey") (Just composite))- `shouldBe` EventAlreadyLinked (StreamName "orders-1") (Just eid)- it "accepts a quoted constraint in legacy detail" $- mapAppend (serverUsage "detail-only error" (Just (constraintMessage "stream_events_pkey" <> "; " <> composite)))- `shouldBe` DuplicateEvent (Just eid)- it "keeps an unnamed unique violation on the conservative fallback" $- mapAppend (serverUsage "localized message" Nothing) `shouldBe` fallback- it "does not classify a transaction constraint lookalike as a duplicate" $- mapTransactionUsageError (serverUsage (constraintMessage "other_events_pkey") (Just scalar))- `shouldBe` UnexpectedServerError "23505" (constraintMessage "other_events_pkey")- it "does not classify a link constraint lookalike as already linked" $- mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey_backup") (Just composite))- `shouldBe` UnexpectedServerError "23505" (constraintMessage "stream_events_pkey_backup")- it "attributes a multi-stream name violation to the named operation" $- attributeMultiStreamError- [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]- (serverUsage (constraintMessage "ix_streams_stream_name") (Just "Key (stream_name)=(orders-2) already exists."))- `shouldBe` StreamAlreadyExists (StreamName "orders-2")- it "does not attribute a multi-stream constraint lookalike to a later operation" $- attributeMultiStreamError- [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]- (serverUsage (constraintMessage "ix_streams_stream_name_backup") (Just "Key (stream_name)=(orders-2) already exists."))- `shouldBe` fallback+ it "uses the message constraint ahead of misleading detail tokens" $+ mapAppend (serverUsage (constraintMessage "ux_stream_events_stream_version") (Just ("events_pkey " <> scalar)))+ `shouldBe` UnexpectedServerError "23505" (constraintMessage "ux_stream_events_stream_version")+ it "retains an unknown message constraint even when detail names a known constraint" $+ mapAppend (serverUsage (constraintMessage "unknown_constraint") (Just ("events_pkey " <> scalar)))+ `shouldBe` fallback+ forM_ ["events_pkey", "stream_events_pkey"] $ \name ->+ forM_ [Nothing, Just "unparseable localized detail"] $ \detail ->+ it ("does not fabricate an event id for " <> show (name, detail)) $+ mapAppend (serverUsage (constraintMessage name) detail) `shouldBe` DuplicateEvent Nothing+ it "maps a composite duplicate inside a transaction with its event id" $+ mapTransactionUsageError (serverUsage (constraintMessage "stream_events_pkey") (Just composite))+ `shouldBe` DuplicateEvent (Just eid)+ it "preserves the link-specific duplicate classification" $+ mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey") (Just composite))+ `shouldBe` EventAlreadyLinked (StreamName "orders-1") (Just eid)+ it "accepts a quoted constraint in legacy detail" $+ mapAppend (serverUsage "detail-only error" (Just (constraintMessage "stream_events_pkey" <> "; " <> composite)))+ `shouldBe` DuplicateEvent (Just eid)+ it "keeps an unnamed unique violation on the conservative fallback" $+ mapAppend (serverUsage "localized message" Nothing) `shouldBe` fallback+ it "does not classify a transaction constraint lookalike as a duplicate" $+ mapTransactionUsageError (serverUsage (constraintMessage "other_events_pkey") (Just scalar))+ `shouldBe` UnexpectedServerError "23505" (constraintMessage "other_events_pkey")+ it "does not classify a link constraint lookalike as already linked" $+ mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey_backup") (Just composite))+ `shouldBe` UnexpectedServerError "23505" (constraintMessage "stream_events_pkey_backup")+ it "attributes a multi-stream name violation to the named operation" $+ attributeMultiStreamError+ [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]+ (serverUsage (constraintMessage "ix_streams_stream_name") (Just "Key (stream_name)=(orders-2) already exists."))+ `shouldBe` StreamAlreadyExists (StreamName "orders-2")+ it "does not attribute a multi-stream constraint lookalike to a later operation" $+ attributeMultiStreamError+ [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]+ (serverUsage (constraintMessage "ix_streams_stream_name_backup") (Just "Key (stream_name)=(orders-2) already exists."))+ `shouldBe` fallback constraintMessage :: Text -> Text constraintMessage name = "duplicate key value violates unique constraint \"" <> name <> "\"" serverUsage :: Text -> Maybe Text -> UsageError serverUsage message detail =- SessionUsageError $- Errors.StatementSessionError 1 0 "" [] True $- Errors.ServerStatementError $- Errors.ServerError "23505" message detail Nothing Nothing+ SessionUsageError $+ Errors.StatementSessionError 1 0 "" [] True $+ Errors.ServerStatementError $+ Errors.ServerError "23505" message detail Nothing Nothing
test/Test/VisibleGlobalHeadPosition.hs view
@@ -14,92 +14,92 @@ spec :: Spec spec = describe "visible global head position" $ do- it "returns zero for an empty migrated store through both public runners" $- withTestStore $ \store -> do- runStoreIO store visibleGlobalHeadPosition- `shouldReturn` Right (GlobalPosition 0)+ it "returns zero for an empty migrated store through both public runners" $+ withTestStore $ \store -> do+ runStoreIO store visibleGlobalHeadPosition+ `shouldReturn` Right (GlobalPosition 0) - resourceResult <-- runEff- . runErrorNoCallStack @StoreError- . runKirokuStoreWith store- . runStoreResource- $ visibleGlobalHeadPosition- resourceResult `shouldBe` Right (GlobalPosition 0)+ resourceResult <-+ runEff+ . runErrorNoCallStack @StoreError+ . runKirokuStoreWith store+ . runStoreResource+ $ visibleGlobalHeadPosition+ resourceResult `shouldBe` Right (GlobalPosition 0) - it "returns the greatest appended position" $- withTestStore $ \store -> do- appendEvents store (StreamName "visible-head-populated") 3- runStoreIO store visibleGlobalHeadPosition- `shouldReturn` Right (GlobalPosition 3)+ it "returns the greatest appended position" $+ withTestStore $ \store -> do+ appendEvents store (StreamName "visible-head-populated") 3+ runStoreIO store visibleGlobalHeadPosition+ `shouldReturn` Right (GlobalPosition 3) - it "falls back across hard-deleted tails while the append frontier stays monotonic" $- withTestStore $ \store -> do- let first = StreamName "visible-head-first"- middle = StreamName "visible-head-middle"- lastStream = StreamName "visible-head-last"- appendEvents store first 1- appendEvents store middle 1- appendEvents store lastStream 1+ it "falls back across hard-deleted tails while the append frontier stays monotonic" $+ withTestStore $ \store -> do+ let first = StreamName "visible-head-first"+ middle = StreamName "visible-head-middle"+ lastStream = StreamName "visible-head-last"+ appendEvents store first 1+ appendEvents store middle 1+ appendEvents store lastStream 1 - Right (Just _) <- runStoreIO store $ hardDeleteStream middle- assertHeadAndFrontier store 3 3+ Right (Just _) <- runStoreIO store $ hardDeleteStream middle+ assertHeadAndFrontier store 3 3 - Right (Just _) <- runStoreIO store $ hardDeleteStream lastStream- assertHeadAndFrontier store 1 3+ Right (Just _) <- runStoreIO store $ hardDeleteStream lastStream+ assertHeadAndFrontier store 1 3 - Right (Just _) <- runStoreIO store $ hardDeleteStream first- assertHeadAndFrontier store 0 3+ Right (Just _) <- runStoreIO store $ hardDeleteStream first+ assertHeadAndFrontier store 0 3 - it "keeps logically truncated and soft-deleted events visible in $all" $- withTestStore $ \store -> do- let name = StreamName "visible-head-logical-lifecycle"- appendEvents store name 3+ it "keeps logically truncated and soft-deleted events visible in $all" $+ withTestStore $ \store -> do+ let name = StreamName "visible-head-logical-lifecycle"+ appendEvents store name 3 - Right (Just _) <-- runStoreIO store $- setStreamTruncateBefore name (StreamVersion 3)- runStoreIO store visibleGlobalHeadPosition- `shouldReturn` Right (GlobalPosition 3)+ Right (Just _) <-+ runStoreIO store $+ setStreamTruncateBefore name (StreamVersion 3)+ runStoreIO store visibleGlobalHeadPosition+ `shouldReturn` Right (GlobalPosition 3) - Right (Just _) <- runStoreIO store $ softDeleteStream name- runStoreIO store visibleGlobalHeadPosition- `shouldReturn` Right (GlobalPosition 3)+ Right (Just _) <- runStoreIO store $ softDeleteStream name+ runStoreIO store visibleGlobalHeadPosition+ `shouldReturn` Right (GlobalPosition 3) - it "does not invoke the event decode hook" $ do- decodeCalls <- newIORef (0 :: Int)- let failingHook event = do- modifyIORef' decodeCalls (+ 1)- ioError (userError ("unexpected decode of " <> show (event ^. #eventId)))- tweak settings =- settings- & #storeSettings- .~ defaultStoreSettings{decodeHook = Just failingHook}- withTestStoreSettings tweak $ \store -> do- appendEvents store (StreamName "visible-head-no-decode") 1+ it "does not invoke the event decode hook" $ do+ decodeCalls <- newIORef (0 :: Int)+ let failingHook event = do+ modifyIORef' decodeCalls (+ 1)+ ioError (userError ("unexpected decode of " <> show (event ^. #eventId)))+ tweak settings =+ settings+ & #storeSettings+ .~ defaultStoreSettings {decodeHook = Just failingHook}+ withTestStoreSettings tweak $ \store -> do+ appendEvents store (StreamName "visible-head-no-decode") 1 - runStoreIO store visibleGlobalHeadPosition- `shouldReturn` Right (GlobalPosition 1)- readIORef decodeCalls `shouldReturn` 0+ runStoreIO store visibleGlobalHeadPosition+ `shouldReturn` Right (GlobalPosition 1)+ readIORef decodeCalls `shouldReturn` 0 appendEvents :: KirokuStore -> StreamName -> Int -> IO () appendEvents store name count = do- let events =- [ makeEvent "VisibleHeadEvent" (Aeson.object [("ordinal", Aeson.Number (fromIntegral ordinal))])- | ordinal <- [1 .. count]- ]- result <- runStoreIO store $ appendToStream name NoStream events- case result of- Left err -> expectationFailure ("append failed: " <> show err)- Right _ -> pure ()+ let events =+ [ makeEvent "VisibleHeadEvent" (Aeson.object [("ordinal", Aeson.Number (fromIntegral ordinal))])+ | ordinal <- [1 .. count]+ ]+ result <- runStoreIO store $ appendToStream name NoStream events+ case result of+ Left err -> expectationFailure ("append failed: " <> show err)+ Right _ -> pure () assertHeadAndFrontier :: KirokuStore -> Integer -> Integer -> IO () assertHeadAndFrontier store expectedHead expectedFrontier = do- runStoreIO store visibleGlobalHeadPosition- `shouldReturn` Right (GlobalPosition (fromIntegral expectedHead))- result <- runStoreIO store subscriptionCheckpointInventory- case result of- Left err -> expectationFailure ("inventory read failed: " <> show err)- Right inventory ->- inventory ^. #storePosition- `shouldBe` GlobalPosition (fromIntegral expectedFrontier)+ runStoreIO store visibleGlobalHeadPosition+ `shouldReturn` Right (GlobalPosition (fromIntegral expectedHead))+ result <- runStoreIO store subscriptionCheckpointInventory+ case result of+ Left err -> expectationFailure ("inventory read failed: " <> show err)+ Right inventory ->+ inventory ^. #storePosition+ `shouldBe` GlobalPosition (fromIntegral expectedFrontier)
test/Test/VisibleGlobalHeadPositionMock.hs view
@@ -11,21 +11,21 @@ spec :: Spec spec = describe "visible global head position mock" $- it "returns the configured position through one Store effect call" $ do- calls <- newIORef (0 :: Int)- let expected = GlobalPosition 23- actual <- runEff $ runVisibleHeadMock calls expected visibleGlobalHeadPosition- actual `shouldBe` expected- readIORef calls `shouldReturn` 1+ it "returns the configured position through one Store effect call" $ do+ calls <- newIORef (0 :: Int)+ let expected = GlobalPosition 23+ actual <- runEff $ runVisibleHeadMock calls expected visibleGlobalHeadPosition+ actual `shouldBe` expected+ readIORef calls `shouldReturn` 1 runVisibleHeadMock ::- (IOE :> es) =>- IORef Int ->- GlobalPosition ->- Eff (Store : es) a ->- Eff es a+ (IOE :> es) =>+ IORef Int ->+ GlobalPosition ->+ Eff (Store : es) a ->+ Eff es a runVisibleHeadMock calls expected = interpret_ $ \case- GetVisibleGlobalHeadPosition -> do- liftIO $ modifyIORef' calls (+ 1)- pure expected- _ -> error "unexpected Store operation in visible-head mock"+ GetVisibleGlobalHeadPosition -> do+ liftIO $ modifyIORef' calls (+ 1)+ pure expected+ _ -> error "unexpected Store operation in visible-head mock"