lsm-tree-1.0.0.0: test/Test/Database/LSMTree/Internal/Index/Compact.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Test.Database.LSMTree.Internal.Index.Compact (tests) where
import Control.DeepSeq (deepseq)
import Control.Monad (foldM)
import Control.Monad.ST (runST)
import Control.Monad.State.Strict (MonadState (..), State, evalState,
get, put)
import Data.Bit (Bit (..))
import qualified Data.Bit as BV
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Short as SBS
import Data.Coerce (coerce)
import Data.Foldable (Foldable (..))
import Data.List.Split (chunksOf)
import qualified Data.Map.Merge.Strict as Merge
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Primitive.ByteArray (ByteArray (..), byteArrayFromList,
sizeofByteArray)
import qualified Data.Vector.Primitive as VP
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Base as VU
import Data.Word
import Database.LSMTree.Extras
import Database.LSMTree.Extras.Generators (ChunkSize (..),
LogicalPageSummaries, LogicalPageSummary (..), Pages (..),
genRawBytes, labelPages, toAppends)
import Database.LSMTree.Extras.Index (Append (..), appendToCompact)
import Database.LSMTree.Internal.BitMath
import Database.LSMTree.Internal.Chunk as Chunk (toByteString)
import Database.LSMTree.Internal.Entry (NumEntries (..))
import Database.LSMTree.Internal.Index.Compact
import Database.LSMTree.Internal.Index.CompactAcc
import Database.LSMTree.Internal.Page (PageNo (PageNo), PageSpan,
multiPage, singlePage)
import Database.LSMTree.Internal.RawBytes (RawBytes (..))
import qualified Database.LSMTree.Internal.RawBytes as RB
import Database.LSMTree.Internal.Serialise
import Numeric (showHex)
import Prelude hiding (max, min, pi)
import qualified Test.QuickCheck as QC
import Test.QuickCheck
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, testCase)
import Test.Tasty.QuickCheck (testProperty)
import Test.Util.Arbitrary (noTags,
prop_arbitraryAndShrinkPreserveInvariant)
import Test.Util.Orphans ()
import Text.Printf (printf)
tests :: TestTree
tests = testGroup "Test.Database.LSMTree.Internal.Index.Compact" [
testProperty "prop_distribution @TestKey" $
prop_distribution @TestKey
, testProperty "prop_searchMinMaxKeysAfterConstruction" $
prop_searchMinMaxKeysAfterConstruction @TestKey 100
, testProperty "prop_differentChunkSizesSameResults" $
prop_differentChunkSizesSameResults @TestKey
, testProperty "prop_singlesEquivMulti" $
prop_singlesEquivMulti @TestKey
, testGroup "(De)serialisation" [
testGroup "Chunks generator" $
prop_arbitraryAndShrinkPreserveInvariant noTags chunksInvariant
, testCase "index-2-clash" $ do
let k1 = SerialisedKey' (VP.replicate 16 0x00)
let k2 = SerialisedKey' (VP.replicate 16 0x11)
let k3 = SerialisedKey' (VP.replicate 15 0x11 <> VP.replicate 1 0x12)
let (chunks, index) = runST $ do
ica <- new 16
ch1 <- flip appendToCompact ica $ AppendSinglePage k1 k2
ch2 <- flip appendToCompact ica $ AppendSinglePage k3 k3
(mCh3, idx) <- unsafeEnd ica
pure (ch1 <> ch2 <> toList mCh3, idx)
let expectedVersion :: [Word8]
expectedVersion = word32toBytesLE 0x0000_0001 <> word32toBytesLE 0x0000_0000
let expectedPrimary :: [Word8]
expectedPrimary = foldMap word64toBytesLE
-- 1. primary array: two pages (64 bits LE)
[ 0x0000_0000_0000_0000, 0x1111_1111_1111_1111
]
let expectedRest :: [Word8]
expectedRest = foldMap word32toBytesLE
[ -- 3. clash indicator: two pages, second one has bit
0x0000_0002, 0
-- 4. larger-than-page: two pages, no bits
, 0, 0
-- 5. clash map: maps k3 to page 1
, 1, 0 {- size = 1 (64 bit LE) -}
, 1, 16 {- page 1, key size 16 byte -}
, 0x1111_1111, 0x1111_1111 {- k3 -}
, 0x1111_1111, 0x1211_1111
-- 6.1 number of pages in the primary array (64 bits LE)
, 2, 0
-- 6.2 number of keys (64bit LE)
, 7, 0
]
let header = LBS.unpack headerLBS
let primary = LBS.unpack $
LBS.fromChunks (map Chunk.toByteString chunks)
let rest = LBS.unpack (finalLBS (NumEntries 7) index)
let comparison msg xs ys = unlines $
(msg <> ":")
: zipWith (\x y -> x <> " | " <> y)
(showBytes xs <> repeat (replicate 17 '.'))
(showBytes ys)
assertEqual (comparison "header" expectedVersion header)
expectedVersion header
assertEqual (comparison "primary" expectedPrimary primary)
expectedPrimary primary
assertEqual (comparison "rest" expectedRest rest)
expectedRest rest
, testProperty "prop_roundtrip_chunks" $
prop_roundtrip_chunks
, testProperty "prop_roundtrip" $
prop_roundtrip @TestKey
, testProperty "prop_total_deserialisation" $ withMaxSuccess 10000
prop_total_deserialisation
, testProperty "prop_total_deserialisation_whitebox" $ withMaxSuccess 10000
prop_total_deserialisation_whitebox
]
]
{-------------------------------------------------------------------------------
Test key
-------------------------------------------------------------------------------}
-- | Key type for compact index tests
--
-- Tests outside this module don't have to worry about generating clashing keys.
-- We can assume that the compact index handles clashes correctly, because we
-- test this extensively in this module already.
newtype TestKey = TestKey RawBytes
deriving stock (Show, Eq, Ord)
deriving newtype SerialiseKey
-- | Generate keys with a non-neglible probability of clashes. This generates
-- sliced keys too.
--
-- Note: recall that keys /clash/ only if their primary bits (first 8 bytes)
-- match. It does not matter whether the other bytes do not match.
instance Arbitrary TestKey where
arbitrary = do
-- Generate primary bits from a relatively small distribution. This
-- ensures that we get clashes between keys with a non-negligible
-- probability.
primBits <- do
lastPrefixByte <- QC.getSmall <$> arbitrary
pure $ RB.pack ([0,0,0,0,0,0,0] <> [lastPrefixByte])
-- The rest of the bits after the primary bits can be anything
restBits <- genRawBytes
-- The compact index should store keys without retaining unused memory.
-- Therefore, we generate slices of keys too.
prefix <- elements [RB.pack [], RB.pack [0]]
suffix <- elements [RB.pack [], RB.pack [0]]
-- Combine the bytes and make sure to take out only the slice we need.
let bytes = prefix <> primBits <> restBits <> suffix
n = RB.size primBits + RB.size restBits
bytes' = RB.take n $ RB.drop (RB.size prefix) bytes
pure $ TestKey bytes'
-- Shrink keys extensively: most failures will occur in small counterexamples,
-- so we don't have to limit the number of shrinks as much.
shrink (TestKey bytes) = [
testkey'
| let RawBytes vec = bytes
, vec' <- VP.fromList <$> shrink (VP.toList vec)
, let testkey' = TestKey $ RawBytes vec'
]
{-------------------------------------------------------------------------------
Properties
-------------------------------------------------------------------------------}
--
-- Search
--
type CounterM a = State Int a
evalCounterM :: CounterM a -> Int -> a
evalCounterM = evalState
incrCounter :: CounterM Int
incrCounter = get >>= \c -> put (c+1) >> pure c
plusCounter :: Int -> CounterM Int
plusCounter n = get >>= \c -> put (c+n) >> pure c
-- | After construction, searching for the minimum/maximum key of every page
-- @pageNo@ returns the @pageNo@.
prop_searchMinMaxKeysAfterConstruction ::
forall k. (SerialiseKey k, Show k, Ord k)
=> ChunkSize
-> LogicalPageSummaries k
-> Property
prop_searchMinMaxKeysAfterConstruction csize ps = eqMapProp real model
where
model = evalCounterM (foldM modelSearch Map.empty $ getPages ps) 0
modelSearch :: Map k PageSpan -> LogicalPageSummary k -> CounterM (Map k PageSpan)
modelSearch m = \case
OnePageOneKey k -> do
c <- incrCounter
pure $ Map.insert k (singlePage (PageNo c)) m
OnePageManyKeys k1 k2 -> do
c <- incrCounter
pure $ Map.insert k1 (singlePage (PageNo c)) $ Map.insert k2 (singlePage (PageNo c)) m
MultiPageOneKey k n -> do
let incr = 1 + fromIntegral n
c <- plusCounter incr
pure $ if incr == 1 then Map.insert k (singlePage (PageNo c)) m
else Map.insert k (multiPage (PageNo c) (PageNo $ c + fromIntegral n)) m
real = foldMap' realSearch (getPages ps)
ic = fromPageSummaries (coerce csize) ps
realSearch :: LogicalPageSummary k -> Map k PageSpan
realSearch = \case
OnePageOneKey k -> Map.singleton k (search (serialiseKey k) ic)
OnePageManyKeys k1 k2 -> Map.fromList [ (k1, search (serialiseKey k1) ic)
, (k2, search (serialiseKey k2) ic)]
MultiPageOneKey k _ -> Map.singleton k (search (serialiseKey k) ic)
--
-- Construction
--
prop_differentChunkSizesSameResults ::
SerialiseKey k
=> ChunkSize
-> ChunkSize
-> LogicalPageSummaries k
-> Property
prop_differentChunkSizesSameResults csize1 csize2 ps =
fromPageSummaries csize1 ps === fromPageSummaries csize2 ps
-- | Constructing an index using only 'appendSingle' is equivalent to using a
-- mix of 'appendSingle' and 'appendMulti'.
prop_singlesEquivMulti ::
SerialiseKey k
=> ChunkSize
-> ChunkSize
-> LogicalPageSummaries k
-> Property
prop_singlesEquivMulti csize1 csize2 ps = ic1 === ic2
where
apps1 = toAppends ps
apps2 = concatMap toSingles apps1
ic1 = fromList (coerce csize1) apps1
ic2 = fromListSingles (coerce csize2) apps2
toSingles :: Append -> [(SerialisedKey, SerialisedKey)]
toSingles (AppendSinglePage k1 k2) = [(k1, k2)]
toSingles (AppendMultiPage k n) = replicate (fromIntegral n + 1) (k, k)
-- | Distribution of generated test data
prop_distribution :: SerialiseKey k => LogicalPageSummaries k -> Property
prop_distribution ps =
labelPages ps $ labelIndex (fromPageSummaries 100 ps) $ property True
-- Generate and serialise chunks directly.
-- This gives more direct shrinking of individual parts and has fewer invariants
-- (covering a larger space).
prop_roundtrip_chunks :: Chunks -> NumEntries -> Property
prop_roundtrip_chunks (Chunks chunks index) numEntries =
counterexample (show (SBS.length sbs) <> " bytes") $
counterexample ("header:\n" <> showBS bsVersion) $
counterexample ("primary:\n" <> showBS bsPrimary) $
counterexample ("rest:\n" <> showBS bsRest) $
Right (numEntries, index) === fromSBS sbs
where
bsVersion = headerLBS
bsPrimary = LBS.fromChunks $
map (Chunk.toByteString . word64VectorToChunk) chunks
bsRest = finalLBS numEntries index
sbs = SBS.toShort (LBS.toStrict (bsVersion <> bsPrimary <> bsRest))
showBS = unlines . showBytes . LBS.unpack
-- Generate the compact index from logical pages.
prop_roundtrip :: SerialiseKey k => ChunkSize -> LogicalPageSummaries k -> NumEntries -> Property
prop_roundtrip csize ps numEntries =
counterexample (show (SBS.length sbs) <> " bytes") $
counterexample ("header:\n" <> showBS bsVersion) $
counterexample ("primary:\n" <> showBS bsPrimary) $
counterexample ("rest:\n" <> showBS bsRest) $
Right (numEntries, index) === fromSBS sbs
where
index = fromPageSummaries csize ps
(bsVersion, bsPrimary, bsRest) = writeIndexCompact numEntries csize ps
sbs = SBS.toShort (LBS.toStrict (bsVersion <> bsPrimary <> bsRest))
showBS = unlines . showBytes . LBS.unpack
prop_total_deserialisation :: [Word32] -> Property
prop_total_deserialisation word32s =
let !(ByteArray ba) = byteArrayFromList word32s
in case fromSBS (SBS.SBS ba) of
Left err -> label err $ property True
Right (numEntries, ic) -> label "parsed successfully" $ property $
-- Just forcing the index is not enough. The underlying vectors might
-- point to outside of the byte array, so we check they are valid.
(numEntries, ic) `deepseq`
vec64IsValid (icPrimary ic)
&& bitVecIsValid (icClashes ic)
&& bitVecIsValid (icLargerThanPage ic)
where
vec64IsValid (VU.V_Word64 (VP.Vector off len ba)) =
off >= 0 && len >= 0 && mul8 (off + len) <= sizeofByteArray ba
bitVecIsValid (BV.BitVec off len ba) =
off >= 0 && len >= 0 && ceilDiv8 (off + len) <= sizeofByteArray ba
prop_total_deserialisation_whitebox :: Small Word16 -> Small Word16 -> [Word32] -> Property
prop_total_deserialisation_whitebox numEntries numPages word32s =
prop_total_deserialisation $
[1] -- version
<> word32s -- primary array, clash bits, LTP bits, clash map
<> [0 | even (length word32s)] -- padding
<> [ fromIntegral numPages, 0
, fromIntegral numEntries, 0
]
{-------------------------------------------------------------------------------
Util
-------------------------------------------------------------------------------}
writeIndexCompact :: SerialiseKey k => NumEntries -> ChunkSize -> LogicalPageSummaries k -> (LBS.ByteString, LBS.ByteString, LBS.ByteString)
writeIndexCompact numEntries (ChunkSize csize) ps = runST $ do
ica <- new csize
cs <- mapM (`appendToCompact` ica) (toAppends ps)
(c, index) <- unsafeEnd ica
pure
( headerLBS
, LBS.fromChunks $
foldMap (map Chunk.toByteString) $ cs <> pure (toList c)
, finalLBS numEntries index
)
fromPageSummaries :: SerialiseKey k => ChunkSize -> LogicalPageSummaries k -> IndexCompact
fromPageSummaries (ChunkSize csize) ps =
fromList csize (toAppends ps)
fromList :: Int -> [Append] -> IndexCompact
fromList maxcsize apps = runST $ do
ica <- new maxcsize
mapM_ (`appendToCompact` ica) apps
(_, index) <- unsafeEnd ica
pure index
-- | One-shot construction using only 'appendSingle'.
fromListSingles :: Int -> [(SerialisedKey, SerialisedKey)] -> IndexCompact
fromListSingles maxcsize apps = runST $ do
ica <- new maxcsize
mapM_ (`appendSingle` ica) apps
(_, index) <- unsafeEnd ica
pure index
labelIndex :: IndexCompact -> (Property -> Property)
labelIndex ic =
checkCoverage
. QC.tabulate "# Clashes" [showPowersOf 2 nclashes]
. QC.cover 60 (nclashes > 0) "Has clashes"
. QC.tabulate "# Contiguous clash runs" [showPowersOf 2 (length nscontig)]
. QC.cover 30 (not (null nscontig)) "Has contiguous clash runs"
. QC.tabulate "Length of contiguous clash runs" (fmap (showPowersOf 2 . snd) nscontig)
. QC.tabulate "Contiguous clashes contain multi-page values" (fmap (show . fst) nscontig)
. QC.cover 3 (any fst nscontig) "Has contiguous clashes that contain multi-page values"
. QC.cover 0.1 (multiPageValuesClash ic) "Has clashing multi-page values"
where nclashes = countClashes ic
nscontig = countContiguousClashes ic
multiPageValuesClash :: IndexCompact -> Bool
multiPageValuesClash ic
| VU.length (icClashes ic) < 3 = False
| otherwise = VU.any p $ VU.zip4 v1 v2 v3 v4
where
-- note: @i = j - 1@ and @k = j + 1@. This gives us a local view of a
-- triplet of contiguous LTP bits, and a C bit corresponding to the middle
-- of the triplet.
p (cj, ltpi, ltpj, ltpk) =
-- two multi-page values border each other
unBit ltpi && not (unBit ltpj) && unBit ltpk
-- and they clash
&& unBit cj
v1 = VU.tail (icClashes ic)
v2 = icLargerThanPage ic
v3 = VU.tail v2
v4 = VU.tail v3
-- Returns the number of entries and whether any multi-page values were in the
-- contiguous clashes
countContiguousClashes :: IndexCompact -> [(Bool, Int)]
countContiguousClashes ic = actualContigClashes
where
-- filtered is a list of maximal sub-vectors that have only only contiguous
-- clashes
zipped = VU.zip (icClashes ic) (icLargerThanPage ic)
grouped = VU.groupBy (\x y -> fst x == fst y) zipped
filtered = filter (VU.all (\(c, _ltp) -> c == Bit True)) grouped
-- clashes that are part of a multi-page value shouldn't be counted towards
-- the total number of /actual/ clashes. We only care about /actual/ clashes
-- if they total more than 1 (otherwise it's just a single clash)
actualContigClashes = filter (\(_, n) -> n > 1) $
fmap (\v -> (countLTP v > 0, countC v - countLTP v)) filtered
countC = VU.length
countLTP = BV.countBits . VU.map snd
-- | Point-wise equality test for two maps, returning counterexamples for each
-- mismatch.
eqMapProp :: (Ord k, Eq v, Show k, Show v) => Map k v -> Map k v -> Property
eqMapProp m1 m2 = conjoin . Map.elems $
Merge.merge
(Merge.mapMissing onlyLeft)
(Merge.mapMissing onlyRight)
(Merge.zipWithMatched both)
m1
m2
where
onlyLeft k x = flip counterexample (property False) $
printf "Key-value pair only on the left, (k, x) = (%s, %s)" (show k) (show x)
onlyRight k y = flip counterexample (property False) $
printf "Key-value pair only on the right, (k, y) = (%s, %s)" (show k) (show y)
both k x y = flip counterexample (property (x == y)) $
printf "Mismatch between x and y, (k, x, y) = (%s, %s, %s)" (show k) (show x) (show y)
showBytes :: [Word8] -> [String]
showBytes = map (unwords . map (foldMap showByte) . chunksOf 4) . chunksOf 8
showByte :: Word8 -> String
showByte b = let str = showHex b "" in replicate (2 - length str) '0' <> str
word32toBytesLE :: Word32 -> [Word8]
word32toBytesLE = take 4 . map fromIntegral . iterate (`div` 256)
word64toBytesLE :: Word64 -> [Word8]
word64toBytesLE = take 8 . map fromIntegral . iterate (`div` 256)
data Chunks = Chunks [VU.Vector Word64] IndexCompact
deriving stock (Show)
-- | The concatenated chunks must correspond to the primary array of the index.
-- Apart from that, the only invariant we make sure to uphold is that the length
-- of the vectors match each other, as this is required for correct
-- deserialisation. These invariants do not guarantee that the 'IndexCompact' is
-- valid in other ways (e.g. can successfully be queried).
chunksInvariant :: Chunks -> Bool
chunksInvariant (Chunks chunks IndexCompact {..}) =
VU.length icPrimary == sum (map VU.length chunks)
&& VU.length icClashes == VU.length icPrimary
&& VU.length icLargerThanPage == VU.length icPrimary
instance Arbitrary Chunks where
arbitrary = do
chunks <- map VU.fromList <$> arbitrary
let icPrimary = mconcat chunks
let numPages = VU.length icPrimary
icClashes <- VU.fromList . map Bit <$> vector numPages
icLargerThanPage <- VU.fromList . map Bit <$> vector numPages
icTieBreaker <- arbitrary
pure (Chunks chunks IndexCompact {..})
shrink (Chunks chunks index) =
-- shrink number of pages
[ Chunks chunks' index
{ icPrimary = primary'
, icClashes = VU.slice 0 numPages' (icClashes index)
, icLargerThanPage = VU.slice 0 numPages' (icLargerThanPage index)
}
| chunks' <- shrink chunks
, let primary' = mconcat chunks'
, let numPages' = VU.length primary'
] ++
-- shrink tie breaker
[ Chunks chunks index
{ icTieBreaker = tieBreaker'
}
| tieBreaker' <- shrink (icTieBreaker index)
]