keiro-0.20.0.0: test/GuardedCatalogSpec.hs
{-# LANGUAGE BlockArguments #-}
module GuardedCatalogSpec (spec, checked, withController, script, waitSql, awaitWorker) where
import Control.Concurrent (forkFinally, killThread, threadDelay)
import Control.Concurrent.MVar
import Control.Exception (bracket, finally)
import Control.Monad (forM_, void)
import Data.Aeson qualified as Aeson
import Data.Either (isLeft)
import Data.IORef (atomicModifyIORef', newIORef)
import Data.Text qualified as Text
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import GuardedCatalogExample qualified as Example
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.Pool.Config qualified as PoolConfig
import Hasql.Session qualified as Session
import Hasql.Statement (preparable)
import Keiki.Core qualified as Keiki
import Keiro
import Keiro.Prelude
import Keiro.Projection
import Keiro.ReadModel
import Keiro.ReadModel.Rebuild qualified
import Keiro.Test.Postgres
import Kiroku.Store qualified as Store
import Kiroku.Store.Subscription.Types (MissingCheckpointPolicy (..))
import Kiroku.Store.Types (GlobalPosition (..), StreamName (..), StreamVersion (..))
import System.Timeout (timeout)
import Test.Hspec hiding (parallel)
import "hasql-transaction" Hasql.Transaction qualified as Tx
spec :: Fixture -> Spec
spec fixture = describe "guarded catalog commands" $ do
it "uses durable PostgreSQL settings for paired benchmark fixtures" $
withMigratedBenchmarkSuite \benchmark ->
withFreshResourceStore benchmark \(_, runner) ->
checked runner (Store.runTransaction (Tx.statement () (preparable "SELECT current_setting('fsync'), current_setting('synchronous_commit'), current_setting('full_page_writes'), current_setting('autovacuum')" E.noParams (D.singleRow ((,,,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text))))))
`shouldReturn` ("on", "on", "on", "on")
for_ [("fresh refusal", 3), ("exhaustion", 0)] \(label, budget) ->
it ("discards an OCC-conflicting decision before " <> label) $
withFreshResourceStoresSameDatabase fixture \((_, retrying), (_, winner)) ->
occScenario retrying winner (Example.handler 1) budget (if budget == 0 then "exhausted" else "reuse")
it "returns a silent decision after OCC retry without invoking a guard" $
withFreshResourceStoresSameDatabase fixture \((_, retrying), (_, winner)) ->
occScenario retrying winner onceHandler 3 "noop"
it "opens distinct backends in the same database and schema" $
withFreshResourceStoresSameDatabase fixture \((_, StoreRunner first), (_, StoreRunner second)) -> do
let facts =
Store.runTransaction $
Tx.statement () $
preparable
"SELECT pg_backend_pid(), current_database()::text, current_schema()::text FROM pg_sleep(0.1)"
E.noParams
(D.singleRow ((,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))
results <- parallel (first facts) (second facts)
case results of
(Right (pidA, dbA, schemaA), Right (pidB, dbB, schemaB)) -> do
pidA `shouldNotBe` pidB
dbA `shouldBe` dbB
schemaA `shouldBe` schemaB
other -> expectationFailure (show other)
around (withFreshResourceStore fixture) $ do
it "preserves encode failures before callback or append" \(_, runner) -> do
Example.setup runner
let options = defaultRunCommandOptions & #metadata ?~ Aeson.Null
result <- checked runner (runDomainCommandWithCatalogProjectionsGuarded options (Example.handler 1) (stream "encode-failure") (Example.CreateNote (Example.notePayload (Example.exampleNote "encode-failure" "shared"))) Example.catalog Example.commandSet (\_ -> Tx.sql "SELECT 1 / 0" >> pure (Right () :: Either Example.NoteRefusal ())))
result `shouldBe` Left (EncodeFailed (NonObjectCallerMetadata Aeson.Null))
counts runner `shouldReturn` (0, 0, 0, 0)
it "reuses canonical identity keys without replacing the original payload" \(_, runner) -> do
Example.setup runner
let first = (Example.exampleNote "winner" " shared ") {Example.anchorType = " Intention ", Example.propertyId = " status ", Example.stateId = " ready "}
second = Example.exampleNote "duplicate" "shared"
Example.ensureStateNoteExample runner first `shouldReturn` Right ("winner", True)
Example.ensureStateNoteExample runner second `shouldReturn` Right ("winner", False)
counts runner `shouldReturn` (1, 1, 0, 0)
it "does not advance a snapshot or fixture effects on refusal" \(_, runner) -> do
Example.setup runner
checked runner (Store.runTransaction (Tx.sql "CREATE TABLE app.fixture_outbox(note_id text); CREATE TABLE app.fixture_reactors(note_id text)"))
let raw = unvalidated (Example.validated 1)
codec :: StateCodec (Example.NoteState, Keiki.RegFile '[])
codec = StateCodec 1 "guarded-ready" "guarded-ready" (const Aeson.Null) (const (Right (Example.Ready, Keiki.RNil)))
validated = either (error . show) id (mkEventStream "guarded-snapshot" raw {snapshotPolicy = Every 1, stateCodec = Just codec})
handler = (Example.handler 1) {eventStream = validated}
note = Example.exampleNote "snapshot-refusal" "shared"
result <-
checked
runner
( runDomainCommandWithCatalogProjectionsGuarded
defaultRunCommandOptions
handler
(stream "snapshot-refusal")
(Example.CreateNote (Example.notePayload note))
Example.catalog
Example.commandSet
( \_ -> do
Tx.sql "INSERT INTO app.fixture_outbox VALUES ('snapshot-refusal'); INSERT INTO app.fixture_reactors VALUES ('snapshot-refusal')"
pure (Left (Example.ExistingNote "winner"))
)
)
result `shouldBe` Right (PreconditionRefused (Example.ExistingNote "winner"))
checked runner (Store.runTransaction (Tx.statement () (preparable "SELECT (SELECT count(*) FROM keiro.keiro_snapshots), (SELECT count(*) FROM app.fixture_outbox), (SELECT count(*) FROM app.fixture_reactors)" E.noParams (D.singleRow ((,,) <$> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8) <*> D.column (D.nonNullable D.int8)))))) `shouldReturn` (0, 0, 0)
counts runner `shouldReturn` (0, 0, 0, 0)
let acceptedNote = note {Example.noteId = "snapshot-accepted"}
acceptedResult <- checked runner (runDomainCommandWithCatalogProjectionsGuarded defaultRunCommandOptions handler (stream "snapshot-accepted") (Example.CreateNote (Example.notePayload acceptedNote)) Example.catalog Example.commandSet (\_ -> pure (Right () :: Either Example.NoteRefusal ())))
acceptedResult `shouldSatisfy` accepted
checked runner (Store.runTransaction (Tx.statement () (preparable "SELECT count(*) FROM keiro.keiro_snapshots" E.noParams (D.singleRow (D.column (D.nonNullable D.int8)))))) `shouldReturn` 1
it "rebuilds both catalog owners without invoking the command guard" \(_, runner) -> do
Example.setup runner
checked runner (Store.initializeSubscriptionCheckpoint (Store.SubscriptionName "note-rich-worker") 0 FromBeginning) >>= (`shouldSatisfy` either (const False) (const True))
let note = Example.exampleNote "winner" "shared"
Example.ensureStateNoteExample runner note `shouldReturn` Right ("winner", True)
for_ [(Example.identityGroup, "identity-rebuild"), (Example.richGroup, "rich-rebuild")] \(group, runName) -> do
let runId = Example.ident Keiro.ReadModel.Rebuild.mkRebuildRunId runName
request = Keiro.ReadModel.Rebuild.RebuildRequest runId "guarded-spec" "rebuild event-derived note owners" (GlobalPosition 0)
options = Keiro.ReadModel.Rebuild.defaultRebuildOptions request
report <- checked runner (Keiro.ReadModel.Rebuild.startCatalogRebuild Example.catalog group options) >>= either (fail . show) pure
report ^. #runStatus `shouldBe` Keiro.ReadModel.Rebuild.RebuildRunPromoted
Example.ensureStateNoteExample runner (note {Example.noteId = "duplicate"}) `shouldReturn` Right ("winner", False)
counts runner `shouldReturn` (1, 1, 1, 0)
payload <- checked runner (Store.runTransaction (Tx.statement "winner" Example.richLookup))
fmap (Aeson.eitherDecodeStrictText :: Text -> Either String Example.Note) payload `shouldBe` Just (Right note)
it "preserves early catalog mismatch even for a selected silent command" \(_, runner) -> do
Example.setup runner
let wrong = Example.commandSet & #projectionSource .~ Example.ident mkSourceId "wrong-source"
result <- checked runner (runDomainCommandWithCatalogProjectionsGuarded defaultRunCommandOptions (Example.handler 1) (stream "silent") Example.IgnoreNote Example.catalog wrong (\_ -> Tx.sql "SELECT 1 / 0" >> pure (Right () :: Either Example.NoteRefusal ())))
result `shouldBe` Right (CatalogCommandOutcome (DomainProjectionCommandCatalogMismatch (Example.ident mkSourceId "wrong-source")))
it "preserves sorted group, declared handler, then event delivery order" \(_, runner) -> do
Example.setup runner
let trace label = InlineHandler (InlineProjection label (\event _ -> Tx.statement (label <> ":" <> case event of Example.NoteCreated _ -> "created"; _ -> "audited") (preparable "INSERT INTO app.guard_markers VALUES ($1)" Example.textParam D.noResult))) Example.site
a = Example.identityDefinition & #handlers .~ (trace "a" :| [trace "b"])
b = Example.richDefinition & #handlers .~ (trace "c" :| [])
set = Example.fullSet & #projectionDefinitions .~ (b :| [a])
definition = Example.catalogDefinition {projectionSets = [SomeProjectionSet set], subscriptions = [], dedupKeys = [], queryModels = []}
catalog = case validateProjectionCatalog definition of Success value -> value; Failure diagnostics -> error (show diagnostics)
-- Re-registering a changed group slice is deliberately refused. Reset
-- the isolated fixture metadata, then register this trace-only catalog.
checked runner (Store.runTransaction (Tx.sql "DELETE FROM keiro.keiro_read_models; DELETE FROM keiro.keiro_projection_rebuild_groups"))
checked runner (Keiro.ReadModel.Rebuild.registerProjectionCatalog catalog) >>= (`shouldSatisfy` either (const False) (const True))
result <-
checked
runner
( runDomainCommandWithCatalogProjectionsGuarded
defaultRunCommandOptions
(Example.handler 4)
(stream "trace")
(Example.CreateNote (Example.notePayload (Example.exampleNote "trace" "trace")))
catalog
set
( \context -> do
map (^. #writeGroupId) (catalogPreconditionBindings context) `seq` pure (Right () :: Either Example.NoteRefusal ())
)
)
result `shouldSatisfy` accepted
checked runner (Store.runTransaction (Tx.statement () (preparable "SELECT marker FROM app.guard_markers ORDER BY ctid" E.noParams (D.rowList (D.column (D.nonNullable D.text))))))
`shouldReturn` concatMap (\label -> [label <> ":created", label <> ":audited", label <> ":audited", label <> ":audited"]) ["a", "b", "c"]
it "rolls back a refusing guard's marker, events, global links and inline rows" \(_, runner) -> do
Example.setup runner
outcome <- guarded runner "loser" (Example.CreateNote (Example.notePayload (Example.exampleNote "loser" "shared"))) $ \_ -> do
Tx.sql "INSERT INTO app.guard_markers VALUES ('private-refusal')"
pure (Left (Example.ExistingNote "winner"))
outcome `shouldBe` Right (PreconditionRefused (Example.ExistingNote "winner"))
checked runner (Store.readStreamForward (StreamName "loser") (StreamVersion 0) 100) `shouldReturn` mempty
counts runner `shouldReturn` (0, 0, 0, 0)
it "never invokes a faulting inline handler after refusal" \(_, runner) -> do
Example.setup runner
let projection :: InlineProjection Example.NoteEvent
projection = InlineProjection "note-identity" (\_ _ -> Tx.sql "SELECT 1 / 0")
definition = Example.identityDefinition & #handlers .~ (InlineHandler projection Example.site :| [])
set = Example.commandSet & #projectionDefinitions .~ (definition :| [])
run guard = checked runner (runDomainCommandWithCatalogProjectionsGuarded defaultRunCommandOptions (Example.handler 1) (stream "handler-fault") (Example.CreateNote (Example.notePayload (Example.exampleNote "handler-fault" "shared"))) Example.catalog set guard)
run (\_ -> pure (Left (Example.ExistingNote "winner"))) `shouldReturn` Right (PreconditionRefused (Example.ExistingNote "winner"))
-- Positive control proves the handler fault is observable if admitted.
run (\_ -> pure (Right ())) >>= (`shouldSatisfy` \case Left StoreFailed {} -> True; _ -> False)
counts runner `shouldReturn` (0, 0, 0, 0)
it "commits the complete four-event batch only after a successful guard" \(_, runner) -> do
Example.setup runner
let note = Example.exampleNote "winner" "shared"
result <- guarded runner "winner" (Example.CreateNote (Example.notePayload note)) (Example.noteGuard note)
result `shouldSatisfy` accepted
events <- checked runner (Store.readStreamForward (StreamName "winner") (StreamVersion 0) 100)
length events `shouldBe` 4
counts runner `shouldReturn` (4, 1, 0, 0)
forM_ events \event -> checked runner (Store.runTransaction (applyAsyncProjectionFromCatalog Example.catalog Example.richOwner Example.richProjection event)) >>= (`shouldBe` CatalogAsyncApplied)
counts runner `shouldReturn` (4, 1, 1, 0)
payload <- checked runner (Store.runTransaction (Tx.statement "winner" Example.richLookup))
fmap (Aeson.eitherDecodeStrictText :: Text -> Either String Example.Note) payload `shouldBe` Just (Right note)
forM_ [Example.IgnoreNote, Example.RejectNote] \command ->
it ("skips catalog and callback SQL for " <> show command) \(_, runner) -> do
Example.setup runner
checked runner (Store.runTransaction (Tx.sql "DELETE FROM keiro.keiro_read_models; DELETE FROM keiro.keiro_projection_rebuild_groups"))
result <- guarded runner "silent" command (\_ -> Tx.sql "SELECT 1 / 0" >> pure (Right ()))
result `shouldSatisfy` \case Right (CatalogCommandOutcome (DomainProjectionCommandApplied _)) -> True; _ -> False
counts runner `shouldReturn` (0, 0, 0, 0)
it "keeps unmatched commands and genuine guard SQL failures operational" \(_, runner) -> do
Example.setup runner
guarded runner "unmatched" Example.UnmatchedNote (\_ -> pure (Right ())) `shouldReturn` Left CommandRejected
let StoreRunner run = runner
failure <- run (runDomainCommandWithCatalogProjectionsGuarded defaultRunCommandOptions (Example.handler 1) (stream "fault") (Example.CreateNote (Example.notePayload (Example.exampleNote "fault" "fault"))) Example.catalog Example.commandSet (\_ -> Tx.sql "SELECT 1 / 0" >> pure (Right () :: Either Example.NoteRefusal ())))
failure `shouldSatisfy` \case Right (Left (StoreFailed _)) -> True; _ -> False
counts runner `shouldReturn` (0, 0, 0, 0)
forM_ ["missing", "rebuilding", "failed"] \status ->
it ("gives a " <> status <> " catalog precedence over duplicate refusal") \(_, runner) -> do
Example.setup runner
checked
runner
( Store.runTransaction
( Tx.sql
( case status of
"missing" -> "DELETE FROM keiro.keiro_projection_rebuild_groups WHERE group_id = 'note-identity-group'"
"rebuilding" -> "UPDATE keiro.keiro_projection_rebuild_groups SET status = 'rebuilding', active_run_id = 'test-run', writes_allowed = false, reads_allowed = false WHERE group_id = 'note-identity-group'"
_ -> "UPDATE keiro.keiro_projection_rebuild_groups SET status = 'failed', active_run_id = 'test-run', writes_allowed = false, reads_allowed = false WHERE group_id = 'note-identity-group'"
)
)
)
result <- guarded runner "fenced" (Example.CreateNote (Example.notePayload (Example.exampleNote "fenced" "shared"))) (\_ -> Tx.sql "SELECT 1 / 0" >> pure (Left (Example.ExistingNote "winner")))
let expected = if status == "missing" then DomainProjectionCommandGroupUnregistered Example.identityGroup else DomainProjectionCommandFenced Example.identityGroup (either (error . show) id (Keiro.ReadModel.Rebuild.mkRebuildRunId "test-run"))
result `shouldBe` Right (CatalogCommandOutcome expected)
counts runner `shouldReturn` (0, 0, 0, 0)
it "uses an inline-only set while the rich group is fenced, and rejects a broad set" \(_, runner) -> do
Example.setup runner
checked runner (Store.runTransaction (Tx.sql "UPDATE keiro.keiro_projection_rebuild_groups SET status = 'failed', active_run_id = 'test-run', reads_allowed = false, writes_allowed = false WHERE group_id = 'note-rich-group'"))
let note = Example.exampleNote "winner" "shared"
faulting :: InlineProjection Example.NoteEvent
faulting = InlineProjection "note-identity" (\_ _ -> Tx.sql "SELECT 1 / 0")
firstDefinition = Example.identityDefinition & #handlers .~ (InlineHandler faulting Example.site :| [])
broadSet = Example.fullSet & #projectionDefinitions .~ (firstDefinition :| [Example.richDefinition])
Example.ensureStateNoteExample runner note `shouldReturn` Right ("winner", True)
Example.ensureStateNoteExample runner (note {Example.noteId = "loser"}) `shouldReturn` Right ("winner", False)
result <- checked runner (runDomainCommandWithCatalogProjectionsGuarded defaultRunCommandOptions (Example.handler 1) (stream "broad") (Example.CreateNote (Example.notePayload (Example.exampleNote "broad" "other"))) Example.catalog broadSet (\_ -> Tx.sql "SELECT 1 / 0" >> pure (Right () :: Either Example.NoteRefusal ())))
result `shouldBe` Right (CatalogCommandOutcome (DomainProjectionCommandFenced Example.richGroup (either (error . show) id (Keiro.ReadModel.Rebuild.mkRebuildRunId "test-run"))))
counts runner `shouldReturn` (1, 1, 0, 0)
it "retains the original payload through duplicate calls, archive and replay" \(_, runner) -> do
Example.setup runner
let winner = Example.exampleNote "winner" "shared"
loser = winner {Example.noteId = "loser", Example.content = "replacement", Example.tags = ["replacement"], Example.provenance = "replacement"}
Example.ensureStateNoteExample runner winner `shouldReturn` Right ("winner", True)
archived <- guardedWithBatch 1 runner "winner" (Example.ArchiveNote "winner") (\_ -> pure (Right ()))
archived `shouldSatisfy` accepted
Example.ensureStateNoteExample runner loser `shouldReturn` Right ("winner", False)
events <- checked runner (Store.readStreamForward (StreamName "winner") (StreamVersion 0) 100)
forM_ events \event -> checked runner (Store.runTransaction (applyAsyncProjectionFromCatalog Example.catalog Example.richOwner Example.richProjection event)) >>= (`shouldBe` CatalogAsyncApplied)
payload <- checked runner (Store.runTransaction (Tx.statement "winner" Example.richLookup))
fmap (Aeson.eitherDecodeStrictText :: Text -> Either String Example.Note) payload `shouldBe` Just (Right winner)
checked runner (Store.runTransaction (Tx.sql "TRUNCATE app.state_note_identity, app.state_notes"))
forM_ events \event -> case Example.decodeNoteRecorded event of
ReplayRelevant decoded -> checked runner (Store.runTransaction (Example.applyIdentity decoded event >> Example.applyRich decoded event))
other -> expectationFailure (show other)
Example.ensureStateNoteExample runner loser `shouldReturn` Right ("winner", False)
counts runner `shouldReturn` (2, 1, 1, 0)
forM_ [False, True] \reverseStart ->
it ("races independent stores with a paused rich worker, reverse=" <> show reverseStart) $
withFreshResourceStoresSameDatabase fixture \((_, first), (_, second)) -> do
Example.setup first
let a = Example.exampleNote "a" "shared"
b = (Example.exampleNote "b" "shared") {Example.content = "different", Example.tags = ["different"], Example.provenance = "different"}
start <- newEmptyMVar
let call runner note = readMVar start >> Example.ensureStateNoteExample runner note
results <-
if reverseStart
then parallel (putMVar start () >> call second b) (call first a)
else parallel (putMVar start () >> call first a) (call second b)
case results of
(Right (winner, changed), Right (same, otherChanged)) -> do
winner `shouldBe` same
changed `shouldBe` not otherChanged
counts first `shouldReturn` (1, 1, 0, 0)
let original = if winner == "a" then a else b
events <- checked first (Store.readStreamForward (StreamName winner) (StreamVersion 0) 10)
forM_ events \event -> checked first (Store.runTransaction (applyAsyncProjectionFromCatalog Example.catalog Example.richOwner Example.richProjection event)) >>= (`shouldBe` CatalogAsyncApplied)
counts first `shouldReturn` (1, 1, 1, 0)
payload <- checked first (Store.runTransaction (Tx.statement winner Example.richLookup))
fmap (Aeson.eitherDecodeStrictText :: Text -> Either String Example.Note) payload `shouldBe` Just (Right original)
other -> expectationFailure (show other)
forM_ [False, True] \rollbackFirst ->
it ("observes an append lock wait then " <> if rollbackFirst then "rolls back the first writer" else "commits the first writer") $
withFreshResourceStoresSameDatabaseWithConnection fixture \connection ((_, first), (_, second)) ->
withController connection \controller -> do
Example.setup first
script controller "SELECT pg_advisory_lock(306)"
firstDone <- newEmptyMVar
secondDone <- newEmptyMVar
let a = Example.exampleNote "a" "shared"
b = Example.exampleNote "b" "shared"
firstGuard context = do
Tx.sql "SET LOCAL application_name = 'keiro-306-held-guard'; SELECT pg_advisory_xact_lock(306)"
if rollbackFirst then pure (Left (Example.GuardConfigurationFault "test rollback")) else Example.noteGuard a context
firstThread <- forkFinally (guarded first "a" (Example.CreateNote (Example.notePayload a)) firstGuard) (putMVar firstDone)
let cleanup = script controller "SELECT pg_advisory_unlock_all()" >> killThread firstThread
flip finally cleanup do
waitSql controller "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE application_name = 'keiro-306-held-guard' AND wait_event_type = 'Lock')"
secondThread <- forkFinally (Example.ensureStateNoteExample second b) (putMVar secondDone)
flip finally (killThread secondThread) do
waitSql controller "SELECT EXISTS(SELECT 1 FROM pg_stat_activity blocked, pg_stat_activity holder WHERE holder.application_name = 'keiro-306-held-guard' AND holder.pid = ANY(pg_blocking_pids(blocked.pid)))"
script controller "SELECT pg_advisory_unlock(306)"
firstResult <- awaitWorker firstDone
secondResult <- awaitWorker secondDone
if rollbackFirst
then do
firstResult `shouldBe` Right (PreconditionRefused (Example.GuardConfigurationFault "test rollback"))
secondResult `shouldBe` Right ("b", True)
counts first `shouldReturn` (1, 1, 0, 0)
else do
firstResult `shouldSatisfy` accepted
secondResult `shouldBe` Right ("a", False)
counts first `shouldReturn` (4, 1, 0, 0)
around (withFreshResourceStore fixture) $ do
forM_ ["40001", "40P01"] \sqlState ->
it ("re-evaluates the callback after a one-time driver " <> sqlState <> " retry") \(_, runner) -> do
Example.setup runner
checked runner (Store.runTransaction (Tx.sql "CREATE SEQUENCE app.driver_attempts"))
let note = Example.exampleNote "retry" "shared"
retryGuard context = do
decision <- Example.noteGuard note context
attempt <- Tx.statement () (preparable "SELECT nextval('app.driver_attempts')" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))
if attempt == 1
then do
Tx.sql (if sqlState == "40001" then "DO $$ BEGIN RAISE EXCEPTION 'test retry' USING ERRCODE = '40001'; END $$" else "DO $$ BEGIN RAISE EXCEPTION 'test retry' USING ERRCODE = '40P01'; END $$")
pure decision
else pure (Left (Example.ExistingNote "fresh-result"))
result <- guarded runner "retry" (Example.CreateNote (Example.notePayload note)) retryGuard
result `shouldBe` Right (PreconditionRefused (Example.ExistingNote "fresh-result"))
checked runner (Store.runTransaction (Tx.statement () (preparable "SELECT last_value FROM app.driver_attempts" E.noParams (D.singleRow (D.column (D.nonNullable D.int8)))))) `shouldReturn` 2
counts runner `shouldReturn` (0, 0, 0, 0)
it "waits after commit and retries the same receipt when the rich view catches up" \(_, runner) -> do
Example.setup runner
checked runner (Store.initializeSubscriptionCheckpoint (Store.SubscriptionName "note-rich-worker") 0 FromBeginning) >>= (`shouldSatisfy` either (const False) (const True))
let note = Example.exampleNote "winner" "shared"
result <- guarded runner "winner" (Example.CreateNote (Example.notePayload note)) (Example.noteGuard note)
receipt <- case result of
Right (CatalogCommandOutcome (DomainProjectionCommandApplied outcome)) -> pure (outcome ^. #result)
other -> expectationFailure (show other) >> error "unreachable"
let options = PositionWaitOptions (receipt ^. #globalPosition) 20000 1000
checked runner (runQueryWithFreshness Nothing (WaitForPosition options) Example.richReadModel "winner") >>= (`shouldSatisfy` \case Left ReadModelWaitTimeout {} -> True; _ -> False)
events <- checked runner (Store.readStreamForward (StreamName "winner") (StreamVersion 0) 100)
forM_ events \event -> checked runner (Store.runTransaction (applyAsyncProjectionFromCatalog Example.catalog Example.richOwner Example.richProjection event)) >>= (`shouldBe` CatalogAsyncApplied)
checked runner (Store.runTransaction (Store.resetSubscriptionCheckpointsTx (Store.SubscriptionName "note-rich-worker" :| []) (fromMaybe (error "accepted receipt lacks position") (receipt ^. #globalPosition))))
ready <- checked runner (runQueryWithFreshness Nothing (WaitForPosition options) Example.richReadModel "winner")
ready `shouldSatisfy` \case Right (Just _) -> True; _ -> False
counts runner `shouldReturn` (4, 1, 1, 0)
checked runner (Store.runTransaction (Tx.sql "UPDATE keiro.keiro_read_models SET status='rebuilding' WHERE name='note-rich-query'"))
checked runner (runQueryWithFreshness Nothing (WaitForPosition options) Example.richReadModel "winner") `shouldReturn` Left (ReadModelNotLive "note-rich-query" Rebuilding)
-- A committed receipt remains valid even when its view is unavailable.
Example.ensureStateNoteExample runner (note {Example.noteId = "later-duplicate"}) `shouldReturn` Right ("winner", False)
it "reads a newly committed winner after a driver retry, under the append protocol" $
withFreshResourceStoresSameDatabaseWithConnection fixture \connection ((_, retrying), (_, other)) ->
withController connection \controller -> do
Example.setup retrying
-- Test-only backend instrumentation survives rollback. A BEFORE
-- STATEMENT trigger gates the retry before it can acquire append row
-- locks, permitting a third transaction to commit a real winner.
checked
retrying
( Store.runTransaction
( Tx.sql
"""
CREATE SEQUENCE app.fresh_driver_attempts;
CREATE FUNCTION app.gate_driver_retry() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_locks WHERE locktype = 'advisory'
AND pid = pg_backend_pid() AND objid = 30601 AND granted) THEN
PERFORM pg_advisory_xact_lock(30602);
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER gate_driver_retry BEFORE UPDATE ON streams
FOR EACH STATEMENT EXECUTE FUNCTION app.gate_driver_retry();
"""
)
)
script controller "SELECT pg_advisory_lock(30602)"
done <- newEmptyMVar
let attempted = Example.exampleNote "retry-candidate" "shared"
winner = Example.exampleNote "winner" "shared"
guard context = do
result <- Example.noteGuard attempted context
count <- Tx.statement () (preparable "SELECT nextval('app.fresh_driver_attempts')" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))
if count == 1
then do
Tx.sql "SELECT pg_advisory_lock(30601); DO $$ BEGIN RAISE EXCEPTION 'one-time retry' USING ERRCODE = '40001'; END $$"
pure result
else do
Tx.sql "SELECT pg_advisory_unlock(30601)"
pure result
worker <- forkFinally (guardedWithBatch 1 retrying "retry-candidate" (Example.CreateNote (Example.notePayload attempted)) guard) (putMVar done)
flip finally (script controller "SELECT pg_advisory_unlock_all()" >> killThread worker) do
waitSql controller "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND objid = 30602 AND NOT granted)"
Example.ensureStateNoteExample other winner `shouldReturn` Right ("winner", True)
script controller "SELECT pg_advisory_unlock(30602)"
awaitWorker done `shouldReturn` Right (PreconditionRefused (Example.ExistingNote "winner"))
counts retrying `shouldReturn` (1, 1, 0, 0)
withController connection =
bracket
(Pool.acquire (PoolConfig.settings [PoolConfig.staticConnectionSettings (Conn.connectionString connection), PoolConfig.size 1]))
Pool.release
script controller sql = Pool.use controller (Session.script sql) >>= either (fail . show) pure
waitSql controller sql = do
result <- timeout (5 * 1000000) loop
case result of Just () -> pure (); Nothing -> fail ("SQL observation timed out: " <> Text.unpack sql)
where
loop = do
ready <- Pool.use controller (Session.statement () (preparable sql E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))) >>= either (fail . show) pure
unless ready (threadDelay 10000 >> loop)
awaitWorker done =
timeout (20 * 1000000) (takeMVar done) >>= \case
Just (Right value) -> pure value
Just (Left err) -> fail (show err)
Nothing -> fail "worker completion timed out"
type Outcome = Either CommandError (GuardedCatalogOutcome Example.NoteStream Example.NoteEvent Text Text Example.NoteRefusal)
guarded :: StoreRunner -> Text -> Example.NoteCommand -> (CatalogPreconditionContext -> Tx.Transaction (Either Example.NoteRefusal ())) -> IO Outcome
guarded = guardedWithBatch 4
guardedWithBatch :: Int -> StoreRunner -> Text -> Example.NoteCommand -> (CatalogPreconditionContext -> Tx.Transaction (Either Example.NoteRefusal ())) -> IO Outcome
guardedWithBatch batch runner candidate command guard = checked runner (runDomainCommandWithCatalogProjectionsGuarded defaultRunCommandOptions (Example.handler batch) (stream candidate) command Example.catalog Example.commandSet guard)
checked :: StoreRunner -> Eff '[Store.Store, Error Store.StoreError, Store.KirokuStoreResource, IOE] a -> IO a
checked (StoreRunner run) action = run action >>= either (fail . show) pure
accepted :: Outcome -> Bool
accepted = \case Right (CatalogCommandOutcome (DomainProjectionCommandApplied DomainCommandOutcome {decision = DomainAccepted {}})) -> True; _ -> False
counts :: StoreRunner -> IO (Int64, Int64, Int64, Int64)
counts runner =
checked
runner
( Store.runTransaction
( Tx.statement
()
( preparable
"SELECT (SELECT count(*) FROM stream_events WHERE stream_id = 0), (SELECT count(*) FROM app.state_note_identity), (SELECT count(*) FROM app.state_notes), (SELECT count(*) FROM app.guard_markers)"
E.noParams
(D.singleRow ((,,,) <$> col <*> col <*> col <*> col))
)
)
)
where
col = D.column (D.nonNullable D.int8)
occScenario retrying winner handler budget expected = do
Example.setup retrying
checked retrying (Store.runTransaction (Tx.sql "CREATE SEQUENCE app.occ_guard_reads"))
hydrated <- newEmptyMVar
release <- newEmptyMVar
attempts <- newIORef (0 :: Int)
done <- newEmptyMVar
let note = Example.exampleNote "same-stream" "shared"
hook = do
attempt <- atomicModifyIORef' attempts (\n -> (n + 1, n))
when (attempt == 0) (putMVar hydrated () >> takeMVar release)
options = defaultRunCommandOptions & #retryLimit .~ budget & #beforeAppend .~ hook
guard context = do
_ <- Tx.statement () (preparable "SELECT nextval('app.occ_guard_reads')" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))
Example.noteGuard note context
run options runner callback = checked runner (runDomainCommandWithCatalogProjectionsGuarded options handler (stream "same-stream") (Example.CreateNote (Example.notePayload note)) Example.catalog Example.commandSet callback)
label = \case
Left RetryExhausted {} -> "exhausted"
Right (PreconditionRefused (Example.ExistingNote "same-stream")) -> "reuse"
Right (CatalogCommandOutcome (DomainProjectionCommandApplied DomainCommandOutcome {decision = DomainNoOp "already-created"})) -> "noop"
Right (CatalogCommandOutcome (DomainProjectionCommandApplied DomainCommandOutcome {decision = DomainAccepted {}})) -> "accepted"
other -> Text.pack (show other)
worker <- forkFinally (label <$> run options retrying guard) (putMVar done)
flip finally (void (tryPutMVar release ()) >> killThread worker) do
timeout (5 * 1000000) (takeMVar hydrated) >>= (`shouldBe` Just ())
label <$> run defaultRunCommandOptions winner (Example.noteGuard note) `shouldReturn` "accepted"
putMVar release ()
awaitWorker done `shouldReturn` expected
checked retrying (Store.runTransaction (Tx.statement () (preparable "SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM app.occ_guard_reads" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))))
`shouldReturn` (if expected == "reuse" then 1 else 0)
counts retrying `shouldReturn` (1, 1, 0, 0)
data OnceState = Uncreated | Stored deriving stock (Eq, Ord, Show, Enum, Bounded)
onceHandler :: DomainCommandHandler (Keiki.HsPred '[] Example.NoteCommand) '[] OnceState Example.NoteCommand Example.NoteEvent Text Text
onceHandler = DomainCommandHandler validated (const (SilentNoOp "already-created"))
where
original = unvalidated (Example.validated 1)
validated =
either
(error . show)
id
( mkEventStream
"note-once"
EventStream
{ transducer =
Keiki.SymTransducer
{ edgesOut = \case
Uncreated -> [Keiki.Edge predicate update outputs Stored mode | Keiki.Edge predicate update outputs _ mode <- take 1 (Keiki.edgesOut (original ^. #transducer) Example.Ready)]
Stored -> [Keiki.Edge (Keiki.matchInCtor Example.createCtor) Keiki.UKeep [] Stored Keiki.Live],
initial = Uncreated,
initialRegs = Keiki.RNil,
isFinal = const False
},
initialState = Uncreated,
initialRegisters = Keiki.RNil,
eventCodec = Example.noteCodec,
resolveStreamName = streamName,
snapshotPolicy = Never,
stateCodec = Nothing
}
)
parallel :: IO a -> IO b -> IO (a, b)
parallel first second = do
a <- newEmptyMVar
b <- newEmptyMVar
threadA <- forkFinally first (putMVar a)
threadB <- forkFinally second (putMVar b)
result <-
timeout (20 * 1000000) ((,) <$> takeMVar a <*> takeMVar b)
`finally` (killThread threadA >> killThread threadB)
case result of
Just (Right x, Right y) -> pure (x, y)
Just other -> fail ("worker fault: " <> showErrors other)
Nothing -> fail "same-database workers timed out"
where
showErrors (a, b) = either show (const "success") a <> "/" <> either show (const "success") b