packages feed

haskoin-store-1.9.0: test/Haskoin/Store/Cache/ProtocolSpec.hs

-- | The protocol of stores sharing one Redis, against a model of Redis:
-- several stores catch up with a chain in which every block changes one
-- cached xpub, in a random interleaving, and decide with the functions of
-- "Haskoin.Store.Cache.Protocol" what to do at each step.
module Haskoin.Store.Cache.ProtocolSpec (spec) where

import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Haskoin.Store.Cache.Protocol
import Test.Hspec
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck

-- | The claim of the change of a block.
data Key = Absent | BusyBy Int | DoneKey
  deriving stock (Eq, Show)

-- | Redis: the height the entry is stamped with, and the claims.
data Redis = Redis
  { stamp :: !(Maybe Integer),
    claims :: !(IntMap Key)
  }
  deriving stock (Show)

-- | A store: its height, the change it holds, and changes to claim again.
data Store = Store
  { height :: !Int,
    holding :: !(Maybe Int),
    retry :: ![Int]
  }

-- | What happened.
data Outcome = Outcome
  { -- | the stamp written by each write, by the store and change it was for
    writes :: ![(Int, Int, Integer)],
    -- | claims given up
    releases :: !Int,
    redis :: !Redis,
    -- | whether every store reached the tip with nothing left to do
    finished :: !Bool
  }
  deriving stock (Show)

-- | Try to claim the change of a block, and do what 'afterClaim' says.
tryClaim :: Int -> Int -> Redis -> Store -> (Redis, Store)
tryClaim s i r st =
  let found = case IntMap.findWithDefault Absent i r.claims of
        Absent -> Claimed
        DoneKey -> Done
        BusyBy _ -> Busy
   in case afterClaim found of
        ReloadNow -> (r {claims = IntMap.insert i (BusyBy s) r.claims}, st {holding = Just i})
        Skip -> (r, st)
        ClaimLater -> (r, st {retry = st.retry <> [i]})

-- | A step of a store, if it can take one: reload for the change it holds,
-- claim a change again, or import the next block and claim its change.
steps :: Int -> Int -> Outcome -> Store -> [(Outcome, Store)]
steps tip s o st = case st.holding of
  Just i ->
    let h = toInteger st.height
        -- the entry has a transaction the store has not seen if it is
        -- stamped with a later block, as each block adds one
        unknown = maybe False (> h) o.redis.stamp
        reload = if mayReplace o.redis.stamp (fromIntegral st.height) unknown then Reloaded else Behind
        r = o.redis
     in case afterReload reload of
          Finish ->
            [ ( o
                  { writes = o.writes <> [(s, i, h)],
                    redis = r {stamp = Just h, claims = IntMap.insert i DoneKey r.claims}
                  },
                st {holding = Nothing}
              )
            ]
          Release ->
            [ ( o {releases = o.releases + 1, redis = r {claims = IntMap.insert i Absent r.claims}},
                st {holding = Nothing}
              )
            ]
          Hold -> []
  Nothing ->
    [ let (r, st') = tryClaim s i o.redis st {retry = rest} in (o {redis = r}, st')
    | i : rest <- [st.retry]
    ]
      <> [ let (r, st') = tryClaim s (st.height + 1) o.redis st {height = st.height + 1}
            in (o {redis = r}, st')
         | st.height < tip
         ]

-- | Run stores that start at the given heights until none can take a step,
-- picking the next step at random. The changes of the blocks up to the
-- lowest store were applied before.
simulate :: Int -> [Int] -> Gen Outcome
simulate tip starts =
  simulateFrom
    (Redis (Just (toInteger (minimum starts))) (IntMap.fromList [(i, DoneKey) | i <- [1 .. minimum starts]]))
    tip
    starts

-- | Like 'simulate', from a state of Redis.
simulateFrom :: Redis -> Int -> [Int] -> Gen Outcome
simulateFrom r0 tip starts = go (10000 :: Int) start0 (IntMap.fromList (zip [0 ..] stores0))
  where
    stores0 = [Store h Nothing [] | h <- starts]
    start0 = Outcome {writes = [], releases = 0, redis = r0, finished = False}
    go 0 o _ = return o
    go n o stores =
      case [(s, x) | (s, st) <- IntMap.toList stores, x <- steps tip s o st] of
        [] -> return o {finished = True}
        xs -> do
          (s, (o', st')) <- elements xs
          go (n - 1) o' (IntMap.insert s st' stores)

-- | A small chain and up to four stores at random heights on it.
chain :: Gen (Int, [Int])
chain = do
  tip <- choose (1, 12)
  stores <- choose (1, 4)
  starts <- vectorOf stores (choose (0, tip))
  return (tip, starts)

spec :: Spec
spec = describe "Stores sharing Redis" $ do
  prop "all reach the tip with nothing left to do" $
    forAll chain $
      \(tip, starts) -> forAll (simulate tip starts) $ \o -> o.finished

  prop "end with the entry of the tip" $
    forAll chain $ \(tip, starts) -> forAll (simulate tip starts) $ \o ->
      o.redis.stamp === Just (toInteger tip)

  prop "never replace an entry with one that knows less" $
    forAll chain $ \(tip, starts) -> forAll (simulate tip starts) $ \o ->
      let stamps = [h | (_, _, h) <- o.writes]
       in and (zipWith (<=) stamps (drop 1 stamps))

  prop "write once per change, and again only for a change given up" $
    forAll chain $ \(tip, starts) -> forAll (simulate tip starts) $ \o ->
      length o.writes <= tip - minimum starts + o.releases

  prop "leave nothing to do for stores that catch up after the first" $
    forAll chain $ \(tip, starts) ->
      forAll (simulate tip [minimum starts]) $ \first ->
        forAll (simulateFrom first.redis tip starts) $ \rest ->
          rest.writes === []

  it "claims, skips or tries again as the claim says" $
    map afterClaim [minBound .. maxBound] `shouldBe` [ReloadNow, Skip, ClaimLater]

  it "finishes, releases or holds as the reload says" $
    map afterReload [minBound .. maxBound] `shouldBe` [Finish, Release, Hold]

  it "replaces an entry of the same height or lower, never a higher one" $ do
    mayReplace Nothing 5 False `shouldBe` True
    mayReplace (Just 5) 5 False `shouldBe` True
    mayReplace (Just 6) 5 False `shouldBe` False
    mayReplace (Just 4) 5 True `shouldBe` False