packages feed

haskoin-store-1.9.0: test/Haskoin/Store/FleetSpec.hs

module Haskoin.Store.FleetSpec (spec) where

import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Haskoin.Store.Fleet
import Test.Hspec
import Test.Hspec.QuickCheck (modifyMaxSuccess)
import Test.QuickCheck

-- | A store of the model: its main chain, newest block first, and the blocks
-- that it has views of.
data Node = Node
  { chain :: ![Int],
    views :: !(Set Int)
  }
  deriving (Show)

-- | A fleet of three stores, the chain that the network has, newest block
-- first, the state in Redis, and the time.
data World = World
  { network :: ![Int],
    fresh :: !Int,
    nodes :: !(Map Int Node),
    redis :: !(Fleet Int Int),
    now :: !Time
  }
  deriving (Show)

-- | Something that happens.
data Op
  = -- | the network finds a block
    Grow
  | -- | the network replaces its last blocks, as many as given, with as many
    -- new ones and one more
    Reorg !Int
  | -- | a store catches up with the network by so many steps, each the
    -- reversal or the import of one block
    Sync !Int !Int
  | -- | a store reports, moves the tip if it can, and lets go of the views
    -- that it need not keep
    Beat !Int
  | -- | time passes
    Tick !Time
  deriving (Show)

instance Arbitrary Op where
  arbitrary =
    frequency
      [ (3, pure Grow),
        (1, Reorg <$> choose (1, 3)),
        (8, Sync <$> choose (0, 2) <*> choose (1, 4)),
        (8, Beat <$> choose (0, 2)),
        (3, Tick <$> choose (1, 6))
      ]

-- | How a store of the model behaves: whether it asks before it reports,
-- and whether it keeps the views that the rules tell it to.
data Rules = Rules
  { asks :: !Bool,
    keeps :: !Bool
  }

byTheRules :: Rules
byTheRules = Rules {asks = True, keeps = True}

-- | Leases last ten ticks, and a store reports its six newest blocks.
lease :: Time
lease = 10

window :: Int
window = 6

start :: World
start =
  World
    { network = [0],
      fresh = 1,
      nodes = Map.fromList [(s, Node {chain = [0], views = Set.singleton 0}) | s <- [0 .. 2]],
      redis = empty,
      now = 0
    }

step :: Rules -> World -> Op -> World
step rules w = \case
  Grow -> w {network = w.fresh : w.network, fresh = w.fresh + 1}
  Reorg n ->
    let kept = drop (min n (length w.network - 1)) w.network
        new = [w.fresh + n, w.fresh + n - 1 .. w.fresh]
     in w {network = new <> kept, fresh = w.fresh + n + 1}
  Sync s n -> w {nodes = Map.adjust (\node -> iterate catchUp node !! n) s w.nodes}
  Tick dt -> w {now = w.now + dt}
  Beat s ->
    let node = w.nodes Map.! s
        has b = Set.member b node.views
        -- only what it still has a view of: a view that was let go cannot be
        -- had again
        blocks = filter has (take window node.chain)
        allowed = not rules.asks || mayReport w.now s has w.redis
        reported = report w.now lease s blocks w.redis
        moved = case move w.now reported.tip reported of
          Moved fleet -> fleet
          _ -> reported
        kept
          | rules.keeps = Set.fromList (keep blocks moved.tip) `Set.intersection` node.views
          | otherwise = Set.fromList (take 1 node.chain)
     in if allowed
          then w {redis = moved, nodes = Map.insert s node {views = kept} w.nodes}
          else w
  where
    -- one step towards the chain of the network: revert a block that it
    -- does not have, or import the next one that it has
    catchUp node
      | b : rest <- node.chain, b `notElem` w.network = node {chain = rest}
      | next : _ <- reverse (takeWhile (`notElem` node.chain) w.network) =
          node {chain = next : node.chain, views = Set.insert next node.views}
      | otherwise = node

-- | The stores whose lease has not run out and that have no view of the
-- tip: those that would have to refuse a request although they are counted
-- in.
stranded :: World -> [Int]
stranded w =
  [ s
  | Just t <- [w.redis.tip],
    s <- Map.keys (live w.now w.redis),
    not (Set.member t (w.nodes Map.! s).views)
  ]

-- | Every state that a list of operations goes through.
states :: Rules -> [Op] -> [World]
states rules = scanl (step rules) start

spec :: Spec
spec = describe "fleet tip" $ do
  it "is the newest block that every live store reported" $ do
    let fleet = foldl' (\f (s, bs) -> report 0 10 s bs f) (empty :: Fleet Int Int) reports'
        reports' = [(0, [5, 4, 3, 2]), (1, [4, 3, 2, 1]), (2, [7, 6, 3, 2])]
    agreed 0 fleet `shouldBe` Just 3
    -- a store whose lease has run out no longer counts
    agreed 12 (report 12 10 0 [5, 4, 3] (report 12 10 1 [4, 3] fleet)) `shouldBe` Just 4
    agreed 20 fleet `shouldBe` Nothing

  it "moves once when two stores try at the same time" $ do
    let fleet = report 0 10 0 [2, 1] (report 0 10 1 [2, 1] (empty :: Fleet Int Int))
    case move 0 Nothing fleet of
      Moved next -> do
        next.tip `shouldBe` Just 2
        -- the second store saw the same tip as the first, and is too late
        move 0 Nothing next `shouldBe` Raced
        move 0 (Just 2) next `shouldBe` Stays
      other -> expectationFailure (show other)

  it "keeps a store that is behind from pulling the tip back" $ do
    let ahead = report 0 10 0 [9, 8, 7, 6] (empty :: Fleet Int Int)
        fleet = case move 0 Nothing ahead of
          Moved f -> f
          _ -> ahead
    fleet.tip `shouldBe` Just 9
    mayReport 1 1 (`elem` [7, 6, 5 :: Int]) fleet `shouldBe` False
    mayReport 1 1 (`elem` [9, 8, 7 :: Int]) fleet `shouldBe` True
    -- when the store that was ahead is gone, the other may start again
    mayReport 11 1 (`elem` [7, 6, 5 :: Int]) fleet `shouldBe` True

  it "answers from the tip, from its own block before there is one, or not at all" $ do
    let fleet = (empty :: Fleet Int Int) {tip = Just 4}
    answer (const True) (empty :: Fleet Int Int) `shouldBe` Alone
    answer (== 4) fleet `shouldBe` At 4
    answer (== 5) fleet `shouldBe` Behind

  modifyMaxSuccess (const 2000) $ do
    it "never leaves a live store without a view of the tip" $
      property $ \ops ->
        let trouble = [(w.redis.tip, stranded w) | w <- states byTheRules ops, not (null (stranded w))]
         in counterexample (show (take 1 trouble)) (null trouble)

    it "moves along and across branches, never to a block that no store has" $
      property $ \ops ->
        let tips = [t | w <- states byTheRules ops, Just t <- [w.redis.tip]]
            made w = w.fresh
         in conjoin
              [ cover 5 (length (Set.fromList tips) > 3) "the tip was at more than three blocks" True,
                property (all (< made (last (states byTheRules ops))) tips)
              ]

    it "ends at the best block once every store has caught up and reported" $
      property $ \ops ->
        let settled = foldl' (step byTheRules) (last (states byTheRules ops)) settle
            settle = [Tick 20] <> [Sync s 100 | s <- [0 .. 2]] <> [Beat s | s <- [0 .. 2]] <> [Beat 0]
         in (maybe [] pure settled.redis.tip, stranded settled) === (take 1 settled.network, [])

    it "needs the store to ask before it reports" $
      expectFailure . property $ \ops ->
        all (null . stranded) (states byTheRules {asks = False} ops)

    it "needs the store to keep the views of what it reported" $
      expectFailure . property $ \ops ->
        all (null . stranded) (states byTheRules {keeps = False} ops)