packages feed

haskoin-store-1.9.0: src/Haskoin/Store/Database/Views.hs

-- |
-- Module      : Haskoin.Store.Database.Views
-- Copyright   : (c) Haskoin Developers
-- License     : MIT
-- Maintainer  : jprupp@protonmail.ch
-- Stability   : experimental
-- Portability : POSIX
--
-- The database as it was at each of its recent blocks: a RocksDB snapshot
-- for each, kept until it is no longer needed. Nothing in the store uses
-- this yet. It is the first part of the store's side of "A fleet that
-- answers as one" in @docs\/layered-database.md@.
--
-- = Why
--
-- Stores that share a Redis all answer from one block, the fleet tip
-- ("Haskoin.Store.Fleet"), which is usually not the newest block that a
-- store has: the tip waits for the slowest store. So a store has to be able
-- to answer from a block that it has already built on, and a snapshot is how
-- RocksDB gives the database as it was. A snapshot cannot be taken of the
-- past, only of the present, so the store takes one as each block is
-- imported and keeps it.
--
-- = Letting go
--
-- RocksDB holds on to every version of a key that a snapshot can see, so
-- snapshots are let go as soon as the rules of the fleet allow ('keepViews').
-- A request may be reading through one at that moment. Releasing it then
-- would hand RocksDB freed memory, so each view counts its readers, and one
-- that is let go while it is read is released when the last reader is done.
--
-- The blocks can be of any type that can be compared, as in
-- "Haskoin.Store.Fleet".
module Haskoin.Store.Database.Views
  ( Views,
    newViews,
    closeViews,
    takeView,
    heldViews,
    keepViews,
    withViewAt,
  )
where

import Control.Monad (forM_, when)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Database.RocksDB (DB, Snapshot, createSnapshot, releaseSnapshot)
import Haskoin.Store.Database.Reader (DatabaseReader (..))
import UnliftIO
  ( TVar,
    atomically,
    bracket,
    mask_,
    newTVarIO,
    readTVar,
    readTVarIO,
    stateTVar,
    writeTVar,
  )

-- | The views of a database, by block.
data Views b = Views
  { reader :: !DatabaseReader,
    held :: !(TVar (Map b Held))
  }

-- | A snapshot, the number of readers that are using it, and whether it is
-- to be released when the last of them is done.
data Held = Held
  { snapshot :: !(DB, Snapshot),
    readers :: !Int,
    doomed :: !Bool
  }

-- | No views yet, of a database.
newViews :: DatabaseReader -> IO (Views b)
newViews reader = Views reader <$> newTVarIO Map.empty

-- | Take a view of the database as it is now, for a block. The caller must
-- be the one that writes to the database, between the import of that block
-- and its next write, so that the view is of that block and nothing else.
-- A view that the block already has is kept, and is no longer to be
-- released: a block that comes back after a reorganisation is the same
-- block.
takeView :: (Ord b) => Views b -> b -> IO ()
takeView views block = mask_ $ do
  fresh <- createSnapshot views.reader.db
  spare <- atomically . stateTVar views.held $ \m -> case Map.lookup block m of
    Just old -> (True, Map.insert block old {doomed = False} m)
    Nothing -> (False, Map.insert block Held {snapshot = fresh, readers = 0, doomed = False} m)
  when spare (releaseSnapshot fresh)

-- | The blocks that there is a view of and that is not on its way out.
heldViews :: Views b -> IO [b]
heldViews views = Map.keys . Map.filter (not . (.doomed)) <$> readTVarIO views.held

-- | Let go of every view but those of the given blocks. A view that is
-- being read is released when its last reader is done, and counts as gone
-- from now on.
keepViews :: (Ord b) => Views b -> [b] -> IO ()
keepViews views blocks = mask_ $ do
  free <- atomically . stateTVar views.held $ \m ->
    let (stay, go) = Map.partitionWithKey (\b _ -> b `elem` blocks) m
        (idle, busy) = Map.partition ((== 0) . (.readers)) go
     in (Map.elems idle, stay <> Map.map (\h -> h {doomed = True}) busy)
  forM_ free (releaseSnapshot . (.snapshot))

-- | Run an action with the database as it was at a block, if there is a
-- view of it: a reader whose every read goes through the snapshot. The
-- reader must not be used after the action returns.
withViewAt :: (Ord b) => Views b -> b -> (DatabaseReader -> IO a) -> IO (Maybe a)
withViewAt views block action = bracket enter leave (mapM run)
  where
    enter = atomically $ do
      m <- readTVar views.held
      case Map.lookup block m of
        Just h | not h.doomed -> do
          writeTVar views.held (Map.insert block h {readers = h.readers + 1} m)
          return (Just h)
        _ -> return Nothing
    run h = action views.reader {db = fst h.snapshot}
    leave Nothing = return ()
    leave (Just _) = do
      free <- atomically . stateTVar views.held $ \m -> case Map.lookup block m of
        Just h
          | h.readers <= 1 && h.doomed -> (Just h, Map.delete block m)
          | otherwise -> (Nothing, Map.insert block h {readers = h.readers - 1} m)
        Nothing -> (Nothing, m)
      forM_ free (releaseSnapshot . (.snapshot))

-- | Release every view. None may be in use.
closeViews :: Views b -> IO ()
closeViews views = mask_ $ do
  m <- atomically (stateTVar views.held (,Map.empty))
  forM_ m (releaseSnapshot . (.snapshot))