packages feed

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

{-# LANGUAGE BlockArguments    #-}
{-# LANGUAGE OverloadedStrings #-}

module Test.Database.LSMTree.Class (
    tests
  ) where
import           Control.Exception (SomeException, assert, try)
import           Control.Monad (forM, when)
import           Control.Monad.ST.Strict (runST)
import           Control.Monad.Trans.State
import qualified Data.ByteString as BS
import           Data.Foldable (toList)
import           Data.Functor.Compose (Compose (..))
import qualified Data.List as List
import qualified Data.List.NonEmpty as NE
import qualified Data.Proxy as Proxy
import qualified Data.Vector as V
import qualified Data.Vector.Algorithms.Merge as VA
import           Data.Word (Word64)
import qualified Database.LSMTree as R
import           Database.LSMTree.Class
import           Database.LSMTree.Extras.Generators ()
import qualified Database.LSMTree.Model.IO as ModelIO
import qualified System.FS.API as FS
import           Test.Database.LSMTree.StateMachine ()
import           Test.QuickCheck.Monadic (monadicIO, monitor, run)
import           Test.Tasty (TestName, TestTree, testGroup)
import qualified Test.Tasty.QuickCheck as QC
import           Test.Tasty.QuickCheck hiding (label)
import qualified Test.Util.FS as FS

tests :: TestTree
tests = testGroup "Test.Database.LSMTree.Class"
    [ testGroup "Model" $ zipWith ($) (props tbl1) expectFailures1
    , testGroup "Real"  $ zipWith ($) (props tbl2) expectFailures2
    ]
  where
    tbl1 :: RunSetup ModelIO.Table IO
    tbl1 = RunSetup $ \conf -> Setup {
          testTableConfig = conf
        , testWithSessionArgs = \action -> action ModelIO.NoSessionArgs
        }

    expectFailures1 = repeat False

    tbl2 :: RunSetup R.Table IO
    tbl2 = RunSetup $ \conf -> Setup {
          testTableConfig = conf
        , testWithSessionArgs = \action ->
            FS.withTempIOHasBlockIO "R" $ \hfs hbio ->
              action (SessionArgs hfs hbio (FS.mkFsPath []))
        }

    expectFailures2 = repeat False

    props RunSetup {..} =
      [ testProperty' "lookup-insert" $ prop_lookupInsert . runSetup
      , testProperty' "lookup-insert-else" $ prop_lookupInsertElse . runSetup
      , testProperty' "lookup-insert-blob" $ prop_lookupInsertBlob . runSetup
      , testProperty' "lookup-delete" $ prop_lookupDelete . runSetup
      , testProperty' "lookup-delete-else" $ prop_lookupDeleteElse . runSetup
      , testProperty' "insert-insert" $ prop_insertInsert . runSetup
      , testProperty' "insert-insert-blob" $ prop_insertInsertBlob . runSetup
      , testProperty' "insert-commutes" $ prop_insertCommutes . runSetup
      , testProperty' "insert-commutes-blob" $ prop_insertCommutesBlob . runSetup
      , testProperty' "invalidated-blob-references" $ prop_updatesMayInvalidateBlobRefs . runSetup
      , testProperty' "dup-insert-insert" $ prop_dupInsertInsert . runSetup
      , testProperty' "dup-insert-comm" $ prop_dupInsertCommutes . runSetup
      , testProperty' "dup-nochanges" $ prop_dupNoChanges . runSetup
      , testProperty' "lookupRange-like-lookups" $ prop_lookupRangeLikeLookups . runSetup
      , testProperty' "lookupRange-insert" $ prop_insertLookupRange . runSetup
      , testProperty' "readCursor-sorted" $ prop_readCursorSorted . runSetup
      , testProperty' "readCursor-num-results" $ prop_readCursorNumResults . runSetup
      , testProperty' "readCursor-insert" $ prop_readCursorInsert . runSetup
      , testProperty' "readCursor-delete" $ prop_readCursorDelete . runSetup
      , testProperty' "readCursor-delete-else" $ prop_readCursorDeleteElse . runSetup
      , testProperty' "readCursor-stable-view" $ prop_readCursorStableView . runSetup
      , testProperty' "readCursor-offset" $ prop_readCursorOffset . runSetup
      , testProperty' "snapshot-nochanges" $ prop_snapshotNoChanges . runSetup
      , testProperty' "snapshot-nochanges2" $ prop_snapshotNoChanges2 . runSetup
      , testProperty' "lookup-mupsert" $ prop_lookupUpdate . runSetup
      , testProperty' "union" $ prop_union . runSetup
      ]

testProperty' :: forall a. Testable a => TestName -> a -> Bool -> TestTree
testProperty' name prop = \b ->
  testProperty name ((if b then expectFailure else property) prop)

-------------------------------------------------------------------------------
-- test setup and helpers
-------------------------------------------------------------------------------

type Key = Word64
type Blob = BS.ByteString

newtype Value = Value BS.ByteString
  deriving stock (Eq, Show)
  deriving newtype (Arbitrary, Semigroup, SerialiseValue)
  deriving ResolveValue via (R.ResolveViaSemigroup Value)

label :: SnapshotLabel
label = SnapshotLabel "Word64 ByteString ByteString"

type Proxy h = Setup h IO

newtype RunSetup h m = RunSetup {
    runSetup :: TableConfig h -> Setup h m
  }

data Setup h m = Setup {
    testTableConfig     :: TableConfig h
  , testWithSessionArgs :: forall a. (SessionArgs (Session h) m -> m a) -> m a
  }

-- | create session, table, and populate it with some data.
withSessionAndTableNew :: forall h m a.
     ( IsTable h
     , IOLike m
     )
  => Setup h m
  -> [(Key, Update Value Blob)]
  -> (Session h m -> h m Key Value Blob -> m a)
  -> m a
withSessionAndTableNew Setup{..} ups action =
    testWithSessionArgs $ \args ->
      withSession args $ \sesh ->
        withTableNew sesh testTableConfig $ \table -> do
          updates table (V.fromList ups)
          action sesh table

-- | Like 'retrieveBlobs' but works for any 'Traversable'.
--
-- Like 'partsOf' in @lens@ this uses state monad.
retrieveBlobsTrav ::
     ( IsTable h
     , IOLike m
     , SerialiseValue b
     , Traversable t
     , C_ b
     )
  => proxy h
  -> Session h m
  -> t (BlobRef h m b)
  -> m (t b)
retrieveBlobsTrav tbl ses brefs = do
  blobs <- retrieveBlobs tbl ses (V.fromList $ toList brefs)
  evalStateT (traverse (\_ -> state un) brefs) (V.toList blobs)
  where
    un []     = error "invalid traversal"
    un (x:xs) = (x, xs)

lookupsWithBlobs :: forall h m k v b.
     ( IsTable h
     , IOLike m
     , C k v b
     )
  => h m k v b
  -> Session h m
  -> V.Vector k
  -> m (V.Vector (LookupResult v b))
lookupsWithBlobs tbl ses ks = do
    res <- lookups tbl ks
    getCompose <$> retrieveBlobsTrav (Proxy.Proxy @h) ses (Compose res)

rangeLookupWithBlobs :: forall h m k v b.
     ( IsTable h
     , IOLike m
     , C k v b
     )
  => h m k v b
  -> Session h m
  -> Range k
  -> m (V.Vector (Entry k v b))
rangeLookupWithBlobs tbl ses r = do
    res <- rangeLookup tbl r
    getCompose <$> retrieveBlobsTrav (Proxy.Proxy @h) ses (Compose res)

readCursorWithBlobs :: forall h m k v b proxy.
     ( IsTable h
     , IOLike m
     , C k v b
     )
  => proxy h
  -> Session h m
  -> Cursor h m k v b
  -> Int
  -> m (V.Vector (Entry k v b))
readCursorWithBlobs tbl ses cursor n = do
    res <- readCursor tbl n cursor
    getCompose <$> retrieveBlobsTrav tbl ses (Compose res)

readCursorAllWithBlobs :: forall h m k v b proxy.
     ( IsTable h
     , IOLike m
     , C k v b
     )
  => proxy h
  -> Session h m
  -> Cursor h m k v b
  -> CursorReadSchedule
  -> m [V.Vector (Entry k v b)]
readCursorAllWithBlobs tbl ses cursor = go . getCursorReadSchedule
  where
    go [] = error "readCursorAllWithBlobs: finite infinite list"
    go (n : ns) = do
      res <- readCursorWithBlobs tbl ses cursor n
      if V.null res
        then pure [res]
        else (res :) <$> go ns

type CursorReadSchedule = InfiniteList (Positive Int)

getCursorReadSchedule :: CursorReadSchedule -> [Int]
getCursorReadSchedule = map getPositive . getInfiniteList

-------------------------------------------------------------------------------
-- implement classic QC tests for basic k/v properties
-------------------------------------------------------------------------------

-- | You can lookup what you inserted.
prop_lookupInsert ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Property
prop_lookupInsert h ups k v = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do

      -- the main dish
      inserts tbl (V.singleton (k, v, Nothing))
      res <- lookupsWithBlobs tbl ses (V.singleton k)

      pure $ res === V.singleton (Found v)

-- | Insert doesn't change the lookup results of other keys.
prop_lookupInsertElse ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key  -> Value -> [Key] -> Property
prop_lookupInsertElse h ups k v testKeys = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do

      let testKeys' = V.fromList $ filter (/= k) testKeys
      res1 <- lookupsWithBlobs tbl ses testKeys'
      inserts tbl (V.singleton (k, v, Nothing))
      res2 <-  lookupsWithBlobs tbl ses testKeys'

      pure $ res1 === res2

-- | You cannot lookup what you have just deleted
prop_lookupDelete ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Property
prop_lookupDelete h ups k = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      deletes tbl (V.singleton k)
      res <- lookupsWithBlobs tbl ses (V.singleton k)
      pure $ res === V.singleton NotFound

-- | Delete doesn't change the lookup results of other keys
prop_lookupDeleteElse ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key  -> [Key] -> Property
prop_lookupDeleteElse h ups k testKeys = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do

      let testKeys' = V.fromList $ filter (/= k) testKeys
      res1 <- lookupsWithBlobs tbl ses testKeys'
      deletes tbl (V.singleton k)
      res2 <-  lookupsWithBlobs tbl ses testKeys'

      pure $ res1 === res2

-- | Last insert wins.
prop_insertInsert ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Value -> Property
prop_insertInsert h ups k v1 v2 = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      inserts tbl (V.fromList [(k, v1, Nothing), (k, v2, Nothing)])
      res <- lookupsWithBlobs tbl ses (V.singleton k)
      pure $ res === V.singleton (Found v2)

-- | Inserts with different keys don't interfere.
prop_insertCommutes ::
       IsTable h
    => Proxy h -> [(Key, Update Value Blob)]
    -> Key -> Value -> Key -> Value -> Property
prop_insertCommutes h ups k1 v1 k2 v2 = k1 /= k2 ==> ioProperty do
    withSessionAndTableNew h ups $ \ses tbl -> do
      inserts tbl (V.fromList [(k1, v1, Nothing), (k2, v2, Nothing)])

      res <- lookupsWithBlobs tbl ses (V.fromList [k1,k2])
      pure $ res === V.fromList [Found v1, Found v2]

-------------------------------------------------------------------------------
-- implement classic QC tests for cursors
-------------------------------------------------------------------------------

-- | Cursor read results are sorted by key.
prop_readCursorSorted ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Maybe Key
  -> CursorReadSchedule
  -> Property
prop_readCursorSorted h ups offset ns = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      res <- withCursor offset tbl $ \cursor -> do
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      let keys = map queryResultKey (V.toList res)
      pure $ keys === List.sort keys

-- | Cursor reads return the requested number of results, until the end.
prop_readCursorNumResults ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Maybe Key
  -> CursorReadSchedule
  -> Property
prop_readCursorNumResults h ups offset ns = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      res <- withCursor offset tbl $ \cursor -> do
        readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      let elemsRead = map V.length res
      let numFullReads = length res - 2
      pure $ last elemsRead === 0
          .&&. take numFullReads elemsRead
           === take numFullReads (getCursorReadSchedule ns)

-- | You can read what you inserted.
prop_readCursorInsert ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> CursorReadSchedule
  -> Key -> Value -> Property
prop_readCursorInsert h ups ns k v = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      inserts tbl (V.singleton (k, v, Nothing))
      res <- withCursor Nothing tbl $ \cursor ->
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      pure $ V.find (\r -> queryResultKey r == k) res
           === Just (Entry k v)

-- | You can't read what you deleted.
prop_readCursorDelete ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> CursorReadSchedule
  -> Key -> Property
prop_readCursorDelete h ups ns k = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      deletes tbl (V.singleton k)
      res <- withCursor Nothing tbl $ \cursor -> do
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      pure $ V.find (\r -> queryResultKey r == k) res === Nothing

-- | Updates don't change the cursor read results of other keys.
prop_readCursorDeleteElse ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Maybe Key
  -> CursorReadSchedule
  -> [(Key, Update Value Blob)] -> Property
prop_readCursorDeleteElse h ups offset ns ups2 = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      res1 <- withCursor offset tbl $ \cursor -> do
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      updates tbl (V.fromList ups2)
      res2 <- withCursor offset tbl $ \cursor -> do
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      let updatedKeys = map fst ups2
      pure $ V.filter (\r -> queryResultKey r `notElem` updatedKeys) res1
           === V.filter (\r -> queryResultKey r `notElem` updatedKeys) res2

-- | Updates don't affect previously created cursors.
prop_readCursorStableView ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Maybe Key
  -> CursorReadSchedule
  -> [(Key, Update Value Blob)] -> Property
prop_readCursorStableView h ups offset ns ups2 = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      res1 <- withCursor offset tbl $ \cursor -> do
        readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      res2 <- withCursor offset tbl $ \cursor -> do
        updates tbl (V.fromList ups2)
        readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      pure $ res1 === res2

-- | Creating a cursor at an offset simply skips a prefix.
prop_readCursorOffset ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key
  -> CursorReadSchedule
  -> Property
prop_readCursorOffset h ups offset ns = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      res1 <- withCursor (Just offset) tbl $ \cursor -> do
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      res2 <- withCursor Nothing tbl $ \cursor -> do
        V.concat <$> readCursorAllWithBlobs (Proxy.Proxy @h) ses cursor ns
      pure $ res1 === V.dropWhile ((< offset) . queryResultKey) res2

-------------------------------------------------------------------------------
-- implement classic QC tests for range lookups
-------------------------------------------------------------------------------

evalRange :: Ord k => Range k -> k -> Bool
evalRange (FromToExcluding lo hi) x = lo <= x && x < hi
evalRange (FromToIncluding lo hi) x = lo <= x && x <= hi

queryResultKey :: Entry k v b -> k
queryResultKey (Entry k _)             = k
queryResultKey (EntryWithBlob k _ _  ) = k

queryResultFromLookup :: k -> LookupResult v b -> Maybe (Entry k v b)
queryResultFromLookup k = \case
   NotFound -> Nothing
   Found v -> Just (Entry k v)
   FoundWithBlob v b -> Just (EntryWithBlob k v b)

-- | A range lookup behaves like many point lookups.
prop_lookupRangeLikeLookups ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Range Key
  -> Property
prop_lookupRangeLikeLookups h ups r = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      res1 <- rangeLookupWithBlobs tbl ses r

      let testKeys = V.fromList $ nubSort $ filter (evalRange r) $ map fst ups
      res2 <- fmap (V.catMaybes . V.zipWith queryResultFromLookup testKeys) $
        lookupsWithBlobs tbl ses testKeys

      pure $ res1 === res2

  where
    nubSort = map NE.head . NE.group . List.sort

-- | Last insert wins.
prop_insertLookupRange ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Range Key  -> Property
prop_insertLookupRange h ups k v r = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do

      res <- rangeLookupWithBlobs tbl ses r

      inserts tbl (V.singleton (k, v, Nothing))

      res' <- rangeLookupWithBlobs tbl ses r

      let p :: Entry Key Value b -> Bool
          p rlr = queryResultKey rlr /= k

      if evalRange r k
      then pure $ vsortOn queryResultKey (V.cons (Entry k v) (V.filter p res)) === res'
      else pure $ res === res'

  where
    vsortOn f vec = runST $ do
        mvec <- V.thaw vec
        VA.sortBy (\e1 e2 -> f e1 `compare` f e2) mvec
        V.unsafeFreeze mvec

-------------------------------------------------------------------------------
-- implement classic QC tests for split-value BLOB retrieval
-------------------------------------------------------------------------------

-- | You can lookup what you inserted.
prop_lookupInsertBlob ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key  -> Value -> Blob -> Property
prop_lookupInsertBlob h ups k v blob = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do

      -- the main dish
      inserts tbl (V.singleton (k, v, Just blob))
      res <- lookupsWithBlobs tbl ses (V.singleton k)

      pure $ res === V.singleton (FoundWithBlob v blob)

-- | Last insert wins.
prop_insertInsertBlob ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Value -> Maybe Blob -> Maybe Blob -> Property
prop_insertInsertBlob h ups k v1 v2 mblob1 mblob2 = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl -> do
      inserts tbl (V.fromList [(k, v1, mblob1), (k, v2, mblob2)])
      res <- lookupsWithBlobs tbl ses (V.singleton k)
      pure $ res === case mblob2 of
          Nothing    -> V.singleton (Found v2)
          Just blob2 -> V.singleton (FoundWithBlob v2 blob2)

-- | Inserts with different keys don't interfere.
prop_insertCommutesBlob ::
       IsTable h
    => Proxy h -> [(Key, Update Value Blob)]
    -> Key -> Value -> Maybe Blob
    -> Key -> Value -> Maybe Blob -> Property
prop_insertCommutesBlob h ups k1 v1 mblob1 k2 v2 mblob2 = k1 /= k2 ==> ioProperty do
    withSessionAndTableNew h ups $ \ses tbl -> do
      inserts tbl (V.fromList [(k1, v1, mblob1), (k2, v2, mblob2)])

      res <- lookupsWithBlobs tbl ses $ V.fromList [k1,k2]
      pure $ res === case (mblob1, mblob2) of
          (Nothing,    Nothing)    -> V.fromList [Found v1,               Found v2]
          (Just blob1, Nothing)    -> V.fromList [FoundWithBlob v1 blob1, Found v2]
          (Nothing,    Just blob2) -> V.fromList [Found v1,               FoundWithBlob v2 blob2]
          (Just blob1, Just blob2) -> V.fromList [FoundWithBlob v1 blob1, FoundWithBlob v2 blob2]

-- | A blob reference may be invalidated by an update.
prop_updatesMayInvalidateBlobRefs ::
     forall h. IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Blob
  -> [(Key, Update Value Blob)]
  -> Property
prop_updatesMayInvalidateBlobRefs h ups k1 v1 blob1 ups' = monadicIO $ do
    (res, blobs, res') <- run $ do
      withSessionAndTableNew h ups $ \ses tbl -> do
        inserts tbl (V.singleton (k1, v1, Just blob1))
        res <- lookups tbl (V.singleton k1)
        blobs <- getCompose <$> retrieveBlobsTrav (Proxy.Proxy @h) ses (Compose res)
        updates tbl (V.fromList ups')
        res' <- try @SomeException (getCompose <$> retrieveBlobsTrav (Proxy.Proxy @h) ses (Compose res))
        pure (res, blobs, res')

    case (V.toList res, V.toList blobs) of
      ([FoundWithBlob{}], [FoundWithBlob _ x])
        | Left _ <- res' ->
            monitor (QC.label "blob reference invalidated") >> pure True
        | Right (V.toList -> [FoundWithBlob _ y]) <- res' ->
            monitor (QC.label "blob reference valid") >> pure (x == y)
      _ -> monitor (counterexample "insert before lookup failed, somehow...") >> pure False

-------------------------------------------------------------------------------
-- implement classic QC tests for monoidal updates
-------------------------------------------------------------------------------

-- | You can lookup what you inserted.
prop_lookupUpdate ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Maybe Blob -> Value -> Property
prop_lookupUpdate h ups k v1 mb1 v2 = ioProperty $ do
    withSessionAndTableNew h ups $ \s tbl -> do

      -- the main dish
      inserts tbl (V.singleton (k, v1, mb1))
      upserts tbl (V.singleton (k, v2))
      res <- lookupsWithBlobs tbl s (V.singleton k)

      -- notice the order.
      pure $ res === V.singleton (Found (resolve v2 v1))

-------------------------------------------------------------------------------
-- implement classic QC tests for monoidal table unions
-------------------------------------------------------------------------------

prop_union :: forall h.
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)] -> [(Key, Update Value Blob)]
  -> [Key]
  -> Positive (Small Int) -- ^ Number of batches for supplying union credits
  -> Property
prop_union h ups1 ups2
  (V.fromList -> testKeys)
  (Positive (Small nSupplyBatches))
  = ioProperty $ do
    withSessionAndTableNew h ups1 $ \s tbl1 -> do
      withTableNew s (testTableConfig h) $ \tbl2 -> do
        updates tbl2 $ V.fromList ups2

        -- union them.
        withTableUnion tbl1 tbl2 $ \tbl3 -> do
          propBegin <- compareLookups s tbl1 tbl2 tbl3

          -- Supply union credits in @nSupplyBatches@ batches
          props <- forM (reverse [1 .. nSupplyBatches]) $ \i -> do
            -- Try to keep the batch sizes roughly the same size
            UnionDebt debt <- remainingUnionDebt tbl3
            _ <- supplyUnionCredits tbl3 (UnionCredits (debt `div` i))

            -- In case @i == 0@, then @debt `div` i == debt@, so the last supply
            -- should have finished the union.
            when (i == 1) $ do
              finalDebt <- remainingUnionDebt tbl3
              assert (finalDebt == UnionDebt 0) $ pure ()

            -- Check that the lookup results are still the same after each batch
            -- of union credits.
            compareLookups s tbl1 tbl2 tbl3

          pure (propBegin .&&. conjoin props)
  where
    compareLookups s tbl1 tbl2 tbl3 = do
      -- results in parts and the union table
      res1 <- lookupsWithBlobs tbl1 s testKeys
      res2 <- lookupsWithBlobs tbl2 s testKeys
      res3 <- lookupsWithBlobs tbl3 s testKeys

      let unionResult ::
               LookupResult Value Blob
            -> LookupResult Value Blob
            -> LookupResult Value Blob

          unionResult r@NotFound   NotFound            = r
          unionResult   NotFound r@(Found _)           = r
          unionResult   NotFound r@(FoundWithBlob _ _) = r

          unionResult r@(Found _)  NotFound
            = r
          unionResult   (Found v1) (Found v2)
            = Found (resolve v1 v2)
          unionResult   (Found v1) (FoundWithBlob v2 _)
            = Found (resolve v1 v2)

          unionResult r@(FoundWithBlob _ _)   NotFound
            = r
          unionResult   (FoundWithBlob v1 b1) (Found v2)
            = FoundWithBlob (resolve v1 v2) b1
          unionResult   (FoundWithBlob v1 b1) (FoundWithBlob v2 _b2)
            = FoundWithBlob (resolve v1 v2) b1

      pure $ V.zipWith unionResult res1 res2 === res3

-------------------------------------------------------------------------------
-- implement classic QC tests for snapshots
-------------------------------------------------------------------------------

-- changes to handle would not change the snapshot
prop_snapshotNoChanges :: forall h.
     IsTable h
    => Proxy h -> [(Key, Update Value Blob)]
    -> [(Key, Update Value Blob)] -> [Key] -> Property
prop_snapshotNoChanges h ups ups' testKeys = ioProperty $ do
    withSessionAndTableNew h ups $ \ses tbl1 -> do

      res <- lookupsWithBlobs tbl1 ses $ V.fromList testKeys

      let name = R.toSnapshotName "foo"

      saveSnapshot name label tbl1
      updates tbl1 (V.fromList ups')

      withTableFromSnapshot @h ses label name$ \tbl2 -> do

        res' <- lookupsWithBlobs tbl2 ses $ V.fromList testKeys

        pure $ res == res'

-- same snapshot may be opened multiple times,
-- and the handles are separate.
prop_snapshotNoChanges2 :: forall h.
     IsTable h
    => Proxy h -> [(Key, Update Value Blob)]
    -> [(Key, Update Value Blob)] -> [Key] -> Property
prop_snapshotNoChanges2 h ups ups' testKeys = ioProperty $ do
    withSessionAndTableNew h ups $ \sess tbl0 -> do
      let name = R.toSnapshotName "foo"
      saveSnapshot name label tbl0

      withTableFromSnapshot @h sess label name $ \tbl1 ->
        withTableFromSnapshot @h sess label name $ \tbl2 -> do

          res <- lookupsWithBlobs tbl1 sess $ V.fromList testKeys
          updates tbl1 (V.fromList ups')
          res' <- lookupsWithBlobs tbl2 sess $ V.fromList testKeys

          pure $ res == res'

-------------------------------------------------------------------------------
-- implement classic QC tests for multiple writable tables
--  - results of insert/delete, monoidal update, lookups, and range lookups should
--    be equal if applied to two duplicated tables
--  - changes to one table should not cause any visible changes in any others
-------------------------------------------------------------------------------

-- | Last insert wins.
prop_dupInsertInsert ::
     IsTable h
  => Proxy h -> [(Key, Update Value Blob)]
  -> Key -> Value -> Value -> [Key] -> Property
prop_dupInsertInsert h ups k v1 v2 testKeys = ioProperty $ do
    withSessionAndTableNew h ups $ \sess tbl1 -> do
      withTableDuplicate tbl1 $ \tbl2 -> do

        inserts tbl1 (V.fromList [(k, v1, Nothing), (k, v2, Nothing)])
        inserts tbl2 (V.fromList [(k, v2, Nothing)])

        res1 <- lookupsWithBlobs tbl1 sess $ V.fromList testKeys
        res2 <- lookupsWithBlobs tbl2 sess $ V.fromList testKeys
        pure $ res1 === res2

-- | Different key inserts commute.
prop_dupInsertCommutes ::
     IsTable h
    => Proxy h -> [(Key, Update Value Blob)]
    -> Key -> Value -> Key -> Value -> [Key] -> Property
prop_dupInsertCommutes h ups k1 v1 k2 v2 testKeys = k1 /= k2 ==> ioProperty do
    withSessionAndTableNew h ups $ \sess tbl1 -> do
      withTableDuplicate tbl1 $ \tbl2 -> do

        inserts tbl1 (V.fromList [(k1, v1, Nothing), (k2, v2, Nothing)])
        inserts tbl2 (V.fromList [(k2, v2, Nothing), (k1, v1, Nothing)])

        res1 <- lookupsWithBlobs tbl1 sess $ V.fromList testKeys
        res2 <- lookupsWithBlobs tbl2 sess $ V.fromList testKeys
        pure $ res1 === res2

-- changes to one handle should not cause any visible changes in any others
prop_dupNoChanges ::
     IsTable h
    => Proxy h -> [(Key, Update Value Blob)]
    -> [(Key, Update Value Blob)] -> [Key] -> Property
prop_dupNoChanges h ups ups' testKeys = ioProperty $ do
    withSessionAndTableNew h ups $ \sess tbl1 -> do

      res <- lookupsWithBlobs tbl1 sess $ V.fromList testKeys

      withTableDuplicate tbl1 $ \tbl2 -> do
        updates tbl2 (V.fromList ups')

        -- lookup tbl1 again.
        res' <- lookupsWithBlobs tbl1 sess $ V.fromList testKeys

        pure $ res == res'