kioku-core-0.4.0.0: test/Kioku/ReadModelReconcileSpec.hs
-- | The fail-closed outage and its repair, end to end.
--
-- Keiro refuses to serve a read model whose registry row disagrees with the code's
-- declared version — a deliberate safety property. The hazard is that nothing used to
-- bring those rows back into agreement: 'Keiro.ReadModel.Schema.registerReadModel' only
-- inserts, so an additive migration that advances a model's version leaves every query
-- for it failing with 'ReadModelStaleSchema' until a human hand-writes a registry
-- migration. That is not hypothetical; it is what happened when the session models went
-- v1 -> v2 -> v3.
--
-- 'reconcileReadModelRegistry' is the repair, and this spec walks the whole arc: startup
-- registration, a healthy query, a downgraded registry row, the resulting outage, the
-- reconcile, and the query working again.
--
-- The relocation of the projections into the @kioku@ schema (migration 0012) leans on the same
-- guard for a second purpose. Nothing in the registry records /where/ a projection physically
-- lives, so the only way to stop a binary from the wrong side of that migration serving traffic
-- is to advance the declared version — memory v2 -> v3, session v4 -> v5, turn v2 -> v3 — and
-- let the check refuse the disagreement in both directions.
module Kioku.ReadModelReconcileSpec (tests) where
import Data.Text qualified as Text
import Data.Text.Encoding (encodeUtf8)
import Data.Time (getCurrentTime)
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Error.Static (Error)
import Hasql.Transaction qualified as Tx
import Keiro.ReadModel
( ConsistencyMode (Eventual),
ReadModel (..),
ReadModelError (..),
runQueryWith,
)
import Kioku.Api.Scope (MemoryScope (..), Namespace (..))
import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)
import Kioku.Id (SessionId, genMemoryId, genSessionId, idText)
import Kioku.Memory qualified as Memory
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Prelude
import Kioku.ReadModel
( ReadModelSchema (..),
ReconcileOutcome (..),
kiokuReadModelSchemas,
reconcileReadModelRegistry,
)
import Kioku.Session qualified as Session
import Kioku.Session.Domain (StartSessionData (..))
import Kioku.Session.ReadModel qualified as Session
import Kioku.SpaceFixtures (testActorPrincipal, testContext, testSpace)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, testCase)
tests :: TestTree
tests =
testGroup
"ReadModel.Reconcile"
[ testCase "the pre-relocation registry fails every query closed, then reconciles" testStaleThenReconcile,
testCase "reconciliation is idempotent" testIdempotent,
testCase "a binary declaring a pre-relocation identity fails closed" testOldBinaryFailsClosed
]
-- | The whole arc. Each step is asserted, including the outage itself — without that
-- assertion the test could pass against a build where the guard never fires at all.
--
-- The registry state it starts from is not invented: it is exactly what a database looks like
-- in the instant after migration 0012 commits. The seven projections have moved to the @kioku@
-- schema, the binary declares memory v3 \/ session v5 \/ turn v3, and every registry row still
-- says v2 \/ v4 \/ v2. Keiro records no physical relation name, so this version disagreement is
-- the only thing standing between an unreconciled deployment and SQL aimed at relations that
-- are no longer where the old code thinks they are.
testStaleThenReconcile :: Assertion
testStaleThenReconcile =
withApp \sid -> do
-- Application startup registered every model at the identity the code declares.
healthy <- Session.getById testSpace sid
liftIO $ assertBool "a fresh database serves session queries" (isRight healthy)
downgradeToPreRelocationIdentities
stale <- Session.getById testSpace sid
liftIO case stale of
Left (ReadModelStaleSchema name expectedVersion foundVersion expectedHash foundHash) -> do
assertEqual "the stale model" "kioku-session-by-id" name
assertEqual "expected version" 5 expectedVersion
assertEqual "found version" 4 foundVersion
assertEqual "expected hash" "kioku-session-v5" expectedHash
assertEqual "found hash" "kioku-session-v4" foundHash
other ->
assertFailure
("expected the session query to fail closed on the stale row, got " <> show (() <$ other))
-- The memory family moved too, so it must be just as closed. A relocation that bumped only
-- the session identity would leave memory reads running against a vanished relation.
-- Any id at all: the guard runs before the query, and on the repaired path finding no row
-- is a success.
probeMemoryId <- genMemoryId
staleMemory <- Memory.getMemoryRowById testSpace probeMemoryId
liftIO case staleMemory of
Left (ReadModelStaleSchema name _ _ expectedHash foundHash) -> do
assertEqual "the stale model" "kioku-memory-by-id" name
assertEqual "expected hash" "kioku-memory-v3" expectedHash
assertEqual "found hash" "kioku-memory-v2" foundHash
other ->
assertFailure
("expected the memory query to fail closed on the stale row, got " <> show (() <$ other))
outcomes <- reconcileReadModelRegistry
liftIO do
assertEqual
"every declared model was accounted for"
(map (.readModelName) kiokuReadModelSchemas)
(map ((.readModelName) . fst) outcomes)
-- Every Kioku read model reads one of the three relocated projections, so the relocation
-- leaves none of them current and reconciliation has to advance all of them.
assertEqual
"every model was advanced"
[]
[schema.readModelName | (schema, outcome) <- outcomes, outcome /= Reconciled]
repaired <- Session.getById testSpace sid
liftIO $ assertBool "the session query works again" (isRight repaired)
repairedMemory <- Memory.getMemoryRowById testSpace probeMemoryId
liftIO $ assertBool "the memory query works again" (isRight repairedMemory)
-- | The other direction of the same guard, and the one that makes the deployment order safe:
-- against a reconciled registry, code still declaring the pre-relocation identity is refused.
--
-- That is what stops an old binary — one whose SQL still says @kiroku.kioku_sessions@ — from
-- serving traffic after the migration. It fails closed on the registry check before it ever
-- reaches a relation that has moved.
testOldBinaryFailsClosed :: Assertion
testOldBinaryFailsClosed =
withApp \sid -> do
_ <- reconcileReadModelRegistry
result <-
runQueryWith
Nothing
Eventual
preRelocationSessionByIdReadModel
Session.SessionByIdQuery {memorySpaceId = testSpace, sessionId = idText sid}
liftIO case result of
Left (ReadModelStaleSchema name expectedVersion foundVersion _ _) -> do
assertEqual "the stale model" "kioku-session-by-id" name
assertEqual "the old binary's declared version" 4 expectedVersion
assertEqual "the reconciled row" 5 foundVersion
other ->
assertFailure
("expected the pre-relocation read model to be refused, got " <> show (() <$ other))
-- | The session read model exactly as the previous release declared it: same name, same query,
-- the identity it carried before the projections moved.
preRelocationSessionByIdReadModel :: ReadModel Session.SessionByIdQuery (Maybe Session.SessionRow)
preRelocationSessionByIdReadModel =
Session.sessionByIdReadModel {version = 4, shapeHash = "kioku-session-v4"}
-- | A second pass must write nothing. If it reported 'Reconciled' again, the reconciler
-- would be rewriting @last_built_at@ on every @just migrate@ — and, worse, would be lying
-- about what it changed.
testIdempotent :: Assertion
testIdempotent =
withApp \sid -> do
void (Session.getById testSpace sid)
_ <- reconcileReadModelRegistry
second <- reconcileReadModelRegistry
liftIO $
assertEqual
"every model is already current on the second pass"
[]
[schema.readModelName | (schema, outcome) <- second, outcome /= AlreadyCurrent]
-- | Pin every registry row back to the identity it carried before the projections moved into
-- the @kioku@ schema. Written as three targeted updates keyed on the /current/ shape hash, so a
-- future family this test does not know about is left alone rather than silently swept along.
downgradeToPreRelocationIdentities :: (Store :> es) => Eff es ()
downgradeToPreRelocationIdentities =
runTransaction . Tx.sql . encodeUtf8 . Text.concat $
[ downgrade 4 "kioku-session-v4" "kioku-session-v5",
downgrade 2 "kioku-memory-v2" "kioku-memory-v3",
downgrade 2 "kioku-turn-v2" "kioku-turn-v3"
]
where
downgrade previousVersion previousHash currentHash =
"UPDATE keiro.keiro_read_models SET version = "
<> Text.pack (show (previousVersion :: Int))
<> ", shape_hash = '"
<> previousHash
<> "' WHERE shape_hash = '"
<> currentHash
<> "';"
isRight :: Either e a -> Bool
isRight = \case
Right _ -> True
Left _ -> False
-- * Harness
withApp :: (SessionId -> Eff AppEffects ()) -> Assertion
withApp use =
withKiokuMigratedDatabase \connStr ->
withNoopAppEnv (defaultConnectionSettings connStr) \env -> do
sid <- genSessionId
result <- runAppIO env (startFixture sid >> use sid)
case result of
Left err -> assertFailure ("store error: " <> show err)
Right () -> pure ()
startFixture ::
(IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
SessionId ->
Eff es ()
startFixture sid = do
now <- liftIO getCurrentTime
result <-
Session.startWithContext
testContext
StartSessionData
{ sessionId = sid,
memorySpaceId = testSpace,
actorPrincipal = testActorPrincipal,
ownerPrincipal = Nothing,
agentId = "test-agent",
focus = "read-model reconciliation",
scope = ScopeGlobal (Namespace "kioku-test"),
subjectRef = Nothing,
previousSessionId = Nothing,
parentSessionId = Nothing,
delegationDepth = 0,
startedAt = now
}
case result of
Left err -> liftIO (assertFailure ("Session.startWithContext testContext: " <> show err))
Right _ -> pure ()