haskoin-store-1.9.0: src/Haskoin/Store/Fleet.hs
-- |
-- Module : Haskoin.Store.Fleet
-- Copyright : (c) Haskoin Developers
-- License : MIT
-- Maintainer : jprupp@protonmail.ch
-- Stability : experimental
-- Portability : POSIX
--
-- How stores that share one Redis agree on the block that they all answer
-- from, as pure decisions. "Haskoin.Store.Fleet.Redis" carries them out.
-- This is the first part of the design in @docs\/layered-database.md@, "A
-- fleet that answers as one", written down so that it can be tested before
-- a store depends on it.
--
-- = The problem
--
-- Stores import the same blocks at slightly different times. Between the
-- moment the first of them has a block and the moment the last has it, the
-- same request gets different answers depending on which store a load
-- balancer picks. Making the stores commit a block together would need
-- every one of them to be there and on time, and one that stalls would hold
-- up all the others.
--
-- = The idea: agree on the state, not on the moment
--
-- Redis holds the /fleet tip/: the newest block that every live store has.
-- Each store imports blocks whenever it gets them, and keeps a view of its
-- database at each recent block. A request reads the fleet tip and is
-- answered from the view at that block. So every store gives the same
-- answer, and the fleet moves to a new block in one step, when the tip
-- moves, although no two stores imported it at the same time.
--
-- = The rules
--
-- * __A store reports its recent blocks__, newest first, under a lease that
-- it has to renew ('report'). A store that dies or hangs stops counting
-- when its lease runs out, and holds nobody up. It reports only the blocks
-- that it still has a view of: a view that was let go cannot be had again,
-- and the model test found at once what happens when the tip moves to such
-- a block. A store that is alive but stuck on old blocks would hold the
-- tip back for as long as it renews its lease, so a store stops reporting
-- when its own health checks fail.
--
-- * __A store reports only if it has a view of the fleet tip__ ('mayReport').
-- One that is catching up, or that just started, would otherwise pull the
-- tip back to an older block, or to one that it alone has.
--
-- * __The tip moves to the newest block that every live store reported__
-- ('agreed', 'move'). Any store may move it. The move is a compare and
-- set, so two stores that try at once do the same thing once.
--
-- * __A store keeps the views of the blocks of its last report, and of the
-- tip that it read after making it__ ('keep'). The tip can only move to a
-- block that every live store reported, so a live store always has the
-- view that it is asked for.
--
-- * __A store that does not have a view of the tip does not answer from
-- another block__ ('answer'). It was away for longer than its lease, and
-- says that it is behind until it has caught up.
--
-- A reorganisation needs no rule of its own. The tip is a block, not a
-- height: when every live store has moved to the other branch, the newest
-- block that they all report is on it, and the tip goes there.
--
-- The stores and the blocks can be of any type that can be compared, so
-- that the tests can run fleets of small numbers.
module Haskoin.Store.Fleet
( -- * The state in Redis
Fleet (..),
Report (..),
Time,
empty,
-- * Reporting
mayReport,
report,
live,
-- * Moving the tip
agreed,
Move (..),
move,
-- * In a store
keep,
Answer (..),
answer,
)
where
import Data.List (find)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Word (Word64)
-- | A moment, in any unit that the stores share. In Redis it is the clock
-- of Redis, so that the stores do not have to agree on theirs.
type Time = Word64
-- | What a store last said about itself.
data Report b = Report
{ -- | the blocks of its main chain that it has views of, newest first
recent :: ![b],
-- | the moment its lease runs out
expires :: !Time
}
deriving (Eq, Show)
-- | What Redis holds for a fleet of stores of type @s@ with blocks of type
-- @b@.
data Fleet s b = Fleet
{ -- | the block that every store answers from, once there is one
tip :: !(Maybe b),
-- | the last report of each store, live or not
reports :: !(Map s (Report b))
}
deriving (Eq, Show)
-- | A fleet that no store has joined.
empty :: Fleet s b
empty = Fleet {tip = Nothing, reports = Map.empty}
-- | The reports whose lease has not run out.
live :: Time -> Fleet s b -> Map s (Report b)
live now fleet = Map.filter ((> now) . (.expires)) fleet.reports
-- | Whether a store may report, given a test for the blocks that it has a
-- view of. It may when it has a view of the tip. It also may when there is
-- no tip yet, or when no other store is live: then there is nobody to
-- disagree with, and a fleet whose stores were all away has to be able to
-- start again.
mayReport :: (Ord s) => Time -> s -> (b -> Bool) -> Fleet s b -> Bool
mayReport now store has fleet =
maybe True has fleet.tip || Map.null (Map.delete store (live now fleet))
-- | Note the recent blocks of a store, newest first, under a lease of some
-- length. The store must have checked 'mayReport' against the same state:
-- in Redis the two are one script.
report :: (Ord s) => Time -> Time -> s -> [b] -> Fleet s b -> Fleet s b
report now lease store blocks fleet =
fleet {reports = Map.insert store Report {recent = blocks, expires = now + lease} fleet.reports}
-- | The newest block that every live store reported, if there is one, and
-- if any store is live. Stores report their blocks newest first, so it is
-- the first block of any one report that all the others have.
agreed :: (Eq b) => Time -> Fleet s b -> Maybe b
agreed now fleet = case Map.elems (live now fleet) of
[] -> Nothing
first : others -> find (\b -> all (elem b . (.recent)) others) first.recent
-- | What became of an attempt to move the tip.
data Move s b
= -- | the tip is now this block
Moved !(Fleet s b)
| -- | the tip is already where the live stores agree, or they agree on
-- nothing
Stays
| -- | the tip was not the one that the store expected: another store moved
-- it first
Raced
deriving (Eq, Show)
-- | Move the tip to the block that the live stores agree on, given the tip
-- that the caller saw. The caller says what it saw because it also changes
-- the shared mempool for the blocks between the two tips, and that change
-- is right for one starting point only.
move :: (Eq b) => Time -> Maybe b -> Fleet s b -> Move s b
move now seen fleet
| seen /= fleet.tip = Raced
| otherwise = case agreed now fleet of
Just b | Just b /= fleet.tip -> Moved fleet {tip = Just b}
_ -> Stays
-- | The blocks whose views a store must keep: those of its last report,
-- and the tip that it read after that report was made. It may let go of
-- every other view.
--
-- The order matters. A tip read before the report could have moved, in
-- between, to a block that only the earlier report had.
keep :: [b] -> Maybe b -> [b]
keep reported seen = maybe reported (: reported) seen
-- | What a store answers a request from.
data Answer b
= -- | the view at this block, as every other store does
At !b
| -- | its own best block: there is no fleet tip yet
Alone
| -- | nothing: it has no view of the fleet tip, so any answer would differ
-- from those of the other stores
Behind
deriving (Eq, Show)
-- | What a store answers a request from, given a test for the blocks that
-- it has a view of and the fleet as the request found it.
answer :: (b -> Bool) -> Fleet s b -> Answer b
answer has fleet = case fleet.tip of
Nothing -> Alone
Just b
| has b -> At b
| otherwise -> Behind