packages feed

haskoin-store-1.9.0: src/Haskoin/Store/Manager.hs

-- |
-- Module      : Haskoin.Store.Manager
-- Copyright   : (c) Haskoin Developers
-- License     : MIT
-- Maintainer  : jprupp@protonmail.ch
-- Stability   : experimental
-- Portability : POSIX
--
-- Wires the components of a store together: the RocksDB database, the
-- peer-to-peer node, the block store that imports data, and the optional Redis
-- cache. Node events are forwarded to the block store and turned into
-- 'StoreEvent's, which the cache follows.
--
-- Keeping the wiring in one place means each component only knows about the
-- interfaces it uses: the node does not know about the block store, and the
-- block store does not know about the cache. 'withStore' starts them in dependency order
-- and stops them all together.
module Haskoin.Store.Manager
  ( StoreConfig (..),
    Store (..),
    withStore,
  )
where

import Control.Monad (forM_, unless, when)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as C
import Data.Serialize (Serialize, decode)
import Data.String.Conversions (cs)
import Data.Time.Clock (NominalDiffTime)
import Data.Word (Word32, Word64)
import Database.Redis qualified as Redis
import Haskoin
  ( BlockHash (..),
    Ctx,
    Inv (..),
    InvType (..),
    InvVector (..),
    Message (..),
    MessageCommand (..),
    Network,
    NetworkAddress (..),
    NotFound (..),
    Pong (..),
    Reject (..),
    TxHash (..),
    VarString (..),
    sockToHostAddress,
  )
import Haskoin.Node
  ( Chain,
    ChainEvent (..),
    LogLevel (..),
    Logger,
    Node (..),
    NodeConfig (..),
    NodeEvent (..),
    PeerEvent (..),
    PeerMgr,
    WithConnection,
    logAt,
    withNode,
    withSource,
  )
import Haskoin.Store.BlockStore
  ( BlockStore,
    BlockStoreConfig (..),
    blockStoreBlockSTM,
    blockStoreHeadSTM,
    blockStoreNotFoundSTM,
    blockStorePeerConnectSTM,
    blockStorePeerDisconnectSTM,
    blockStoreTxHashSTM,
    blockStoreTxSTM,
    withBlockStore,
  )
import Haskoin.Store.Cache (Cache, CacheConfig (..), cachePrefix, withCache)
import Haskoin.Store.Common
  ( StoreEvent (..),
    loopForever,
  )
import Haskoin.Store.Database.Migrate (Migration (..), upgradeDatabase)
import Haskoin.Store.Database.Reader
  ( DatabaseReader (..),
    createDataMetrics,
    withLayeredReader,
  )
import Haskoin.Store.Fleet.Member
  ( Member,
    MemberConfig (..),
    memberCacheLoop,
    memberEvents,
    memberForget,
    memberLoop,
    newMember,
    noteBest,
  )
import Haskoin.Store.Fleet.Redis (FleetEvent (..))
import NQE
  ( Inbox,
    Publisher,
    publish,
    publishSTM,
    receive,
    withPublisher,
    withSubscription,
  )
import Network.Socket (SockAddr (..))
import System.Metrics.StatsD
import System.Random (randomIO)
import UnliftIO
  ( STM,
    atomically,
    link,
    withAsync,
  )

-- | Store mailboxes. Handles to the running components of a store, given by
-- 'withStore'.
data Store = Store
  { -- | peer manager of the node
    peerMgr :: !PeerMgr,
    -- | block header chain of the node
    chain :: !Chain,
    -- | process that imports blocks and transactions
    block :: !BlockStore,
    -- | database reader
    db :: !DatabaseReader,
    -- | cache of extended public keys, if Redis is configured and the store
    -- is not a member of a fleet
    cache :: !(Maybe Cache),
    -- | the store as a member of a fleet, if it is one
    fleet :: !(Maybe Member),
    -- | what the store tells its clients: new best blocks and changes of
    -- the mempool. For a store alone they are its own, those that the
    -- block store publishes.
    -- For a member of a fleet they are those of the fleet, read from the
    -- Redis that it shares, so that every server tells the same.
    events :: !(Publisher StoreEvent),
    -- | store events are published here
    pub :: !(Publisher StoreEvent),
    -- | network constants
    net :: !Network,
    -- | secp256k1 context
    ctx :: !Ctx
  }

-- | Configuration for a 'Store'.
data StoreConfig = StoreConfig
  { -- | max peers to connect to
    maxPeers :: !Int,
    -- | static set of peers to connect to
    initPeers :: ![String],
    -- | discover new peers
    discover :: !Bool,
    -- | RocksDB database path
    db :: !FilePath,
    -- | network constants
    net :: !Network,
    -- | Redis URL for the cache of extended public keys ("Haskoin.Store.Cache");
    -- 'Nothing' for no cache, an empty URL for the local Redis
    redis :: !(Maybe String),
    -- | Secp256k1 context
    ctx :: !Ctx,
    -- | gap on extended public key with no transactions
    initGap :: !Word32,
    -- | gap for extended public keys
    gap :: !Word32,
    -- | cache xpubs with at least this many used addresses
    redisMinAddrs :: !Int,
    -- | or with at least this many transactions in their addresses
    redisMinTxs :: !Int,
    -- | evict the least recently used xpubs from the cache past this many
    redisMaxXPubs :: !Int,
    -- | do not index new mempool transactions
    noMempool :: !Bool,
    -- | wipe mempool when starting
    wipeMempool :: !Bool,
    -- | answer as one of a fleet of stores that share the Redis of 'redis':
    -- every store of the fleet then gives the same answer to the same
    -- request, from the block that they agree on and the mempool that they
    -- share ("Haskoin.Store.Fleet.Member"). The cache of extended public
    -- keys of a store alone ("Haskoin.Store.Cache") is not used then: its
    -- entries follow each store's own database, which is what a fleet does
    -- not answer from. A fleet has one of its own
    -- ("Haskoin.Store.Fleet.XPub"), under the same three settings.
    fleet :: !Bool,
    -- | sync mempool from peers
    syncMempool :: !Bool,
    -- | blocks to request from the syncing peer in one batch
    blockWindow :: !Int,
    -- | delete mempool transactions older than this number of days
    mempoolTimeout :: !Int,
    -- | disconnect peer if message not received for this many seconds
    peerTimeout :: !NominalDiffTime,
    -- | disconnect peer if it has been connected this long
    maxPeerLife :: !NominalDiffTime,
    -- | connect to peers using the function 'Haskoin.Node.withConnection'
    connect :: !(SockAddr -> WithConnection),
    -- | StatsD
    stats :: !(Maybe Stats),
    -- | Bloom filters on the column families that are read by key
    bloom :: !Bool,
    -- | RocksDB max_open_files ('Nothing' = unlimited: every SST file's index
    -- and filter blocks stay resident for the life of the process)
    maxOpenFiles :: !(Maybe Int),
    -- | bytes of RocksDB memtables across all column families ('Nothing' =
    -- unlimited: each column family keeps up to two 64 MiB memtables)
    writeBufferSize :: !(Maybe Int),
    -- | bytes of write-ahead log after which the column families holding the
    -- oldest log file are flushed ('Nothing' = RocksDB default)
    maxWalSize :: !(Maybe Int),
    -- | bytes of RocksDB block cache shared by all column families, which
    -- also holds their index and filter blocks ('Nothing' = RocksDB default:
    -- 32 MiB per column family, index and filter blocks outside it)
    blockCache :: !(Maybe Int),
    -- | log messages here
    logger :: !Logger
  }

-- | Run a store and pass its handles to the given action. Everything is stopped
-- when the action returns, and an exception in any component is re-thrown in
-- the calling thread.
--
-- The database is opened first, then the node, the block store and the cache
-- (if 'redis' is set).
withStore ::
  StoreConfig ->
  (Store -> IO a) ->
  IO a
withStore cfg action =
  connectDB cfg $ \db ->
    withPublisher $ \pub ->
      withPublisher $ \node_pub ->
        withSubscription node_pub $ \node_sub ->
          withNode (nodeCfg cfg db node_pub) $ \node -> do
            member <- storeMember cfg db
            withBlockStore (blockStoreCfg cfg node pub db member) $ \block_store ->
              withAsync (nodeForwarder block_store pub node_sub) $ \fwd -> do
                link fwd
                withStoreFleet cfg pub member . withStoreCache cfg db node.chain pub $ \cache ->
                  withFleetEvents cfg pub member $ \events ->
                    action $
                      Store
                        { peerMgr = node.peerMgr,
                          chain = node.chain,
                          block = block_store,
                          db = db,
                          cache = cache,
                          fleet = member,
                          events = events,
                          pub = pub,
                          net = cfg.net,
                          ctx = cfg.ctx
                        }

-- | The store as a member of a fleet, if the configuration says that it is
-- one and has a Redis to share: under a name of its own, with a lease of
-- ten seconds, views of its last six blocks, and the store's limit on the
-- age of unconfirmed transactions.
storeMember :: StoreConfig -> DatabaseReader -> IO (Maybe Member)
storeMember cfg db = case (cfg.fleet, cfg.redis) of
  (True, Just url) -> do
    info <-
      if null url
        then return Redis.defaultConnectInfo
        else either fail return (Redis.parseConnectInfo url)
    conn <- Redis.checkedConnect info
    name <- C.pack . show <$> (randomIO :: IO Word64)
    Just
      <$> newMember
        MemberConfig
          { prefix = cachePrefix cfg.net,
            name,
            lease = 10000,
            depth = 6,
            expiry = fromIntegral cfg.mempoolTimeout * 24 * 3600,
            cacheMax = cfg.redisMaxXPubs,
            cacheMinAddrs = cfg.redisMinAddrs,
            cacheMinTxs = cfg.redisMinTxs,
            stats = cfg.stats,
            note = \level -> logAt level (withSource "Fleet" cfg.logger)
          }
        conn
        db
  _ -> return Nothing

-- | Take the store's turns as a member of its fleet while an action runs,
-- if it is one: every two seconds, and whenever the best block changes.
-- Beside them, in a thread of its own, the member builds the entries of
-- the fleet's cache of extended public keys that requests asked for.
withStoreFleet :: StoreConfig -> Publisher StoreEvent -> Maybe Member -> IO a -> IO a
withStoreFleet _ _ Nothing action = action
withStoreFleet cfg pub (Just member) action =
  withSubscription pub $ \sub ->
    withAsync (memberLoop member (nextBlock sub) complain) $ \a ->
      withAsync (memberCacheLoop member cacheComplain) $ \b -> link a >> link b >> action
  where
    -- until the next block, telling the member what the store deletes
    nextBlock sub =
      receive sub >>= \case
        StoreBestBlock _ -> return ()
        StoreMempoolDelete h -> memberForget member [h] >> nextBlock sub
        _ -> nextBlock sub
    complain why = logAt LevelError (withSource "Fleet" cfg.logger) ("Could not take a turn: " <> cs why)
    cacheComplain why = logAt LevelError (withSource "Fleet" cfg.logger) ("Could not cache an xpub: " <> cs why)

-- | Run an action with the events that the store tells its clients. A
-- store alone tells its own. A member of a fleet tells those of the
-- fleet, which a thread reads from Redis for as long as the action runs.
withFleetEvents ::
  StoreConfig ->
  Publisher StoreEvent ->
  Maybe Member ->
  (Publisher StoreEvent -> IO a) ->
  IO a
withFleetEvents _ pub Nothing action = action pub
withFleetEvents cfg _ (Just member) action =
  withPublisher $ \shared ->
    withAsync (memberEvents member False (tell shared) complain) $ \a -> link a >> action shared
  where
    tell shared event = forM_ (told event) (`publish` shared)
    told = \case
      FleetBlock h -> StoreBestBlock <$> hash h
      FleetEntered h -> StoreMempoolNew <$> hash h
      FleetLeft h -> StoreMempoolDelete <$> hash h
    hash :: (Serialize x) => ByteString -> Maybe x
    hash = either (const Nothing) Just . decode
    complain why = logAt LevelError (withSource "Fleet" cfg.logger) ("Could not read the events: " <> cs why)

-- | Run the cache while an action runs, if Redis is configured.
withStoreCache ::
  StoreConfig ->
  DatabaseReader ->
  Chain ->
  Publisher StoreEvent ->
  (Maybe Cache -> IO a) ->
  IO a
withStoreCache cfg db chain pub action =
  case cfg.redis of
    Nothing -> action Nothing
    Just _ | cfg.fleet -> action Nothing
    Just url ->
      withCache
        CacheConfig
          { url = url,
            minAddrs = cfg.redisMinAddrs,
            minTxs = cfg.redisMinTxs,
            maxXPubs = cfg.redisMaxXPubs,
            stats = cfg.stats
          }
        db
        chain
        pub
        (withSource "Cache" cfg.logger)
        (action . Just)

-- | Open the database described by the configuration, bring it to the
-- layout that the store runs on ('upgradeDatabase'), and run an action with
-- its reader. A database of version 20 has its mempool moved to an area of
-- its own here, once, before anything reads it; older stores refuse the
-- database from then on.
connectDB ::
  StoreConfig ->
  (DatabaseReader -> IO a) ->
  IO a
connectDB cfg f = do
  stats <- mapM createDataMetrics cfg.stats
  withLayeredReader
    cfg.net
    cfg.ctx
    cfg.initGap
    cfg.gap
    cfg.db
    cfg.bloom
    cfg.maxOpenFiles
    cfg.writeBufferSize
    cfg.maxWalSize
    cfg.blockCache
    stats
    $ \db -> do
      done <- upgradeDatabase (logAt LevelInfo (withSource "Database" cfg.logger)) db
      when (done.moved > 0) $
        logAt LevelInfo (withSource "Database" cfg.logger) $
          "Moved " <> cs (show done.moved) <> " mempool transactions to their own area"
      when (done.rewritten > 0) $
        logAt LevelInfo (withSource "Database" cfg.logger) $
          "Wrote the records of " <> cs (show done.rewritten) <> " confirmed transactions again, to be written once"
      f db

-- | Configuration of the block store, from the store configuration.
blockStoreCfg ::
  StoreConfig ->
  Node ->
  Publisher StoreEvent ->
  DatabaseReader ->
  Maybe Member ->
  BlockStoreConfig
blockStoreCfg cfg node pub db member =
  BlockStoreConfig
    { chain = node.chain,
      peerMgr = node.peerMgr,
      pub = pub,
      db = db,
      net = cfg.net,
      ctx = cfg.ctx,
      noMempool = cfg.noMempool,
      wipeMempool = cfg.wipeMempool,
      onBest = maybe (\_ -> return ()) noteBest member,
      syncMempool = cfg.syncMempool,
      blockWindow = cfg.blockWindow,
      mempoolTimeout = cfg.mempoolTimeout,
      peerTimeout = cfg.peerTimeout,
      stats = cfg.stats,
      logger = withSource "BlockStore" cfg.logger
    }

-- | Configuration of the node, from the store configuration. The node keeps its
-- headers in the default column family of the store database.
nodeCfg ::
  StoreConfig ->
  DatabaseReader ->
  Publisher NodeEvent ->
  NodeConfig
nodeCfg cfg db pub =
  NodeConfig
    { maxPeers = cfg.maxPeers,
      db = db.db,
      cf = Nothing,
      peers = cfg.initPeers,
      discover = cfg.discover,
      pub = pub,
      address =
        NetworkAddress
          0
          (sockToHostAddress (SockAddrInet 0 0)),
      net = cfg.net,
      timeout = cfg.peerTimeout,
      maxPeerLife = cfg.maxPeerLife,
      connect = cfg.connect,
      logger = cfg.logger
    }

-- | Forward every node event to the block store and the store publisher, forever.
nodeForwarder ::
  BlockStore ->
  Publisher StoreEvent ->
  Inbox NodeEvent ->
  IO ()
nodeForwarder b pub sub =
  loopForever $ receive sub >>= atomically . storeDispatch b pub

-- | Dispatcher of node events. Peer connections, blocks, transactions,
-- inventories and not-found messages go to the block store; peer events,
-- pongs, transaction announcements and rejections are published as store
-- events; new best headers go to the block store.
storeDispatch ::
  BlockStore ->
  Publisher StoreEvent ->
  NodeEvent ->
  STM ()
storeDispatch b pub (PeerEvent pe) =
  case pe of
    PeerConnected p -> do
      publishSTM (StorePeerConnected p) pub
      blockStorePeerConnectSTM p b
    PeerDisconnected p -> do
      publishSTM (StorePeerDisconnected p) pub
      blockStorePeerDisconnectSTM p b
    PeerMessage p msg ->
      case msg of
        MPong (Pong n) ->
          publishSTM (StorePeerPong p n) pub
        MBlock block ->
          blockStoreBlockSTM p block b
        MTx tx ->
          blockStoreTxSTM p tx b
        MNotFound (NotFound is) -> do
          let blocks =
                [ BlockHash h
                | InvVector t h <- is,
                  t == InvBlock || t == InvWitnessBlock
                ]
          unless (null blocks) $ blockStoreNotFoundSTM p blocks b
        MInv (Inv is) -> do
          let txs =
                [ TxHash h
                | InvVector t h <- is,
                  t == InvTx || t == InvWitnessTx
                ]
          publishSTM (StoreTxAnnounce p txs) pub
          unless (null txs) $ blockStoreTxHashSTM p txs b
        -- A reject that names no transaction hash cannot be attributed to a
        -- transaction and falls through to the catch-all below.
        MReject Reject {message = MCTx, extra = Just h, code, reason} ->
          publishSTM (StoreTxReject p (TxHash h) code reason.get) pub
        _ -> return ()
storeDispatch b _ (ChainEvent (ChainBestBlock bn)) =
  blockStoreHeadSTM bn b
storeDispatch _ _ (ChainEvent _) = return ()