packages feed

lsm-tree-1.0.0.0: test/Test/Database/LSMTree/StateMachine/DL.hs

{-# LANGUAGE TemplateHaskell #-}

{-# OPTIONS_GHC -Wno-unused-do-bind #-}
{-# OPTIONS_GHC -Wno-orphans #-}

module Test.Database.LSMTree.StateMachine.DL (
    tests
  ) where

import           Control.Monad (void)
import           Control.RefCount
import           Control.Tracer
import qualified Data.Map.Strict as Map
import           Data.Typeable (Typeable)
import qualified Data.Vector as V
import           Database.LSMTree as R
import qualified Database.LSMTree.Internal.Config as R (TableConfig (..))
import qualified Database.LSMTree.Model.Session as Model (fromSomeTable, tables)
import qualified Database.LSMTree.Model.Table as Model (values)
import           Prelude
import           SafeWildCards
import           System.FS.API.Types
import           System.FS.Sim.Error hiding (Blob)
import qualified System.FS.Sim.Stream as Stream
import           System.FS.Sim.Stream (Stream)
import           Test.Database.LSMTree.StateMachine hiding (tests)
import           Test.Database.LSMTree.StateMachine.Op
import           Test.QuickCheck as QC hiding (Some, label)
import           Test.QuickCheck.DynamicLogic
import qualified Test.QuickCheck.Gen as QC
import qualified Test.QuickCheck.Random as QC
import           Test.QuickCheck.StateModel.Lockstep
import qualified Test.QuickCheck.StateModel.Lockstep.Defaults as QLS
import           Test.QuickCheck.StateModel.Variables
import           Test.Tasty (TestTree, testGroup, withResource)
import qualified Test.Tasty.QuickCheck as QC
import           Test.Util.FS
import           Test.Util.PrettyProxy

tests :: TestTree
tests = testGroup "Test.Database.LSMTree.StateMachine.DL" [
      QC.testProperty "prop_example" prop_example
    , test_noSwallowedExceptions
    ]

instance DynLogicModel (Lockstep (ModelState IO R.Table))

-- | An example of how dynamic logic formulas can be run.
--
-- 'dl_example' is a manually created formula, but the same method of running a
-- formula also applies to counterexamples produced by a state machine property
-- test, such as 'propLockstep_RealImpl_RealFS_IO'. Such counterexamples are
-- (often almost) valid 'DL' expression. They can be copy-pasted into a Haskell
-- module with minor tweaks to make the compiler accept the copied code, and
-- then they can be run as any other 'DL' expression.
prop_example :: Property
prop_example =
    -- Run the example ...
    forAllDL dl_example $
    -- ... with the given lockstep property
    propLockstep_RealImpl_MockFS_IO tr CheckCleanup CheckFS CheckRefs (QC.Fixed 17)
  where
    -- To enable tracing, use something like @show `contramap` stdoutTracer@
    -- instead
    tr = nullTracer

-- | Create an initial "large" table
dl_example :: Typeable m => DL (Lockstep (ModelState m R.Table)) ()
dl_example = do
    -- Create an initial table and fill it with some inserts
    var3 <- action $ Action Nothing $ NewTableWith (PrettyProxy @((Key, Value, Blob))) (R.TableConfig {
          confMergePolicy = LazyLevelling
        , confSizeRatio = Four
        , confWriteBufferAlloc = AllocNumEntries 4
        , confBloomFilterAlloc = AllocFixed 10
        , confFencePointerIndex = OrdinaryIndex
        , confDiskCachePolicy = DiskCacheNone
        , confMergeSchedule = OneShot
        , confMergeBatchSize = MergeBatchSize 4
        })
    let kvs :: Map.Map Key Value
        kvs = Map.fromList $
              QC.unGen (QC.vectorOf 37 $ (,) <$> QC.arbitrary <*> QC.arbitrary)
                       (QC.mkQCGen 42) 30
        ups :: V.Vector (Key, Update Value Blob)
        ups = V.fromList
            . map (\(k,v) -> (k, Insert v Nothing))
            . Map.toList $ kvs
    action $ Action Nothing $ Updates ups (unsafeMkGVar var3 (OpFromRight `OpComp` OpId))
    -- This is a rather ugly assertion, and could be improved using some helper
    -- function(s). However, it does serve its purpose as checking that the
    -- insertions we just did were successful.
    assertModel "table size" $ \s ->
        let (ModelState s' _) = getModel s in
        case Map.elems (Model.tables s') of
          [(_, smTbl)]
            | Just tbl <- (Model.fromSomeTable @Key @Value @Blob smTbl)
            -> Map.size (Model.values tbl) == Map.size kvs
          _ -> False

{-------------------------------------------------------------------------------
  Swallowed exceptions
-------------------------------------------------------------------------------}

-- | See 'prop_noSwallowedExceptions'.
--
-- Forgotten reference checks are disabled completely, because we allow bugs
-- (like forgotten references) in exception unsafe code where we inject disk
-- faults.
test_noSwallowedExceptions :: TestTree
test_noSwallowedExceptions =
    withResource
      (checkForgottenRefs >> disableForgottenRefChecks)
      (\_ -> enableForgottenRefChecks) $ \ !_ ->
      QC.testProperty "prop_noSwallowedExceptions" prop_noSwallowedExceptions

-- | Test that the @lsm-tree@ library does not swallow exceptions.
--
-- A functional requirement for the @lsm-tree@ library is that all exceptions
-- are properly communicated to the user. An alternative way of stating this
-- requirement is that no exceptions should be /swallowed/ by the library. We
-- test this requirement by running the state machine test with injected disk
-- errors using @fs-sim@, and asserting that no exceptions are swallowed.
--
-- The state machine test compares the SUT against a model by checking that
-- their responses to @lsm-tree@ actions are the same. As of writing this
-- property, not all of these actions on the SUT are guaranteed to be fully
-- exception safe. As a result, an exception might leave the database (i.e.,
-- session, tables, cursors) in an inconsistent state. The results of subsequent
-- operations on the inconsistent database should be considered undefined. As
-- such, it is highly likely that the SUT and model will thereafter disagree,
-- leading to a failing property.
--
-- Still, we want to run the swallowed error assertion on /all/ operations,
-- regardless of whether they are exception safe. We overcome this problem by
-- /definitely/ injecting errors (and running a swallowed error assertion) for
-- the last action in a sequence of actions. This may leave the final database
-- state inconsistent, but that is okay. However, we'll also have to disable
-- sanity checks like 'NoCheckCleanup', 'NoCheckFS', and 'NoCheckRefs', because
-- they are likely to fail if the database is an inconsistent state.
--
-- TODO: running only one swallowed exception assertion per action sequence is
-- restrictive, but this automatically improves as we make more actions
-- exceptions safe. When we generate injected errors for these errors by default
-- (in @arbitraryWithVars@), the swallowed exception assertion automatically
-- runs for those actions as well.
prop_noSwallowedExceptions :: QC.Fixed Salt -> Property
prop_noSwallowedExceptions salt = forAllDL dl_noSwallowExceptions runner
  where
    -- disable all file system and reference checks
    runner = propLockstep_RealImpl_MockFS_IO tr NoCheckCleanup NoCheckFS NoCheckRefs salt
    tr = nullTracer

-- | Run any number of actions using the default actions generator, and finally
-- run a single action with errors *definitely* enabled.
dl_noSwallowExceptions :: Typeable m => DL (Lockstep (ModelState m R.Table)) ()
dl_noSwallowExceptions = do
    -- Run any number of actions as normal
    anyActions_

    -- Generate a single action as normal
    varCtx <- getVarContextDL
    st <- getModelStateDL
    let
      gen = QLS.arbitraryAction varCtx st
      predicate (Some a) = QLS.precondition st a
      shr (Some a) = QLS.shrinkAction varCtx st a
    Some a <- forAllQ $ withGenQ gen predicate shr

    -- Overwrite the maybe errors of the generated action with *definitely* just
    -- errors.
    case a of
      Action _merrs a' -> do
        HasNoVariables errs <-
          forAllQ $ hasNoVariablesQ $ withGenQ arbitraryErrors (\_ -> True) shrinkErrors
        -- Run the modified action
        void $ action $ Action (Just errs) a'

-- | Generate an 'Errors' with arbitrary probabilities of exceptions.
--
-- The default 'genErrors' from @fs-sim@ generates streams of 'Maybe' exceptions
-- with a fixed probability for a 'Just' or 'Nothing'. The version here
-- generates an arbitrary probability for each stream, which should generate a
-- larger variety of 'Errors' structures.
--
-- TODO: upstream to @fs-sim@ to replace the default 'genErrors'?
arbitraryErrors :: Gen Errors
arbitraryErrors = do
    dumpStateE                <- genStream arbitrary
    hCloseE                   <- genStream arbitrary
    hTruncateE                <- genStream arbitrary
    doesDirectoryExistE       <- genStream arbitrary
    doesFileExistE            <- genStream arbitrary
    hOpenE                    <- genStream arbitrary
    hSeekE                    <- genStream arbitrary
    hGetSomeE                 <- genErrorStreamGetSome
    hGetSomeAtE               <- genErrorStreamGetSome
    hPutSomeE                 <- genErrorStreamPutSome
    hGetSizeE                 <- genStream arbitrary
    createDirectoryE          <- genStream arbitrary
    createDirectoryIfMissingE <- genStream arbitrary
    listDirectoryE            <- genStream arbitrary
    removeDirectoryRecursiveE <- genStream arbitrary
    removeFileE               <- genStream arbitrary
    renameFileE               <- genStream arbitrary
    hGetBufSomeE              <- genErrorStreamGetSome
    hGetBufSomeAtE            <- genErrorStreamGetSome
    hPutBufSomeE              <- genErrorStreamPutSome
    hPutBufSomeAtE            <- genErrorStreamPutSome
    pure $ filterErrors Errors {..}
  where
    -- Generate a stream using 'genLikelihoods' for its 'Maybe' elements.
    genStream :: forall a. Gen a -> Gen (Stream a)
    genStream genA = do
        (pNothing, pJust) <- genLikelihoods
        Stream.genInfinite $ Stream.genMaybe pNothing pJust genA

    -- Generate two integer likelihoods for 'Nothing' and 'Just' constructors.
    genLikelihoods :: Gen (Int, Int)
    genLikelihoods = do
      NonNegative pNothing <- arbitrary
      NonNegative pJust <- arbitrary
      if pNothing == 0 then
        pure (0, 1)
      else if pJust == 0 then
        pure (1, 0)
      else
        pure (pNothing, pJust)

    genErrorStreamGetSome :: Gen ErrorStreamGetSome
    genErrorStreamGetSome = genStream $ liftArbitrary2 arbitrary arbitrary

    genErrorStreamPutSome :: Gen ErrorStreamPutSome
    genErrorStreamPutSome = genStream $ flip liftArbitrary2 arbitrary $ do
        errorType <- arbitrary
        maybePutCorruption <- liftArbitrary genPutCorruption
        pure (errorType, maybePutCorruption)

    genPutCorruption :: Gen PutCorruption
    genPutCorruption = oneof [
          PartialWrite <$> arbitrary
        , SubstituteWithJunk <$> arbitrary
        ]
      where
        _coveredAllCases x = case x of
          PartialWrite{}       -> pure ()
          SubstituteWithJunk{} -> pure ()

    -- TODO: there is one case where an 'FsReachEOF' error is swallowed. Is that
    -- valid behaviour, or should we change it?
    filterErrors errs = errs {
          hGetBufSomeE = Stream.filter (not . isFsReachedEOFError) (hGetBufSomeE errs)
        }

    isFsReachedEOFError = maybe False (either isFsReachedEOF (const False))

-- | Shrink each error stream and all error stream elements.
--
-- The default 'shrink' from @fs-sim@ shrinks only the stream structure, but not
-- the elements contained in those streams.
--
-- TODO: upstream to @fs-sim@ to replace the default 'shrink'?
shrinkErrors :: Errors -> [Errors]
shrinkErrors err@($(fields 'Errors))
    | allNull err = []
    | otherwise = emptyErrors : concatMap (filter (not . allNull))
        [ (\s' -> err { dumpStateE = s' })                <$> Stream.liftShrinkStream shrink dumpStateE
        , (\s' -> err { hOpenE = s' })                    <$> Stream.liftShrinkStream shrink hOpenE
        , (\s' -> err { hCloseE = s' })                   <$> Stream.liftShrinkStream shrink hCloseE
        , (\s' -> err { hSeekE = s' })                    <$> Stream.liftShrinkStream shrink hSeekE
        , (\s' -> err { hGetSomeE = s' })                 <$> Stream.liftShrinkStream shrink hGetSomeE
        , (\s' -> err { hGetSomeAtE = s' })               <$> Stream.liftShrinkStream shrink hGetSomeAtE
        , (\s' -> err { hPutSomeE = s' })                 <$> Stream.liftShrinkStream shrink hPutSomeE
        , (\s' -> err { hTruncateE = s' })                <$> Stream.liftShrinkStream shrink hTruncateE
        , (\s' -> err { hGetSizeE = s' })                 <$> Stream.liftShrinkStream shrink hGetSizeE
        , (\s' -> err { createDirectoryE = s' })          <$> Stream.liftShrinkStream shrink createDirectoryE
        , (\s' -> err { createDirectoryIfMissingE = s' }) <$> Stream.liftShrinkStream shrink createDirectoryIfMissingE
        , (\s' -> err { listDirectoryE = s' })            <$> Stream.liftShrinkStream shrink listDirectoryE
        , (\s' -> err { doesDirectoryExistE = s' })       <$> Stream.liftShrinkStream shrink doesDirectoryExistE
        , (\s' -> err { doesFileExistE = s' })            <$> Stream.liftShrinkStream shrink doesFileExistE
        , (\s' -> err { removeDirectoryRecursiveE = s' }) <$> Stream.liftShrinkStream shrink removeDirectoryRecursiveE
        , (\s' -> err { removeFileE = s' })               <$> Stream.liftShrinkStream shrink removeFileE
        , (\s' -> err { renameFileE = s' })               <$> Stream.liftShrinkStream shrink renameFileE
        , (\s' -> err { hGetBufSomeE = s' })              <$> Stream.liftShrinkStream shrink hGetBufSomeE
        , (\s' -> err { hGetBufSomeAtE = s' })            <$> Stream.liftShrinkStream shrink hGetBufSomeAtE
        , (\s' -> err { hPutBufSomeE = s' })              <$> Stream.liftShrinkStream shrink hPutBufSomeE
        , (\s' -> err { hPutBufSomeAtE = s' })            <$> Stream.liftShrinkStream shrink hPutBufSomeAtE
        ]

deriving stock instance Enum FsErrorType
deriving stock instance Bounded FsErrorType

instance Arbitrary FsErrorType where
  arbitrary = arbitraryBoundedEnum
  shrink = shrinkBoundedEnum