ppad-lmdb-0.2.0: test/Main.hs
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Concurrent
( forkIO
, isCurrentThreadBound
, newEmptyMVar
, putMVar
, rtsSupportsBoundThreads
, takeMVar
, yield
)
import qualified Control.Exception as E
import Control.Monad (forM, forM_, replicateM_)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import qualified Data.Map.Strict as Map
import qualified Database.LMDB as L
import System.Directory
( createDirectory
, getTemporaryDirectory
, removeDirectoryRecursive
)
import System.FilePath ((</>))
import System.IO.Error (isAlreadyExistsError)
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck as QC
main :: IO ()
main = defaultMain $ testGroup "ppad-lmdb"
[ unitTests
, propTests
]
-- helpers --------------------------------------------------------------------
-- | Run an action against a fresh temporary directory. The directory
-- is unique to the call, so tests may run concurrently, and is
-- removed afterwards even if the action throws.
withTmpDir :: (FilePath -> IO a) -> IO a
withTmpDir k = do
tmpRoot <- getTemporaryDirectory
E.bracket (fresh tmpRoot (0 :: Int)) removeDirectoryRecursive k
where
fresh root n = do
let dir = root </> ("ppad-lmdb-test-" ++ show n)
r <- E.try (createDirectory dir)
case r of
Right () -> pure dir
Left e
| isAlreadyExistsError e -> fresh root (n + 1)
| otherwise -> E.throwIO e
withTmpEnv :: L.EnvFlags -> (L.Env -> IO a) -> IO a
withTmpEnv envFlags k = withTmpDir $ \dir -> L.withEnv dir envFlags k
-- | 'withTmpEnv', with the default database opened.
withTmpDb :: L.EnvFlags -> (L.Env -> L.Dbi -> IO a) -> IO a
withTmpDb envFlags k = withTmpEnv envFlags $ \env ->
L.openDbi env Nothing True >>= k env
-- shared bigger map for tests
flags :: L.EnvFlags
flags = L.defaultEnvFlags
{ L.envMapSize = 64 * 1024 * 1024
, L.envMaxDbs = 4
}
writeKV
:: L.Env -> L.Dbi -> [(BS.ByteString, BS.ByteString)] -> IO ()
writeKV env dbi kvs = L.withWriteTxn env $ \txn ->
forM_ kvs $ \(k, v) -> L.put txn dbi k v
readKey :: L.Env -> L.Dbi -> BS.ByteString -> IO (Maybe BS.ByteString)
readKey env dbi k = L.withReadTxn env $ \txn -> L.get txn dbi k
-- | Run each action in its own forkIO thread and wait for all of
-- them, rethrowing the first failure.
forkAll :: [IO ()] -> IO ()
forkAll acts = do
dones <- forM acts $ \act -> do
done <- newEmptyMVar
_ <- forkIO $
(E.try act :: IO (Either E.SomeException ())) >>= putMVar done
pure done
rs <- mapM takeMVar dones
either E.throwIO pure (sequence_ rs)
-- unit tests -----------------------------------------------------------------
unitTests :: TestTree
unitTests = testGroup "unit"
[ testCase "put then get roundtrip" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [("hello", "world")]
v <- L.withReadTxn env $ \txn ->
L.get txn dbi "hello"
v @?= Just "world"
, testCase "get on missing key returns Nothing" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [("a", "1")]
v <- L.withReadTxn env $ \txn ->
L.get txn dbi "absent"
v @?= Nothing
, testCase "del returns True only when key existed" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [("a", "1"), ("b", "2")]
(existed, repeated, missing) <- L.withWriteTxn env $ \txn -> do
e <- L.del txn dbi "a"
e2 <- L.del txn dbi "a"
m <- L.del txn dbi "never-existed"
pure (e, e2, m)
existed @?= True
repeated @?= False
missing @?= False
, testCase "write txn aborted on exception" $
withTmpDb flags $ \env dbi -> do
-- attempt that throws after a write
let attempt = L.withWriteTxn env $ \txn -> do
L.put txn dbi "k" "v"
E.throwIO (userError "boom")
r <- E.try attempt :: IO (Either IOError ())
case r of
Left _ -> pure ()
Right _ -> assertFailure "expected the user error"
-- key must not be visible to a fresh read txn
v <- L.withReadTxn env $ \txn ->
L.get txn dbi "k"
v @?= Nothing
, testCase "failed commit leaves env usable" $
withTmpDb flags { L.envMapSize = 1024 * 1024 } $ \env dbi -> do
-- a caught MapFull poisons the txn, so its commit must fail
r <- E.try $ L.withWriteTxn env $ \txn -> do
full <- E.try $ forM_ [0 :: Int ..] $ \i ->
L.put txn dbi (BSC.pack (show i)) (BS.replicate 512 0)
case full of
Left L.LMDBMapFull -> pure ()
_ -> assertFailure "expected LMDBMapFull"
case r of
Left (L.LMDBOther (-30782) _) -> pure () -- MDB_BAD_TXN
_ -> assertFailure ("expected MDB_BAD_TXN, got " ++ show r)
forM_ [1 :: Int .. 3] $ \_ -> writeKV env dbi [("k", "v")]
v <- L.withReadTxn env $ \txn ->
L.get txn dbi "k"
v @?= Just "v"
, testCase "dbi name is used exactly as given" $
withTmpEnv flags $ \env -> do
-- a slice: the bytes after it are not a NUL terminator
_ <- L.openDbi env (Just (BS.take 3 "foobar")) True
r <- E.try (L.openDbi env (Just "foobar") False)
case r of
Left L.LMDBNotFound -> pure ()
_ -> assertFailure "opened a database that was never created"
_ <- L.openDbi env (Just "foo") False
pure ()
, testCase "dbi name containing NUL is rejected" $
withTmpEnv flags $ \env -> do
r <- E.try (L.openDbi env (Just "foo\0bar") True)
case r of
Left (L.LMDBOther 22 _) -> pure () -- EINVAL
_ -> assertFailure "expected EINVAL"
, testCase "named databases are independent" $
withTmpEnv flags $ \env -> do
a <- L.openDbi env (Just "a") True
b <- L.openDbi env (Just "b") True
L.withWriteTxn env $ \txn -> do
L.put txn a "k" "1"
L.put txn b "k" "2"
vs <- L.withReadTxn env $ \txn ->
(,) <$> L.get txn a "k" <*> L.get txn b "k"
vs @?= (Just "1", Just "2")
, testCase "concurrent openDbi calls" $
withTmpEnv flags $ \env ->
forkAll
[ do dbi <- L.openDbi env (Just name) True
writeKV env dbi [("k", name)]
v <- readKey env dbi "k"
v @?= Just name
| name <- ["w", "x", "y", "z"] ]
, testCase "read-only env serves reads, refuses writes" $
withTmpDir $ \dir -> do
L.withEnv dir flags $ \env -> do
dbi <- L.openDbi env Nothing True
writeKV env dbi [("k", "v")]
L.withEnv dir flags { L.envReadOnly = True } $ \env -> do
dbi <- L.openDbi env Nothing False
v <- readKey env dbi "k"
v @?= Just "v"
w <- E.try (L.withWriteTxn env (\_ -> pure ()))
case w of
Left (L.LMDBOther 13 _) -> pure () -- EACCES
_ -> assertFailure "expected EACCES from withWriteTxn"
c <- E.try (L.openDbi env (Just "new") True)
case c of
Left (L.LMDBOther 13 _) -> pure ()
_ -> assertFailure "expected EACCES from openDbi"
, testCase "read txns nest" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [("k", "v")]
v <- L.withReadTxn env $ \_ -> readKey env dbi "k"
v @?= Just "v"
, testCase "concurrent readers see their own snapshots" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [("k", "old")]
entered <- newEmptyMVar
written <- newEmptyMVar
seen <- newEmptyMVar
forkAll
[ L.withReadTxn env $ \txn -> do
putMVar entered ()
takeMVar written
L.get txn dbi "k" >>= putMVar seen
, do takeMVar entered
-- a second reader, while the first is live
old <- readKey env dbi "k"
old @?= Just "old"
writeKV env dbi [("k", "new")]
putMVar written ()
]
stale <- takeMVar seen
stale @?= Just "old"
fresh <- readKey env dbi "k"
fresh @?= Just "new"
, testCase "concurrent writers serialize" $
withTmpDb flags { L.envNoSync = True } $ \env dbi -> do
let bump = L.withWriteTxn env $ \txn -> do
n <- maybe 0 (maybe 0 fst . BSC.readInt)
<$> L.get txn dbi "n"
yield -- invite another writer in mid-txn
L.put txn dbi "n" (BSC.pack (show (n + 1)))
forkAll (replicate 8 (replicateM_ 50 bump))
n <- readKey env dbi "n"
n @?= Just "400"
, testCase "write txns run bound where the RTS allows" $
withTmpEnv flags $ \env -> do
bound <- newEmptyMVar
forkAll
[ L.withWriteTxn env (\_ -> isCurrentThreadBound)
>>= putMVar bound ]
b <- takeMVar bound
b @?= rtsSupportsBoundThreads
, testCase "open rejects out-of-range flags" $
forM_ [ flags { L.envMapSize = -1 }
, flags { L.envMaxDbs = -1 }
, flags { L.envMaxDbs = 2 ^ (32 :: Int) }
] $ \bad -> do
r <- E.try (withTmpEnv bad (\_ -> pure ()))
case r of
Left (L.LMDBOther 22 _) -> pure () -- EINVAL
_ -> assertFailure ("expected EINVAL for " ++ show bad)
, testCase "sync flushes a no-sync env" $
withTmpDb flags { L.envNoSync = True } $ \env dbi -> do
writeKV env dbi [("k", "v")]
L.sync env
, testCase "cursor walks keys in ascending order" $
withTmpDb flags $ \env dbi -> do
let kvs = [ (BSC.pack [c], BSC.pack [c])
| c <- "abcdefghij" ]
writeKV env dbi kvs
seen <- L.withReadTxn env $ \txn ->
L.withCursor txn dbi (cursorWalk [])
seen @?= kvs
, testCase "cursorSeek lands on first >= key" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [ ("a","1"), ("c","3"), ("e","5") ]
r <- L.withReadTxn env $ \txn ->
L.withCursor txn dbi $ \cur ->
L.cursorSeek cur "b"
r @?= Just ("c", "3")
, testCase "cursorSeek past the last key finds nothing" $
withTmpDb flags $ \env dbi -> do
writeKV env dbi [ ("a","1"), ("c","3") ]
r <- L.withReadTxn env $ \txn ->
L.withCursor txn dbi $ \cur ->
L.cursorSeek cur "d"
r @?= Nothing
, testCase "cursor walks keys in descending order" $
withTmpDb flags $ \env dbi -> do
let kvs = [ (BSC.pack [c], BSC.pack [c])
| c <- "abcdefghij" ]
writeKV env dbi kvs
seen <- L.withReadTxn env $ \txn ->
L.withCursor txn dbi $ \cur -> do
let go acc Nothing = pure (reverse acc)
go acc (Just kv) = L.cursorPrev cur >>= go (kv : acc)
L.cursorLast cur >>= go []
seen @?= reverse kvs
, testCase "cursors on an empty database find nothing" $
withTmpDb flags $ \env dbi -> do
r <- L.withReadTxn env $ \txn ->
L.withCursor txn dbi $ \cur ->
(,,) <$> L.cursorFirst cur
<*> L.cursorLast cur
<*> L.cursorSeek cur "a"
r @?= (Nothing, Nothing, Nothing)
]
-- walk a cursor, accumulating pairs from current position to end
cursorWalk
:: [(BS.ByteString, BS.ByteString)]
-> L.Cursor s
-> IO [(BS.ByteString, BS.ByteString)]
cursorWalk acc cur = do
step <- L.cursorFirst cur
go (reverse acc) step
where
go ys Nothing = pure (reverse ys)
go ys (Just kv) = do
next <- L.cursorNext cur
go (kv : ys) next
-- properties -----------------------------------------------------------------
newtype Key = Key BS.ByteString deriving Show
newtype Val = Val BS.ByteString deriving Show
-- LMDB rejects empty keys; restrict to non-empty bounded strings.
instance Arbitrary Key where
arbitrary = do
n <- QC.chooseInt (1, 32)
Key . BS.pack <$> QC.vectorOf n QC.arbitrary
instance Arbitrary Val where
arbitrary = do
n <- QC.chooseInt (0, 64)
Val . BS.pack <$> QC.vectorOf n QC.arbitrary
propTests :: TestTree
propTests = testGroup "props"
[ QC.testProperty "put / get roundtrip" $ \(Key k) (Val v) ->
QC.ioProperty $ withTmpDb flags $ \env dbi -> do
writeKV env dbi [(k, v)]
got <- L.withReadTxn env $ \txn ->
L.get txn dbi k
pure (got == Just v)
, QC.testProperty "cursor enumerates Map.toAscList" $ \kvs0 ->
let kvs = uniqueKeys [(k, v) | (Key k, Val v) <- kvs0]
in QC.ioProperty $ withTmpDb flags $ \env dbi -> do
writeKV env dbi kvs
seen <- L.withReadTxn env $ \txn ->
L.withCursor txn dbi (cursorWalk [])
pure (seen == Map.toAscList (Map.fromList kvs))
]
-- keep only the last write for each key, matching LMDB's overwrite
-- semantics under MDB_NOOVERWRITE-not-set
uniqueKeys
:: [(BS.ByteString, BS.ByteString)]
-> [(BS.ByteString, BS.ByteString)]
uniqueKeys = Map.toList . Map.fromList