|
| 1 | +{-# LANGUAGE TemplateHaskell #-} |
| 2 | + |
1 | 3 | {-# OPTIONS_GHC -Wno-unused-do-bind #-} |
2 | 4 | {-# OPTIONS_GHC -Wno-orphans #-} |
3 | 5 |
|
4 | 6 | module Test.Database.LSMTree.StateMachine.DL ( |
5 | 7 | tests |
6 | 8 | ) where |
7 | 9 |
|
| 10 | +import Control.Monad (void) |
| 11 | +import Control.RefCount |
8 | 12 | import Control.Tracer |
9 | 13 | import qualified Data.Map.Strict as Map |
10 | 14 | import qualified Data.Vector as V |
11 | 15 | import Database.LSMTree as R |
12 | 16 | import qualified Database.LSMTree.Model.Session as Model (fromSomeTable, tables) |
13 | 17 | import qualified Database.LSMTree.Model.Table as Model (values) |
14 | 18 | import Prelude |
| 19 | +import SafeWildCards |
| 20 | +import System.FS.API.Types |
| 21 | +import System.FS.Sim.Error hiding (Blob) |
| 22 | +import qualified System.FS.Sim.Stream as Stream |
| 23 | +import System.FS.Sim.Stream (Stream) |
15 | 24 | import Test.Database.LSMTree.StateMachine hiding (tests) |
16 | 25 | import Test.Database.LSMTree.StateMachine.Op |
17 | | -import Test.QuickCheck as QC |
| 26 | +import Test.QuickCheck as QC hiding (label) |
18 | 27 | import Test.QuickCheck.DynamicLogic |
19 | 28 | import qualified Test.QuickCheck.Gen as QC |
20 | 29 | import qualified Test.QuickCheck.Random as QC |
21 | 30 | import Test.QuickCheck.StateModel.Lockstep |
22 | | -import Test.Tasty (TestTree, testGroup) |
| 31 | +import qualified Test.QuickCheck.StateModel.Lockstep.Defaults as QLS |
| 32 | +import Test.QuickCheck.StateModel.Variables |
| 33 | +import Test.Tasty (TestTree, testGroup, withResource) |
23 | 34 | import qualified Test.Tasty.QuickCheck as QC |
24 | 35 | import Test.Util.PrettyProxy |
25 | 36 |
|
26 | 37 | tests :: TestTree |
27 | 38 | tests = testGroup "Test.Database.LSMTree.StateMachine.DL" [ |
28 | 39 | QC.testProperty "prop_example" prop_example |
| 40 | + , test_noSwallowedExceptions |
29 | 41 | ] |
30 | 42 |
|
31 | 43 | instance DynLogicModel (Lockstep (ModelState R.Table)) |
@@ -80,3 +92,203 @@ dl_example = do |
80 | 92 | | Just tbl <- (Model.fromSomeTable @Key @Value @Blob smTbl) |
81 | 93 | -> Map.size (Model.values tbl) == Map.size kvs |
82 | 94 | _ -> False |
| 95 | + |
| 96 | +{------------------------------------------------------------------------------- |
| 97 | + Swallowed exceptions |
| 98 | +-------------------------------------------------------------------------------} |
| 99 | + |
| 100 | +-- | See 'prop_noSwallowedExceptions'. |
| 101 | +-- |
| 102 | +-- Forgotten reference checks are disabled completely, because we allow bugs |
| 103 | +-- (like forgotten references) in exception unsafe code where we inject disk |
| 104 | +-- faults. |
| 105 | +test_noSwallowedExceptions :: TestTree |
| 106 | +test_noSwallowedExceptions = |
| 107 | + withResource |
| 108 | + (checkForgottenRefs >> disableForgottenRefChecks) |
| 109 | + (\_ -> enableForgottenRefChecks) $ \ !_ -> |
| 110 | + QC.testProperty "prop_noSwallowedExceptions" prop_noSwallowedExceptions |
| 111 | + |
| 112 | +-- | Test that the @lsm-tree@ library does not swallow exceptions. |
| 113 | +-- |
| 114 | +-- A functional requirement for the @lsm-tree@ library is that all exceptions |
| 115 | +-- are properly communicated to the user. An alternative way of stating this |
| 116 | +-- requirement is that no exceptions should be /swallowed/ by the library. We |
| 117 | +-- test this requirement by running the state machine test with injected disk |
| 118 | +-- errors using @fs-sim@, and asserting that no exceptions are swallowed. |
| 119 | +-- |
| 120 | +-- The state machine test compares the SUT against a model by checking that |
| 121 | +-- their responses to @lsm-tree@ actions are the same. As of writing this |
| 122 | +-- property, not all of these actions on the SUT are guaranteed to be fully |
| 123 | +-- exception safe. As a result, an exception might leave the database (i.e., |
| 124 | +-- session, tables, cursors) in an inconsistent state. The results of subsequent |
| 125 | +-- operations on the inconsistent database should be considered undefined. As |
| 126 | +-- such, it is highly likely that the SUT and model will thereafter disagree, |
| 127 | +-- leading to a failing property. |
| 128 | +-- |
| 129 | +-- Still, we want to run the swallowed error assertion on /all/ operations, |
| 130 | +-- regardless of whether they are exception safe. We overcome this problem by |
| 131 | +-- /definitely/ injecting errors (and running a swallowed error assertion) for |
| 132 | +-- the last action in a sequence of actions. This may leave the final database |
| 133 | +-- state inconsistent, but that is okay. However, we'll also have to disable |
| 134 | +-- sanity checks like 'NoCheckCleanup', 'NoCheckFS', and 'NoCheckRefs', because |
| 135 | +-- they are likely to fail if the database is an inconsistent state. |
| 136 | +-- |
| 137 | +-- TODO: running only one swallowed exception assertion per action sequence is |
| 138 | +-- restrictive, but this automatically improves as we make more actions |
| 139 | +-- exceptions safe. When we generate injected errors for these errors by default |
| 140 | +-- (in @arbitraryWithVars@), the swallowed exception assertion automatically |
| 141 | +-- runs for those actions as well. |
| 142 | +prop_noSwallowedExceptions :: Property |
| 143 | +prop_noSwallowedExceptions = forAllDL dl_noSwallowExceptions runner |
| 144 | + where |
| 145 | + -- disable all file system and reference checks |
| 146 | + runner = propLockstep_RealImpl_MockFS_IO tr NoCheckCleanup NoCheckFS NoCheckRefs |
| 147 | + tr = nullTracer |
| 148 | + |
| 149 | +-- | Run any number of actions using the default actions generator, and finally |
| 150 | +-- run a single action with errors *definitely* enabled. |
| 151 | +dl_noSwallowExceptions :: DL (Lockstep (ModelState R.Table)) () |
| 152 | +dl_noSwallowExceptions = do |
| 153 | + -- Run any number of actions as normal |
| 154 | + anyActions_ |
| 155 | + |
| 156 | + -- Generate a single action as normal |
| 157 | + varCtx <- getVarContextDL |
| 158 | + st <- getModelStateDL |
| 159 | + let |
| 160 | + gen = QLS.arbitraryAction varCtx st |
| 161 | + predicate (Some a) = QLS.precondition st a |
| 162 | + shr (Some a) = QLS.shrinkAction varCtx st a |
| 163 | + Some a <- forAllQ $ withGenQ gen predicate shr |
| 164 | + |
| 165 | + -- Overwrite the maybe errors of the generated action with *definitely* just |
| 166 | + -- errors. |
| 167 | + case a of |
| 168 | + Action _merrs a' -> do |
| 169 | + HasNoVariables errs <- |
| 170 | + forAllQ $ hasNoVariablesQ $ withGenQ arbitraryErrors (\_ -> True) shrinkErrors |
| 171 | + -- Run the modified action |
| 172 | + void $ action $ Action (Just errs) a' |
| 173 | + |
| 174 | +-- | Generate an 'Errors' with arbitrary probabilities of exceptions. |
| 175 | +-- |
| 176 | +-- The default 'genErrors' from @fs-sim@ generates streams of 'Maybe' exceptions |
| 177 | +-- with a fixed probability for a 'Just' or 'Nothing'. The version here |
| 178 | +-- generates an arbitrary probability for each stream, which should generate a |
| 179 | +-- larger variety of 'Errors' structures. |
| 180 | +-- |
| 181 | +-- TODO: upstream to @fs-sim@ to replase the default 'genErrors'? |
| 182 | +arbitraryErrors :: Gen Errors |
| 183 | +arbitraryErrors = do |
| 184 | + dumpStateE <- genStream arbitrary |
| 185 | + hCloseE <- genStream arbitrary |
| 186 | + hTruncateE <- genStream arbitrary |
| 187 | + doesDirectoryExistE <- genStream arbitrary |
| 188 | + doesFileExistE <- genStream arbitrary |
| 189 | + hOpenE <- genStream arbitrary |
| 190 | + hSeekE <- genStream arbitrary |
| 191 | + hGetSomeE <- genErrorStreamGetSome |
| 192 | + hGetSomeAtE <- genErrorStreamGetSome |
| 193 | + hPutSomeE <- genErrorStreamPutSome |
| 194 | + hGetSizeE <- genStream arbitrary |
| 195 | + createDirectoryE <- genStream arbitrary |
| 196 | + createDirectoryIfMissingE <- genStream arbitrary |
| 197 | + listDirectoryE <- genStream arbitrary |
| 198 | + removeDirectoryRecursiveE <- genStream arbitrary |
| 199 | + removeFileE <- genStream arbitrary |
| 200 | + renameFileE <- genStream arbitrary |
| 201 | + hGetBufSomeE <- genErrorStreamGetSome |
| 202 | + hGetBufSomeAtE <- genErrorStreamGetSome |
| 203 | + hPutBufSomeE <- genErrorStreamPutSome |
| 204 | + hPutBufSomeAtE <- genErrorStreamPutSome |
| 205 | + return $ filterErrors Errors {..} |
| 206 | + where |
| 207 | + -- Generate a stream using 'genLikelihoods' for its 'Maybe' elements. |
| 208 | + genStream :: forall a. Gen a -> Gen (Stream a) |
| 209 | + genStream genA = do |
| 210 | + (pNothing, pJust) <- genLikelihoods |
| 211 | + Stream.genInfinite $ Stream.genMaybe pNothing pJust genA |
| 212 | + |
| 213 | + -- Generate two integer likelihoods for 'Nothing' and 'Just' constructors. |
| 214 | + genLikelihoods :: Gen (Int, Int) |
| 215 | + genLikelihoods = do |
| 216 | + NonNegative pNothing <- arbitrary |
| 217 | + NonNegative pJust <- arbitrary |
| 218 | + if pNothing == 0 then |
| 219 | + pure (0, 1) |
| 220 | + else if pJust == 0 then |
| 221 | + pure (1, 0) |
| 222 | + else |
| 223 | + pure (pNothing, pJust) |
| 224 | + |
| 225 | + genErrorStreamGetSome :: Gen ErrorStreamGetSome |
| 226 | + genErrorStreamGetSome = genStream $ liftArbitrary2 arbitrary arbitrary |
| 227 | + |
| 228 | + genErrorStreamPutSome :: Gen ErrorStreamPutSome |
| 229 | + genErrorStreamPutSome = genStream $ flip liftArbitrary2 arbitrary $ do |
| 230 | + errorType <- arbitrary |
| 231 | + maybePutCorruption <- liftArbitrary genPutCorruption |
| 232 | + pure (errorType, maybePutCorruption) |
| 233 | + |
| 234 | + genPutCorruption :: Gen PutCorruption |
| 235 | + genPutCorruption = oneof [ |
| 236 | + PartialWrite <$> arbitrary |
| 237 | + , SubstituteWithJunk <$> arbitrary |
| 238 | + ] |
| 239 | + where |
| 240 | + _coveredAllCases x = case x of |
| 241 | + PartialWrite{} -> pure () |
| 242 | + SubstituteWithJunk{} -> pure () |
| 243 | + |
| 244 | + -- TODO: there is one case where an 'FsReachEOF' error is swallowed. Is that |
| 245 | + -- valid behaviour, or should we change it? |
| 246 | + filterErrors errs = errs { |
| 247 | + hGetBufSomeE = Stream.filter (not . isFsReachedEOF) (hGetBufSomeE errs) |
| 248 | + } |
| 249 | + |
| 250 | + isFsReachedEOF Nothing = False |
| 251 | + isFsReachedEOF (Just (Left e)) = case e of |
| 252 | + FsReachedEOF -> True |
| 253 | + _ -> False |
| 254 | + isFsReachedEOF (Just (Right _)) = False |
| 255 | + |
| 256 | +-- | Shrink each error stream and all error stream elements. |
| 257 | +-- |
| 258 | +-- The default 'shrink' from @fs-sim@ shrinks only the stream structure, but not |
| 259 | +-- the elements contained in those streams. |
| 260 | +-- |
| 261 | +-- TODO: upstream to @fs-sim@ to replace the default 'shrink'? |
| 262 | +shrinkErrors :: Errors -> [Errors] |
| 263 | +shrinkErrors err@($(fields 'Errors)) |
| 264 | + | allNull err = [] |
| 265 | + | otherwise = emptyErrors : concatMap (filter (not . allNull)) |
| 266 | + [ (\s' -> err { dumpStateE = s' }) <$> Stream.liftShrinkStream shrink dumpStateE |
| 267 | + , (\s' -> err { hOpenE = s' }) <$> Stream.liftShrinkStream shrink hOpenE |
| 268 | + , (\s' -> err { hCloseE = s' }) <$> Stream.liftShrinkStream shrink hCloseE |
| 269 | + , (\s' -> err { hSeekE = s' }) <$> Stream.liftShrinkStream shrink hSeekE |
| 270 | + , (\s' -> err { hGetSomeE = s' }) <$> Stream.liftShrinkStream shrink hGetSomeE |
| 271 | + , (\s' -> err { hGetSomeAtE = s' }) <$> Stream.liftShrinkStream shrink hGetSomeAtE |
| 272 | + , (\s' -> err { hPutSomeE = s' }) <$> Stream.liftShrinkStream shrink hPutSomeE |
| 273 | + , (\s' -> err { hTruncateE = s' }) <$> Stream.liftShrinkStream shrink hTruncateE |
| 274 | + , (\s' -> err { hGetSizeE = s' }) <$> Stream.liftShrinkStream shrink hGetSizeE |
| 275 | + , (\s' -> err { createDirectoryE = s' }) <$> Stream.liftShrinkStream shrink createDirectoryE |
| 276 | + , (\s' -> err { createDirectoryIfMissingE = s' }) <$> Stream.liftShrinkStream shrink createDirectoryIfMissingE |
| 277 | + , (\s' -> err { listDirectoryE = s' }) <$> Stream.liftShrinkStream shrink listDirectoryE |
| 278 | + , (\s' -> err { doesDirectoryExistE = s' }) <$> Stream.liftShrinkStream shrink doesDirectoryExistE |
| 279 | + , (\s' -> err { doesFileExistE = s' }) <$> Stream.liftShrinkStream shrink doesFileExistE |
| 280 | + , (\s' -> err { removeDirectoryRecursiveE = s' }) <$> Stream.liftShrinkStream shrink removeDirectoryRecursiveE |
| 281 | + , (\s' -> err { removeFileE = s' }) <$> Stream.liftShrinkStream shrink removeFileE |
| 282 | + , (\s' -> err { renameFileE = s' }) <$> Stream.liftShrinkStream shrink renameFileE |
| 283 | + , (\s' -> err { hGetBufSomeE = s' }) <$> Stream.liftShrinkStream shrink hGetBufSomeE |
| 284 | + , (\s' -> err { hGetBufSomeAtE = s' }) <$> Stream.liftShrinkStream shrink hGetBufSomeAtE |
| 285 | + , (\s' -> err { hPutBufSomeE = s' }) <$> Stream.liftShrinkStream shrink hPutBufSomeE |
| 286 | + , (\s' -> err { hPutBufSomeAtE = s' }) <$> Stream.liftShrinkStream shrink hPutBufSomeAtE |
| 287 | + ] |
| 288 | + |
| 289 | +deriving stock instance Enum FsErrorType |
| 290 | +deriving stock instance Bounded FsErrorType |
| 291 | + |
| 292 | +instance Arbitrary FsErrorType where |
| 293 | + arbitrary = arbitraryBoundedEnum |
| 294 | + shrink = shrinkBoundedEnum |
0 commit comments