packages feed

haskoin-store 1.7.0 → 1.8.0

raw patch · 13 files changed

+820/−113 lines, 13 filesdep +vectordep ~haskoin-coredep ~haskoin-store-datadep ~rocksdb-haskell-jpruppPVP ok

version bump matches the API change (PVP)

Dependencies added: vector

Dependency ranges changed: haskoin-core, haskoin-store-data, rocksdb-haskell-jprupp

API changes (from Hackage documentation)

+ Haskoin.Store: [blockCache] :: StoreConfig -> !Maybe Int
+ Haskoin.Store: getManyTxData :: StoreReadBase m => [TxHash] -> m [Maybe TxData]
+ Haskoin.Store: getTransactions :: StoreReadBase m => [TxHash] -> m [Maybe Transaction]
+ Haskoin.Store.Common: getManyTxData :: StoreReadBase m => [TxHash] -> m [Maybe TxData]
+ Haskoin.Store.Common: getTransactions :: StoreReadBase m => [TxHash] -> m [Maybe Transaction]
+ Haskoin.Store.Common: mergeDescGroups :: forall (m :: Type -> Type) a k. (Monad m, Ord a, Ord k) => (a -> k) -> Maybe Word32 -> [ConduitT () a m ()] -> ConduitT () a m ()
+ Haskoin.Store.Database.Reader: [derived] :: DatabaseReader -> !IORef DerivedCache
+ Haskoin.Store.Manager: [blockCache] :: StoreConfig -> !Maybe Int
- Haskoin.Store: StoreConfig :: !Int -> ![String] -> !Bool -> !FilePath -> !Network -> !Maybe String -> !Ctx -> !Word32 -> !Word32 -> !Int -> !Integer -> !Bool -> !Bool -> !Bool -> !Int -> !Int -> !NominalDiffTime -> !NominalDiffTime -> !SockAddr -> WithConnection -> !Maybe Stats -> !Int -> !Bool -> !Maybe Int -> !Maybe Int -> !Maybe Int -> !Logger -> StoreConfig
+ Haskoin.Store: StoreConfig :: !Int -> ![String] -> !Bool -> !FilePath -> !Network -> !Maybe String -> !Ctx -> !Word32 -> !Word32 -> !Int -> !Integer -> !Bool -> !Bool -> !Bool -> !Int -> !Int -> !NominalDiffTime -> !NominalDiffTime -> !SockAddr -> WithConnection -> !Maybe Stats -> !Int -> !Bool -> !Maybe Int -> !Maybe Int -> !Maybe Int -> !Maybe Int -> !Logger -> StoreConfig
- Haskoin.Store.Database.Reader: DatabaseReader :: !DB -> !Word32 -> !Word32 -> !Network -> !Maybe DataMetrics -> !Ctx -> DatabaseReader
+ Haskoin.Store.Database.Reader: DatabaseReader :: !DB -> !Word32 -> !Word32 -> !Network -> !Maybe DataMetrics -> !Ctx -> !IORef DerivedCache -> DatabaseReader
- Haskoin.Store.Database.Reader: withDatabaseReader :: MonadUnliftIO m => Network -> Ctx -> Word32 -> Word32 -> FilePath -> Bool -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe DataMetrics -> DatabaseReaderT m a -> m a
+ Haskoin.Store.Database.Reader: withDatabaseReader :: MonadUnliftIO m => Network -> Ctx -> Word32 -> Word32 -> FilePath -> Bool -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe DataMetrics -> DatabaseReaderT m a -> m a
- Haskoin.Store.Manager: StoreConfig :: !Int -> ![String] -> !Bool -> !FilePath -> !Network -> !Maybe String -> !Ctx -> !Word32 -> !Word32 -> !Int -> !Integer -> !Bool -> !Bool -> !Bool -> !Int -> !Int -> !NominalDiffTime -> !NominalDiffTime -> !SockAddr -> WithConnection -> !Maybe Stats -> !Int -> !Bool -> !Maybe Int -> !Maybe Int -> !Maybe Int -> !Logger -> StoreConfig
+ Haskoin.Store.Manager: StoreConfig :: !Int -> ![String] -> !Bool -> !FilePath -> !Network -> !Maybe String -> !Ctx -> !Word32 -> !Word32 -> !Int -> !Integer -> !Bool -> !Bool -> !Bool -> !Int -> !Int -> !NominalDiffTime -> !NominalDiffTime -> !SockAddr -> WithConnection -> !Maybe Stats -> !Int -> !Bool -> !Maybe Int -> !Maybe Int -> !Maybe Int -> !Maybe Int -> !Logger -> StoreConfig

Files

app/Main.hs view
@@ -136,7 +136,8 @@     noWeb :: !Bool,     maxOpenFiles :: !Int,     writeBufferMB :: !Int,-    maxWalMB :: !Int+    maxWalMB :: !Int,+    blockCacheMB :: !Int   }  env :: (MonadIO m) => String -> a -> (String -> Maybe a) -> m a@@ -228,6 +229,8 @@     env "WRITE_BUFFER_MB" 256 readMaybe   maxWalMB <-     env "MAX_WAL_MB" 1024 readMaybe+  blockCacheMB <-+    env "BLOCK_CACHE_MB" 1024 readMaybe   return Config {version = False, ..}   where     tickerString =@@ -580,6 +583,13 @@         <> help "RocksDB write-ahead log size in MiB that forces flushes so old log files can be deleted (0 = RocksDB default)"         <> showDefault         <> value c.maxWalMB+  blockCacheMB <-+    option auto $+      metavar "MB"+        <> long "block-cache-mb"+        <> help "RocksDB block cache in MiB, shared by all column families, holding index and filter blocks too (0 = RocksDB default of 32 MiB per column family)"+        <> showDefault+        <> value c.blockCacheMB   pure Config {..}  networkReader :: String -> Either String Network@@ -655,6 +665,10 @@             maxWalSize =               if cfg.maxWalMB > 0                 then Just (cfg.maxWalMB * 1024 * 1024)+                else Nothing,+            blockCache =+              if cfg.blockCacheMB > 0+                then Just (cfg.blockCacheMB * 1024 * 1024)                 else Nothing,             logger = logger           }
+ bench/Main.hs view
@@ -0,0 +1,306 @@+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Load-test server for the web API.+--+-- Syncs a synthetic regtest chain from an in-process mock peer into a fresh+-- database, then serves the web API on it until killed, so that a load+-- generator such as wrk can be pointed at it. See @scripts/bench-web.sh@.+--+-- > haskoin-store-bench PORT BLOCKS [REDIS_URL]+--+-- Every block holds a coinbase and ten transactions. The coinbase and one+-- output of each transaction pay the "hot" address, so it gains eleven+-- transactions per block. Nine outputs per block go to the first 100+-- external and change addresses of the benchmark xpub, in turn. Both are+-- printed once the chain is synced.+module Main (main) where++import Conduit+import Control.Monad (forever, unless)+import Control.Monad.Reader (runReaderT)+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.HashMap.Strict qualified as HashMap+import Data.List (find)+import Control.DeepSeq (NFData, force)+import Data.Aeson (toEncoding)+import Data.Aeson.Encoding (encodingToLazyByteString)+import Data.Default (def)+import Data.IntMap.Strict qualified as IntMap+import Data.Vector qualified as V+import GHC.Clock (getMonotonicTimeNSec)+import Haskoin.Store.Data+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import Data.Serialize (decode, encode, runGet, runPut)+import Data.Text qualified as T+import Data.Text.IO qualified as T+import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Word (Word32)+import Haskoin+import Haskoin.Node+import Haskoin.Store+import NQE+import Network.Socket (SockAddr (..))+import System.Environment (getArgs)+import System.Random (randomIO)+import UnliftIO++main :: IO ()+main = do+  args <- getArgs+  case args of+    ["micro"] -> micro+    _ -> server args++server :: [String] -> IO ()+server args = do+  let (port, nblocks, redis) = case args of+        [p, n] -> (read p, read n, Nothing)+        [p, n, r] -> (read p, read n, Just r)+        _ -> error "usage: haskoin-store-bench PORT BLOCKS [REDIS_URL]"+  createContext >>= \ctx ->+    withSystemTempDirectory "haskoin-store-bench-" $ \dir -> do+      let net = bchRegTest+          blocks = chain ctx net nblocks+      requests <- newTVarIO []+      withStore (storeConfig ctx net dir redis blocks requests) $ \store -> do+        withSubscription store.pub $ \sub -> waitHeight store sub nblocks+        T.putStrLn $ "hot address: " <> addrText net (hotAddr ctx)+        T.putStrLn $ "xpub: " <> xPubExport net ctx (benchXPub ctx)+        T.putStrLn $ "xpub address: " <> addrText net (xpubAddr ctx 0 0)+        bh <- runReaderT getBestBlock store.db+        T.putStrLn $ "best block: " <> maybe "" blockHashToHex bh+        putStrLn $ "serving on port " <> show port+        hFlush stdout+        runWeb (webConfig store port)++addrText :: Network -> Address -> T.Text+addrText net = fromMaybe (error "address") . addrToText net++waitHeight :: Store -> Inbox StoreEvent -> Int -> IO ()+waitHeight store sub n = do+  h <- maybe 0 (.height) <$> runReaderT (getBestBlock >>= maybe (return Nothing) getBlock) store.db+  unless (fromIntegral h >= n) $ do+    _ <- receiveMatch sub $ \case+      StoreBestBlock b -> Just b+      _ -> Nothing+    waitHeight store sub n++storeConfig ::+  Ctx -> Network -> FilePath -> Maybe String -> [Block] -> TVar [Int] -> StoreConfig+storeConfig ctx net dir redis blocks requests =+  StoreConfig+    { maxPeers = 20,+      initPeers = [],+      discover = True,+      db = dir,+      net = net,+      ctx = ctx,+      redis = redis,+      gap = 32,+      initGap = 20,+      redisMinAddrs = 20,+      redisMaxKeys = 100 * 1000 * 1000,+      noMempool = False,+      wipeMempool = False,+      syncMempool = False,+      blockWindow = 64,+      mempoolTimeout = 14,+      peerTimeout = 60,+      maxPeerLife = 48 * 3600,+      connect = mockPeer blocks requests net ad,+      stats = Nothing,+      redisSyncInterval = 30,+      bloom = True,+      maxOpenFiles = Nothing,+      writeBufferSize = Nothing,+      maxWalSize = Nothing,+      blockCache = Just (1024 * 1024 * 1024),+      logger = quiet+    }+  where+    ad = NetworkAddress nodeNetwork (sockToHostAddress (SockAddrInet 0 0))++quiet :: Logger+quiet = Logger {level = LevelError, source = "", push = \_ -> return ()}++webConfig :: Store -> Int -> WebConfig+webConfig store port =+  WebConfig+    { host = "127.0.0.1",+      port = port,+      store = store,+      maxLaggingBlocks = 1000000,+      maxPendingTxs = 1000000,+      minPeers = 0,+      limits = def,+      version = "bench",+      noMempool = False,+      stats = Nothing,+      tickerRefresh = 90 * 1000 * 1000,+      tickerURL = "",+      priceHistoryURL = "",+      noXPub = False,+      noBlockchainInfo = False,+      healthCheckInterval = 30,+      logger = quiet+    }++---------------------+-- Synthetic chain --+---------------------++benchXPub :: Ctx -> XPubKey+benchXPub ctx = deriveXPubKey ctx (makeXPrvKey "haskoin-store benchmark seed")++xpubAddr :: Ctx -> KeyIndex -> KeyIndex -> Address+xpubAddr ctx m i = fst (deriveAddr ctx (pubSubKey ctx (benchXPub ctx) m) i)++hotAddr :: Ctx -> Address+hotAddr ctx = pubKeyAddr ctx (derivePublicKey ctx (wrapSecKey True (fromMaybe (error "key") (secKey (B.replicate 32 0x42)))))++scriptOf :: Ctx -> Address -> ByteString+scriptOf ctx = fromMaybe (error "script") . addressToScriptBS ctx++-- | Blocks at heights 1 to n on top of the regtest genesis.+chain :: Ctx -> Network -> Int -> [Block]+chain ctx net n = go 1 net.genesisHeader Nothing+  where+    hot = scriptOf ctx (hotAddr ctx)+    go h parent prevcb+      | h > n = []+      | otherwise =+          let cb = Tx 1 [TxIn nullOutPoint (encode (fromIntegral h :: Word32)) maxBound] [TxOut 5000000000 hot] [] 0+              txs = maybe [] (spends h) prevcb+              block = mine net parent (cb : txs)+           in block : go (h + 1) block.header (Just (txHash cb))+    spends h cb =+      let t1 = Tx 1 [TxIn (OutPoint cb 0) "" maxBound] (replicate 9 (TxOut 500000000 hot)) [] 0+          tk k =+            let j = fromIntegral (h * 9 + k) :: Word32+                a = xpubAddr ctx (j `mod` 2) ((j `div` 2) `mod` 50)+             in Tx+                  1+                  [TxIn (OutPoint (txHash t1) (fromIntegral k)) "" maxBound]+                  [TxOut 400000000 (scriptOf ctx a), TxOut 99000000 hot]+                  []+                  0+       in t1 : map tk [0 .. 8]++mine :: Network -> BlockHeader -> [Tx] -> Block+mine net parent txs = Block header txs+  where+    merkle = either error id (buildMerkleRoot (map txHash txs))+    candidate = BlockHeader 0x20000000 (headerHash parent) merkle (parent.timestamp + 600) parent.bits+    header =+      fromMaybe (error "no nonce") $+        find (isValidPOW net) (map candidate [0 ..])++-----------------------+-- Microbenchmarks --+-----------------------++-- | Time per call of the pure functions on the encoding path of a response.+-- Each call gets one of 64 different inputs, so that GHC cannot share the+-- result between calls.+micro :: IO ()+micro = do+  ctx <- createContext+  let net = bchRegTest+      blocks = chain ctx net 66+      txs = V.fromList [b.txs !! 2 | b <- drop 2 blocks]+      addrs = V.generate 64 (xpubAddr ctx 0 . fromIntegral)+      hot = scriptOf ctx (hotAddr ctx)+      txdata tx =+        TxData+          { block = BlockRef 3 2,+            tx = tx,+            prevs = IntMap.fromList [(0, Prev hot 500000000)],+            deleted = False,+            rbf = False,+            timestamp = 1700000000,+            spenders = IntMap.fromList [(0, Spender (txHash tx) 0)]+          }+      tds = V.map txdata txs+      tdbytes = V.map encode tds+      ts = V.map (toTransaction net ctx) tds+      pick :: V.Vector a -> Int -> a+      pick v i = v V.! (i `mod` 64)+  _ <- evaluate (force (tds, tdbytes, ts, addrs))+  bench "addrToText cashaddr" (addrToText net . pick addrs)+  bench "addrToText base58" (addrToText btc . pick addrs)+  bench "Address JSON (cashaddr)" (encodingToLazyByteString . marshalEncoding net . pick addrs)+  bench "txHash" (txHash . pick txs)+  bench "TxData decode" (\i -> either error id (decode (pick tdbytes i)) :: TxData)+  bench "toTransaction" (toTransaction net ctx . pick tds)+  bench "Transaction JSON" (encodingToLazyByteString . marshalEncoding net . pick ts)+  bench "TxHash JSON" (encodingToLazyByteString . toEncoding . txHash . pick txs)+  bench "BlockRef JSON" (\i -> encodingToLazyByteString (toEncoding (BlockRef (fromIntegral i) 1)))+  bench "StoreOutput JSON" (encodingToLazyByteString . marshalEncoding net . firstOf . (.outputs) . pick ts)+  bench "StoreInput JSON" (encodingToLazyByteString . marshalEncoding net . firstOf . (.inputs) . pick ts)+  where+    n = 200000+    firstOf = fromMaybe (error "empty") . listToMaybe+    bench :: (NFData a) => String -> (Int -> a) -> IO ()+    bench name f = do+      _ <- evaluate (force (f 0))+      t0 <- getMonotonicTimeNSec+      let go i+            | i >= n = return ()+            | otherwise = evaluate (force (f i)) >> go (i + 1)+      go 0+      t1 <- getMonotonicTimeNSec+      putStrLn $ name <> ": " <> show (fromIntegral (t1 - t0) / fromIntegral n / 1000 :: Double) <> " us"++---------------+-- Mock peer --+---------------++mockPeer :: [Block] -> TVar [Int] -> Network -> NetworkAddress -> SockAddr -> WithConnection+mockPeer blocks _requests net ad sa f = do+  r <- newInbox+  s <- newInbox+  let s' = inboxToMailbox s+  withAsync (go r s') $ \_ -> do+    let o = awaitForever (`send` r)+        i = forever (receive s >>= yield)+    f (Conduits i o) :: IO ()+  where+    heights = HashMap.fromList (zip (map (headerHash . (.header)) blocks) [1 :: Int ..])+    byHash = HashMap.fromList [(headerHash b.header, b) | b <- blocks]+    go :: Inbox ByteString -> Mailbox ByteString -> IO ()+    go r s = do+      nonce <- randomIO+      now <- round <$> liftIO getPOSIXTime+      let rmt = NetworkAddress 0 (sockToHostAddress sa)+          ver = buildVersion net nonce 0 ad rmt now+      runPut (putMessage net (MVersion ver)) `send` s+      runConduit $+        forever (receive r >>= yield)+          .| inc+          .| concatMapC react+          .| mapC (runPut . putMessage net)+          .| awaitForever (`send` s)+    inc = forever $ do+      x <- takeCE 24 .| foldC+      case decode x of+        Left _ -> error "mock peer: bad message header"+        Right (MessageHeader _ _ len _) -> do+          y <- takeCE (fromIntegral len) .| foldC+          case runGet (getMessage net) (x <> y) of+            Right m -> yield m+            Left e -> error ("mock peer: " <> e)+    react (MPing (Ping n)) = [MPong (Pong n)]+    react (MVersion _) = [MVerAck]+    react (MGetHeaders (GetHeaders _ locator _)) =+      let start = fromMaybe 0 (listToMaybe (mapMaybe (`HashMap.lookup` heights) locator))+          hdr :: Block -> (BlockHeader, VarInt)+          hdr b = (b.header, VarInt (fromIntegral (length b.txs)))+       in [MHeaders (Headers (map hdr (take 2000 (drop start blocks))))]+    react (MGetData (GetData ivs)) =+      [MBlock b | InvVector InvBlock h <- ivs, Just b <- [HashMap.lookup (BlockHash h) byHash]]+    react _ = []
haskoin-store.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           haskoin-store-version:        1.7.0+version:        1.8.0 synopsis:       Storage and index for Bitcoin and Bitcoin Cash description:    Please see the README on GitHub at <https://github.com/jprupp/haskoin-store#readme> category:       Bitcoin, Finance, Network@@ -52,9 +52,9 @@     , deepseq >=1.4.4.0     , hashable >=1.3.0.0     , hashtables >=1.2.4.2-    , haskoin-core >=1.6.0+    , haskoin-core >=1.6.1     , haskoin-node >=2.3.0-    , haskoin-store-data ==1.7.0+    , haskoin-store-data ==1.8.0     , hedis >=0.16.1     , http-types >=0.12.3     , lens >=4.18.1@@ -63,7 +63,7 @@     , network >=3.1.1.1     , nqe >=0.6.5     , random >=1.1-    , rocksdb-haskell-jprupp >=2.5.0+    , rocksdb-haskell-jprupp >=2.6.0     , rocksdb-query >=0.6.0     , scotty >=0.22     , statsd-rupp >=0.4@@ -88,7 +88,7 @@       Paths_haskoin_store   hs-source-dirs:       app-  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N4+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N   build-depends:       aeson >=1.4.7.1     , aeson-pretty >=0.8.8@@ -104,10 +104,10 @@     , filepath     , hashable >=1.3.0.0     , hashtables >=1.2.4.2-    , haskoin-core >=1.6.0+    , haskoin-core >=1.6.1     , haskoin-node >=2.3.0     , haskoin-store-    , haskoin-store-data ==1.7.0+    , haskoin-store-data ==1.8.0     , hedis >=0.16.1     , http-types >=0.12.3     , lens >=4.18.1@@ -117,7 +117,7 @@     , nqe >=0.6.5     , optparse-applicative     , random >=1.1-    , rocksdb-haskell-jprupp >=2.5.0+    , rocksdb-haskell-jprupp >=2.6.0     , rocksdb-query >=0.6.0     , scotty >=0.22     , statsd-rupp >=0.4@@ -142,6 +142,7 @@   other-modules:       Haskoin.Store.BlockStoreSpec       Haskoin.Store.CacheSpec+      Haskoin.Store.CommonSpec       Haskoin.Store.LogicSpec       Haskoin.Store.TestBlocks       Haskoin.Store.WebSpec@@ -149,7 +150,7 @@       Paths_haskoin_store   hs-source-dirs:       test-  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts "-with-rtsopts=-N4 -T"+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts "-with-rtsopts=-N -T"   build-depends:       QuickCheck >=2.13.2     , aeson >=1.4.7.1@@ -167,10 +168,10 @@     , fast-logger >=3.0     , hashable >=1.3.0.0     , hashtables >=1.2.4.2-    , haskoin-core >=1.6.0+    , haskoin-core >=1.6.1     , haskoin-node >=2.3.0     , haskoin-store-    , haskoin-store-data ==1.7.0+    , haskoin-store-data ==1.8.0     , hedis >=0.16.1     , hspec >=2.7.1     , http-types >=0.12.3@@ -180,7 +181,7 @@     , network >=3.1.1.1     , nqe >=0.6.5     , random >=1.1-    , rocksdb-haskell-jprupp >=2.5.0+    , rocksdb-haskell-jprupp >=2.6.0     , rocksdb-query >=0.6.0     , scotty >=0.22     , statsd-rupp >=0.4@@ -199,3 +200,57 @@     , wreq >=0.5.3.2   default-language: Haskell2010   build-tool-depends: hspec-discover:hspec-discover++benchmark haskoin-store-bench+  type: exitcode-stdio-1.0+  main-is: Main.hs+  other-modules:+      Paths_haskoin_store+  hs-source-dirs:+      bench+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      aeson >=1.4.7.1+    , aeson-pretty >=0.8.8+    , base >=4.9 && <5+    , base16 >=2.0+    , bytes >=0.17+    , bytestring >=0.10.10.0+    , cereal >=0.5.8.1+    , conduit >=1.3.2+    , containers >=0.6.2.1+    , data-default >=0.7.1.1+    , deepseq >=1.4.4.0+    , hashable >=1.3.0.0+    , hashtables >=1.2.4.2+    , haskoin-core >=1.6.1+    , haskoin-node >=2.3.0+    , haskoin-store+    , haskoin-store-data ==1.8.0+    , hedis >=0.16.1+    , http-types >=0.12.3+    , lens >=4.18.1+    , monad-control >=1.0.2.3+    , mtl >=2.2.2+    , network >=3.1.1.1+    , nqe >=0.6.5+    , random >=1.1+    , rocksdb-haskell-jprupp >=2.6.0+    , rocksdb-query >=0.6.0+    , scotty >=0.22+    , statsd-rupp >=0.4+    , stm >=2.5.0.0+    , string-conversions >=0.4.0.1+    , text >=1.2.3.0+    , time >=1.9.3+    , transformers >=0.5.6.2+    , unliftio >=0.2.12.1+    , unordered-containers >=0.2.10.0+    , vault >=0.3.1.5+    , vector+    , wai >=3.2.2.1+    , wai-websockets >=3.0.1.2+    , warp >=3.3.10+    , websockets >=0.13+    , wreq >=0.5.3.2+  default-language: Haskell2010
src/Haskoin/Store.hs view
@@ -27,6 +27,7 @@      -- * Useful Fuctions     getTransaction,+    getTransactions,     getDefaultBalance,     getActiveTxData,     blockAtOrBefore,
src/Haskoin/Store/BlockStore.hs view
@@ -399,6 +399,8 @@     runRocksDB . getBlock   getTxData =     runRocksDB . getTxData+  getManyTxData =+    runRocksDB . getManyTxData   getSpender =     runRocksDB . getSpender   getUnspent =
src/Haskoin/Store/Cache.hs view
@@ -266,6 +266,7 @@   getBlocksAtHeight = lift . getBlocksAtHeight   getBlock = lift . getBlock   getTxData = lift . getTxData+  getManyTxData = lift . getManyTxData   getSpender = lift . getSpender   getBalance = lift . getBalance   getUnspent = lift . getUnspent@@ -279,6 +280,7 @@   getBlocksAtHeight = lift . getBlocksAtHeight   getBlock = lift . getBlock   getTxData = lift . getTxData+  getManyTxData = lift . getManyTxData   getSpender = lift . getSpender   getBalance = lift . getBalance   getUnspent = lift . getUnspent@@ -349,35 +351,74 @@ xpubLock :: XPubSpec -> ByteString xpubLock xpub = "xlock-" <> encode xpub +-- | Run a read of the cache entries of an xpub in the same round trip to+-- Redis as the check that the xpub is cached. Redis pipelines the two+-- commands, since the reply of the first is not read before the second is+-- sent. The read is 'Nothing' if the xpub is not cached; it then found no+-- entries, as the keys of an xpub are all deleted together.+whenXPubCached ::+  (MonadIO m) =>+  XPubSpec ->+  Redis (Either Reply a) ->+  CacheX m (Maybe a)+whenXPubCached xpub action = do+  (cached, x) <- runRedis $ do+    e <- redisIsXPubCached xpub+    r <- action+    return $ (,) <$> e <*> r+  return $ if cached then Just x else Nothing++-- | The score to start reading a sorted set of an xpub from, when it does+-- not need a database lookup.+startScore :: Maybe Start -> Maybe (Maybe Double)+startScore Nothing = Just Nothing+startScore (Just (AtBlock h)) = Just (Just (blockRefScore (BlockRef h maxBound)))+startScore (Just AtTx {}) = Nothing+ getXPubTxs ::   (MonadUnliftIO m, StoreReadExtra m) =>   XPubSpec ->   [XPubBal] ->   Limits ->   CacheX m [TxRef]-getXPubTxs xpub xbals limits = do-  cached <- isXPubCached xpub-  if cached-    then do-      txs <- cacheGetXPubTxs xpub limits+getXPubTxs xpub xbals limits =+  case startScore limits.start of+    Just score ->+      whenXPubCached xpub (get_txs score) >>= \case+        Just xs -> do+          touchKeys [xpub]+          hit (map (\(t, s) -> TxRef {txid = t, block = scoreBlockRef s}) xs)+        Nothing -> lift $ xPubTxs xpub xbals limits+    Nothing -> do+      cached <- isXPubCached xpub+      if cached+        then hit =<< cacheGetXPubTxs xpub limits+        else lift $ xPubTxs xpub xbals limits+  where+    get_txs score =+      getFromSortedSet+        (txSetPfx <> encode xpub)+        score+        limits.offset+        limits.limit+    hit txs = do       withMetrics $ \s ->         incrementCounter s.xTxs (length txs)       return txs-    else lift $ xPubTxs xpub xbals limits  getXPubTxCount ::   (MonadUnliftIO m, StoreReadExtra m) =>   XPubSpec ->   [XPubBal] ->   CacheX m Word32-getXPubTxCount xpub xbals = do-  cached <- isXPubCached xpub-  if cached-    then do+getXPubTxCount xpub xbals =+  whenXPubCached xpub (redisGetXPubTxCount xpub) >>= \case+    Just count -> do       withMetrics $ \s ->         incrementCounter s.xTxCount 1-      cacheGetXPubTxCount xpub-    else lift $ xPubTxCount xpub xbals+      touchKeys [xpub]+      return (fromInteger count)+    Nothing -> lift $ xPubTxCount xpub xbals  getXPubUnspents ::   (MonadUnliftIO m, StoreReadExtra m) =>@@ -385,11 +426,27 @@   [XPubBal] ->   Limits ->   CacheX m [XPubUnspent]-getXPubUnspents xpub xbals limits = do-  cached <- isXPubCached xpub-  if cached-    then do-      ops <- map snd <$> cacheGetXPubUnspents xpub limits+getXPubUnspents xpub xbals limits =+  case startScore limits.start of+    Just score ->+      whenXPubCached xpub (get_ops score) >>= \case+        Just xs -> do+          touchKeys [xpub]+          hit (map fst xs)+        Nothing -> lift $ xPubUnspents xpub xbals limits+    Nothing -> do+      cached <- isXPubCached xpub+      if cached+        then hit . map snd =<< cacheGetXPubUnspents xpub limits+        else lift $ xPubUnspents xpub xbals limits+  where+    get_ops score =+      getFromSortedSet+        (utxoPfx <> encode xpub)+        score+        limits.offset+        limits.limit+    hit ops = do       uns <- catMaybes <$> lift (mapM getUnspent ops)       net <- lift getNetwork       ctx <- lift getCtx@@ -412,22 +469,20 @@           i a u = h u <$> g a       withMetrics $ \s -> incrementCounter s.xUnspents (length us)       return $ mapMaybe (uncurry i) us-    else lift $ xPubUnspents xpub xbals limits  getXPubBalances ::   (MonadUnliftIO m, StoreReadExtra m) =>   XPubSpec ->   CacheX m [XPubBal]-getXPubBalances xpub = do-  cached <- isXPubCached xpub-  if cached-    then do-      xbals <- cacheGetXPubBalances xpub+getXPubBalances xpub =+  whenXPubCached xpub (redisGetXPubBalances xpub) >>= \case+    Just xbals -> do+      touchKeys [xpub]       withMetrics $ \s -> do         incrementCounter s.hits 1         incrementCounter s.xBalances (length xbals)       return xbals-    else do+    Nothing -> do       withMetrics $ \s -> incrementCounter s.misses 1       withRetryLock (xpubLock xpub) (load_and_cache xpub) >>= \case         Just bals -> return bals@@ -453,21 +508,6 @@  redisIsXPubCached :: (RedisCtx m f) => XPubSpec -> m (f Bool) redisIsXPubCached xpub = Redis.exists (balancesPfx <> encode xpub)--cacheGetXPubBalances :: (MonadIO m) => XPubSpec -> CacheX m [XPubBal]-cacheGetXPubBalances xpub = do-  bals <- runRedis $ redisGetXPubBalances xpub-  touchKeys [xpub]-  return bals--cacheGetXPubTxCount ::-  (MonadUnliftIO m) =>-  XPubSpec ->-  CacheX m Word32-cacheGetXPubTxCount xpub = do-  count <- fromInteger <$> runRedis (redisGetXPubTxCount xpub)-  touchKeys [xpub]-  return count  redisGetXPubTxCount :: (RedisCtx m f) => XPubSpec -> m (f Integer) redisGetXPubTxCount xpub = Redis.zcard (txSetPfx <> encode xpub)
src/Haskoin/Store/Common.hs view
@@ -19,6 +19,7 @@     getActiveTxData,     getDefaultBalance,     getTransaction,+    getTransactions,     getNumTransaction,     blockAtOrAfter,     blockAtOrBefore,@@ -36,6 +37,7 @@     microseconds,     streamThings,     joinDescStreams,+    mergeDescGroups,     loopForever,   ) where@@ -60,7 +62,9 @@ import Data.HashSet qualified as H import Data.Hashable (Hashable) import Data.Map.Strict qualified as Map-import Data.Maybe (mapMaybe)+import Data.List (sortOn)+import Data.Maybe (catMaybes, mapMaybe)+import Data.Ord (Down (..)) import Data.Time.Clock.System   ( getSystemTime,     systemNanoseconds,@@ -120,6 +124,12 @@   getBlocksAtHeight :: BlockHeight -> m [BlockHash]   getBlock :: BlockHash -> m (Maybe BlockData)   getTxData :: TxHash -> m (Maybe TxData)++  -- | Data of many transactions, in the order of their hashes. Stores that+  -- can read many keys at once read them together.+  getManyTxData :: [TxHash] -> m [Maybe TxData]+  getManyTxData = mapM getTxData+   getSpender :: OutPoint -> m (Maybe Spender)   getBalance :: Address -> m (Maybe Balance)   getUnspent :: OutPoint -> m (Maybe Unspent)@@ -212,6 +222,14 @@   ctx <- getCtx   fmap (toTransaction net ctx) <$> getTxData h +-- | Many transactions, in the order of their hashes, read together.+getTransactions ::+  (StoreReadBase m) => [TxHash] -> m [Maybe Transaction]+getTransactions hs = do+  net <- getNetwork+  ctx <- getCtx+  map (fmap (toTransaction net ctx)) <$> getManyTxData hs+ getNumTransaction ::   (StoreReadExtra m) => Word64 -> m [Transaction] getNumTransaction i = do@@ -347,11 +365,17 @@     [] -> return ()     ls -> mapM_ yield ls >> go limits (last ls)   where+    -- Without a transaction to start the next page from, the next page+    -- starts at an offset, which the store reads and drops. Double the+    -- page each time, up to 'maxPage', so that the rows read and dropped+    -- stay in proportion to the rows streamed, instead of growing with+    -- their square.     h l x = case gettx of       Just g -> Just l {offset = 1, start = Just (AtTx (g x))}       Nothing -> case l.limit of         0 -> Nothing-        _ -> Just l {offset = l.offset + l.limit}+        _ -> Just l {offset = l.offset + l.limit, limit = max l.limit (min maxPage (2 * l.limit))}+    maxPage = 1024     go l x = case h l x of       Nothing -> return ()       Just l' ->@@ -383,3 +407,44 @@         let mp2 = Map.deleteMax mp             mp' = Map.unionWith (++) mp1 mp2         go (Just x) mp'++-- | Merge streams whose keys never increase, taking at most the given number+-- of items from each stream ('Nothing': all of them). The items come out in+-- groups of equal key, in decreasing order of key, and each group is sorted+-- in decreasing order of the items themselves. So the output is the same as+-- sorting the first items of every stream in decreasing order, but a stream+-- is read only as far as the consumer needs: a consumer that stops after+-- the first n items makes each stream be read only a little past its first+-- n items, instead of to the end of its quota.+mergeDescGroups ::+  (Monad m, Ord a, Ord k) =>+  (a -> k) ->+  Maybe Word32 ->+  [ConduitT () a m ()] ->+  ConduitT () a m ()+mergeDescGroups key quota streams = do+  heads <- lift $ mapM (next quota . sealConduitT) streams+  go (insertAll (catMaybes heads) Map.empty)+  where+    insertAll hs mp =+      foldr (\h@(x, _, _) -> Map.insertWith (<>) (Down (key x)) [h]) mp hs+    -- the next item of a stream, if any is left within its quota+    next (Just 0) _ = return Nothing+    next q s = do+      (s', mx) <- s $$++ await+      return $ (\x -> (x, subtract 1 <$> q, s')) <$> mx+    -- all the items of a stream with this key, and the first one after them+    group k (x, q, s) = do+      n <- next q s+      case n of+        Just h@(y, _, _)+          | key y == k -> do+              (ys, rest) <- group k h+              return (x : ys, rest)+        _ -> return ([x], n)+    go mp = case Map.minViewWithKey mp of+      Nothing -> return ()+      Just ((Down k, hs), mp') -> do+        gs <- lift $ mapM (group k) hs+        mapM_ yield (sortOn Down (concatMap fst gs))+        go (insertAll (mapMaybe snd gs) mp')
src/Haskoin/Store/Database/Reader.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-}@@ -31,18 +32,20 @@ where  import Conduit-import Control.Monad (forM, forM_)+import Control.Monad (forM, forM_, replicateM, (>=>)) import Control.Monad.Reader import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) import Data.Bits ((.&.)) import Data.ByteString qualified as BS import Data.ByteString.Char8 qualified as BS8 import Data.Default (def)+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap import Data.IntMap.Strict qualified as IntMap-import Data.List (sortOn)-import Data.Maybe (fromMaybe, mapMaybe)-import Data.Ord (Down (..))-import Data.Serialize (decode, encode)+import Data.Functor ((<&>))+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)+import Data.Serialize (Serialize, decode, encode) import Data.Text (Text) import Data.Text qualified as T import Data.Word (Word32)@@ -64,9 +67,52 @@     initGap :: !Word32,     net :: !Network,     metrics :: !(Maybe DataMetrics),-    ctx :: !Ctx+    ctx :: !Ctx,+    -- | addresses derived from recently queried xpubs+    derived :: !(IORef DerivedCache)   } +-- | Addresses of an xpub, external then change, in order of index. Each+-- list is infinite and lazy: it is derived as far as queries have needed,+-- and shared by later queries of the same xpub. Deriving an address costs+-- an elliptic curve operation and two hashes, which made up most of the+-- time of an xpub query.+data Derived = Derived ![(Word32, Address)] ![(Word32, Address)]++-- | Recently used xpubs and their addresses. When the newer generation is+-- full it becomes the older one, so the cache holds at most twice+-- 'derivedCacheSize' xpubs, and an xpub stays while it is in use.+data DerivedCache = DerivedCache+  { newer :: !(HashMap XPubSpec Derived),+    older :: !(HashMap XPubSpec Derived)+  }++derivedCacheSize :: Int+derivedCacheSize = 512++-- | Addresses of an xpub, from the cache or a new lazy derivation.+derivedAddrs :: (MonadIO m) => XPubSpec -> DatabaseReaderT m Derived+derivedAddrs xpub = do+  ref <- asks (.derived)+  ctx <- asks (.ctx)+  let fresh =+        let addrs m =+              deriveAddresses+                (deriveFunction ctx xpub.deriv)+                (pubSubKey ctx xpub.key m)+                0+         in Derived (addrs 0) (addrs 1)+  liftIO . atomicModifyIORef' ref $ \c ->+    case HashMap.lookup xpub c.newer of+      Just d -> (c, d)+      Nothing ->+        let d = fromMaybe fresh (HashMap.lookup xpub c.older)+            c'+              | HashMap.size c.newer >= derivedCacheSize =+                  DerivedCache {newer = HashMap.singleton xpub d, older = c.newer}+              | otherwise = c {newer = HashMap.insert xpub d c.newer}+         in (c', d)+ data DataMetrics = DataMetrics   { dataBestCount :: !StatCounter,     dataBlockCount :: !StatCounter,@@ -120,12 +166,14 @@   Maybe Int ->   Maybe Int ->   Maybe Int ->+  Maybe Int ->   Maybe DataMetrics ->   DatabaseReaderT m a ->   m a-withDatabaseReader net ctx igap gap dir bloom maxOpenFiles writeBuffers maxWal stats m =+withDatabaseReader net ctx igap gap dir bloom maxOpenFiles writeBuffers maxWal blockCache stats m =   withRunInIO $ \run ->-    withDBCF dir cfg (columnFamilyConfig bloom) $ \db -> do+    withDBCF dir cfg (columnFamilyConfig bloom (isJust blockCache)) $ \db -> do+      derived <- newIORef (DerivedCache HashMap.empty HashMap.empty)       let bdb =             DatabaseReader               { db = db,@@ -133,7 +181,8 @@                 net = net,                 initGap = igap,                 metrics = stats,-                ctx = ctx+                ctx = ctx,+                derived = derived               }       initRocksDB bdb       run $ runReaderT m bdb@@ -144,7 +193,9 @@           createMissingColumnFamilies = True,           maxFiles = maxOpenFiles,           dbWriteBufferSize = writeBuffers,-          maxTotalWalSize = maxWal+          maxTotalWalSize = maxWal,+          blockCacheSize = blockCache,+          cacheIndexAndFilterBlocks = isJust blockCache         }  -- | Memory that RocksDB uses outside the GHC heap, per column family:@@ -172,25 +223,29 @@   where     cfs =       ("default", Nothing)-        : zip (map (T.pack . fst) (columnFamilyConfig False)) (map Just (columnFamilies db))+        : zip (map (T.pack . fst) (columnFamilyConfig False False)) (map Just (columnFamilies db))     prop cf p = do       v <- maybe (getProperty db p) (\c -> getPropertyCF db c p) cf       return (v >>= readMaybe . BS8.unpack)     num :: Maybe Integer -> Text     num = maybe "?" (T.pack . show) -columnFamilyConfig :: Bool -> [(String, Config)]-columnFamilyConfig bloom =-  [ ("addr-tx", def {prefixLength = Just 22, bloomFilter = bloom}),-    ("addr-out", def {prefixLength = Just 22, bloomFilter = bloom}),-    ("tx", def {prefixLength = Just 33, bloomFilter = bloom}),-    ("spender", def {prefixLength = Just 33, bloomFilter = bloom}), -- unused-    ("unspent", def {prefixLength = Just 37, bloomFilter = bloom}),-    ("block", def {prefixLength = Just 33, bloomFilter = bloom}),-    ("height", def {prefixLength = Nothing, bloomFilter = bloom}),-    ("balance", def {prefixLength = Just 22, bloomFilter = bloom}),-    ("mempool", def {prefixLength = Nothing, bloomFilter = bloom})+-- | Column families, with Bloom filters or not, and with their index and+-- filter blocks in the block cache or not.+columnFamilyConfig :: Bool -> Bool -> [(String, Config)]+columnFamilyConfig bloom cached =+  [ ("addr-tx", c (Just 22)),+    ("addr-out", c (Just 22)),+    ("tx", c (Just 33)),+    ("spender", c (Just 33)), -- unused+    ("unspent", c (Just 37)),+    ("block", c (Just 33)),+    ("height", c Nothing),+    ("balance", c (Just 22)),+    ("mempool", c Nothing)   ]+  where+    c p = def {prefixLength = p, bloomFilter = bloom, cacheIndexAndFilterBlocks = cached}  addrTxCF :: DB -> ColumnFamily addrTxCF db = columnFamilies db !! 0@@ -394,6 +449,11 @@     go = withRunInIO $ \run ->       withIterCF db cf $ \it -> run (f it) +-- | Run an action with this many iterators on a column family. They are+-- opened and closed in a single bracket rather than one bracket each: each+-- bracket leaves a frame on the stack, and every call into RocksDB through+-- an iterator walks the stack, so with one bracket per address an xpub of a+-- hundred addresses made each step of each iterator a hundred times slower. withManyIters ::   (MonadUnliftIO m) =>   DB ->@@ -401,11 +461,31 @@   Int ->   ([Iterator] -> DatabaseReaderT m a) ->   DatabaseReaderT m a-withManyIters db cf i f = go [] i+withManyIters db cf i f = do+  m <- asks (.metrics)+  bracket_ (gauge m i) (gauge m (negate i)) $+    withRunInIO $ \run ->+      bracket (newIORef []) (readIORef >=> mapM_ destroyIterator) $ \ref -> do+        its <- replicateM i . mask_ $ do+          it <- createIterator db (Just cf)+          modifyIORef' ref (it :)+          return it+        run (f its)   where-    go acc 0 = f acc-    go acc n = withIterCFStat db cf $ \it -> go (it : acc) (n - 1)+    gauge (Just m) n = incrementGauge m.dataIters n+    gauge Nothing _ = return () +-- | Values of many keys of a column family, read with one multi-get, in+-- the order of the keys.+retrieveManyCF ::+  (KeyValue key value, Serialize key, Serialize value) =>+  DB ->+  ColumnFamily ->+  [key] ->+  IO [Maybe value]+retrieveManyCF db cf keys =+  map (fmap (either error id . decode)) <$> multiGetCF db cf (map encode keys)+ joinConduits ::   (Monad m, Ord o) =>   [ConduitT () o m ()] ->@@ -426,6 +506,12 @@         withMetrics $ \s -> incrementCounter s.dataTxCount 1         return (Just t) +  getManyTxData ths = do+    db <- asks (.db)+    tds <- liftIO (retrieveManyCF db (txCF db) (map TxKey ths))+    withMetrics $ \s -> incrementCounter s.dataTxCount (length (catMaybes tds))+    return tds+   getSpender op = runMaybeT $ do     td <- MaybeT $ getTxData op.hash     let i = fromIntegral op.index@@ -539,33 +625,33 @@     forM_ r.metrics $ \s -> incrementCounter s.dataTxCount (length txs)     return txs +  getBalances [] = return []   getBalances as = do-    zipWith f as <$> mapM getBalance as+    db <- asks (.db)+    withMetrics $ \s -> incrementCounter s.dataBalanceCount (length as)+    vals <- liftIO (retrieveManyCF db (balanceCF db) (map BalKey as))+    return (zipWith f as vals)     where       f a Nothing = zeroBalance a-      f _ (Just b) = b+      f a (Just v) = valToBalance a v    xPubBals xpub = do-    ctx <- asks (.ctx)     igap <- asks (.initGap)     gap <- asks (.maxGap)-    ext1 <- derive_until_gap gap 0 (take (fromIntegral igap) (aderiv ctx 0 0))+    Derived ext chg <- derivedAddrs xpub+    ext1 <- derive_until_gap gap 0 (take (fromIntegral igap) ext)     if all nullxb ext1       then do         withMetrics $ \s -> incrementCounter s.dataXPubBals (length ext1)         return ext1       else do-        ext2 <- derive_until_gap gap 0 (aderiv ctx 0 igap)-        chg <- derive_until_gap gap 1 (aderiv ctx 1 0)-        let bals = ext1 <> ext2 <> chg+        ext2 <- derive_until_gap gap 0 (drop (fromIntegral igap) ext)+        chg' <- derive_until_gap gap 1 chg+        let bals = ext1 <> ext2 <> chg'         withMetrics $ \s -> incrementCounter s.dataXPubBals (length bals)         return bals     where       nullxb b = nullBalance b.balance-      aderiv ctx m =-        deriveAddresses-          (deriveFunction ctx xpub.deriv)-          (pubSubKey ctx xpub.key m)       derive_until_gap gap m adrs =         let xb b n = XPubBal {path = [m, n], balance = b}             ig = fromIntegral gap@@ -585,16 +671,29 @@                   go (xs <> acc) as''          in go [] adrs +  -- The first unspent outputs of each address, at most offset plus limit+  -- of them, sorted together. Read the addresses in step, so that each is+  -- read only as far as the page needs, rather than decoding the whole+  -- quota of every address to keep one page of it.   xPubUnspents _xspec xbals limits = do-    us <- concat <$> mapM h cs+    db <- asks (.db)+    net <- asks (.net)+    ctx <- asks (.ctx)+    let quota = case (deOffset limits).limit of+          0 -> Nothing+          l -> Just l+        stream b it =+          unspentConduit net ctx b.balance.address limits.start it+            .| mapC (\u -> XPubUnspent {path = b.path, unspent = u})+    us <- withManyIters db (addrOutCF db) (length cs) $ \its ->+      runConduit $+        mergeDescGroups (.unspent.block) quota (zipWith stream cs its)+          .| applyLimitsC limits+          .| sinkList     withMetrics $ \s -> incrementCounter s.dataXPubUnspents (length us)-    return . applyLimits limits $ sortOn Down us+    return us     where-      l = deOffset limits       cs = filter ((> 0) . (.balance.utxo)) xbals-      i b = getAddressUnspents b.balance.address l-      f b t = XPubUnspent {path = b.path, unspent = t}-      h b = map (f b) <$> i b    xPubTxs _xspec xbals limits = do     let as =@@ -605,8 +704,11 @@     withMetrics $ \s -> incrementCounter s.dataXPubTxs (length txs)     return txs -  -- Count the merged streams instead of building the list of every-  -- transaction of the xpub only to take its length.+  -- Count the transactions of all the addresses, each once even when it+  -- touches several of them. The entries of an address are sorted, and the+  -- same transaction has the same key after the address prefix in every+  -- address, so merge the addresses by those raw key bytes and count the+  -- distinct ones, without decoding any key or reading any value.   xPubTxCount _xspec xbals = do     withMetrics $ \s -> incrementCounter s.dataXPubTxCount 1     db <- asks (.db)@@ -615,5 +717,23 @@             filter (not . nullBalance) $               map (.balance) xbals     withManyIters db (addrTxCF db) (length as) $ \its ->-      runConduit (joinDescStreams (zipWith (`addressConduit` Nothing) as its) .| lengthC)-        >>= \n -> return (fromIntegral (n :: Int))+      liftIO $ fromIntegral <$> countDistinctSuffixes (zip (map (encode . AddrTxKeyA) as) its)++-- | Number of distinct keys, after their prefix, in a set of iterators that+-- each walk the keys with one prefix, in order.+countDistinctSuffixes :: [(BS.ByteString, Iterator)] -> IO Int+countDistinctSuffixes streams = do+  heads <- mapM start streams+  go 0 (Map.fromListWith (<>) [(k, [x]) | (k, x) <- catMaybes heads])+  where+    start x@(pfx, it) = iterSeek it pfx >> current x+    current x@(pfx, it) =+      iterKey it <&> \case+        Just k | pfx `BS.isPrefixOf` k -> Just (BS.drop (BS.length pfx) k, x)+        _ -> Nothing+    advance x@(_, it) = iterNext it >> current x+    go !n m = case Map.minViewWithKey m of+      Nothing -> return n+      Just ((_, xs), m') -> do+        nexts <- catMaybes <$> mapM advance xs+        go (n + 1) (foldr (\(k, x) -> Map.insertWith (<>) k [x]) m' nexts)
src/Haskoin/Store/Manager.hs view
@@ -173,6 +173,10 @@     -- | 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   }@@ -223,6 +227,7 @@     cfg.maxOpenFiles     cfg.writeBufferSize     cfg.maxWalSize+    cfg.blockCache     stats     f 
src/Haskoin/Store/Web.hs view
@@ -458,6 +458,7 @@   getBlocksAtHeight height = runInWebReader (getBlocksAtHeight height)   getBlock bh = runInWebReader (getBlock bh)   getTxData th = runInWebReader (getTxData th)+  getManyTxData ths = runInWebReader (getManyTxData ths)   getSpender op = runInWebReader (getSpender op)   getUnspent op = runInWebReader (getUnspent op)   getBalance a = runInWebReader (getBalance a)@@ -488,6 +489,7 @@   getBlocksAtHeight = lift . getBlocksAtHeight   getBlock = lift . getBlock   getTxData = lift . getTxData+  getManyTxData = lift . getManyTxData   getSpender = lift . getSpender   getUnspent = lift . getUnspent   getBalance = lift . getBalance@@ -972,7 +974,7 @@  toRawBlock :: (StoreReadBase m) => BlockData -> m H.Block toRawBlock b = do-  txs <- map transactionData . catMaybes <$> mapM getTransaction b.txs+  txs <- map transactionData . catMaybes <$> getTransactions b.txs   return H.Block {header = b.header, txs}  -- GET BlockBest / BlockBestRaw --@@ -1093,7 +1095,7 @@   (MonadUnliftIO m) => GetTxs -> ActionT m [Transaction] scottyTxs (GetTxs txids) =   withMetrics (.tx) $-    catMaybes <$> mapM getTransaction (nub txids)+    catMaybes <$> getTransactions (nub txids)  scottyTxRaw ::   (MonadUnliftIO m) => GetTxRaw -> ActionT m (RawResult Tx)@@ -1111,7 +1113,7 @@ scottyTxsRaw (GetTxsRaw txids) =   withMetrics (.txRaw) $     RawResultList . map transactionData . catMaybes-      <$> mapM getTransaction (nub txids)+      <$> getTransactions (nub txids)  getTxsBlock ::   (MonadUnliftIO m) =>@@ -1120,7 +1122,7 @@ getTxsBlock h =   getBlock h >>= \case     Nothing -> raise ThingNotFound-    Just b -> catMaybes <$> mapM getTransaction b.txs+    Just b -> catMaybes <$> getTransactions b.txs  scottyTxsBlock ::   (MonadUnliftIO m) =>@@ -1340,7 +1342,7 @@ scottyAddrTxsFull (GetAddrTxsFull addr pLimits) =   withMetrics (.addressTxFull) $ do     txs <- getAddressTxs addr =<< paramToLimits True pLimits-    catMaybes <$> mapM (getTransaction . (.txid)) txs+    catMaybes <$> getTransactions (map (.txid) txs)  scottyAddrsTxsFull ::   (MonadUnliftIO m) =>@@ -1349,7 +1351,7 @@ scottyAddrsTxsFull (GetAddrsTxsFull addrs pLimits) =   withMetrics (.addressTxFull) $ do     txs <- getAddressesTxs addrs =<< paramToLimits True pLimits-    catMaybes <$> mapM (getTransaction . (.txid)) txs+    catMaybes <$> getTransactions (map (.txid) txs)  scottyAddrBalance :: (MonadUnliftIO m) => GetAddrBalance -> ActionT m Balance scottyAddrBalance (GetAddrBalance addr) =@@ -1417,7 +1419,7 @@ scottyXPubTxsFull (GetXPubTxsFull xpub deriv plimits (NoCache nocache)) =   withMetrics (.xpubTxFull) $     fmap catMaybes $-      lift . runNoCache nocache . mapM (getTransaction . (.txid))+      lift . runNoCache nocache . getTransactions . map (.txid)         =<< getXPubTxs xpub deriv plimits nocache  scottyXPubBalances ::@@ -2219,7 +2221,7 @@     streamEncoding $ marshalEncoding (net, ctx) is   where     get_binfo_blocks numtxid ns b = do-      txs <- catMaybes <$> mapM getTransaction b.txs+      txs <- catMaybes <$> getTransactions b.txs       let h x = H.headerHash x.header           nbs = [h n | n <- ns, n.header.prev == h b]           bts = map (toBinfoTxSimple numtxid) txs@@ -2255,7 +2257,7 @@   where     go numtxid hex bh = do       b <- maybe (raise ThingNotFound) return =<< getBlock bh-      txs <- catMaybes <$> mapM getTransaction b.txs+      txs <- catMaybes <$> getTransactions b.txs       nhs <- fmap catMaybes $ getBlocksAtHeight (b.height + 1) >>= mapM getBlock       let h x = H.headerHash x.header           nxt = [h n | n <- nhs, n.header.prev == h b]@@ -2377,7 +2379,7 @@         else           map snd             <$> getMempoolPage (Limits (fromIntegral n) (fromIntegral offset) Nothing)-    txs <- catMaybes <$> mapM getTransaction txids+    txs <- catMaybes <$> getTransactions txids     net <- askl (.config.store.net)     setHeaders     let mem = BinfoMempool $ map (toBinfoTxSimple numtxid) txs
+ test/Haskoin/Store/CommonSpec.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE OverloadedRecordDot #-}++module Haskoin.Store.CommonSpec (spec) where++import Conduit (runConduit, runConduitPure, sinkList, takeC, yieldMany, (.|))+import Control.Monad.State.Strict (State, modify', runState)+import Data.List (sortOn)+import Data.Ord (Down (..))+import Data.Word (Word32)+import Haskoin.Store.Common+  ( Limits (..),+    applyLimit,+    applyLimits,+    applyLimitsC,+    deOffset,+    mergeDescGroups,+    streamThings,+  )+import Test.Hspec+import Test.QuickCheck++spec :: Spec+spec = do+  describe "streamThings" $ do+    it "streams every item in order when paging by offset" $+      property $ \(NonNegative n) (Positive page) ->+        let xs = [1 .. n :: Int]+            (ys, _) = paged (fromIntegral (page `mod` 50 + 1 :: Int)) xs Nothing+         in ys === xs+    it "reads a number of rows in proportion to the rows taken" $+      property $ \(NonNegative n) ->+        let xs = [1 .. 100000 :: Int]+            (ys, reads) = paged 16 xs (Just n)+         in ys === take n xs .&&. counterexample (show reads) (reads <= 4 * n + 2048)+  describe "mergeDescGroups" $ do+    it "pages like sorting the first offset plus limit items of every stream" $+      property $ \(Streams ss) ->+        forAll (choose (0, 8)) $ \l ->+          forAll (choose (0, 8)) $ \o ->+            let limits = Limits l o Nothing+             in merged limits ss === reference limits ss+    it "stops reading once the page is full" $+      -- an undefined item after the first key of each stream would throw+      let ss = [[(9, 1), (8, 2)] <> undefined, [(9, 3), (7, 4)] <> undefined]+          limits = Limits 2 0 Nothing+       in merged limits ss `shouldBe` [(9, 3), (9, 1)]++-- | The paging of xpub unspent outputs before 'mergeDescGroups': the first+-- offset plus limit items of each stream, all sorted, then the page.+reference :: Limits -> [[(Int, Int)]] -> [(Int, Int)]+reference limits =+  applyLimits limits . sortOn Down . concatMap (applyLimit (deOffset limits).limit)++merged :: Limits -> [[(Int, Int)]] -> [(Int, Int)]+merged limits ss =+  runConduitPure $+    mergeDescGroups fst quota (map yieldMany ss)+      .| applyLimitsC limits+      .| sinkList+  where+    quota = case (deOffset limits).limit of+      0 -> Nothing+      q -> Just (q :: Word32)++-- | Streams of (key, item) whose keys never increase, with many equal keys+-- and the items of each key in any order, like the unspent outputs of the+-- addresses of an xpub: keyed by block, in key order within a block.+-- Items are unique across all streams, as outputs are.+newtype Streams = Streams [[(Int, Int)]]+  deriving (Show)++instance Arbitrary Streams where+  arbitrary = do+    n <- choose (0, 6)+    ss <- vectorOf n $ do+      keys <- sortOn Down <$> listOf (choose (0, 5))+      items <- shuffle [0 .. length keys - 1]+      return (zip keys items)+    -- number the items so that they are unique across streams+    return . Streams $+      zipWith (\i -> map (\(k, x) -> (k, x * 10 + i))) [0 ..] ss++-- | Stream a list through 'streamThings' paging by offset, taking at most+-- the given number of items. Also gives the rows that the store read,+-- counting those it skipped to reach the offset of each page.+paged :: Word32 -> [Int] -> Maybe Int -> ([Int], Int)+paged page xs n =+  flip runState 0 . runConduit $+    streamThings getit Nothing (Limits page 0 Nothing)+      .| maybe sinkList (\k -> takeC k .| sinkList) n+  where+    getit :: Limits -> State Int [Int]+    getit l = do+      let ys = applyLimits l xs+      modify' (+ (fromIntegral l.offset + length ys))+      return ys
test/Haskoin/Store/LogicSpec.hs view
@@ -82,7 +82,7 @@     it "moves the mempool into its column family when opened" $       withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do         let open :: DatabaseReaderT IO () -> IO ()-            open = withDatabaseReader btcRegTest ctx 20 32 dir False Nothing Nothing Nothing Nothing+            open = withDatabaseReader btcRegTest ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing             h1 = TxHash (sha256 ("one" :: B.ByteString))             h2 = TxHash (sha256 ("two" :: B.ByteString))             old = [(200, h2), (100, h1)] :: [(Word64, TxHash)]@@ -99,7 +99,7 @@     it "indexes the mempool by time when opened" $       withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do         let open :: DatabaseReaderT IO () -> IO ()-            open = withDatabaseReader btcRegTest ctx 20 32 dir False Nothing Nothing Nothing Nothing+            open = withDatabaseReader btcRegTest ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing             old = sortOn Down (zip [300, 100, 200] (hashes 3))         open $ ask >>= \r -> liftIO $ do           insert r.db VersionKey (19 :: Word32)@@ -398,7 +398,7 @@ withDB :: Network -> Ctx -> (DatabaseReader -> IO a) -> IO a withDB net ctx go =   withSystemTempDirectory "haskoin-store-logic-" $ \dir ->-    withDatabaseReader net ctx 20 32 dir False Nothing Nothing Nothing Nothing (ask >>= liftIO . go)+    withDatabaseReader net ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing (ask >>= liftIO . go)  -- | Run with the genesis block and one mined block, passing the output of -- that block's coinbase, worth 5000000000.
test/Haskoin/StoreSpec.hs view
@@ -168,6 +168,7 @@               maxOpenFiles = Nothing,               writeBufferSize = Nothing,               maxWalSize = Nothing,+              blockCache = Nothing,               logger = Logger {level = LevelError, source = "", push = \_ -> return ()}             }     withStore cfg $ \store ->