packages feed

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

{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}

module Test.Database.LSMTree (tests) where

import           Control.Exception
import           Control.Tracer
import           Data.Function (on)
import           Data.IORef
import           Data.Monoid
import           Data.Typeable (Typeable)
import qualified Data.Vector as V
import qualified Data.Vector.Algorithms as VA
import           Data.Void
import           Data.Word
import           Database.LSMTree
import           Database.LSMTree.Extras (showRangesOf)
import           Database.LSMTree.Extras.Generators ()
import qualified System.FS.API as FS
import qualified System.FS.BlockIO.API as FS
import           Test.QuickCheck
import           Test.Tasty
import           Test.Tasty.QuickCheck
import           Test.Util.FS

tests :: TestTree
tests = testGroup "Test.Database.LSMTree" [
      testGroup "Session" [
          -- openSession
          testProperty "prop_openSession_newSession" prop_openSession_newSession
        , testProperty "prop_openSession_restoreSession" prop_openSession_restoreSession
          -- happy path
        , testProperty "prop_newSession_restoreSession_happyPath" prop_newSession_restoreSession_happyPath
          -- missing session directory
        , testProperty "prop_sessionDirDoesNotExist" prop_sessionDirDoesNotExist
          -- session directory already locked
        , testProperty "prop_sessionDirLocked" prop_sessionDirLocked
          -- malformed session directory
        , testProperty "prop_sessionDirCorrupted" prop_sessionDirCorrupted
          -- salt
        , testProperty "prop_goodAndBadSessionSalt" prop_goodAndBadSessionSalt
        ]
    ]

{-------------------------------------------------------------------------------
  Test types and utilities
-------------------------------------------------------------------------------}

newtype Key = Key Word64
  deriving stock (Show, Eq, Ord)
  deriving newtype (Arbitrary, SerialiseKey)

newtype Value = Value Word64
  deriving stock (Show, Eq)
  deriving newtype (Arbitrary, SerialiseValue)
  deriving ResolveValue via Sum Word64

newtype Blob = Blob Word64
  deriving stock (Show, Eq)
  deriving newtype (Arbitrary, SerialiseValue)

data NewOrRestore = New | Restore
  deriving stock (Show, Eq, Bounded, Enum)

instance Arbitrary NewOrRestore where
  arbitrary = arbitraryBoundedEnum
  shrink = shrinkBoundedEnum

-- | If 'New', use 'newSession', otherwise if 'Restore', use 'restoreSession'.
--
-- This allows us to run properties on both 'newSession' and 'restoreSession',
-- without having to write almost identical code twice.
--
-- In a sense, this is somewhat similar to 'openSession', but whereas
-- 'openSession' would defer to 'newSession' or 'restoreSession' based on the
-- directory contents, here the user gets to pick whether to use 'newSession' or
-- 'restoreSession'.
withNewSessionOrRestoreSession ::
     (IOLike m, Typeable h)
  => NewOrRestore
  -> Tracer m LSMTreeTrace
  -> FS.HasFS m h
  -> FS.HasBlockIO m h
  -> Salt
  -> FS.FsPath
  -> (Session m -> m a)
  -> m a
withNewSessionOrRestoreSession newOrRestore tr hfs hbio salt path =
    case newOrRestore of
      New     -> withNewSession tr hfs hbio salt path
      Restore -> withRestoreSession tr hfs hbio path

{-------------------------------------------------------------------------------
  Session: openSession
-------------------------------------------------------------------------------}

-- | When the session directory is empty, 'openSession' will call 'newSession'
prop_openSession_newSession :: Property
prop_openSession_newSession =
    ioProperty $
    withTempIOHasBlockIO "prop_openSession_newSession" $ \hfs hbio -> do
    -- Use resultsVar to record which session functions were called
    resultsVar <- newIORef []
    withOpenSession
      (mkSessionOpenModeTracer resultsVar) hfs hbio
      testSalt (FS.mkFsPath [])
      $ \_session -> pure ()
    results <- readIORef resultsVar
    -- Check that we first called openSession, then newSession
    pure $ results === ["Created", "New", "Open"]
  where
    testSalt = 6

-- | When the session directory is non-empty, 'openSession' will call 'restoreSession'
prop_openSession_restoreSession :: Property
prop_openSession_restoreSession =
    ioProperty $
    withTempIOHasBlockIO "prop_openSession_restoreSession" $ \hfs hbio -> do
    withOpenSession nullTracer hfs hbio testSalt (FS.mkFsPath [])
      $ \_session1 -> pure ()
    -- Use resultsVar to record which session functions were called
    resultsVar <- newIORef []
    withOpenSession
      (mkSessionOpenModeTracer resultsVar) hfs hbio
      testSalt (FS.mkFsPath [])
      $ \_session2 -> pure ()
    results <- readIORef resultsVar
    -- Check that we first called openSession, then restoreSession
    pure $ results === ["Created", "Restore", "Open"]
  where
    testSalt = 6

-- | A tracer that records session open, session new, and session restore
-- messages in a mutable variable.
mkSessionOpenModeTracer :: IORef [String] -> Tracer IO LSMTreeTrace
mkSessionOpenModeTracer var = Tracer $ emit $ \case
    TraceSession _ TraceOpenSession{} -> modifyIORef var ("Open" :)
    TraceSession _ TraceNewSession{} -> modifyIORef var ("New" :)
    TraceSession _ TraceRestoreSession{} -> modifyIORef var ("Restore" :)
    TraceSession _ TraceCreatedSession{} -> modifyIORef var ("Created" :)
    _ -> pure ()

{-------------------------------------------------------------------------------
  Session: happy path
-------------------------------------------------------------------------------}

prop_newSession_restoreSession_happyPath ::
     Positive (Small Int)
  -> V.Vector (Key, Value)
  -> Property
prop_newSession_restoreSession_happyPath (Positive (Small bufferSize)) ins =
    ioProperty $
    withTempIOHasBlockIO "prop_newSession_restoreSession_happyPath" $ \hfs hbio -> do
    withNewSession nullTracer hfs hbio testSalt (FS.mkFsPath []) $ \session1 ->
      withTableWith conf session1 $ \(table :: Table IO Key Value Blob) -> do
        inserts table $ V.map (\(k, v) -> (k, v, Nothing)) ins
        saveSnapshot "snap" "KeyValueBlob" table
    withRestoreSession nullTracer hfs hbio (FS.mkFsPath []) $ \session2 ->
      withTableFromSnapshot session2 "snap" "KeyValueBlob"
        $ \(_ :: Table IO Key Value Blob) -> pure ()
  where
    testSalt = 6
    conf = defaultTableConfig {
        confWriteBufferAlloc = AllocNumEntries bufferSize
      }

{-------------------------------------------------------------------------------
  Session: missing session directory
-------------------------------------------------------------------------------}

prop_sessionDirDoesNotExist :: NewOrRestore -> Property
prop_sessionDirDoesNotExist newOrRestore =
    ioProperty $
    withTempIOHasBlockIO "prop_sessionDirDoesNotExist" $ \hfs hbio -> do
    result <- try @SessionDirDoesNotExistError $
      withNewSessionOrRestoreSession
        newOrRestore
        nullTracer hfs hbio testSalt (FS.mkFsPath ["missing-dir"])
        $ \_session -> pure ()
    pure
      $ counterexample
          ("Expecting an ErrSessionDirDoesNotExist error, but got: " ++ show result)
      $ case result of
          Left ErrSessionDirDoesNotExist{} -> True
          _                                -> False
  where
    testSalt = 6

{-------------------------------------------------------------------------------
  Session: session directory already locked
-------------------------------------------------------------------------------}

prop_sessionDirLocked :: NewOrRestore -> Property
prop_sessionDirLocked newOrRestore =
    ioProperty $
    withTempIOHasBlockIO "prop_sessionDirLocked" $ \hfs hbio -> do
    result <-
      withNewSession nullTracer hfs hbio testSalt (FS.mkFsPath []) $ \_session1 -> do
        try @SessionDirLockedError $
          withNewSessionOrRestoreSession
            newOrRestore
            nullTracer hfs hbio testSalt (FS.mkFsPath [])
            $ \_session2 -> pure ()
    pure
      $ counterexample
          ("Expecting an ErrSessionDirLocked error, but got: " ++ show result)
      $ case result of
        Left ErrSessionDirLocked{} -> True
        _                          -> False
  where
    testSalt = 6

{-------------------------------------------------------------------------------
  Session: malformed session directory
-------------------------------------------------------------------------------}

prop_sessionDirCorrupted :: NewOrRestore -> Property
prop_sessionDirCorrupted newOrRestore =
    ioProperty $
    withTempIOHasBlockIO "sessionDirCorrupted" $ \hfs hbio -> do
    FS.createDirectory hfs (FS.mkFsPath ["unexpected-directory"])
    result <- try @SessionDirCorruptedError $
      withNewSessionOrRestoreSession
        newOrRestore
        nullTracer hfs hbio testSalt (FS.mkFsPath [])
        $ \_session -> pure ()
    pure
      $ counterexample
          ("Expecting an ErrSessionDirCorrupted error, but got: " ++ show result)
      $ case result of
        Left ErrSessionDirCorrupted{} -> True
        _                             -> False
  where
    testSalt = 6

{-------------------------------------------------------------------------------
  Session: salt
-------------------------------------------------------------------------------}

-- | When we call 'openSession' on an existing session directory, then the salt
-- value we pass in is ignored and the actual salt is restored from a metatada
-- file instead. This property verifies that we indeed ignore the salt value by
-- checking that lookups return the right results, which wouldn't happen if the
-- wrong salt was used.
--
-- NOTE: this only tests with /positive/ lookups, i.e., lookups for keys that
-- are known to exist in the tables.
prop_goodAndBadSessionSalt ::
     Positive (Small Int)
  -> V.Vector (Key, Value)
  -> Property
prop_goodAndBadSessionSalt (Positive (Small bufferSize)) ins =
    checkCoverage $
    ioProperty $
    withTempIOHasBlockIO "prop_sessionSalt" $ \hfs hbio -> do
      -- Open a session and create a snapshot for some arbitrary table contents
      withOpenSession nullTracer hfs hbio goodSalt sessionDir $ \session ->
        withTableWith conf session $ \(table :: Table IO Key Value Void) -> do
          inserts table $ V.map (\(k, v) -> (k, v, Nothing)) insWithoutDupKeys
          saveSnapshot "snap" "KeyValueBlob" table

      -- Determine the expected results of key lookups
      let
        expectedValues :: V.Vector (Maybe Value)
        expectedValues = V.map (Just . snd) insWithoutDupKeys

      -- Open the session using the good salt, open the snapshot, perform lookups
      goodSaltLookups <-
        withOpenSession nullTracer hfs hbio goodSalt sessionDir $ \session ->
          withTableFromSnapshot session "snap" "KeyValueBlob" $ \(table :: Table IO Key Value Void) -> do
            lookups table $ V.map fst insWithoutDupKeys

      -- Determine the result of key lookups using the good salt
      let
        goodSaltValues :: V.Vector (Maybe Value)
        goodSaltValues = V.map getValue goodSaltLookups

      -- Open the session using a bad salt, open the snapshot, perform lookups
      badSaltLookups <-
        withOpenSession nullTracer hfs hbio badSalt sessionDir $ \session ->
          withTableFromSnapshot session "snap" "KeyValueBlob" $ \(table :: Table IO Key Value Void) -> do
            lookups table $ V.map fst insWithoutDupKeys

      -- Determine the result of key lookups using a bad salt
      let
        badSaltValues :: V.Vector (Maybe Value)
        badSaltValues = V.map getValue badSaltLookups

      pure $
        tabulate "number of keys" [ showRangesOf 10 (V.length insWithoutDupKeys) ] $
        -- Regardless of whether the salt we passed to 'openSession' was a good
        -- or bad salt, the lookup results are correct.
        expectedValues === badSaltValues .&&.
        expectedValues === goodSaltValues
  where
    -- Duplicate keys in inserts make the property more complicated, because
    -- keys that are inserted /earlier/ (towards the head of the vector) are
    -- overridden by keys that are inserted /later/ (towards the tail of the
    -- vector). So, we remove duplicate keys instead
    insWithoutDupKeys :: V.Vector (Key, Value)
    insWithoutDupKeys = VA.nubBy (compare `on` fst) ins

    goodSalt :: Salt
    goodSalt = 17

    badSalt :: Salt
    badSalt = 19

    sessionDir = FS.mkFsPath []

    conf = defaultTableConfig {
        confWriteBufferAlloc = AllocNumEntries bufferSize
      }