kiroku-store-0.3.0.0: test/Test/TruncateBefore.hs
{- | Tests for the logical truncate-before marker (close-the-book
compaction, ExecPlan docs/plans/65).
The marker is a per-stream cursor that hides a prefix from the /ordered
per-stream/ reads ('readStreamForward' / 'readStreamBackward' and the paged
'readStreamForwardStream') while leaving the global @$all@ log, category
reads, and the physical row count untouched. The distinguishing property —
and the single most important assertion here — is that after a truncate the
@$all@ log and 'readCategory' still return the full history and
'countEvents' is unchanged: nothing is deleted, only hidden, and the
operation is fully reversible.
-}
module Test.TruncateBefore (spec) where
import Control.Lens ((^.))
import Data.Aeson qualified as Aeson
import Data.Generics.Labels ()
import Data.Text qualified as T
import Data.Vector qualified as V
import Kiroku.Store
import Streamly.Data.Stream qualified as Stream
import Test.Helpers (countEvents, makeEvent, withTestStore)
import Test.Hspec
-- | Append @n@ events @E1 .. En@ to a fresh stream and return its name.
seedStream :: KirokuStore -> StreamName -> Int -> IO ()
seedStream store name n = do
let events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. n]]
Right _ <- runStoreIO store $ appendToStream name NoStream events
pure ()
-- | The per-stream versions returned by a forward read, in order.
forwardVersions :: KirokuStore -> StreamName -> IO [StreamVersion]
forwardVersions store name = do
Right v <- runStoreIO store $ readStreamForward name (StreamVersion 0) 100
pure (map (^. #streamVersion) (V.toList v))
spec :: Spec
spec = describe "TruncateBefore" $
around withTestStore $ do
it "bounds per-stream reads to the kept suffix" $ \store -> do
let name = StreamName "preference-abc"
seedStream store name 6 -- versions 1..6; treat v6 as the snapshot
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)
forwardVersions store name `shouldReturn` [StreamVersion 6]
Right back <- runStoreIO store $ readStreamBackward name (StreamVersion 0) 100
map (^. #streamVersion) (V.toList back) `shouldBe` [StreamVersion 6]
it "leaves the $all global log and category reads intact" $ \store -> do
let name = StreamName "preference-abc"
seedStream store name 6
before <- countEvents store
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)
-- \$all global log still returns the full history.
Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000
length (V.toList allEvents) `shouldBe` 6
-- Category read still returns the full history.
Right catEvents <- runStoreIO store $ readCategory (CategoryName "preference") (GlobalPosition 0) 1000
length (V.toList catEvents) `shouldBe` 6
-- Nothing was physically deleted.
after <- countEvents store
after `shouldBe` before
it "is reversible via clearStreamTruncateBefore" $ \store -> do
let name = StreamName "preference-abc"
seedStream store name 6
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)
forwardVersions store name `shouldReturn` [StreamVersion 6]
Right (Just _) <- runStoreIO store $ clearStreamTruncateBefore name
forwardVersions store name
`shouldReturn` map StreamVersion [1 .. 6]
it "is idempotent" $ \store -> do
let name = StreamName "preference-abc"
seedStream store name 6
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)
first <- forwardVersions store name
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)
second <- forwardVersions store name
first `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]
second `shouldBe` first
it "applies across paged reads" $ \store -> do
let name = StreamName "preference-abc"
seedStream store name 6
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)
-- Page size 2 forces multiple pages across the kept suffix.
Right streamed <-
runStoreIO store $
Stream.toList $
fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)
streamed `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]
it "reflects the marker in getStream" $ \store -> do
let name = StreamName "preference-abc"
seedStream store name 6
Right (Just info0) <- runStoreIO store $ getStream name
(info0 ^. #truncateBefore) `shouldBe` StreamVersion 0
Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 3)
Right (Just info1) <- runStoreIO store $ getStream name
(info1 ^. #truncateBefore) `shouldBe` StreamVersion 3
it "returns Nothing for missing or soft-deleted streams" $ \store -> do
Right missing <- runStoreIO store $ setStreamTruncateBefore (StreamName "does-not-exist") (StreamVersion 1)
missing `shouldBe` Nothing
let name = StreamName "preference-abc"
seedStream store name 3
Right (Just _) <- runStoreIO store $ softDeleteStream name
Right deleted <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 1)
deleted `shouldBe` Nothing
it "rejects $all with ReservedStreamName" $ \store -> do
result <- runStoreIO store $ setStreamTruncateBefore (StreamName "$all") (StreamVersion 1)
case result of
Left (ReservedStreamName (StreamName "$all")) -> pure ()
other ->
expectationFailure
("Expected Left (ReservedStreamName \"$all\"), got: " <> show other)