diff --git a/ouroboros-consensus-cardano/src/byron/Ouroboros/Consensus/Byron/Node.hs b/ouroboros-consensus-cardano/src/byron/Ouroboros/Consensus/Byron/Node.hs
index acb23ba761..737498bcf3 100644
--- a/ouroboros-consensus-cardano/src/byron/Ouroboros/Consensus/Byron/Node.hs
+++ b/ouroboros-consensus-cardano/src/byron/Ouroboros/Consensus/Byron/Node.hs
@@ -145,13 +145,14 @@ byronBlockForging creds =
tickedPBftState
, forgeBlock = \ForgeBlockArgs{..} ->
return $
- forgeByronBlock
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- fbRbTxs
- fbIsLeader
+ flip (,) Nothing $
+ forgeByronBlock
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ fbRbTxs
+ fbIsLeader
, finalize = pure ()
}
where
diff --git a/ouroboros-consensus-cardano/src/shelley/Ouroboros/Consensus/Shelley/Ledger/Forge.hs b/ouroboros-consensus-cardano/src/shelley/Ouroboros/Consensus/Shelley/Ledger/Forge.hs
index d5f57f1ad3..8a0e27c443 100644
--- a/ouroboros-consensus-cardano/src/shelley/Ouroboros/Consensus/Shelley/Ledger/Forge.hs
+++ b/ouroboros-consensus-cardano/src/shelley/Ouroboros/Consensus/Shelley/Ledger/Forge.hs
@@ -24,18 +24,16 @@ import qualified Cardano.Ledger.Shelley.API as SL (Block (..), extractTx)
import Cardano.Prelude (nonEmpty)
import qualified Cardano.Protocol.TPraos.BHeader as SL
import Control.Exception
-import Control.Monad (void, when)
+import Control.Monad (when)
import Control.Tracer (traceWith)
import Data.ByteString.Short (fromShort)
import Data.Maybe (isJust)
import Data.Maybe.Strict (StrictMaybe (..), maybeToStrictMaybe)
import qualified Data.Sequence.Strict as Seq
import qualified Data.Typeable as Typeable
-import LeiosDemoDb (LeiosDbConnection (..))
import LeiosDemoTypes
( EbAnnouncement (..)
, ForgedLeiosEb (..)
- , LeiosPoint (..)
, RbHash (..)
, TraceLeiosKernel (..)
, forgeLeiosEb
@@ -73,7 +71,7 @@ forgeShelleyBlock ::
HotKey (ProtoCrypto proto) m ->
CanBeLeader proto ->
ForgeBlockArgs m (ShelleyBlock proto era) ->
- m (ShelleyBlock proto era)
+ m (ShelleyBlock proto era, Maybe ForgedLeiosEb)
forgeShelleyBlock hotKey cbl ForgeBlockArgs{..} = do
-- Forge an RB and attempt to announce an EB and/or certify a previously announced one:
--
@@ -85,7 +83,7 @@ forgeShelleyBlock hotKey cbl ForgeBlockArgs{..} = do
-- been rebased onto the post-certificate ledger state.
mayEbAnn <-
case Typeable.eqT @era @DijkstraEra of
- Just Refl -> mkAndStoreEb
+ Just Refl -> mkEb
Nothing -> pure Nothing
let rbBody = mkBody fbMayLeiosCert
actualRbBodySize = SL.blockBodySize protocolVersion rbBody
@@ -122,9 +120,10 @@ forgeShelleyBlock hotKey cbl ForgeBlockArgs{..} = do
traceWith fbLeiosTracer $
TraceLeiosCertifiedAndAnnounced{atSlot = fbCurrentSlotNo, rbHash = MkRbHash announcingRbHashBytes}
Nothing -> pure ()
- return $
- assert (verifyBlockIntegrity (configSlotsPerKESPeriod $ configConsensus fbConfig) blk) $
- blk
+ return
+ ( assert (verifyBlockIntegrity (configSlotsPerKESPeriod $ configConsensus fbConfig) blk) blk
+ , fst <$> mayEbAnn
+ )
where
protocolVersion = shelleyProtocolVersion $ configBlock fbConfig
@@ -154,16 +153,12 @@ forgeShelleyBlock hotKey cbl ForgeBlockArgs{..} = do
. getTipHash
$ fbCurrentTickedLedgerState
- -- Produce an EB from fbEbTxs, store it into fbLeiosDb, and return the
- -- announcement to embed in the header. An honest forger only emits an
- -- EB when it has txs to put in it; empty mempool ⇒ no EB ⇒ no
- -- announcement (matches the original prototype). Persists the EB into
- -- 'LeiosDb' before returning, so the closure is available locally
- -- before the header carrying the announcement is finalised and
- -- diffused — a peer that fetches our header will be able to pull the
- -- closure from us in the same round-trip.
- mkAndStoreEb :: m (Maybe (ForgedLeiosEb, EbAnnouncement))
- mkAndStoreEb = case nonEmpty (fmap extractTx fbEbTxs) of
+ -- Produce an EB from 'fbEbTxs' and return it together with the announcement
+ -- to embed in the header. An honest forger only emits an EB when it has txs
+ -- to put in it; empty mempool ⇒ no EB ⇒ no announcement (matches the original
+ -- prototype).
+ mkEb :: m (Maybe (ForgedLeiosEb, EbAnnouncement))
+ mkEb = case nonEmpty (fmap extractTx fbEbTxs) of
Nothing -> pure Nothing
Just ebTxs -> do
let forgedEb = forgeLeiosEb fbCurrentSlotNo ebTxs
@@ -174,11 +169,6 @@ forgeShelleyBlock hotKey cbl ForgeBlockArgs{..} = do
{ ebAnnouncementHash = ebHash
, ebAnnouncementSize = ebSize
}
- ebPoint =
- MkLeiosPoint
- { pointSlotNo = fbCurrentSlotNo
- , pointEbHash = ebHash
- }
traceWith fbLeiosTracer $
TraceLeiosBlockForged
{ slot = fbCurrentSlotNo
@@ -186,12 +176,4 @@ forgeShelleyBlock hotKey cbl ForgeBlockArgs{..} = do
, ebMeasure = ByteSize32 ebSize
, mempoolRestMeasure = ByteSize32 0
}
- leiosDbInsertEbPoint fbLeiosDb ebPoint ebSize
- void $ leiosDbInsertEbBody fbLeiosDb ebPoint forgedEb.body
- void $ leiosDbInsertTxs fbLeiosDb forgedEb.txClosure
- traceWith fbLeiosTracer $
- TraceLeiosBlockStored
- { slot = fbCurrentSlotNo
- , eb = forgedEb.body
- }
pure (Just (forgedEb, ebAnn))
diff --git a/ouroboros-consensus-cardano/src/unstable-byron-testlib/Ouroboros/Consensus/ByronDual/Node.hs b/ouroboros-consensus-cardano/src/unstable-byron-testlib/Ouroboros/Consensus/ByronDual/Node.hs
index 2143613f5d..548d6f8963 100644
--- a/ouroboros-consensus-cardano/src/unstable-byron-testlib/Ouroboros/Consensus/ByronDual/Node.hs
+++ b/ouroboros-consensus-cardano/src/unstable-byron-testlib/Ouroboros/Consensus/ByronDual/Node.hs
@@ -67,13 +67,14 @@ dualByronBlockForging creds =
, checkCanForge = checkCanForge . dualTopLevelConfigMain
, forgeBlock = \ForgeBlockArgs{..} ->
return $
- forgeDualByronBlock
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- fbRbTxs
- fbIsLeader
+ flip (,) Nothing $
+ forgeDualByronBlock
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ fbRbTxs
+ fbIsLeader
, finalize = return ()
}
where
diff --git a/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBAnalyser/Run.hs b/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBAnalyser/Run.hs
index 151f5f2d81..d119fb220f 100644
--- a/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBAnalyser/Run.hs
+++ b/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBAnalyser/Run.hs
@@ -187,6 +187,7 @@ analyse dbaConfig args =
shfs
flavargs
leiosDbHandle
+ (\_ -> pure ()) -- no LeiosTxCache in this tool
$ ChainDB.defaultArgs
-- Set @k=1@ to reduce the memory usage of the LedgerDB. We only ever
-- go forward so we don't need to account for rollbacks.
diff --git a/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Forging.hs b/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Forging.hs
index e7ebc45ed1..2e30771eab 100644
--- a/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Forging.hs
+++ b/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Forging.hs
@@ -111,7 +111,7 @@ runForge ::
GenTxs blk ->
LeiosDbConnection IO ->
IO ForgeResult
-runForge epochSize_ nextSlot opts chainDB blockForging cfg genTxs leiosDb = do
+runForge epochSize_ nextSlot opts chainDB blockForging cfg genTxs _leiosDb = do
putStrLn $ "--> epoch size: " ++ show epochSize_
putStrLn $ "--> will process until: " ++ show opts
-- Synthetic forging doesn't gather votes; supply a vote state with
@@ -233,7 +233,9 @@ runForge epochSize_ nextSlot opts chainDB blockForging cfg genTxs leiosDb = do
tickedLedgerState
-- Actually produce the block
- newBlock <-
+ --
+ -- TODO the block may be accompanied by an EB, which is entirely ignored
+ (newBlock, _mForgedEb) <-
lift $
Block.forgeBlock
blockForging'
@@ -246,7 +248,6 @@ runForge epochSize_ nextSlot opts chainDB blockForging cfg genTxs leiosDb = do
, fbEbTxs = []
, fbIsLeader = proof
, fbChainDepState = Nothing
- , fbLeiosDb = leiosDb
, fbLeiosTracer = Trace.nullTracer
, fbLeiosVoteState = leiosVoteState
, fbMayLeiosCert = Nothing
diff --git a/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Run.hs b/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Run.hs
index dfc33ae2d6..49c71cbd2a 100644
--- a/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Run.hs
+++ b/ouroboros-consensus-cardano/src/unstable-cardano-tools/Cardano/Tools/DBSynthesizer/Run.hs
@@ -170,6 +170,7 @@ synthesize genTxs DBSynthesizerConfig{confOptions, confShelleyGenesis, confDbDir
(Node.stdMkChainDbHasFS confDbDir)
flavargs
leiosDbHandle
+ (\_ -> pure ()) -- no LeiosTxCache in this tool
$ ChainDB.defaultArgs
mbfs <- mkForgers nullTracer
diff --git a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs
index b88369d737..2aad154b1d 100644
--- a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs
+++ b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Network/NodeToNode.hs
@@ -136,6 +136,10 @@ import qualified Network.Mux as Mux
import Network.TypedProtocol.Codec
import Network.TypedProtocol.Peer (Peer (Effect))
import Ouroboros.Consensus.Block
+import Ouroboros.Consensus.BlockchainTime.WallClock.Types
+ ( diffRelTime
+ , systemTimeCurrent
+ )
import Ouroboros.Consensus.Config (DiffusionPipeliningSupport (..))
import Ouroboros.Consensus.HeaderValidation (HeaderWithTime)
import Ouroboros.Consensus.Ledger.SupportsMempool
@@ -157,7 +161,7 @@ import Ouroboros.Consensus.Storage.LedgerDB.Forker
( ResolveLeiosBlock
)
import Ouroboros.Consensus.Storage.Serialisation (SerialisedHeader)
-import Ouroboros.Consensus.Util (ShowProxy)
+import Ouroboros.Consensus.Util (ShowProxy, whenJust)
import Ouroboros.Consensus.Util.IOLike
import Ouroboros.Consensus.Util.Orphans ()
import Ouroboros.Network.Block
@@ -351,6 +355,7 @@ mkHandlers ::
( IOLike m
, MonadTime m
, MonadTimer m
+ , ConvertRawHash blk
, LedgerSupportsMempool blk
, HasTxId (GenTx blk)
, LedgerSupportsProtocol blk
@@ -397,8 +402,29 @@ mkHandlers
, CsClient.tracer =
contramap (TraceLabelPeer peer) (Node.chainSyncClientTracer tracers)
, CsClient.getDiffusionPipeliningSupport = getDiffusionPipeliningSupport
- , CsClient.leiosCertRbCallback =
- Leios.leiosCertRbCallback (getLeiosOutstanding, getLeiosReady) peerVars
+ , CsClient.leiosMsgRollForwardCallback = \hdr hdrSlotTime cds -> do
+ Leios.checkMsgRollForwardForLeiosOffers
+ (getLeiosOutstanding, getLeiosReady)
+ peerVars
+ hdr
+ cds
+ -- Feed any EB this header announces into the central
+ -- announcement state (relay + dedup + txCache), central-only:
+ -- a roll-forward is not this peer announcing over LeiosNotify,
+ -- so no PeerState is touched. Date it from the header slot's
+ -- onset (its ChainSync arrival latency).
+ whenJust (Leios.mkAnnouncingHeader hdr) $ \ancHdr -> do
+ now <- systemTimeCurrent systemTime
+ Leios.processAnnouncementCentrally
+ (Node.leiosKernelTracer tracers)
+ getLeiosCentralState
+ (getLeiosOutstanding, getLeiosReady)
+ getLeiosTxCache
+ (Just peer)
+ Leios.ReceivedViaChainSync
+ Announcements.DoRelay
+ (Just (diffRelTime now hdrSlotTime))
+ ancHdr
}
dynEnv
, hChainSyncServer = \peer _version ->
@@ -493,23 +519,20 @@ mkHandlers
(Leios.ancHeader ancH)
)
-- central part of the processing
- ( \ancHdr (shouldRelay, age, anc'@(p, _sz)) -> do
+ ( \ancHdr (shouldRelay, age, (p, _sz)) -> do
traceWith tracer $
MkTraceLeiosPeer $
"MsgLeiosBlockAnnouncement new: " <> Leios.prettyLeiosPoint p
- MVar.modifyMVar_ getLeiosCentralState $ \cst ->
- -- TODO OK to hold this the whole time we're writing to the LeiosNotify queues (NB those enqeues never block)?
- Announcements.onAnnouncementCentral
- (contramap Leios.traceNewAnnouncement kernelTracer)
- Leios.ancElId
- ( \_elSt ->
- Leios.recordAnnouncedEb (getLeiosOutstanding, getLeiosReady) anc'
- )
- cst
- (Just peer)
- shouldRelay
- (Just age)
- ancHdr
+ Leios.processAnnouncementCentrally
+ kernelTracer
+ getLeiosCentralState
+ (getLeiosOutstanding, getLeiosReady)
+ getLeiosTxCache
+ (Just peer)
+ Leios.ReceivedViaLeiosNotify
+ shouldRelay
+ (Just age)
+ ancHdr
)
peerSt0
anc
@@ -666,6 +689,7 @@ mkHandlers
(leiosPeerTracer peer)
((== Terminate) <$> controlMessageSTM)
(getLeiosOutstanding, getLeiosReady)
+ getLeiosTxCache
leiosConn
(Leios.MkPeerId peer)
reqVar
@@ -684,6 +708,7 @@ mkHandlers
, getLeiosOutstanding
, getLeiosReady
, getLeiosCentralState
+ , getLeiosTxCache
} = nodeKernel
leiosPeerTracer peer = TraceLabelPeer peer `contramap` Node.leiosPeerTracer tracers
diff --git a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Node.hs b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Node.hs
index 52a8c2d472..01f16589b5 100644
--- a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Node.hs
+++ b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/Node.hs
@@ -110,6 +110,8 @@ import Data.Set (Set)
import Data.Time (NominalDiffTime)
import Data.Typeable (Typeable)
import LeiosDemoDb (LeiosDbHandle)
+import LeiosDemoTypes (SerializedEbBody)
+import LeiosTxCache (LeiosTxCache, evictOlderThan, newHashTableLeiosTxCache)
import Ouroboros.Consensus.Block
import Ouroboros.Consensus.BlockchainTime hiding (getSystemStart)
import Ouroboros.Consensus.Config
@@ -193,7 +195,7 @@ import System.FS.API (SomeHasFS (..))
import System.FS.API.Types (MountPoint (..))
import System.FS.IO (ioHasFS)
import System.FilePath ((>))
-import System.Random (StdGen, newStdGen, randomIO, splitGen)
+import System.Random (StdGen, newStdGen, randomIO, splitGen, uniform)
{-------------------------------------------------------------------------------
The arguments to the Consensus Layer node functionality
@@ -535,6 +537,12 @@ runWith RunNodeArgs{..} encAddrNtN decAddrNtN LowLevelRunNodeArgs{..} =
forM_ (sanityCheckConfig cfg) $ \issue ->
traceWith (consensusSanityCheckTracer rnTraceConsensus) issue
+ -- Created before the ChainDB so its GC thread can prune the cache to
+ -- each GC slot just before GCing the LeiosDb (the ordering contract in
+ -- "LeiosTxCache"). 2^22 slots (~168 MiB) is ~2x the ~1.9M worst case
+ -- (128 EBs x maxTxsPerEb), so the table never fills.
+ leiosTxCache <- newHashTableLeiosTxCache 22 leiosTxCacheSalt0 leiosTxCacheSalt1
+
(chainDB, finalArgs) <-
openChainDB
registry
@@ -544,6 +552,7 @@ runWith RunNodeArgs{..} encAddrNtN decAddrNtN LowLevelRunNodeArgs{..} =
llrnMkVolatileHasFS
llrnLdbFlavorArgs
rnLeiosDb
+ (\slot -> void (evictOlderThan leiosTxCache slot))
llrnChainDbArgsDefaults
( setLoEinChainDbArgs
. maybeValidateAll
@@ -607,6 +616,7 @@ runWith RunNodeArgs{..} encAddrNtN decAddrNtN LowLevelRunNodeArgs{..} =
rnMempoolTimeoutConfig
rnTxSubmissionInitDelay
rnLeiosDb
+ leiosTxCache
nodeKernel <- initNodeKernel nodeKernelArgs
rnNodeKernelHook registry nodeKernel
churnModeVar <- StrictSTM.newTVarIO (ChurnMode (PraosFetchMode FetchModeDeadline))
@@ -667,7 +677,13 @@ runWith RunNodeArgs{..} encAddrNtN decAddrNtN LowLevelRunNodeArgs{..} =
where
(gsmAntiThunderingHerd, rng') = splitGen llrnRng
(peerSelectionRng, rng'') = splitGen rng'
- (keepAliveRng, ntnAppsRng) = splitGen rng''
+ (keepAliveRng, rng''') = splitGen rng''
+ -- 'leiosTxCacheSaltRng' seeds the LeiosTxCache SipHash salt: a per-node secret,
+ -- an independent split of the node generator, never exposed (tx hashes are
+ -- grindable, so an unpredictable salt is what prevents hash-flooding the table).
+ (ntnAppsRng, leiosTxCacheSaltRng) = splitGen rng'''
+ (leiosTxCacheSalt0, leiosTxCacheSaltRng') = uniform leiosTxCacheSaltRng
+ (leiosTxCacheSalt1, _) = uniform leiosTxCacheSaltRng'
ProtocolInfo
{ pInfoConfig = cfg
@@ -869,12 +885,15 @@ openChainDB ::
LedgerDbBackendArgs m blk ->
-- | Leios demo DB handle
LeiosDbHandle m ->
+ -- | Prune the LeiosTxCache to a slot; the ChainDB GC runs this just before it
+ -- GCs the LeiosDb at the same slot (the "LeiosTxCache" ordering contract).
+ (SlotNo -> m ()) ->
-- | A set of default arguments (possibly modified from 'defaultArgs')
Incomplete ChainDbArgs m blk ->
-- | Customise the 'ChainDbArgs'
(Complete ChainDbArgs m blk -> Complete ChainDbArgs m blk) ->
m (ChainDB m blk, Complete ChainDbArgs m blk)
-openChainDB registry cfg initLedger fsImm fsVol flavorArgs leiosDb defArgs customiseArgs =
+openChainDB registry cfg initLedger fsImm fsVol flavorArgs leiosDb leiosEvictTxCache defArgs customiseArgs =
let args =
customiseArgs $
ChainDB.completeChainDbArgs
@@ -887,6 +906,7 @@ openChainDB registry cfg initLedger fsImm fsVol flavorArgs leiosDb defArgs custo
fsVol
flavorArgs
leiosDb
+ leiosEvictTxCache
defArgs
in (,args) <$> ChainDB.openDB args
@@ -915,6 +935,7 @@ mkNodeKernelArgs ::
Maybe Mempool.MempoolTimeoutConfig ->
TxSubmissionInitDelay ->
LeiosDbHandle m ->
+ LeiosTxCache m () () SerializedEbBody ->
m (NodeKernelArgs m addrNTN (ConnectionId addrNTC) blk)
mkNodeKernelArgs
registry
@@ -938,7 +959,8 @@ mkNodeKernelArgs
getDiffusionPipeliningSupport
mempoolTimeoutConfig
txSubmissionInitDelay
- leiosDB =
+ leiosDB
+ leiosTxCache =
do
let (kaRng, rng') = splitGen rng
(psRng, _) = splitGen rng'
@@ -974,6 +996,7 @@ mkNodeKernelArgs
, getDiffusionPipeliningSupport
, txSubmissionInitDelay
, leiosDB
+ , leiosTxCache
}
-- | We allow the user running the node to customise the 'NodeKernelArgs'
diff --git a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel.hs b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel.hs
index 2d23ecb398..4fc244708d 100644
--- a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel.hs
+++ b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel.hs
@@ -70,6 +70,7 @@ import LeiosDemoTypes
, TraceLeiosKernel (..)
)
import qualified LeiosDemoTypes as Leios
+import LeiosTxCache (LeiosTxCache)
import LeiosUtils.CallTrace
( SomeJsonCallTrace (SomeJsonCallTrace)
, callTraceSameThread
@@ -249,6 +250,10 @@ data NodeKernel m addrNTN addrNTC blk = NodeKernel
, getLeiosCentralState ::
MVar.MVar m (Announcements.CentralState m (ConnectionId addrNTN) (Leios.AnnouncingHeader blk))
-- ^ Node-wide EB-announcement state
+ , getLeiosTxCache ::
+ LeiosTxCache m () () Leios.SerializedEbBody
+ -- ^ Shadow in-memory tx-cache (see 'LeiosTxCache'); maintained but
+ -- not yet consulted, so it changes no observable behavior.
}
-- | Arguments required when initializing a node
@@ -285,6 +290,10 @@ data NodeKernelArgs m addrNTN addrNTC blk = NodeKernelArgs
-- (forge loop, leios fetch logic, LeiosNotify / LeiosFetch handlers)
-- opens its own connection from this handle. 'LeiosDbConnection' is
-- documented as not thread-safe, so connections must not be shared.
+ , leiosTxCache :: LeiosTxCache m () () Leios.SerializedEbBody
+ -- ^ The in-memory tx-presence index. Created in "Ouroboros.Consensus.Node"
+ -- (before the ChainDB, so the ChainDB GC can prune it just before the LeiosDb)
+ -- and threaded through here.
}
initNodeKernel ::
@@ -331,6 +340,7 @@ initNodeKernel
, leiosOutstanding = getLeiosOutstanding
, leiosReady = getLeiosReady
, leiosCentralState = getLeiosCentralState
+ , leiosTxCache = getLeiosTxCache
, leiosPeersVars = getLeiosPeersVars
, leiosVoteState
} = st
@@ -588,6 +598,7 @@ initNodeKernel
, getLeiosOutstanding = getLeiosOutstanding
, getLeiosReady = getLeiosReady
, getLeiosCentralState = getLeiosCentralState
+ , getLeiosTxCache = getLeiosTxCache
}
where
blockForgingController ::
@@ -640,6 +651,8 @@ data InternalState m addrNTN addrNTC blk = IS
, leiosReady :: MVar.MVar m ()
, leiosCentralState ::
MVar.MVar m (Announcements.CentralState m (ConnectionId addrNTN) (Leios.AnnouncingHeader blk))
+ , leiosTxCache ::
+ LeiosTxCache m () () Leios.SerializedEbBody
, leiosPeersVars ::
LazySTM.TVar m (Map.Map (Leios.PeerId (ConnectionId addrNTN)) (LeiosPeerVars m))
, leiosVoteState :: LeiosVoteState m
@@ -673,6 +686,7 @@ initInternalState
, getDiffusionPipeliningSupport
, genesisArgs
, leiosDB
+ , leiosTxCache
} = do
varGsmState <- do
let GsmNodeKernelArgs{..} = gsmArgs
@@ -771,6 +785,7 @@ forkBlockForging IS{..} (MkBlockForging blockForgingM) =
leiosVoteState
bf
leiosConn
+ leiosTxCache
announceForgedBlock
currentSlot
)
@@ -778,25 +793,25 @@ forkBlockForging IS{..} (MkBlockForging blockForgingM) =
label :: String
label = "NodeKernel.blockForging"
- -- Concurrently (fire-and-forget) relay this node's own freshly-forged EB
- -- announcement, if any, to downstream peers via LeiosNotify. 'forge' invokes
- -- this right after forging and before adoption, so adoption never gates
- -- getting the announcement onto the wire.
+ -- Relay this node's own freshly-forged EB announcement, if any, to downstream
+ -- peers via LeiosNotify. 'forge' invokes this right after forging and before
+ -- adoption — and, crucially, before persisting the EB body to the LeiosDb.
+ -- The relay is synchronous: writing the body is what offers the EB to peers,
+ -- so the announcement must be enqueued first, else a peer could receive the
+ -- offer before the announcement.
announceForgedBlock :: Header blk -> m ()
announceForgedBlock forgedHeader =
whenJust (Leios.mkAnnouncingHeader forgedHeader) $ \anc ->
- void $
- async $
- MVar.modifyMVar_ leiosCentralState $ \cst ->
- Announcements.onAnnouncementCentral
- (contramap Leios.traceNewAnnouncement (leiosKernelTracer tracers))
- Leios.ancElId
- (\_elSt -> pure ()) -- we forged the EB; nothing to fetch locally
- cst
- Nothing -- the source is this node, not an upstream peer
- Announcements.DoRelay -- our newly forged block can't be too old
- Nothing -- no wall-clock lateness for a locally-forged announcement
- anc
+ MVar.modifyMVar_ leiosCentralState $ \cst ->
+ Announcements.onAnnouncementCentral
+ (contramap (Leios.traceNewAnnouncement Leios.ForgedLocally) (leiosKernelTracer tracers))
+ Leios.ancElId
+ (\_elSt -> pure ()) -- we forged the EB; nothing to fetch locally
+ cst
+ Nothing -- the source is this node, not an upstream peer
+ Announcements.DoRelay -- our newly forged block can't be too old
+ Nothing -- no wall-clock lateness for a locally-forged announcement
+ anc
-- 'LeiosDbConnection' is not thread-safe, so we open one per
-- forge-credentials thread (and close it when the thread exits).
diff --git a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel/Forge.hs b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel/Forge.hs
index f6c0300ffb..4ad1fb3fbb 100644
--- a/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel/Forge.hs
+++ b/ouroboros-consensus-diffusion/src/ouroboros-consensus-diffusion/Ouroboros/Consensus/NodeKernel/Forge.hs
@@ -5,6 +5,7 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
@@ -27,11 +28,16 @@ import Data.Proxy
import LeiosDemoDb
( LeiosDbConnection (..)
)
+import LeiosDemoLogic (recordForgedEbAndClosureInTxCache)
import LeiosDemoTypes
( LeiosCert
+ , RbHash (MkRbHash)
+ , SerializedEbBody
, TraceLeiosKernel (..)
+ , leiosEbBytesSize
)
import qualified LeiosDemoTypes as Leios
+import LeiosTxCache (LeiosTxCache)
import LeiosUtils.CallTrace
( CallCtx
, CallName
@@ -97,13 +103,14 @@ forge ::
LeiosVoteState m ->
BlockForging m blk ->
LeiosDbConnection m ->
+ LeiosTxCache m () () SerializedEbBody ->
-- | Invoked with the freshly-forged block's header, after forging and
-- /before/ adoption, so the caller can act on the new block (e.g. concurrently
-- announce its EB) without adoption gating it.
(Header blk -> m ()) ->
SlotNo ->
WithEarlyExit m ()
-forge forgeEventTracer forgeStateInfoTracer leiosTracer forgeCCtx cfg chainDB mempool leiosVoteState blockForging leiosConn afterForge currentSlot = do
+forge forgeEventTracer forgeStateInfoTracer leiosTracer forgeCCtx cfg chainDB mempool leiosVoteState blockForging leiosConn leiosTxCache afterForge currentSlot = do
let trace :: TraceForgeEvent blk -> WithEarlyExit m ()
trace =
lift
@@ -235,7 +242,6 @@ forge forgeEventTracer forgeStateInfoTracer leiosTracer forgeCCtx cfg chainDB me
, Block.fbIsLeader = proof
, Block.fbChainDepState = Just (headerStateChainDep (headerState unticked))
, Block.fbMayLeiosCert = fst <$> mayLeiosCertAndAnnouncement
- , Block.fbLeiosDb = leiosConn
, Block.fbLeiosTracer = leiosTracer
, Block.fbLeiosVoteState = leiosVoteState
}
@@ -244,8 +250,8 @@ forge forgeEventTracer forgeStateInfoTracer leiosTracer forgeCCtx cfg chainDB me
, rbTxsSize
)
- -- Actually produce the block
- newBlock <-
+ -- Actually produce the block (and the EB it announces, if any)
+ (newBlock, mForgedEb) <-
forgeTrace'Via
(const ())
"forge-block"
@@ -262,9 +268,29 @@ forge forgeEventTracer forgeStateInfoTracer leiosTracer forgeCCtx cfg chainDB me
rbTxsSize
-- Hand the freshly-forged block's header to the caller before adoption, so it
- -- can act on it (e.g. concurrently announce its EB) without adoption gating it.
+ -- can act on it (e.g. relay its EB announcement) without adoption gating it.
lift $ afterForge (getHeader newBlock)
+ -- Persist the forged EB, but only /after/ 'afterForge' has relayed the
+ -- announcement: writing the body to the LeiosDb ('leiosDbInsertEbBody') is
+ -- what makes the EB offerable to peers, so deferring it past the relay
+ -- guarantees a downstream peer never receives the body offer before the
+ -- announcement.
+ --
+ -- Also register the body in the LeiosTxCache.
+ lift $ forM_ mForgedEb $ \forgedEb -> do
+ let ebPoint = forgedEb.point
+ ebSize = leiosEbBytesSize forgedEb.body
+ leiosDbInsertEbPoint leiosConn ebPoint ebSize
+ void $ leiosDbInsertEbBody leiosConn ebPoint forgedEb.body
+ void $ leiosDbInsertTxs leiosConn forgedEb.txClosure
+ traceWith leiosTracer $ TraceLeiosBlockStored{slot = currentSlot, eb = forgedEb.body}
+ recordForgedEbAndClosureInTxCache
+ leiosTracer
+ leiosTxCache
+ (MkRbHash (toRawHash (Proxy @blk) (blockHash newBlock)))
+ forgedEb
+
forgeTrace'Via
(const ())
"add-block-to-chaindb"
diff --git a/ouroboros-consensus-diffusion/src/unstable-diffusion-testlib/Test/ThreadNet/Network.hs b/ouroboros-consensus-diffusion/src/unstable-diffusion-testlib/Test/ThreadNet/Network.hs
index 02d4492bd7..6ae9268f27 100644
--- a/ouroboros-consensus-diffusion/src/unstable-diffusion-testlib/Test/ThreadNet/Network.hs
+++ b/ouroboros-consensus-diffusion/src/unstable-diffusion-testlib/Test/ThreadNet/Network.hs
@@ -91,6 +91,7 @@ import qualified LeiosDemoDb
import LeiosDemoOnlyTestFetch (LeiosFetch)
import LeiosDemoOnlyTestNotify (LeiosNotify)
import qualified LeiosDemoTypes
+import LeiosTxCache (LeiosTxCache, evictOlderThan, newPureLeiosTxCache)
import Network.TypedProtocol.Codec
( AnyMessage (..)
, CodecFailure
@@ -768,7 +769,11 @@ runThreadNetwork
NodeDBs (StrictTMVar m MockFS) ->
LeiosState (MonadSTMStrict.StrictTVar m) ->
CoreNodeId ->
- m (LeiosDemoDb.LeiosDbHandle m, ChainDbArgs Identity m blk)
+ m
+ ( LeiosDemoDb.LeiosDbHandle m
+ , LeiosTxCache m () () LeiosDemoTypes.SerializedEbBody
+ , ChainDbArgs Identity m blk
+ )
mkArgs
registry
cfg
@@ -782,6 +787,7 @@ runThreadNetwork
leiosState
_coreNodeId = do
leiosDbHandle <- LeiosDemoDb.newLeiosDBInMemoryWith (lsLeiosDb leiosState)
+ leiosTxCache <- newPureLeiosTxCache
let args =
fromMinimalChainDbArgs
MinimalChainDbArgs
@@ -794,7 +800,7 @@ runThreadNetwork
}
let tr = instrumentationTracer <> nullTracer
pure $
- (,) leiosDbHandle $
+ (,,) leiosDbHandle leiosTxCache $
args
{ cdbImmDbArgs =
(cdbImmDbArgs args)
@@ -815,6 +821,7 @@ runThreadNetwork
{ -- TODO: Vary cdbsGcDelay, cdbsGcInterval, cdbsBlockToAddSize
cdbsGcDelay = 0
, cdbsTracer = instrumentationTracer <> nullTracer
+ , cdbsLeiosEvictTxCache = \slot -> void (evictOlderThan leiosTxCache slot)
}
}
where
@@ -892,7 +899,7 @@ runThreadNetwork
selTracer = wrapTracer $ nodeEventsSelects nodeInfoEvents
headerAddTracer = wrapTracer $ nodeEventsHeaderAdds nodeInfoEvents
pipeliningTracer = nodeEventsPipelining nodeInfoEvents
- (leiosDbHandle, chainDbArgs) <-
+ (leiosDbHandle, leiosTxCache, chainDbArgs) <-
mkArgs
registry
pInfoConfig
@@ -912,7 +919,7 @@ runThreadNetwork
let customForgeBlock ::
BlockForging m blk ->
ForgeBlockArgs m blk ->
- m blk
+ m (blk, Maybe LeiosDemoTypes.ForgedLeiosEb)
customForgeBlock origBlockForging fbArgs = do
let currentBno = fbCurrentBlockNo fbArgs
currentSlot = fbCurrentSlotNo fbArgs
@@ -1145,6 +1152,7 @@ runThreadNetwork
, getDiffusionPipeliningSupport = DiffusionPipeliningOn
, txSubmissionInitDelay = NoTxSubmissionInitDelay
, leiosDB = leiosDbHandle
+ , leiosTxCache
}
nodeKernel <- initNodeKernel nodeKernelArgs
diff --git a/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/A.hs b/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/A.hs
index bfaea575aa..849cb2f370 100644
--- a/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/A.hs
+++ b/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/A.hs
@@ -362,13 +362,14 @@ blockForgingA =
, checkCanForge = \_ _ _ _ _ -> return ()
, forgeBlock = \ForgeBlockArgs{..} ->
return $
- forgeBlockA
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- (fmap txForgetValidated fbRbTxs)
- fbIsLeader
+ flip (,) Nothing $
+ forgeBlockA
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ (fmap txForgetValidated fbRbTxs)
+ fbIsLeader
, finalize = return ()
}
diff --git a/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/B.hs b/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/B.hs
index 4685a6fdba..34cd4a927d 100644
--- a/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/B.hs
+++ b/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/HardFork/Combinator/B.hs
@@ -304,13 +304,14 @@ blockForgingB =
, checkCanForge = \_ _ _ _ _ -> return ()
, forgeBlock = \ForgeBlockArgs{..} ->
return $
- forgeBlockB
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- (fmap txForgetValidated fbRbTxs)
- fbIsLeader
+ flip (,) Nothing $
+ forgeBlockB
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ (fmap txForgetValidated fbRbTxs)
+ fbIsLeader
, finalize = return ()
}
diff --git a/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/PeerSimulator/ChainSync.hs b/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/PeerSimulator/ChainSync.hs
index b2e255cba1..db1493320a 100644
--- a/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/PeerSimulator/ChainSync.hs
+++ b/ouroboros-consensus-diffusion/test/consensus-test/Test/Consensus/PeerSimulator/ChainSync.hs
@@ -119,7 +119,7 @@ basicChainSyncClient
-- do not care about this in these tests.
CSClient.historicityCheck = HistoricityCheck.noCheck
, CSClient.getDiffusionPipeliningSupport = DiffusionPipeliningOn
- , CSClient.leiosCertRbCallback = \_ _ -> pure ()
+ , CSClient.leiosMsgRollForwardCallback = \_ _ _ -> pure ()
}
CSClient.DynamicEnv
{ CSClient.version = maxBound
diff --git a/ouroboros-consensus.cabal b/ouroboros-consensus.cabal
index 3a29c2cba4..335045fc7f 100644
--- a/ouroboros-consensus.cabal
+++ b/ouroboros-consensus.cabal
@@ -112,6 +112,11 @@ library
LeiosDemoOnlyTestFetch
LeiosDemoOnlyTestNotify
LeiosDemoTypes
+ LeiosTxCache
+ LeiosTxCache.API
+ LeiosTxCache.Optimized
+ LeiosTxCache.Optimized.MutableHashTable
+ LeiosTxCache.Reference
LeiosUtils.CallTrace
LeiosVoteState
LeiosVoting
@@ -392,6 +397,7 @@ library
io-classes:{io-classes, si-timers, strict-mvar, strict-stm} ^>=1.8.0.1 || ^>=1.9,
measures,
mempack,
+ microlens,
monoid-subclasses,
mtl,
multiset ^>=0.3,
@@ -691,6 +697,36 @@ library unstable-tutorials
ouroboros-network:api,
serialise,
+-- A dedicated suite that recompiles the hash-table source (and its test) with
+-- GHC's array-primop bounds checking, so an out-of-bounds MutableByteArray access
+-- in the probing / backward-shift / bitset arithmetic fails loudly instead of
+-- silently corrupting memory. It deliberately does NOT depend on
+-- 'ouroboros-consensus': the module and its test are self-contained on
+-- base/containers/primitive/tasty, so recompiling them here (rather than linking
+-- the library's unchecked copy) both applies the flag and avoids a
+-- duplicate-module clash.
+test-suite leios-txcache-bounds-checked
+ import: common-test
+ type: exitcode-stdio-1.0
+ hs-source-dirs:
+ ouroboros-consensus/test/leios-txcache-bounds-checked
+ ouroboros-consensus/test/consensus-test
+ ouroboros-consensus/src/ouroboros-consensus
+
+ main-is: Main.hs
+ other-modules:
+ LeiosTxCache.Optimized.MutableHashTable
+ Test.LeiosTxCache.Optimized.MutableHashTable
+
+ ghc-options: -fcheck-prim-bounds
+ build-depends:
+ base,
+ containers,
+ primitive,
+ tasty,
+ tasty-hunit,
+ tasty-quickcheck,
+
test-suite consensus-test
import: common-test
type: exitcode-stdio-1.0
@@ -728,6 +764,9 @@ test-suite consensus-test
Test.LeiosDemoLogic
Test.LeiosDemoLogic.Announcements
Test.LeiosDemoTypes
+ Test.LeiosTxCache.Optimized
+ Test.LeiosTxCache.Optimized.MutableHashTable
+ Test.LeiosTxCache.Reference
Test.LeiosUtils.CallTrace
Test.LeiosVoteState
@@ -1038,6 +1077,26 @@ benchmark leios-db-bench
time,
vector,
+benchmark leios-txcache-bench
+ import: common-bench
+ type: exitcode-stdio-1.0
+ hs-source-dirs: ouroboros-consensus/bench/leios-txcache-bench
+ main-is: Main.hs
+ other-modules: LeiosTxCache.Bench.SQLite
+ ghc-options: -with-rtsopts=-T
+ build-depends:
+ base,
+ bytestring,
+ cardano-slotting,
+ containers,
+ deepseq,
+ direct-sqlite,
+ directory,
+ ouroboros-consensus,
+ text,
+ unix,
+ vector,
+
test-suite doctest
import: common-test
main-is: doctest.hs
diff --git a/ouroboros-consensus/bench/ChainSync-client-bench/Main.hs b/ouroboros-consensus/bench/ChainSync-client-bench/Main.hs
index cfa729de18..6f6077593e 100644
--- a/ouroboros-consensus/bench/ChainSync-client-bench/Main.hs
+++ b/ouroboros-consensus/bench/ChainSync-client-bench/Main.hs
@@ -151,7 +151,7 @@ oneBenchRun
pipelineDecisionLowHighMark 10 20
, CSClient.getDiffusionPipeliningSupport =
DiffusionPipeliningOn
- , CSClient.leiosCertRbCallback = \_ _ -> pure ()
+ , CSClient.leiosMsgRollForwardCallback = \_ _ _ -> pure ()
}
CSClient.DynamicEnv
{ CSClient.version = maxBound :: NodeToNodeVersion
diff --git a/ouroboros-consensus/bench/leios-txcache-bench/LeiosTxCache/Bench/SQLite.hs b/ouroboros-consensus/bench/leios-txcache-bench/LeiosTxCache/Bench/SQLite.hs
new file mode 100644
index 0000000000..908a652456
--- /dev/null
+++ b/ouroboros-consensus/bench/leios-txcache-bench/LeiosTxCache/Bench/SQLite.hs
@@ -0,0 +1,173 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | 'LeiosTxCache' handles backed by a dedicated SQLite database, for
+-- benchmarking the cache against an on-disk by-hash index. This module is built
+-- /only/ as part of the @leios-txcache-bench@ benchmark — never the library —
+-- hence the @Bench@ in its name.
+--
+-- The handles track only tx /presence/ — enough for the lookup path the
+-- benchmark measures — not the announcement and eviction bookkeeping the
+-- in-memory implementations maintain, so 'insertAnnouncement' and
+-- 'evictOlderThan' are inert.
+--
+-- There are two factories over the same file, because one configuration cannot
+-- serve both phases:
+--
+-- * 'newSQLiteLeiosTxCacheForPopulation' — fast bulk insert.
+-- * 'newSQLiteLeiosTxCacheForQueries' — the representative, /coolable/ read
+-- configuration.
+--
+-- The benchmark populates through the first, @fsync@s the file, then reads
+-- through the second (a separate connection, so its private page cache starts
+-- cold). This is @not@ reusing "LeiosDemoDb.SQLite": its @WAL@ + @mmap@ pin the
+-- pages and defeat cooling.
+module LeiosTxCache.Bench.SQLite
+ ( newSQLiteLeiosTxCacheForPopulation
+ , newSQLiteLeiosTxCacheForQueries
+ ) where
+
+import Control.Monad (zipWithM_)
+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
+import qualified Data.Set as Set
+import Data.String (fromString)
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Database.SQLite3 as DB
+import LeiosDemoTypes (TxHash (..))
+import LeiosTxCache.API
+ ( LeiosTxCache (..)
+ , ReferencesTxsByHash (..)
+ )
+
+-- | Fast, unsafe configuration for bulk population: no journal and no @fsync@,
+-- with a large private cache so the whole random-key B-tree stays hot in memory
+-- (32 kB pages otherwise make each random-key insert re-log a full page — the
+-- write amplification that makes the safe configuration unbearably slow to
+-- populate). Not crash-safe, which is fine for a benchmark. The caller must
+-- @fsync@ the file before reading, so the query connection's pages are clean and
+-- hence evictable by @posix_fadvise@.
+newSQLiteLeiosTxCacheForPopulation ::
+ ReferencesTxsByHash b => FilePath -> IO (LeiosTxCache IO () () b)
+newSQLiteLeiosTxCacheForPopulation =
+ newSQLiteLeiosTxCacheWith
+ [ "PRAGMA page_size = 32768;"
+ , "PRAGMA journal_mode = OFF;"
+ , "PRAGMA synchronous = OFF;"
+ , "PRAGMA temp_store = MEMORY;"
+ , "PRAGMA cache_size = -262144;" -- ~256 MB: holds the whole index hot
+ , "CREATE TABLE IF NOT EXISTS txs (txHashBytes BLOB PRIMARY KEY);"
+ ]
+
+-- | Representative, coolable configuration for the timed lookups. Returns the
+-- handle together with a /reopen/ action: to measure a genuinely cold batch, the
+-- caller reopens the connection (fresh, empty private cache) and
+-- @posix_fadvise@s the file (cold OS cache) before each batch.
+--
+-- * @mmap_size = 0@ — reads go through @read()@, so @posix_fadvise(DONTNEED)@
+-- on the file actually evicts them.
+-- * @cache_size@ large enough to hold the batch's working set — so /within/ a
+-- batch each index leaf is read once (cold), not once per lookup. The
+-- adversary owns the OS page cache (cooled per batch), not SQLite's
+-- intra-batch reuse: that is the honest "best SQLite can do cold".
+-- * DELETE journal — data in the single main file, no WAL\/-shm (itself an
+-- mmap) to cool separately; equivalent to WAL for a cold, disk-bound lookup.
+-- * @page_size = 32768@ — matches "LeiosDemoDb.SQLite".
+--
+-- The batch is resolved by the buffer-and-lie trick: each 'look' records its
+-- hash and answers 'Nothing' (only the timing matters), then one @IN@ query runs
+-- over the whole buffer. (This variant's reported hit count is thus always 0 —
+-- cosmetic.) @cacheSize@ is the @PRAGMA cache_size@ value; @nParams@ the fixed
+-- probe length, so the statement is prepared once per connection.
+newSQLiteLeiosTxCacheForQueries ::
+ Int -> Int -> FilePath -> IO (LeiosTxCache IO () () b, IO ())
+newSQLiteLeiosTxCacheForQueries cacheSize nParams path = do
+ -- A large @IN (?,..)@ rather than the @json_each(?)@ + @unhex@ form that
+ -- LeiosDemoDb.SQLite uses: json_each was measured both slower (parsing the
+ -- array and unhex-decoding every element) and far heavier on allocation
+ -- (~50 MiB/batch building the hex text vs a few MiB here), and the
+ -- SQLITE_MAX_VARIABLE_NUMBER worry that motivated it never bit — the limit is
+ -- 32766, well above the ~15k probe.
+ let inSql =
+ "SELECT count(*) FROM txs WHERE txHashBytes IN ("
+ <> T.intercalate "," (replicate nParams "?")
+ <> ");"
+ openConn = do
+ db <- DB.open (fromString path)
+ mapM_
+ (DB.exec db)
+ [ "PRAGMA page_size = 32768;"
+ , "PRAGMA journal_mode = DELETE;"
+ , "PRAGMA synchronous = NORMAL;"
+ , "PRAGMA mmap_size = 0;"
+ , fromString ("PRAGMA cache_size = " <> show cacheSize <> ";")
+ , "CREATE TABLE IF NOT EXISTS txs (txHashBytes BLOB PRIMARY KEY);"
+ ]
+ stmt <- DB.prepare db inSql
+ pure (db, stmt)
+ connRef <- newIORef =<< openConn
+ let reopen = do
+ (oldDb, oldStmt) <- readIORef connRef
+ DB.finalize oldStmt
+ DB.close oldDb
+ writeIORef connRef =<< openConn
+ batchLookup stmt hashes = do
+ zipWithM_
+ (\i (MkTxHash bs) -> DB.bindBlob stmt (fromIntegral (i :: Int)) bs)
+ [1 ..]
+ hashes
+ _ <- DB.step stmt
+ DB.reset stmt
+ let handle =
+ LeiosTxCache
+ { insertAnnouncement = \_slot _rbh _ebh -> pure (Set.empty, Set.empty)
+ , evictOlderThan = \_boundary -> pure (Set.empty, Set.empty)
+ , insertBody = \_ebh _body -> pure Nothing
+ , lookupBody = \_ebh -> pure Nothing
+ , withLockedInsertUnappliedTx = \k -> do _ <- k () (\w _txh _ -> pure w); pure ()
+ , withLockedInsertAppliedTx = \k -> do _ <- k () (\w _txh _ -> pure w); pure ()
+ , withLookupTx = \k -> do
+ (_, stmt) <- readIORef connRef
+ buf <- newIORef []
+ r <- k (\txh -> modifyIORef' buf (txh :) >> pure Nothing)
+ hashes <- readIORef buf
+ batchLookup stmt hashes
+ pure r
+ }
+ pure (handle, reopen)
+
+-- | Open a connection at @path@, run @pragmas@ (page_size must precede the first
+-- table for it to take on a fresh file), and build the handle. Only @a = v = ()@
+-- is meaningful; a present tx reads back as @Just (Left ())@, an absent one as
+-- 'Nothing'.
+newSQLiteLeiosTxCacheWith ::
+ ReferencesTxsByHash b => [Text] -> FilePath -> IO (LeiosTxCache IO () () b)
+newSQLiteLeiosTxCacheWith pragmas path = do
+ db <- DB.open (fromString path)
+ mapM_ (DB.exec db) pragmas
+ insertStmt <- DB.prepare db "INSERT OR IGNORE INTO txs (txHashBytes) VALUES (?);"
+ lookupStmt <- DB.prepare db "SELECT 1 FROM txs WHERE txHashBytes = ? LIMIT 1;"
+ let insertOne (MkTxHash bs) = do
+ DB.bindBlob insertStmt 1 bs
+ _ <- DB.step insertStmt
+ DB.reset insertStmt
+ lookupOne (MkTxHash bs) = do
+ DB.bindBlob lookupStmt 1 bs
+ r <- DB.step lookupStmt
+ DB.reset lookupStmt
+ pure $ case r of
+ DB.Row -> Just (Left ())
+ DB.Done -> Nothing
+ pure
+ LeiosTxCache
+ { insertAnnouncement = \_slot _rbh _ebh -> pure (Set.empty, Set.empty)
+ , evictOlderThan = \_boundary -> pure (Set.empty, Set.empty)
+ , insertBody = \_ebh body -> do
+ DB.exec db "BEGIN;"
+ mapM_ insertOne (foldTxReferences (flip (:)) [] body)
+ DB.exec db "COMMIT;"
+ pure Nothing
+ , lookupBody = \_ebh -> pure Nothing
+ , withLockedInsertUnappliedTx = \k -> do _ <- k () (\w _txh _ -> pure w); pure ()
+ , withLockedInsertAppliedTx = \k -> do _ <- k () (\w _txh _ -> pure w); pure ()
+ , withLookupTx = \k -> k lookupOne
+ }
diff --git a/ouroboros-consensus/bench/leios-txcache-bench/Main.hs b/ouroboros-consensus/bench/leios-txcache-bench/Main.hs
new file mode 100644
index 0000000000..6553d732d7
--- /dev/null
+++ b/ouroboros-consensus/bench/leios-txcache-bench/Main.hs
@@ -0,0 +1,441 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE NumericUnderscores #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | Worst-case benchmark for the 'LeiosTxCache' handle: max residency,
+-- allocation rate during population, and — chiefly — the latency of an EB-sized
+-- batch of lookups via 'withLookupTx'.
+--
+-- It is written against the impure handle, so the same workload runs against
+-- both the pure-wrapped index ('newPureLeiosTxCache') and a future mutable
+-- implementation (add another 'runBench' call).
+--
+-- Worst-case hard bounds: an EB references up to ~512 kB \/ 34 B == 'maxTxsPerEb'
+-- txs, and up to 'maxAnnouncementCount' EBs sit in the index at once with fully
+-- disjoint tx closures — ~1.9M distinct txs resident.
+--
+-- Run (the stanza bakes in @-T@; add @-s@ for the RTS summary):
+--
+-- @cabal bench leios-txcache-bench@
+module Main (main) where
+
+import Cardano.Slotting.Slot (SlotNo (..))
+import Control.DeepSeq (force)
+import Control.Exception (evaluate)
+import Control.Monad (foldM, forM, forM_, when)
+import qualified Data.Bits as Bits
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Builder as BB
+import qualified Data.ByteString.Lazy as BSL
+import Data.List (isPrefixOf, partition, sort, stripPrefix, transpose)
+import qualified Data.Vector.Strict as V
+import Data.Word (Word64)
+import Foreign.C.Types (CInt (..))
+import GHC.Clock (getMonotonicTimeNSec)
+import GHC.Stats
+import LeiosDemoTypes (EbHash (..), RbHash (..), TxHash (..))
+import LeiosTxCache
+import LeiosTxCache.Bench.SQLite
+ ( newSQLiteLeiosTxCacheForPopulation
+ , newSQLiteLeiosTxCacheForQueries
+ )
+import Numeric (showFFloat)
+import System.Directory (doesFileExist, removeFile)
+import System.Environment (getArgs)
+import System.Exit (die)
+import System.IO (IOMode (ReadMode, ReadWriteMode), hFlush, openFile, stdout)
+import System.Mem (performMajorGC)
+import System.Posix.IO (closeFd, handleToFd)
+import System.Posix.Types (COff (..))
+
+-- * Configuration
+
+numEbs :: Int
+numEbs = maxAnnouncementCount
+
+-- | Worst-case tx references in one EB: a ~512 kB body at 34 B/item (32-byte
+-- hash + 2-byte size) in a compact, non-CBOR layout — the encoding-independent
+-- ceiling. (The current CBOR-precise 'LeiosDemoTypes.maxTxsPerEb' is 13888.)
+txsPerEb :: Int
+txsPerEb = 15_058
+
+-- | Timed repetitions of the batch lookup (plus one warmup).
+numLookupRuns :: Int
+numLookupRuns = 20
+
+-- | A body carrying its tx hashes as packed bytes (mirroring the production
+-- serialized body). Crucially it does NOT retain a boxed 'TxHash' per tx: were
+-- the body a @Vector TxHash@ instead, both handles would hold the ~1.9M boxed
+-- hashes and the residency comparison would be meaningless — the pure map's
+-- boxed keys are exactly what the flat table replaces with inline bytes. The
+-- fold copies each 32-byte chunk out, so the pure map's keys are separate
+-- buffers (the realistic wire-derived case).
+--
+-- If the pure map were preferred for other reasons, then we could reconsider
+-- whether the production should ust Vector TxHash instead of a flat
+-- ByteString. One non-option: do not stor the body as a bytestring and have the
+-- keys be bytestring slices of that big one, because then a key that is
+-- preserved by younger bodies would retain the older body's foreign pointer
+-- even after it was evicted. That could cause catastrophic space leaks, in
+-- pathological cases.
+newtype BenchBody = BenchBody ByteString -- concatenated 32-byte tx hashes
+
+instance ReferencesTxsByHash BenchBody where
+ foldTxReferences f z0 (BenchBody bs) = go z0 0
+ where
+ n = BS.length bs `div` 32
+ go !acc i
+ | i >= n = acc
+ | otherwise = go (f acc (MkTxHash (BS.copy (BS.take 32 (BS.drop (i * 32) bs))))) (i + 1)
+
+type BenchCache = LeiosTxCache IO () () BenchBody
+
+-- * Main
+
+main :: IO ()
+main = do
+ enabled <- getRTSStatsEnabled
+ when (not enabled) $
+ error "GHC RTS stats not enabled; run with +RTS -T (the stanza bakes it in)"
+ putStr $
+ unlines
+ [ "LeiosTxCache worst-case benchmark"
+ , " EBs in index : " <> show numEbs
+ , " txs per EB : " <> show txsPerEb
+ , " total txs : " <> show (numEbs * txsPerEb)
+ ]
+ printPrivilegeReminder
+ args <- getArgs
+ -- CLI: an optional variant (pure | ht | sqlite; default all three) plus the
+ -- SQLite-only knobs --cache=small|big and --cycle-connection=yes|no. The knobs
+ -- error if a non-sqlite variant is named.
+ let (flags, positionals) = partition ("--" `isPrefixOf`) args
+ flagValue name = case [v | f <- flags, Just v <- [stripPrefix (name <> "=") f]] of
+ [] -> Nothing
+ (v : _) -> Just v
+ forM_ flags $ \f ->
+ when (not (any (`isPrefixOf` f) ["--cache=", "--cycle-connection="])) $
+ die ("unknown flag: " <> f)
+ cache <- case flagValue "--cache" of
+ Nothing -> pure BigCache
+ Just "small" -> pure SmallCache
+ Just "big" -> pure BigCache
+ Just v -> die ("--cache must be small|big, got: " <> v)
+ cycleConn <- case flagValue "--cycle-connection" of
+ Nothing -> pure CycleYes
+ Just "yes" -> pure CycleYes
+ Just "no" -> pure CycleNo
+ Just v -> die ("--cycle-connection must be yes|no, got: " <> v)
+ variant <- case positionals of
+ [] -> pure Nothing
+ [v] | v `elem` ["pure", "ht", "sqlite"] -> pure (Just v)
+ _ -> die "expected at most one of: pure | ht | sqlite"
+ when (not (null flags) && maybe False (/= "sqlite") variant) $
+ die "--cache / --cycle-connection apply only to the sqlite variant"
+ let salt0, salt1 :: Word64
+ salt0 = 0xD1CED00DFEEDFACE
+ salt1 = 0x0123456789ABCDEF
+ -- In-memory variants use one handle for both phases and need no cooling.
+ mkInMem nm mk = do
+ h <- mk
+ pure (BenchTarget nm h h (pure ()) (pure ()))
+ -- SQLite populates through a fast, unsafe connection and reads through a
+ -- separate, coolable one; the fsync between makes its pages evictable.
+ -- --cache sizes the query connection's private cache; --cycle-connection
+ -- reopens it per batch (cold private cache) — together with the OS-cache
+ -- eviction that is what makes a batch genuinely cold.
+ mkSqlite = do
+ exists <- doesFileExist sqliteDbPath
+ when exists $ removeFile sqliteDbPath
+ popCache <- newSQLiteLeiosTxCacheForPopulation sqliteDbPath
+ (queryCache, reopenQuery) <-
+ newSQLiteLeiosTxCacheForQueries (cacheSizePragma cache) txsPerEb sqliteDbPath
+ let coolBatch = case cycleConn of
+ CycleYes -> reopenQuery >> coolFile sqliteDbPath
+ CycleNo -> coolFile sqliteDbPath
+ pure $
+ BenchTarget
+ (sqliteName cache cycleConn)
+ popCache
+ queryCache
+ (syncFile sqliteDbPath)
+ coolBatch
+ mkPure = mkInMem "pure-wrapped index" newPureLeiosTxCache
+ mkHt = mkInMem "hash-table (shift 22)" (newHashTableLeiosTxCache 22 salt0 salt1)
+ targets :: [IO BenchTarget]
+ targets = case variant of
+ Just "pure" -> [mkPure]
+ Just "ht" -> [mkHt]
+ Just "sqlite" -> [mkSqlite]
+ _ -> [mkPure, mkHt, mkSqlite]
+ mapM_ (>>= runBench) targets
+
+-- | A benchmark subject: the two cache handles (the same one twice for the
+-- in-memory variants; distinct connections for SQLite), the post-population sync
+-- that makes SQLite's pages evictable, and the per-batch cooling.
+-- Positional (see the pattern in 'runBench'): name, population handle, query
+-- handle, post-population sync, per-batch cooling.
+data BenchTarget
+ = BenchTarget String BenchCache BenchCache (IO ()) (IO ())
+
+-- | Where the SQLite variant keeps its database (removed and recreated per run).
+sqliteDbPath :: FilePath
+sqliteDbPath = "leios-txcache-bench.sqlite"
+
+-- | The query connection's private page-cache size (@--cache@).
+data Cache = SmallCache | BigCache
+
+-- | Whether to reopen the query connection before each batch (@--cycle-connection@),
+-- giving a cold private cache per batch.
+data Cycle = CycleYes | CycleNo
+
+-- | The @PRAGMA cache_size@ value: 16 pages (~512 kB, far too small to hold the
+-- index) vs -262144 (~256 MB, holds the whole batch working set).
+cacheSizePragma :: Cache -> Int
+cacheSizePragma SmallCache = 16
+cacheSizePragma BigCache = -262144
+
+sqliteName :: Cache -> Cycle -> String
+sqliteName c y =
+ "sqlite (cache="
+ <> (case c of SmallCache -> "small"; BigCache -> "big")
+ <> ", cycle="
+ <> (case y of CycleYes -> "yes"; CycleNo -> "no")
+ <> ")"
+
+-- | The SQLite variant only reaches its cold-cache worst case if the db file's
+-- pages can be evicted from the OS page cache. 'coolFile' does that best-effort
+-- via @posix_fadvise@, but a guaranteed cold cache needs privilege to drop the
+-- page cache. Always shown, so the caveat is never silently lost.
+printPrivilegeReminder :: IO ()
+printPrivilegeReminder =
+ putStr $
+ unlines
+ [ ""
+ , "NOTE: the SQLite variant's cold-cache worst case relies on evicting its"
+ , "db file from the OS page cache before each batch. posix_fadvise(DONTNEED)"
+ , "is best-effort; for a guaranteed cold cache, run with elevated privileges"
+ , "(so the OS page cache can be dropped). Otherwise the reported SQLite"
+ , "latency may understate the true worst case."
+ ]
+
+-- * OS page-cache cooling (SQLite variant)
+
+foreign import ccall unsafe "posix_fadvise"
+ c_posix_fadvise :: CInt -> COff -> COff -> CInt -> IO CInt
+
+-- | @POSIX_FADV_DONTNEED@ (Linux).
+posixFadvDontneed :: CInt
+posixFadvDontneed = 4
+
+-- | Evict a file's pages from the OS page cache, so subsequent reads fault from
+-- disk. Best-effort: only clean (flushed) pages are dropped, and it does nothing
+-- for pages pinned by an active mmap — which is why the SQLite cache opens with
+-- @mmap_size = 0@.
+coolFile :: FilePath -> IO ()
+coolFile path = do
+ h <- openFile path ReadMode
+ fd <- handleToFd h
+ _ <- c_posix_fadvise (fromIntegral fd) 0 0 posixFadvDontneed
+ closeFd fd
+
+foreign import ccall unsafe "fsync"
+ c_fsync :: CInt -> IO CInt
+
+-- | Flush a file's dirty pages to disk so a subsequent 'coolFile' can evict them
+-- (posix_fadvise drops only clean pages). Run once after the fast, unsafe
+-- population — which uses @synchronous = OFF@ and so never fsyncs on its own.
+syncFile :: FilePath -> IO ()
+syncFile path = do
+ h <- openFile path ReadWriteMode
+ fd <- handleToFd h
+ _ <- c_fsync (fromIntegral fd)
+ closeFd fd
+
+runBench :: BenchTarget -> IO ()
+runBench (BenchTarget name popCache queryCache syncAfterPop coolBatch) = do
+ -- Pre-generate all EB data (hashes fully forced) OUTSIDE the timed region, so
+ -- the measured population allocation is index-op churn, not hash generation.
+ putStr "\ngenerating data... " >> hFlush stdout
+ ebData <-
+ forM [0 .. numEbs - 1] $ \e -> do
+ let !txhs = force $ V.generate txsPerEb (\i -> mkTxHash (e * txsPerEb + i))
+ !bs = BS.concat [b | MkTxHash b <- V.toList txhs]
+ pure (mkEbHash e, mkRbHash e, SlotNo (fromIntegral e), txhs, bs)
+ _ <- evaluate (length ebData)
+ putStrLn "done"
+
+ -- Populate the index (timed) via the population handle. Exactly 'numEbs'
+ -- announcements, so no eviction.
+ putStr "populating index... " >> hFlush stdout
+ allocBefore <- bytesAllocated
+ (_, popNs) <-
+ timedNs $
+ forM_ ebData $ \(ebh, rbh, slot, txhs, bs) -> do
+ _ <- insertAnnouncement popCache slot rbh ebh
+ _ <- insertBody popCache ebh (BenchBody bs)
+ withLockedInsertUnappliedTx popCache $ \z step ->
+ foldM (\ !acc txh -> step acc txh ()) z txhs
+ allocAfter <- bytesAllocated
+ -- Flush population to disk (a no-op for the in-memory variants) so the query
+ -- handle reads durable, coolable pages.
+ syncAfterPop
+ putStrLn "done"
+
+ -- Residency (post-major-GC live set); ebData is now dead and collectable.
+ performMajorGC
+ stats <- getRTSStats
+ let live = gcdetails_live_bytes (gc stats)
+ maxLive = max_live_bytes stats
+ peakMem = max_mem_in_use_bytes stats
+ popAlloc = allocAfter - allocBefore
+
+ -- One-shot report: population and residency.
+ putStr $
+ unlines
+ [ ""
+ , "== " <> name <> " =="
+ , "population:"
+ , " time : " <> showNs popNs
+ , " allocated : " <> showBytes popAlloc
+ , " alloc rate : " <> showBytes (perSecond popAlloc popNs) <> "/s"
+ , "residency (post major GC):"
+ , " live : " <> showBytes live
+ , " max live : " <> showBytes maxLive
+ , " peak mem in use : " <> showBytes peakMem
+ , "lookup — EB batch of " <> show txsPerEb <> " (x" <> show numLookupRuns <> " each):"
+ ]
+
+ -- Lookup latency at each hit ratio. Probe hashes are forced up front so timing
+ -- excludes their generation; every batch uses the index, so it stays live
+ -- through all measurements (including the residency read above). The loop
+ -- prints a per-ratio summary as it goes and collects the sorted durations as a
+ -- column; the full grid is printed once at the end.
+ let ratios = [0, 20, 40, 60, 80, 100 :: Int]
+ cols <- forM ratios $ \pct -> do
+ probe <- evaluate $ force $ mkProbe pct
+ let lookupBatch =
+ withLookupTx queryCache $ \look ->
+ foldM (\ !hits txh -> (\r -> hits + maybe 0 (const 1) r) <$> look txh) (0 :: Int) probe
+ coolBatch
+ hits <- lookupBatch -- warmup, and the actual resident count
+ laBefore <- bytesAllocated
+ -- 'coolBatch' runs OUTSIDE 'timedNs' so cooling is excluded from the latency;
+ -- for the SQLite variant it evicts the db file so each batch reads cold. It is
+ -- a no-op for the in-memory variants. (Its own tiny allocation does fall inside
+ -- the 'lookupAlloc' bracket below.)
+ times <- forM [1 .. numLookupRuns] $ \_ -> do
+ coolBatch
+ snd <$> timedNs lookupBatch
+ laAfter <- bytesAllocated
+ let avgNs = sum times `div` fromIntegral numLookupRuns
+ perTxNs = fromIntegral avgNs / fromIntegral txsPerEb :: Double
+ lookupAlloc = (laAfter - laBefore) `div` fromIntegral numLookupRuns
+ putStrLn $
+ " "
+ <> lpad 3 (show pct)
+ <> "% hits ("
+ <> show hits
+ <> "/"
+ <> show txsPerEb
+ <> "): avg "
+ <> showNs avgNs
+ <> ", per-tx "
+ <> showFFloat (Just 1) perTxNs " ns"
+ <> ", alloc "
+ <> showBytes lookupAlloc
+ pure (sort times)
+
+ -- The grid: one column per ratio, rows the sorted batch durations.
+ let colW = 11
+ header = " " <> concatMap (lpad colW . (<> "%") . show) ratios
+ grid = [" " <> concatMap (lpad colW . showNs) row | row <- transpose cols]
+ putStr $
+ unlines $
+ [ ""
+ , "batch durations (ascending per column; "
+ <> show numLookupRuns
+ <> " rows x "
+ <> show (length ratios)
+ <> " ratios):"
+ , header
+ ]
+ ++ grid
+
+-- * Deterministic, well-distributed 32-byte hashes
+
+-- | 32 bytes from a counter via a splitmix64 avalanche, so keys are spread like
+-- real Blake2b hashes (differ in early bytes) — usable by an ordered map today
+-- and a hash table later.
+bytes32 :: Word64 -> ByteString
+bytes32 k =
+ BSL.toStrict $
+ BB.toLazyByteString $
+ BB.word64BE (mix (4 * k))
+ <> BB.word64BE (mix (4 * k + 1))
+ <> BB.word64BE (mix (4 * k + 2))
+ <> BB.word64BE (mix (4 * k + 3))
+ where
+ mix z0 =
+ let z1 = (z0 `Bits.xor` (z0 `Bits.shiftR` 30)) * 0xbf58476d1ce4e5b9
+ z2 = (z1 `Bits.xor` (z1 `Bits.shiftR` 27)) * 0x94d049bb133111eb
+ in z2 `Bits.xor` (z2 `Bits.shiftR` 31)
+
+mkTxHash :: Int -> TxHash
+mkTxHash = MkTxHash . bytes32 . fromIntegral
+
+mkEbHash :: Int -> EbHash
+mkEbHash = MkEbHash . bytes32 . fromIntegral
+
+mkRbHash :: Int -> RbHash
+mkRbHash = MkRbHash . bytes32 . fromIntegral
+
+-- | The resident EB whose txs serve as the "hit" probe hashes.
+probeEb :: Int
+probeEb = numEbs `div` 2
+
+-- | A probe batch of 'txsPerEb' hashes with @pct@% present, interleaved: hits
+-- drawn from the resident 'probeEb', misses from an index range never inserted.
+mkProbe :: Int -> V.Vector TxHash
+mkProbe pct =
+ V.generate txsPerEb $ \i ->
+ if i `mod` 5 < pct `div` 20
+ then mkTxHash (probeEb * txsPerEb + i) -- resident: a hit
+ else mkTxHash (numEbs * txsPerEb + i) -- never inserted: a miss
+
+-- * Helpers
+
+timedNs :: IO a -> IO (a, Word64)
+timedNs act = do
+ t0 <- getMonotonicTimeNSec
+ !x <- act
+ t1 <- getMonotonicTimeNSec
+ pure (x, t1 - t0)
+
+bytesAllocated :: IO Word64
+bytesAllocated = allocated_bytes <$> getRTSStats
+
+perSecond :: Word64 -> Word64 -> Word64
+perSecond bytes ns
+ | ns == 0 = 0
+ | otherwise = round (fromIntegral bytes * 1e9 / fromIntegral ns :: Double)
+
+showBytes :: Word64 -> String
+showBytes b
+ | b < ki = show b <> " B"
+ | b < ki * ki = showFFloat (Just 1) (fromIntegral b / fromIntegral ki :: Double) " KiB"
+ | b < ki * ki * ki = showFFloat (Just 1) (fromIntegral b / fromIntegral (ki * ki) :: Double) " MiB"
+ | otherwise = showFFloat (Just 2) (fromIntegral b / fromIntegral (ki * ki * ki) :: Double) " GiB"
+ where
+ ki = 1024 :: Word64
+
+showNs :: Word64 -> String
+showNs ns
+ | ns < 1_000 = show ns <> " ns"
+ | ns < 1_000_000 = showFFloat (Just 2) (fromIntegral ns / 1e3 :: Double) " µs"
+ | ns < 1_000_000_000 = showFFloat (Just 2) (fromIntegral ns / 1e6 :: Double) " ms"
+ | otherwise = showFFloat (Just 2) (fromIntegral ns / 1e9 :: Double) " s"
+
+lpad :: Int -> String -> String
+lpad n s = replicate (max 0 (n - length s)) ' ' <> s
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs
index 23b82ca9c7..5eb068db31 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoLogic.hs
@@ -19,11 +19,11 @@ import qualified Control.Concurrent.Class.MonadMVar as MVar
import qualified Control.Concurrent.Class.MonadSTM as LazySTM
import Control.Concurrent.Class.MonadSTM.Strict (StrictTVar)
import qualified Control.Concurrent.Class.MonadSTM.Strict as StrictSTM
-import Control.Monad (forM_, when)
+import Control.Monad (foldM, forM_, when)
import Control.Monad.Class.MonadThrow (Exception, catch, throwIO)
import Control.Monad.Except (runExcept)
import Control.Monad.Primitive (PrimMonad, PrimState)
-import Control.Tracer (Tracer, traceWith)
+import Control.Tracer (Tracer, contramap, traceWith)
import qualified Data.Bits as Bits
import qualified Data.ByteString as BS
import Data.DList (DList)
@@ -35,6 +35,7 @@ import qualified Data.IntSet as IntSet
import Data.List (unfoldr)
import Data.Map (Map)
import qualified Data.Map.Strict as Map
+import Data.Proxy (Proxy (..))
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import Data.Set (Set)
@@ -63,6 +64,7 @@ import LeiosDemoLogic.Announcements
, TraceLeiosNotifyPeerEvent (..)
, prunePeerState
)
+import qualified LeiosDemoLogic.Announcements as Announcements
import LeiosDemoLogic.Announcements.ElBimap (ElId)
import LeiosDemoLogic.Announcements.Validate
( AnnouncementInvalidity
@@ -85,21 +87,28 @@ import LeiosDemoTypes
, LeiosPoint (..)
, LeiosTx (..)
, PeerId (..)
+ , RbHash (..)
+ , SerializedEbBody
, TraceLeiosKernel (..)
, TraceLeiosPeer (..)
, TxHash (..)
, hashLeiosEb
, hashLeiosTx
, leiosEbBytesSize
+ , leiosEbTxs
, maxTxsPerEb
)
import qualified LeiosDemoTypes as Leios
+import LeiosTxCache (LeiosTxCache (..))
import Ouroboros.Consensus.Block
( BlockProtocol
+ , ConvertRawHash
, HasHeader
, Header
, WithOrigin (NotOrigin)
+ , blockSlot
, headerHash
+ , toRawHash
)
import Ouroboros.Consensus.BlockchainTime.WallClock.Types
( SystemTime
@@ -125,6 +134,54 @@ traceException :: (IOLike m, Exception e) => Tracer m a -> (e -> a) -> m b -> m
traceException tracer toTrace action =
action `catch` \e -> traceWith tracer (toTrace e) >> throwIO e
+{-------------------------------------------------------------------------------
+ Shadow LeiosTxCache wiring
+
+ The 'LeiosTxCache' handle is maintained (announcements, bodies, and txs inserted
+ at the same sites as the LeiosDb) but not yet consulted, so it changes no
+ observable behavior. The node's handle is
+ @'LeiosTxCache' m () () 'SerializedEbBody'@: only presence (@()@) is recorded
+ per tx, and the serialized body is the @b@.
+-------------------------------------------------------------------------------}
+
+-- | Insert an EB announcement into the tx-cache index, keyed by the announced
+-- slot, the announcing RB header's hash, and the announced EB hash. Evicted
+-- bodies\/txs are discarded; they can be useful for debugging/etc.
+recordAnnouncementInTxCache ::
+ forall blk m.
+ (ConvertRawHash blk, HasHeader (Header blk), IOLike m) =>
+ LeiosTxCache m () () SerializedEbBody ->
+ AnnouncingHeader blk ->
+ LeiosPoint ->
+ m ()
+recordAnnouncementInTxCache txCache ancHdr point =
+ void $ txCache.insertAnnouncement point.pointSlotNo rbh point.pointEbHash
+ where
+ rbh = MkRbHash (toRawHash (Proxy @blk) (headerHash (ancHeader ancHdr)))
+
+-- | Register a locally-forged EB in the tx-cache: its announcement, its
+-- body, and each of its txs as already-applied (the forger drew them from its
+-- validated mempool, so they are known-valid). This mirrors the receive side,
+-- which splits the same inserts between announcement handling
+-- ('recordAnnouncementInTxCache') and body acquisition. The applied-tagging must
+-- follow 'insertBody', which creates the per-tx entries that the tagging upgrades.
+recordForgedEbAndClosureInTxCache ::
+ Monad m =>
+ Tracer m TraceLeiosKernel ->
+ LeiosTxCache m () () SerializedEbBody ->
+ RbHash ->
+ Leios.ForgedLeiosEb ->
+ m ()
+recordForgedEbAndClosureInTxCache tracer txCache rbh forgedEb = do
+ _ <- txCache.insertAnnouncement point.pointSlotNo rbh point.pointEbHash
+ mSummary <- txCache.insertBody point.pointEbHash (Leios.serializeEbBody eb)
+ forM_ mSummary $ traceWith tracer . TraceLeiosTxCacheEbBody point
+ withLockedInsertAppliedTx txCache $ \w0 step ->
+ foldM (\w (txh, _sz) -> step w txh ()) w0 (leiosEbTxs eb)
+ where
+ point = forgedEb.point
+ eb = forgedEb.body
+
-----
data SomeLeiosFetchContext m
@@ -572,6 +629,7 @@ nextLeiosFetchClientCommand ::
( MVar m (LeiosOutstanding pid)
, MVar m ()
) ->
+ LeiosTxCache m () () SerializedEbBody ->
LeiosDbConnection m ->
PeerId pid ->
StrictTVar m (Seq LeiosFetchRequest) ->
@@ -585,7 +643,7 @@ nextLeiosFetchClientCommand ::
(m (Either () (LF.SomeLeiosFetchJob LeiosPoint LeiosEb LeiosTx m)))
(Either () (LF.SomeLeiosFetchJob LeiosPoint LeiosEb LeiosTx m))
)
-nextLeiosFetchClientCommand ktracer tracer stopSTM kernelVars db peerId reqsVar responseQ = do
+nextLeiosFetchClientCommand ktracer tracer stopSTM kernelVars txCache db peerId reqsVar responseQ = do
drainResponses
StrictSTM.atomically checkOrPeek >>= \case
Right result -> pure $ Right result
@@ -597,9 +655,9 @@ nextLeiosFetchClientCommand ktracer tracer stopSTM kernelVars db peerId reqsVar
pending <- StrictSTM.atomically $ LazySTM.flushTQueue responseQ
forM_ pending $ \case
PendingBlockResponse req eb ->
- msgLeiosBlock ktracer tracer kernelVars db peerId req eb
+ msgLeiosBlock ktracer tracer kernelVars txCache db peerId req eb
PendingBlockTxsResponse req txs ->
- msgLeiosBlockTxs ktracer tracer kernelVars db peerId req txs
+ msgLeiosBlockTxs ktracer tracer kernelVars txCache db peerId req txs
-- Non-blocking: return 'Right result' if stop or a request is available,
-- or 'Left ()' if we'd have to block (caller returns Left blockingLoop).
@@ -667,12 +725,13 @@ msgLeiosBlock ::
( MVar m (LeiosOutstanding pid)
, MVar m ()
) ->
+ LeiosTxCache m () () SerializedEbBody ->
LeiosDbConnection m ->
PeerId pid ->
LeiosBlockRequest ->
LeiosEb ->
m ()
-msgLeiosBlock ktracer tracer (outstandingVar, readyVar) db peerId req eb = do
+msgLeiosBlock ktracer tracer (outstandingVar, readyVar) txCache db peerId req eb = do
-- validate it
let MkLeiosBlockRequest point ebBytesSize = req
traceWith tracer $ MkTraceLeiosPeer $ "[start] MsgLeiosBlock " <> Leios.prettyLeiosPoint point
@@ -705,6 +764,8 @@ msgLeiosBlock ktracer tracer (outstandingVar, readyVar) db peerId req eb = do
traceWith ktracer $ TraceLeiosBlockPointMissing point
leiosDbInsertEbPoint db point ebBytesSize
completedByBody <- leiosDbInsertEbBody db point eb
+ mSummary <- txCache.insertBody ebHash (Leios.serializeEbBody eb)
+ forM_ mSummary $ traceWith ktracer . TraceLeiosTxCacheEbBody point
traceWith ktracer $ TraceLeiosBlockAcquired point
forM_ completedByBody $ traceWith ktracer . TraceLeiosBlockTxsAcquired
-- update NodeKernel state
@@ -843,12 +904,13 @@ msgLeiosBlockTxs ::
( MVar m (LeiosOutstanding pid)
, MVar m ()
) ->
+ LeiosTxCache m () () SerializedEbBody ->
LeiosDbConnection m ->
PeerId pid ->
LeiosBlockTxsRequest ->
V.Vector LeiosTx ->
m ()
-msgLeiosBlockTxs ktracer tracer (outstandingVar, readyVar) db peerId req txs = do
+msgLeiosBlockTxs ktracer tracer (outstandingVar, readyVar) txCache db peerId req txs = do
traceWith tracer $ MkTraceLeiosPeer $ "[start] " ++ Leios.prettyLeiosBlockTxsRequest req
-- validate it
-- TODO: could validate the returned point + bitmaps too (added to response recently)
@@ -876,6 +938,10 @@ msgLeiosBlockTxs ktracer tracer (outstandingVar, readyVar) db peerId req txs = d
traceException tracer TraceLeiosPeerDbException $ do
completed <- leiosDbInsertTxs db (V.toList $ V.zip txHashes txBytess)
forM_ completed $ traceWith ktracer . TraceLeiosBlockTxsAcquired
+ -- crucially: add the txs to the TxCacheIndex _after_ they've been written
+ -- to the LeiosDb, since it's currently what the TxCacheIndex is indexing
+ withLockedInsertUnappliedTx txCache $ \z step ->
+ foldM (\acc txh -> step acc txh ()) z txHashes
-- update NodeKernel state
MVar.modifyMVar_ outstandingVar $ \outstanding -> do
let (requestedTxPeers', reverseEbIndexByTx', txsBytesSize) =
@@ -940,7 +1006,7 @@ msgLeiosBlockTxs ktracer tracer (outstandingVar, readyVar) db peerId req txs = d
-- peer as offering both the body and its txs, then wake the fetch logic. (The
-- block-aware decision of /whether/ to call this — recognising the CertRB,
-- extracting its announcement, and waiting for the peer's LeiosNotify vars to
--- register — stays in the ChainSync client's @leiosCertRbCallback@.)
+-- register — stays in 'checkMsgRollForwardForLeiosOffers'.)
leiosCertRbOffer ::
IOLike m =>
( MVar m (LeiosOutstanding pid)
@@ -972,16 +1038,13 @@ leiosCertRbOffer (outstandingVar, readyVar) peerVars (point, ebBytesSize) = do
-----
--- | When a CertRB header arrives via ChainSync, update this peer's LeiosFetch
--- state as if its LeiosNotify client had offered the EB that the CertRB
--- certifies. The block-aware entry point: recognise the CertRB
--- ('headerContainsLeiosCert') and read the EB it certifies — the announcement
--- still recorded in the predecessor's chain-dep state, which the CertRB's own
--- transition would overwrite ('chainDepStateLeiosAnnouncement'). A no-op when
--- the header is not a CertRB or no announcement is recorded; otherwise the
--- LeiosFetch-side effect is recorded via 'leiosCertRbOffer' against the peer's
--- (already-resolved) LeiosNotify vars.
-leiosCertRbCallback ::
+-- | The offer-side handling of a 'MsgRollForward': when the header is a CertRB
+-- ('headerContainsLeiosCert'), record this peer as offering the EB it certifies
+-- (via 'leiosCertRbOffer'), reading that EB from the predecessor's chain-dep
+-- state ('chainDepStateLeiosAnnouncement'), which the CertRB's own transition
+-- would overwrite. A no-op otherwise. The announcement-side handling of the same
+-- header is separate; see the ChainSync client's 'leiosMsgRollForwardCallback'.
+checkMsgRollForwardForLeiosOffers ::
forall blk pid m.
(IOLike m, ResolveLeiosBlock blk) =>
( MVar m (LeiosOutstanding pid)
@@ -991,7 +1054,7 @@ leiosCertRbCallback ::
Header blk ->
ChainDepState (BlockProtocol blk) ->
m ()
-leiosCertRbCallback kernelVars peerVars hdr cds =
+checkMsgRollForwardForLeiosOffers kernelVars peerVars hdr cds =
when (headerContainsLeiosCert hdr) $
forM_ (protocolStateLeiosAnnouncement @blk cds) $ \announcement ->
leiosCertRbOffer kernelVars peerVars announcement
@@ -1030,6 +1093,54 @@ mkAnnouncingHeader h =
ancElId :: AnnouncingHeader blk -> ElId
ancElId = announcementElection . ancAnnouncementFields
+-- | The central-state handling shared by an incoming LeiosNotify
+-- 'MsgLeiosBlockAnnouncement' and a ChainSync 'MsgRollForward' that announces an
+-- EB: run 'Announcements.onAnnouncementCentral' (relay + dedup) and, for a
+-- genuinely new announcement, record the EB as awaited ('recordAnnouncedEb') and
+-- in the tx-cache ('recordAnnouncementInTxCache'). Central-only: no per-peer
+-- state is touched.
+processAnnouncementCentrally ::
+ forall blk peer pid m.
+ (IOLike m, ConvertRawHash blk, HasHeader (Header blk), Ord peer) =>
+ Tracer m TraceLeiosKernel ->
+ MVar m (Announcements.CentralState m peer (AnnouncingHeader blk)) ->
+ (MVar m (LeiosOutstanding pid), MVar m ()) ->
+ LeiosTxCache m () () SerializedEbBody ->
+ Maybe peer ->
+ AnnouncementSource ->
+ ShouldRelay ->
+ Maybe NominalDiffTime ->
+ AnnouncingHeader blk ->
+ m ()
+processAnnouncementCentrally
+ kernelTracer
+ centralVar
+ kernelVars
+ txCache
+ source
+ provenance
+ shouldRelay
+ age
+ ancHdr =
+ MVar.modifyMVar_ centralVar $ \cst ->
+ Announcements.onAnnouncementCentral
+ (contramap (traceNewAnnouncement provenance) kernelTracer)
+ ancElId
+ ( \_elSt -> do
+ recordAnnouncedEb kernelVars (point, Leios.announcementEbBodySize fields)
+ recordAnnouncementInTxCache txCache ancHdr point
+ )
+ cst
+ source
+ shouldRelay
+ age
+ ancHdr
+ where
+ fields = ancAnnouncementFields ancHdr
+ -- The announced EB's slot is the announcing header's own slot (see
+ -- 'headerLeiosAnnouncement'); its ebHash is kept in 'ancAnnouncementFields'.
+ point = MkLeiosPoint (blockSlot (ancHeader ancHdr)) (announcementEbHash fields)
+
-- | Thrown when a peer misbehaves on the announcement protocol; the ensuing
-- thread death disconnects the peer. It carries the
-- 'ErrAnnouncement' verbatim (the @blk@ is existential); every
@@ -1172,17 +1283,16 @@ tracePeerAnnouncement (TracePeerAnnouncement elSt) =
in TraceLeiosPeerAnnouncement equivocation fields
-- | Render an 'Announcements' node-wide announcement event as a
--- 'TraceLeiosKernel'.
+-- 'TraceLeiosKernel'. The 'AnnouncementSource' is supplied by the caller (only
+-- it knows which path delivered the announcement); the event's own @mbPeer@
+-- cannot distinguish LeiosNotify from ChainSync, as both carry a peer.
traceNewAnnouncement ::
+ AnnouncementSource ->
TraceLeiosNotifyEvent peer (AnnouncingHeader blk) ->
TraceLeiosKernel
-traceNewAnnouncement (TraceNewAnnouncement mbPeer _elId elSt age) =
+traceNewAnnouncement source (TraceNewAnnouncement _mbPeer _elId elSt age) =
let (equivocation, fields) = announcementTraceFields elSt
- in TraceLeiosAnnouncementAccepted
- (maybe ForgedLocally (const ReceivedFromPeer) mbPeer)
- equivocation
- fields
- age
+ in TraceLeiosAnnouncementAccepted source equivocation fields age
-- | Do not relay (to downstream peers) an announcement whose slot's wall-clock
-- onset is older than this. See 'ShouldRelay'.
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs
index b4e0e0cb0e..d7084b681b 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosDemoTypes.hs
@@ -672,6 +672,15 @@ decodeLeiosEb = do
fmap MkLeiosEb $ V.generateM n $ \_i -> do
(,) <$> (fmap MkTxHash CBOR.decodeBytes) <*> CBOR.decodeWord32
+-- | An EB body as its canonical CBOR bytes: the @b@ stored in the
+-- 'LeiosTxCache' index. Its 'LeiosTxCache.API.ReferencesTxsByHash' instance
+-- (defined alongside the class in "LeiosTxCache.API") decodes it to enumerate the
+-- referenced txs.
+newtype SerializedEbBody = MkSerializedEbBody SBS.ShortByteString
+
+serializeEbBody :: LeiosEb -> SerializedEbBody
+serializeEbBody = MkSerializedEbBody . SBS.toShort . toStrictByteString . encodeLeiosEb
+
-- * Voting
-- | Select the voting committee from a stake (weight) distribution per CIP-164:
@@ -902,6 +911,25 @@ messageLeiosFetchToObject = \case
LeiosFetch.MsgDone ->
"kind" .= Aeson.String "MsgDone"
+-- | Summary of an EB body inserted into the LeiosTxCache, for observability (see
+-- 'TraceLeiosTxCacheEbBody'). The counts nest:
+-- @ibsTxsInEb >= ibsTracked >= ibsAcquired >= ibsValidated@.
+data InsertBodySummary = InsertBodySummary
+ { ibsTxsInEb :: !Int
+ -- ^ txs the EB body references
+ , ibsTracked :: !Int
+ -- ^ of those, how many the cache already tracked
+ , ibsAcquired :: !Int
+ -- ^ of those tracked, how many were already acquired (inserted or validated)
+ , ibsValidated :: !Int
+ -- ^ of those acquired, how many were already validated
+ , ibsCacheTxCount :: !Int
+ -- ^ total txs the cache tracks after this insert
+ , ibsCacheLoad :: !Double
+ -- ^ 'ibsCacheTxCount' as a fraction of the worst-case cache capacity
+ }
+ deriving (Eq, Show)
+
data TraceLeiosKernel
= MkTraceLeiosKernel String
| TraceLeiosBlockAcquired LeiosPoint
@@ -909,6 +937,8 @@ data TraceLeiosKernel
-- unexpected as the point should have been inserted during announcement handling.
TraceLeiosBlockPointMissing LeiosPoint
| TraceLeiosBlockTxsAcquired LeiosPoint
+ | -- | An EB body was inserted into the LeiosTxCache; carries the insertion summary.
+ TraceLeiosTxCacheEbBody LeiosPoint InsertBodySummary
| forall m. (Show m, TxMeasureMetrics m) => TraceLeiosBlockForged
{ slot :: SlotNo
, eb :: LeiosEb
@@ -968,9 +998,14 @@ data AnnouncementEquivocation
| Equivocation
deriving (Eq, Show)
--- | Whether the node accepted an EB announcement it forged itself or one
--- relayed by an upstream peer.
-data AnnouncementSource = ForgedLocally | ReceivedFromPeer
+-- | How the node came to accept an EB announcement.
+data AnnouncementSource
+ = -- | The node forged the EB itself.
+ ForgedLocally
+ | -- | An upstream peer relayed it over the LeiosNotify mini-protocol.
+ ReceivedViaLeiosNotify
+ | -- | It rode in on a ChainSync 'MsgRollForward' header (the announcing RB).
+ ReceivedViaChainSync
deriving (Eq, Show)
-- | Reasons 'runLeiosVoting' may decline to cast a vote after acquiring an
@@ -1014,6 +1049,18 @@ traceLeiosKernelToObject = \case
, "ebHash" .= prettyEbHash ebHash
, "ebSlot" .= ebSlot
]
+ TraceLeiosTxCacheEbBody (MkLeiosPoint (SlotNo ebSlot) ebHash) ibs ->
+ mconcat
+ [ "kind" .= Aeson.String "LeiosTxCacheEbBody"
+ , "ebHash" .= prettyEbHash ebHash
+ , "ebSlot" .= ebSlot
+ , "txsInEb" .= ibsTxsInEb ibs
+ , "tracked" .= ibsTracked ibs
+ , "acquired" .= ibsAcquired ibs
+ , "validated" .= ibsValidated ibs
+ , "cacheTxCount" .= ibsCacheTxCount ibs
+ , "cacheLoad" .= ibsCacheLoad ibs
+ ]
TraceLeiosBlockForged{slot, eb, ebMeasure, mempoolRestMeasure} ->
mconcat
[ "kind" .= Aeson.String "LeiosBlockForged"
@@ -1110,7 +1157,8 @@ announcementEquivocationToObject = \case
announcementSourceText :: AnnouncementSource -> Aeson.Value
announcementSourceText = \case
ForgedLocally -> Aeson.String "forgedLocally"
- ReceivedFromPeer -> Aeson.String "receivedFromPeer"
+ ReceivedViaLeiosNotify -> Aeson.String "receivedViaLeiosNotify"
+ ReceivedViaChainSync -> Aeson.String "receivedViaChainSync"
notVotedReasonText :: LeiosNotVotedReason -> Aeson.Value
notVotedReasonText = \case
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache.hs
new file mode 100644
index 0000000000..26ccc2623d
--- /dev/null
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache.hs
@@ -0,0 +1,129 @@
+{-# LANGUAGE Rank2Types #-}
+
+-- | The LeiosTxCache tracks txs that were acquired because a /recent/ EB
+-- referenced them.
+--
+-- The LeiosTxCacheIndex records two facts about each tracked tx: whether it is
+-- already acquired and whether it has already been validated (by the Mempool or
+-- by the LeiosVoting thread).
+--
+-- The index is in-memory so that latency-critical consumers (LeiosFetch,
+-- LeiosVoting) can query it with constantly low latency; its eventual purpose is
+-- to /supplant/ the by-hash membership check that @filterMissingWork@ does
+-- against the LeiosDb. The size of the LeiosTxCache is bounded first and foremost
+-- by the requirement that its index fits comfortably in-memory, even in a worst
+-- case.
+--
+-- This module is the umbrella: it re-exports the "LeiosTxCache.API" interface
+-- and both handle factories, 'newPureLeiosTxCache' from
+-- "LeiosTxCache.Reference" and 'newHashTableLeiosTxCache' from
+-- "LeiosTxCache.Optimized".
+--
+-- == Invariants
+--
+-- (TODO This is written as if LeiosNotify writes announcements to the LeiosDb,
+-- but it doesn't already... and I'm not sure it will?)
+--
+-- (This is written as if LeiosFetch already reads the LeiosTxCache, but it
+-- doesn't yet. Remove this warning once it does.)
+--
+-- - INVARIANT: an EB announcement in the LeiosTxCacheIndex is in the LeiosDb
+--
+-- - INVARIANT: 'LeiosTxCache.API.BodyAlreadyInserted' EbBody is in the LeiosDb
+-- /and pinned/
+--
+-- - INVARIANT: a 'Pure.TxAlreadyInserted' tx is in the LeiosDb /and pinned/
+--
+-- These invariants are maintained as follows.
+--
+-- - LeiosNotify\/LeiosFetch\/the block forge inserts an EB
+-- announcement\/body\/tx into the LeiosDb /before/ it inserts into the
+-- LeiosTxCacheIndex.
+--
+-- - An EB announcement\/body is inserted into the LeiosDb\/LeiosTxCacheIndex
+-- before its body\/txs are inserted (unless it's shared with an earlier
+-- announcement\/body).
+--
+-- - Within the LeiosDb, announcements\/bodies pin the bodies\/txs they refer
+-- to.
+--
+-- - The ChainDB evicts "too old" announcements from the LeiosTxCacheIndex
+-- /before/ it prunes them from the LeiosDb.
+--
+-- That ensures an object's lifetime inside the LeiosTxCacheIndex is contained
+-- within that object's lifetime within the LeiosDb.
+--
+-- LeiosFetch checks the LeiosTxCacheIndex to see if it needs to fetch an EB
+-- body\/closure promised by some EB announcement\/body. If the
+-- LeiosTxCacheIndex reports
+-- 'LeiosTxCache.API.BodyAlreadyInserted'\/'Pure.TxAlreadyInserted', then
+-- LeiosFetch won't fetch it. Therefore, it's crucial that the body\/tx is
+-- actually in the LeiosDb.
+--
+-- It is still possible to see a LeiosTxCache hit when processing some
+-- announcement\/body and then later fail to read it from the LeiosDb. But
+-- because of the LeiosDb pinning, the only way a LeiosTxCacheIndex hit could
+-- precede a failed LeiosDb read is if /the announcement itself has also been
+-- pruned/ from the LeiosDb, in which case whatever logic issued the failed read
+-- can soundly short-circuit: it was attempting to process an orphaned EB.
+module LeiosTxCache
+ ( module LeiosTxCache.API
+ , newPureLeiosTxCache
+ , newHashTableLeiosTxCache
+ , nullLeiosTxCache
+ ) where
+
+import qualified Control.Concurrent.Class.MonadMVar as MVar
+import qualified Data.Set as Set
+import LeiosTxCache.API
+import LeiosTxCache.Optimized (newHashTableLeiosTxCache)
+import qualified LeiosTxCache.Reference as Pure
+import Ouroboros.Consensus.Util.IOLike (IOLike)
+
+-- | A handle backed by the pure reference index behind an MVar.
+newPureLeiosTxCache ::
+ (IOLike m, ReferencesTxsByHash b) =>
+ m (LeiosTxCache m a v b)
+newPureLeiosTxCache = do
+ var <- MVar.newMVar Pure.emptyLeiosTxCacheIndex
+ pure
+ LeiosTxCache
+ { insertAnnouncement = \slot rbh ebh ->
+ MVar.modifyMVar var $ \idx ->
+ let (idx', evEbs, evTxs) = Pure.insertAnnouncement slot rbh ebh idx
+ in pure (idx', (evEbs, evTxs))
+ , evictOlderThan = \boundary ->
+ MVar.modifyMVar var $ \idx ->
+ let (idx', evEbs, evTxs) = Pure.evictOlderThan boundary idx
+ in pure (idx', (evEbs, evTxs))
+ , insertBody = \ebh b ->
+ MVar.modifyMVar var $ \idx -> pure (Pure.insertBody ebh b idx)
+ , lookupBody = \ebh -> do
+ idx <- MVar.readMVar var
+ pure $! Pure.lookupBody ebh idx
+ , withLockedInsertUnappliedTx = \k ->
+ MVar.modifyMVar_ var $ \idx ->
+ k idx (\idx' txh a -> pure $! Pure.insertUnappliedTx txh a idx')
+ , withLockedInsertAppliedTx = \k ->
+ MVar.modifyMVar_ var $ \idx ->
+ k idx (\idx' txh v -> pure $! Pure.insertAppliedTx txh v idx')
+ , withLookupTx = \k -> do
+ idx <- MVar.readMVar var
+ k $ \txh -> pure $! Pure.lookupTx txh idx
+ }
+
+-- | A handle whose every operation is inert: announcements evict nothing, bodies
+-- are never summarised, tx insertions do nothing, and lookups always miss. For
+-- forge\/replay contexts that don't maintain the cache (e.g.
+-- "Cardano.Tools.DBSynthesizer").
+nullLeiosTxCache :: Applicative m => LeiosTxCache m a v b
+nullLeiosTxCache =
+ LeiosTxCache
+ { insertAnnouncement = \_slot _rbh _ebh -> pure (Set.empty, Set.empty)
+ , evictOlderThan = \_boundary -> pure (Set.empty, Set.empty)
+ , insertBody = \_ebh _b -> pure Nothing
+ , lookupBody = \_ebh -> pure Nothing
+ , withLockedInsertUnappliedTx = \k -> k () (\w _txh _a -> pure w)
+ , withLockedInsertAppliedTx = \k -> k () (\w _txh _v -> pure w)
+ , withLookupTx = \k -> k (\_txh -> pure Nothing)
+ }
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/API.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/API.hs
new file mode 100644
index 0000000000..d4b25db625
--- /dev/null
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/API.hs
@@ -0,0 +1,128 @@
+{-# LANGUAGE Rank2Types #-}
+
+-- | The LeiosTxCache interface: the handle type and the small set of types
+-- shared by every implementation. See "LeiosTxCache" for the overview and the
+-- backing-store design note.
+module LeiosTxCache.API
+ ( -- * Handle
+ LeiosTxCache (..)
+
+ -- * Body payloads
+ , ReferencesTxsByHash (..)
+
+ -- * Shared refcount \/ body state
+ , RefCount (..)
+ , BodyState (..)
+ , maxAnnouncementCount
+
+ -- * Insert-body observability summary
+ , InsertBodySummary (..)
+ , mkInsertBodySummary
+ , worstCaseCacheTxCount
+ ) where
+
+import Cardano.Slotting.Slot (SlotNo)
+import Codec.CBOR.Read (deserialiseFromBytes)
+import qualified Data.ByteString.Lazy as LBS
+import Data.ByteString.Short (fromShort)
+import Data.Set (Set)
+import qualified Data.Vector.Strict as V
+import Data.Word (Word8)
+import LeiosDemoTypes
+ ( EbHash
+ , InsertBodySummary (..)
+ , RbHash
+ , SerializedEbBody (..)
+ , TxHash
+ , decodeLeiosEb
+ , leiosEbTxs
+ , maxTxsPerEb
+ )
+
+-- | A monadic tx-cache handle: the pure index operations, each performing its
+-- state update in @m@.
+data LeiosTxCache m a v b = LeiosTxCache
+ { insertAnnouncement :: SlotNo -> RbHash -> EbHash -> m (Set EbHash, Set TxHash)
+ -- ^ Insert an announcement; returns the bodies and txs it evicted, if any. A
+ -- no-op for an EB whose slot is strictly older than the latest 'evictOlderThan'
+ -- boundary — the cache has already been pruned past it.
+ , evictOlderThan :: SlotNo -> m (Set EbHash, Set TxHash)
+ -- ^ Evict every retained announcement whose slot is strictly older than the
+ -- given boundary; returns the bodies and txs it evicted, if any. The tip-driven
+ -- eviction entrypoint (see "LeiosTxCache"), complementing the count-driven
+ -- eviction that 'insertAnnouncement' performs. Also records the boundary, after
+ -- which 'insertAnnouncement' ignores any EB that old.
+ --
+ -- ORDERING CONTRACT — MUST HOLD: prune this in-memory cache to a slot @X@
+ -- /strictly before/ the LeiosDb is pruned to that same @X@ — never after, never
+ -- concurrently. This cache is only an index of the LeiosDb, so evicting from the
+ -- index first is what guarantees it can never report a hit for a tx the LeiosDb
+ -- has already dropped. Reverse the order and you arm the hit-prune hazard: a
+ -- false hit ⇒ a skipped fetch ⇒ a silently-incomplete EB closure.
+ , insertBody :: EbHash -> b -> m (Maybe InsertBodySummary)
+ , lookupBody :: EbHash -> m (Maybe b)
+ -- ^ The EB's body if we hold it (its 'BodyState' is 'BodyAlreadyInserted');
+ -- 'Nothing' if the EB is untracked or only announced. Unlike a tx, an EB body
+ -- pins itself: a hit means it is in the LeiosDb and stays there until the EB is
+ -- pruned, so no cross-object reasoning is needed.
+ , withLockedInsertUnappliedTx :: (forall w. w -> (w -> TxHash -> a -> m w) -> m w) -> m ()
+ -- ^ Has exclusive write-access
+ , withLockedInsertAppliedTx :: (forall w. w -> (w -> TxHash -> v -> m w) -> m w) -> m ()
+ -- ^ Has exclusive write-access
+ , withLookupTx :: forall r. ((TxHash -> m (Maybe (Either a v))) -> m r) -> m r
+ -- ^ Does not not hold the lock
+ }
+
+-- | A body @b@ from which the referenced txs can be enumerated by hash.
+--
+-- The fold must visit each referenced 'TxHash' at most once per body (a valid EB
+-- body references a tx at most once), so that a body contributes exactly one to
+-- each of its txs' refcounts.
+class ReferencesTxsByHash b where
+ foldTxReferences :: (r -> TxHash -> r) -> r -> b -> r
+
+-- | The production body type: 'SerializedEbBody' is decoded to enumerate its
+-- referenced txs. (The type lives in "LeiosDemoTypes"; the instance lives here,
+-- with the class, to keep it non-orphan.)
+instance ReferencesTxsByHash SerializedEbBody where
+ foldTxReferences f z (MkSerializedEbBody sbs) =
+ V.foldl' (\acc (txh, _sz) -> f acc txh) z (leiosEbTxs eb)
+ where
+ eb = case deserialiseFromBytes decodeLeiosEb (LBS.fromStrict (fromShort sbs)) of
+ Right (_leftover, decoded) -> decoded
+ Left err -> error $ "SerializedEbBody: undecodable: " <> show err
+
+-- | The maximum number of EB announcements retained. Inserting past it evicts
+-- the oldest, cascading through the body and tx refcounts.
+maxAnnouncementCount :: Int
+maxAnnouncementCount = 128 -- TODO magic number
+
+-- | A reference count.
+--
+-- INVARIANT: @> 0@ (an entry at zero is removed rather than stored).
+newtype RefCount = MkRefCount Word8
+ deriving (Eq, Show)
+
+data BodyState b
+ = -- | An announcement of this EB has been inserted, but not its body.
+ BodyNotYetInserted {-# UNPACK #-} !RefCount
+ | BodyAlreadyInserted {-# UNPACK #-} !RefCount !b
+
+-- | The worst-case number of txs the cache can hold: a full 'maxAnnouncementCount'
+-- window of EBs, each referencing the maximum 'maxTxsPerEb' distinct txs. The fixed
+-- denominator for the cache's load factor.
+worstCaseCacheTxCount :: Int
+worstCaseCacheTxCount = maxAnnouncementCount * maxTxsPerEb
+
+-- | Build an 'InsertBodySummary' from the raw counts, computing the load factor
+-- ('ibsCacheLoad') against 'worstCaseCacheTxCount'.
+mkInsertBodySummary :: Int -> Int -> Int -> Int -> Int -> InsertBodySummary
+mkInsertBodySummary txsInEb tracked acquired validated cacheTxCount =
+ InsertBodySummary
+ { ibsTxsInEb = txsInEb
+ , ibsTracked = tracked
+ , ibsAcquired = acquired
+ , ibsValidated = validated
+ , ibsCacheTxCount = cacheTxCount
+ , ibsCacheLoad = fromIntegral cacheTxCount / fromIntegral worstCaseCacheTxCount
+ }
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Optimized.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Optimized.hs
new file mode 100644
index 0000000000..961a7e15e0
--- /dev/null
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Optimized.hs
@@ -0,0 +1,361 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE RankNTypes #-}
+{-# OPTIONS_GHC -O2 #-}
+
+-- | A 'LeiosTxCache' handle backed by the mutable 'HT.MutableHashTable': the
+-- counterpart to 'LeiosTxCache.newPureLeiosTxCache'. The ~2M-entry tx map lives
+-- in the hash table (value = the tx's refcount and 2-bit state tag packed into
+-- the 'Word64'); the small announcement and body state stays in 'Map's behind an
+-- 'MVar' that also serializes every hash-table access (the \"Locked\" ops hold it
+-- for writes; 'withLookupTx' holds it for the read batch). The refcount
+-- maintenance and eviction cascade mirror "LeiosTxCache.Reference" exactly — this
+-- is the mutable re-implementation validated against the pure one.
+--
+-- Only @a = v = ()@ is supported (the node's instantiation), since the value is
+-- a bare 'Word64'.
+module LeiosTxCache.Optimized
+ ( newHashTableLeiosTxCache
+ ) where
+
+import Cardano.Slotting.Slot (SlotNo (..))
+import qualified Control.Concurrent.Class.MonadMVar as MVar
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Data.Bits (unsafeShiftL, unsafeShiftR, (.&.), (.|.))
+import qualified Data.ByteString.Unsafe as BSU
+import Data.Map.NonEmpty (NEMap)
+import qualified Data.Map.NonEmpty as NEMap
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Set (Set)
+import qualified Data.Set as Set
+import Data.Word (Word64)
+import LeiosDemoTypes (EbHash, RbHash, TxHash (..))
+import LeiosTxCache.API
+ ( BodyState (..)
+ , LeiosTxCache (..)
+ , RefCount (..)
+ , ReferencesTxsByHash (..)
+ , maxAnnouncementCount
+ , mkInsertBodySummary
+ )
+import qualified LeiosTxCache.Optimized.MutableHashTable as HT
+import Ouroboros.Consensus.Util.IOLike (IOLike)
+
+-- | The small, map-resident state (the tx map is the hash table, not here).
+data HtState b = HtState
+ { hsAnnouncements :: !(Map SlotNo (NEMap RbHash EbHash))
+ , hsCount :: !Int
+ , hsBodies :: !(Map EbHash (BodyState b))
+ , hsPrunedSlot :: !SlotNo
+ -- ^ Greatest slot 'evictOlderThan' has pruned to; 'insertAnnouncement' ignores
+ -- any EB strictly older. Mirrors 'LeiosTxCache.Reference.prunedSlot'.
+ }
+
+emptyHtState :: HtState b
+emptyHtState = HtState Map.empty 0 Map.empty (SlotNo 0)
+
+-- | A hash-table-backed handle. @nshift@ sizes the table (@2 ^ nshift@ slots; use
+-- 22 for the ~1.9M worst case) and @k0@\/@k1@ are the SipHash salt (feed a
+-- securely-random pair).
+newHashTableLeiosTxCache ::
+ (IOLike m, ReferencesTxsByHash b) =>
+ Int ->
+ Word64 ->
+ Word64 ->
+ m (LeiosTxCache m () () b)
+{-# SPECIALIZE newHashTableLeiosTxCache ::
+ ReferencesTxsByHash b => Int -> Word64 -> Word64 -> IO (LeiosTxCache IO () () b)
+ #-}
+newHashTableLeiosTxCache nshift k0 k1 = do
+ ht <- HT.new nshift k0 k1
+ stateVar <- MVar.newMVar emptyHtState
+ pure
+ LeiosTxCache
+ { insertAnnouncement = \slot rbh ebh ->
+ MVar.modifyMVar stateVar $ \st ->
+ if slot < hsPrunedSlot st || announcementPresent slot rbh st
+ then pure (st, (Set.empty, Set.empty))
+ else
+ evictWhile
+ ht
+ ((> maxAnnouncementCount) . hsCount)
+ (addAnnouncement slot rbh ebh st)
+ Set.empty
+ Set.empty
+ , evictOlderThan = \boundary ->
+ MVar.modifyMVar stateVar $ \st ->
+ let st' = st{hsPrunedSlot = max (hsPrunedSlot st) boundary}
+ in evictWhile ht (oldestIsStale (hsPrunedSlot st')) st' Set.empty Set.empty
+ , insertBody = \ebh b ->
+ MVar.modifyMVar stateVar $ \st ->
+ case Map.lookup ebh (hsBodies st) of
+ Nothing -> pure (st, Nothing)
+ Just BodyAlreadyInserted{} -> pure (st, Nothing)
+ Just (BodyNotYetInserted rc) -> do
+ -- bump each tx's refcount and classify its prior state in one pass
+ (n, tracked, acquired, validated) <-
+ foldTxReferences
+ ( \acc txh -> do
+ (!nn, !tt, !aa, !vv) <- acc
+ (dt, da, dv) <- priorClass <$> bumpTx ht txh
+ pure (nn + 1, tt + dt, aa + da, vv + dv)
+ )
+ (pure (0, 0, 0, 0))
+ b
+ cacheTxCount <- HT.size ht
+ let st' = st{hsBodies = Map.insert ebh (BodyAlreadyInserted rc b) (hsBodies st)}
+ pure (st', Just (mkInsertBodySummary n tracked acquired validated cacheTxCount))
+ , lookupBody = \ebh ->
+ MVar.withMVar stateVar $ \st ->
+ pure $ case Map.lookup ebh (hsBodies st) of
+ Just (BodyAlreadyInserted _ b) -> Just b
+ _ -> Nothing
+ , withLockedInsertUnappliedTx = \k ->
+ MVar.modifyMVar_ stateVar $ \st -> do
+ _ <- k () (\_ txh _ -> setTag ht tagAlreadyInserted txh)
+ pure st
+ , withLockedInsertAppliedTx = \k ->
+ MVar.modifyMVar_ stateVar $ \st -> do
+ _ <- k () (\_ txh _ -> setTag ht tagAlreadyValidated txh)
+ pure st
+ , withLookupTx = \k ->
+ MVar.withMVar stateVar $ \_ -> k (lookupOne ht)
+ }
+
+{-------------------------------------------------------------------------------
+ Announcement \/ body state (mirrors LeiosTxCacheIndex, txs excepted)
+-------------------------------------------------------------------------------}
+
+announcementPresent :: SlotNo -> RbHash -> HtState b -> Bool
+announcementPresent slot rbh st =
+ maybe False (NEMap.member rbh) (Map.lookup slot (hsAnnouncements st))
+
+addAnnouncement :: SlotNo -> RbHash -> EbHash -> HtState b -> HtState b
+addAnnouncement slot rbh ebh st =
+ HtState
+ { hsAnnouncements =
+ Map.alter
+ (Just . maybe (NEMap.singleton rbh ebh) (NEMap.insert rbh ebh))
+ slot
+ (hsAnnouncements st)
+ , hsCount = hsCount st + 1
+ , hsBodies =
+ Map.alter
+ (Just . maybe (BodyNotYetInserted (MkRefCount 1)) incBodyRc)
+ ebh
+ (hsBodies st)
+ , hsPrunedSlot = hsPrunedSlot st
+ }
+
+-- | Repeatedly 'evictOldest' while @shouldEvict@ holds of the state: the shared
+-- core of the two eviction entrypoints (count-driven from 'insertAnnouncement',
+-- slot-driven from 'evictOlderThan'). Mirrors 'LeiosTxCache.Reference.evictWhile'.
+evictWhile ::
+ (PrimMonad m, ReferencesTxsByHash b) =>
+ HT.MutableHashTable (PrimState m) ->
+ (HtState b -> Bool) ->
+ HtState b ->
+ Set EbHash ->
+ Set TxHash ->
+ m (HtState b, (Set EbHash, Set TxHash))
+{-# SPECIALIZE evictWhile ::
+ ReferencesTxsByHash b =>
+ HT.MutableHashTable (PrimState IO) ->
+ (HtState b -> Bool) ->
+ HtState b ->
+ Set EbHash ->
+ Set TxHash ->
+ IO (HtState b, (Set EbHash, Set TxHash))
+ #-}
+evictWhile ht shouldEvict = go
+ where
+ go st !evEbs !evTxs
+ | shouldEvict st = do
+ (st', ebs', txs') <- evictOldest ht st
+ go st' (evEbs <> ebs') (evTxs <> txs')
+ | otherwise = pure (st, (evEbs, evTxs))
+
+-- | Whether the oldest retained announcement's slot is strictly older than the
+-- boundary. The slot-driven eviction predicate.
+oldestIsStale :: SlotNo -> HtState b -> Bool
+oldestIsStale boundary st = case Map.lookupMin (hsAnnouncements st) of
+ Just (slotMin, _) -> slotMin < boundary
+ Nothing -> False
+
+evictOldest ::
+ (PrimMonad m, ReferencesTxsByHash b) =>
+ HT.MutableHashTable (PrimState m) ->
+ HtState b ->
+ m (HtState b, Set EbHash, Set TxHash)
+{-# SPECIALIZE evictOldest ::
+ ReferencesTxsByHash b =>
+ HT.MutableHashTable (PrimState IO) ->
+ HtState b ->
+ IO (HtState b, Set EbHash, Set TxHash)
+ #-}
+evictOldest ht st = do
+ let (slotMin, nem) = Map.findMin (hsAnnouncements st)
+ (rbhMin, ebhEvicted) = NEMap.findMin nem
+ announcements' = case NEMap.nonEmptyMap (NEMap.delete rbhMin nem) of
+ Nothing -> Map.delete slotMin (hsAnnouncements st)
+ Just nem' -> Map.insert slotMin nem' (hsAnnouncements st)
+ (bodies', evEbs, evTxs) <- decBody ht ebhEvicted (hsBodies st)
+ pure
+ ( HtState
+ { hsAnnouncements = announcements'
+ , hsCount = hsCount st - 1
+ , hsBodies = bodies'
+ , hsPrunedSlot = hsPrunedSlot st
+ }
+ , evEbs
+ , evTxs
+ )
+
+decBody ::
+ (PrimMonad m, ReferencesTxsByHash b) =>
+ HT.MutableHashTable (PrimState m) ->
+ EbHash ->
+ Map EbHash (BodyState b) ->
+ m (Map EbHash (BodyState b), Set EbHash, Set TxHash)
+{-# SPECIALIZE decBody ::
+ ReferencesTxsByHash b =>
+ HT.MutableHashTable (PrimState IO) ->
+ EbHash ->
+ Map EbHash (BodyState b) ->
+ IO (Map EbHash (BodyState b), Set EbHash, Set TxHash)
+ #-}
+decBody ht ebh bodies = case Map.lookup ebh bodies of
+ Nothing -> pure (bodies, Set.empty, Set.empty)
+ Just bs -> case decRefCount (bodyRefCount bs) of
+ Just rc' -> pure (Map.insert ebh (setBodyRefCount rc' bs) bodies, Set.empty, Set.empty)
+ Nothing -> do
+ evTxs <- case bs of
+ BodyNotYetInserted _ -> pure Set.empty
+ BodyAlreadyInserted _ b -> decBodyTxs ht b
+ pure (Map.delete ebh bodies, Set.singleton ebh, evTxs)
+
+decBodyTxs ::
+ (PrimMonad m, ReferencesTxsByHash b) =>
+ HT.MutableHashTable (PrimState m) ->
+ b ->
+ m (Set TxHash)
+{-# SPECIALIZE decBodyTxs ::
+ ReferencesTxsByHash b => HT.MutableHashTable (PrimState IO) -> b -> IO (Set TxHash)
+ #-}
+decBodyTxs ht =
+ foldTxReferences
+ ( \act txh -> do
+ s <- act
+ evicted <- decTx ht txh
+ pure (if evicted then Set.insert txh s else s)
+ )
+ (pure Set.empty)
+
+{-------------------------------------------------------------------------------
+ Refcount helpers (RefCount's own inc\/dec are internal to LeiosTxCacheIndex)
+-------------------------------------------------------------------------------}
+
+incBodyRc :: BodyState b -> BodyState b
+incBodyRc (BodyNotYetInserted (MkRefCount n)) = BodyNotYetInserted (MkRefCount (n + 1))
+incBodyRc (BodyAlreadyInserted (MkRefCount n) b) = BodyAlreadyInserted (MkRefCount (n + 1)) b
+
+bodyRefCount :: BodyState b -> RefCount
+bodyRefCount (BodyNotYetInserted rc) = rc
+bodyRefCount (BodyAlreadyInserted rc _) = rc
+
+setBodyRefCount :: RefCount -> BodyState b -> BodyState b
+setBodyRefCount rc (BodyNotYetInserted _) = BodyNotYetInserted rc
+setBodyRefCount rc (BodyAlreadyInserted _ b) = BodyAlreadyInserted rc b
+
+decRefCount :: RefCount -> Maybe RefCount
+decRefCount (MkRefCount n)
+ | n <= 1 = Nothing
+ | otherwise = Just (MkRefCount (n - 1))
+
+{-------------------------------------------------------------------------------
+ Tx map operations, over the hash table
+-------------------------------------------------------------------------------}
+
+tagNotYetInserted, tagAlreadyInserted, tagAlreadyValidated :: Word64
+tagNotYetInserted = 0
+tagAlreadyInserted = 1
+tagAlreadyValidated = 2
+
+-- value = (refcount << 2) | tag
+mkVal :: Word64 -> Word64 -> Word64
+mkVal rc tag = (rc `unsafeShiftL` 2) .|. tag
+
+valRefcount :: Word64 -> Word64
+valRefcount w = w `unsafeShiftR` 2
+
+valTag :: Word64 -> Word64
+valTag w = w .&. 3
+
+-- | A body now refers to this tx: create at refcount 1 (NotYetInserted) or bump.
+-- Returns the tx's /prior/ packed value ('Nothing' if it was untracked), so the
+-- caller can classify it without a second lookup.
+bumpTx :: PrimMonad m => HT.MutableHashTable (PrimState m) -> TxHash -> m (Maybe Word64)
+{-# SPECIALIZE bumpTx :: HT.MutableHashTable (PrimState IO) -> TxHash -> IO (Maybe Word64) #-}
+bumpTx ht txh = do
+ let key = toKey txh
+ mv <- HT.lookup ht key
+ case mv of
+ Nothing -> HT.insert ht key (mkVal 1 tagNotYetInserted)
+ Just w -> HT.insert ht key (mkVal (valRefcount w + 1) (valTag w))
+ pure mv
+
+-- | Classify a tx's prior packed value into @(tracked, acquired, validated)@ count
+-- deltas for the insert-body summary.
+priorClass :: Maybe Word64 -> (Int, Int, Int)
+priorClass Nothing = (0, 0, 0)
+priorClass (Just w)
+ | valTag w == tagAlreadyValidated = (1, 1, 1)
+ | valTag w == tagAlreadyInserted = (1, 1, 0)
+ | otherwise = (1, 0, 0)
+
+-- | An evicted body no longer refers to this tx: decrement, deleting (and
+-- reporting) it at zero.
+decTx :: PrimMonad m => HT.MutableHashTable (PrimState m) -> TxHash -> m Bool
+{-# SPECIALIZE decTx :: HT.MutableHashTable (PrimState IO) -> TxHash -> IO Bool #-}
+decTx ht txh = do
+ let key = toKey txh
+ mv <- HT.lookup ht key
+ case mv of
+ Nothing -> pure False
+ Just w
+ | valRefcount w <= 1 -> HT.delete ht key >> pure True
+ | otherwise -> HT.insert ht key (mkVal (valRefcount w - 1) (valTag w)) >> pure False
+
+-- | Set a present tx's state tag, preserving its refcount; no-op if absent.
+setTag :: PrimMonad m => HT.MutableHashTable (PrimState m) -> Word64 -> TxHash -> m ()
+{-# SPECIALIZE setTag :: HT.MutableHashTable (PrimState IO) -> Word64 -> TxHash -> IO () #-}
+setTag ht tag txh = do
+ let key = toKey txh
+ mv <- HT.lookup ht key
+ case mv of
+ Nothing -> pure ()
+ Just w -> HT.insert ht key (mkVal (valRefcount w) tag)
+
+lookupOne :: PrimMonad m => HT.MutableHashTable (PrimState m) -> TxHash -> m (Maybe (Either () ()))
+{-# SPECIALIZE lookupOne ::
+ HT.MutableHashTable (PrimState IO) -> TxHash -> IO (Maybe (Either () ()))
+ #-}
+lookupOne ht txh = do
+ mv <- HT.lookup ht (toKey txh)
+ pure $ case mv of
+ Nothing -> Nothing
+ Just w
+ | valTag w == tagAlreadyInserted -> Just (Left ())
+ | valTag w == tagAlreadyValidated -> Just (Right ())
+ | otherwise -> Nothing -- NotYetInserted: referenced but not held
+
+-- | The 32-byte hash as the table's four-word 'HT.Key' (big-endian words).
+-- TODO: direct once 'TxHash' is @PackedBytes 32@ rather than a 'ByteString'.
+toKey :: TxHash -> HT.Key
+toKey (MkTxHash bs) = HT.Key (rd 0) (rd 8) (rd 16) (rd 24)
+ where
+ rd off = go 0 off (off + 8)
+ go !acc o end
+ | o >= end = acc
+ | otherwise =
+ go ((acc `unsafeShiftL` 8) .|. fromIntegral (BSU.unsafeIndex bs o)) (o + 1) end
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Optimized/MutableHashTable.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Optimized/MutableHashTable.hs
new file mode 100644
index 0000000000..4f8fe026d4
--- /dev/null
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Optimized/MutableHashTable.hs
@@ -0,0 +1,367 @@
+{-# LANGUAGE BangPatterns #-}
+{-# OPTIONS_GHC -O2 #-}
+
+-- | A mutable, salted open-addressing hash table for the Leios tx cache: linear
+-- probing with backward-shift (tombstone-free) deletion, a salted SipHash-2-4,
+-- and a separate occupancy bitset — a Haskell port of the reference
+-- @hash_table.c@, mapping a 32-byte key to a 'Word64' value.
+--
+-- The backing store is a 'MutableByteArray' (via "Data.Primitive"): GHC-owned,
+-- so its lifetime is GC-managed and its footprint shows up in the RTS stats, but
+-- as a large byte object the collector never traces into it or copies it —
+-- contiguous and off to the side, just accounted for. Entries are five
+-- 'Word64's each (four key words + one value); occupancy is one bit per slot.
+--
+-- The key is four 'Word64's ('Key'), so there is no per-key heap object and both
+-- hashing and equality read the words directly.
+module LeiosTxCache.Optimized.MutableHashTable
+ ( MutableHashTable
+ , Key (..)
+ , new
+ , insert
+ , lookup
+ , delete
+ , size
+ , capacity
+ , siphash24
+ , homeSlot
+ , checkInvariants
+ ) where
+
+import Control.Monad.Primitive (PrimMonad, PrimState)
+import Data.Bits
+import Data.Primitive.ByteArray
+ ( MutableByteArray
+ , fillByteArray
+ , newByteArray
+ , readByteArray
+ , writeByteArray
+ )
+import Data.Primitive.MutVar (MutVar, modifyMutVar', newMutVar, readMutVar)
+import qualified Data.Set as Set
+import Data.Word (Word64)
+import Prelude hiding (lookup)
+
+-- | A 32-byte key as four 'Word64's.
+data Key
+ = Key
+ {-# UNPACK #-} !Word64
+ {-# UNPACK #-} !Word64
+ {-# UNPACK #-} !Word64
+ {-# UNPACK #-} !Word64
+ deriving (Eq, Ord, Show)
+
+data MutableHashTable s = MutableHashTable
+ { mhtCap :: !Int
+ -- ^ capacity, a power of two
+ , mhtMask :: !Int
+ -- ^ @mhtCap - 1@
+ , mhtEntries :: !(MutableByteArray s)
+ -- ^ @mhtCap * 5@ words: entry @i@ is words @[i*5 .. i*5+3]@ (key) then @i*5+4@ (value)
+ , mhtOccupied :: !(MutableByteArray s)
+ -- ^ @mhtCap \`div\` 64@ words: one bit per slot
+ , mhtK0 :: !Word64
+ , mhtK1 :: !Word64
+ , mhtSize :: !(MutVar s Int)
+ }
+
+-- | Allocate a table of @2 ^ nshift@ slots (@nshift >= 6@) with the given 128-bit
+-- salt. Feed a securely-random salt: keys are adversarial.
+new :: PrimMonad m => Int -> Word64 -> Word64 -> m (MutableHashTable (PrimState m))
+{-# SPECIALIZE new :: Int -> Word64 -> Word64 -> IO (MutableHashTable (PrimState IO)) #-}
+new nshift k0 k1
+ | nshift < 6 = error "MutableHashTable.new: nshift must be >= 6"
+ | otherwise = do
+ let cap = 1 `unsafeShiftL` nshift
+ occWords = cap `div` 64
+ entries <- newByteArray (cap * 5 * 8)
+ occupied <- newByteArray (occWords * 8)
+ fillByteArray occupied 0 (occWords * 8) 0
+ szRef <- newMutVar 0
+ pure
+ MutableHashTable
+ { mhtCap = cap
+ , mhtMask = cap - 1
+ , mhtEntries = entries
+ , mhtOccupied = occupied
+ , mhtK0 = k0
+ , mhtK1 = k1
+ , mhtSize = szRef
+ }
+
+capacity :: MutableHashTable s -> Int
+capacity = mhtCap
+
+size :: PrimMonad m => MutableHashTable (PrimState m) -> m Int
+{-# SPECIALIZE size :: MutableHashTable (PrimState IO) -> IO Int #-}
+size = readMutVar . mhtSize
+
+{-------------------------------------------------------------------------------
+ Occupancy bitset
+-------------------------------------------------------------------------------}
+
+isOccupied :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> m Bool
+{-# SPECIALIZE isOccupied :: MutableHashTable (PrimState IO) -> Int -> IO Bool #-}
+isOccupied ht i = do
+ w <- readByteArray (mhtOccupied ht) (i `unsafeShiftR` 6)
+ pure $ testBit (w :: Word64) (i .&. 63)
+
+setOccupied :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> m ()
+{-# SPECIALIZE setOccupied :: MutableHashTable (PrimState IO) -> Int -> IO () #-}
+setOccupied ht i = do
+ let j = i `unsafeShiftR` 6
+ w <- readByteArray (mhtOccupied ht) j
+ writeByteArray (mhtOccupied ht) j (setBit (w :: Word64) (i .&. 63))
+
+clearOccupied :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> m ()
+{-# SPECIALIZE clearOccupied :: MutableHashTable (PrimState IO) -> Int -> IO () #-}
+clearOccupied ht i = do
+ let j = i `unsafeShiftR` 6
+ w <- readByteArray (mhtOccupied ht) j
+ writeByteArray (mhtOccupied ht) j (clearBit (w :: Word64) (i .&. 63))
+
+{-------------------------------------------------------------------------------
+ Entry access
+-------------------------------------------------------------------------------}
+
+readKey :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> m Key
+{-# SPECIALIZE readKey :: MutableHashTable (PrimState IO) -> Int -> IO Key #-}
+readKey ht i = do
+ let b = i * 5
+ Key
+ <$> readByteArray (mhtEntries ht) b
+ <*> readByteArray (mhtEntries ht) (b + 1)
+ <*> readByteArray (mhtEntries ht) (b + 2)
+ <*> readByteArray (mhtEntries ht) (b + 3)
+
+writeKey :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> Key -> m ()
+{-# SPECIALIZE writeKey :: MutableHashTable (PrimState IO) -> Int -> Key -> IO () #-}
+writeKey ht i (Key a b c d) = do
+ let o = i * 5
+ writeByteArray (mhtEntries ht) o a
+ writeByteArray (mhtEntries ht) (o + 1) b
+ writeByteArray (mhtEntries ht) (o + 2) c
+ writeByteArray (mhtEntries ht) (o + 3) d
+
+readVal :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> m Word64
+{-# SPECIALIZE readVal :: MutableHashTable (PrimState IO) -> Int -> IO Word64 #-}
+readVal ht i = readByteArray (mhtEntries ht) (i * 5 + 4)
+
+writeVal :: PrimMonad m => MutableHashTable (PrimState m) -> Int -> Word64 -> m ()
+{-# SPECIALIZE writeVal :: MutableHashTable (PrimState IO) -> Int -> Word64 -> IO () #-}
+writeVal ht i = writeByteArray (mhtEntries ht) (i * 5 + 4)
+
+{-------------------------------------------------------------------------------
+ SipHash-2-4, unrolled for a 32-byte (4-word) key
+-------------------------------------------------------------------------------}
+
+-- | The four-word SipHash state: an isomorph of 'Key', kept distinct and used as
+-- the input and output of 'sipround'\/'compress' so the rounds thread unboxed
+-- words rather than a boxed 4-tuple.
+data SIP
+ = SIP
+ {-# UNPACK #-} !Word64
+ {-# UNPACK #-} !Word64
+ {-# UNPACK #-} !Word64
+ {-# UNPACK #-} !Word64
+
+rotl :: Word64 -> Int -> Word64
+rotl x b = (x `unsafeShiftL` b) .|. (x `unsafeShiftR` (64 - b))
+
+sipround :: SIP -> SIP
+sipround (SIP v0 v1 v2 v3) =
+ let v0a = v0 + v1
+ v1a = rotl v1 13 `xor` v0a
+ v0b = rotl v0a 32
+ v2a = v2 + v3
+ v3a = rotl v3 16 `xor` v2a
+ v0c = v0b + v3a
+ v3b = rotl v3a 21 `xor` v0c
+ v2b = v2a + v1a
+ v1b = rotl v1a 17 `xor` v2b
+ v2c = rotl v2b 32
+ in SIP v0c v1b v2c v3b
+
+-- | Absorb one message word: @v3 ^= m; SIPROUND; SIPROUND; v0 ^= m@.
+compress :: Word64 -> SIP -> SIP
+compress m (SIP v0 v1 v2 v3) =
+ let !(SIP a0 a1 a2 a3) = sipround (SIP v0 v1 v2 (v3 `xor` m))
+ !(SIP b0 b1 b2 b3) = sipround (SIP a0 a1 a2 a3)
+ in SIP (b0 `xor` m) b1 b2 b3
+
+-- | Raw SipHash-2-4 of the four-word (32-byte) key under the 128-bit salt
+-- @(k0, k1)@. Exposed for validation against the published SipHash-2-4 test
+-- vectors; 'hashKey' just reduces this to a slot index.
+{-# INLINE siphash24 #-}
+siphash24 :: Word64 -> Word64 -> Key -> Word64
+siphash24 k0 k1 (Key m0 m1 m2 m3) = g0 `xor` g1 `xor` g2 `xor` g3
+ where
+ s0 =
+ SIP
+ (0x736f6d6570736575 `xor` k0)
+ (0x646f72616e646f6d `xor` k1)
+ (0x6c7967656e657261 `xor` k0)
+ (0x7465646279746573 `xor` k1)
+ absorbed = compress m3 (compress m2 (compress m1 (compress m0 s0)))
+ !(SIP p0 p1 p2 p3) = compress (32 `unsafeShiftL` 56) absorbed
+ -- finalization: v2 ^= 0xff; SIPROUND x4
+ !(SIP g0 g1 g2 g3) =
+ sipround (sipround (sipround (sipround (SIP p0 p1 (p2 `xor` 0xff) p3))))
+
+-- | The home slot a key hashes to in a table whose mask is @cap - 1@, under the
+-- 128-bit salt. Exposed so tests can construct colliding keys (a shared home).
+{-# INLINE homeSlot #-}
+homeSlot :: Int -> Word64 -> Word64 -> Key -> Int
+homeSlot mask k0 k1 key = fromIntegral (folded .&. fromIntegral mask)
+ where
+ h64 = siphash24 k0 k1 key
+ folded = h64 `xor` (h64 `unsafeShiftR` 32)
+
+hashKey :: MutableHashTable s -> Key -> Int
+hashKey ht = homeSlot (mhtMask ht) (mhtK0 ht) (mhtK1 ht)
+
+{-------------------------------------------------------------------------------
+ Operations
+-------------------------------------------------------------------------------}
+
+-- | Insert or overwrite. Guarded against the full-table infinite loop: raises.
+insert :: PrimMonad m => MutableHashTable (PrimState m) -> Key -> Word64 -> m ()
+{-# SPECIALIZE insert :: MutableHashTable (PrimState IO) -> Key -> Word64 -> IO () #-}
+insert ht key val = go 0 (hashKey ht key)
+ where
+ cap = mhtCap ht
+ mask = mhtMask ht
+ go !steps !idx
+ | steps >= cap = error "MutableHashTable.insert: table full"
+ | otherwise = do
+ occ <- isOccupied ht idx
+ if occ
+ then do
+ k <- readKey ht idx
+ if k == key
+ then writeVal ht idx val
+ else go (steps + 1) ((idx + 1) .&. mask)
+ else do
+ writeKey ht idx key
+ writeVal ht idx val
+ setOccupied ht idx
+ modifyMutVar' (mhtSize ht) (+ 1)
+
+lookup :: PrimMonad m => MutableHashTable (PrimState m) -> Key -> m (Maybe Word64)
+{-# SPECIALIZE lookup :: MutableHashTable (PrimState IO) -> Key -> IO (Maybe Word64) #-}
+lookup ht key = go 0 (hashKey ht key)
+ where
+ cap = mhtCap ht
+ mask = mhtMask ht
+ go !steps !idx
+ | steps >= cap = pure Nothing
+ | otherwise = do
+ occ <- isOccupied ht idx
+ if not occ
+ then pure Nothing
+ else do
+ k <- readKey ht idx
+ if k == key
+ then Just <$> readVal ht idx
+ else go (steps + 1) ((idx + 1) .&. mask)
+
+-- | Delete with backward-shift: after clearing the slot, pull following entries
+-- back toward their ideal index so no tombstone is left behind. Returns whether
+-- the key was present.
+delete :: PrimMonad m => MutableHashTable (PrimState m) -> Key -> m Bool
+{-# SPECIALIZE delete :: MutableHashTable (PrimState IO) -> Key -> IO Bool #-}
+delete ht key = do
+ mIdx <- findSlot 0 (hashKey ht key)
+ case mIdx of
+ Nothing -> pure False
+ Just idx -> do
+ clearOccupied ht idx
+ modifyMutVar' (mhtSize ht) (subtract 1)
+ goShift 0 idx ((idx + 1) .&. mask)
+ pure True
+ where
+ cap = mhtCap ht
+ mask = mhtMask ht
+
+ findSlot !steps !idx
+ | steps >= cap = pure Nothing
+ | otherwise = do
+ occ <- isOccupied ht idx
+ if not occ
+ then pure Nothing
+ else do
+ k <- readKey ht idx
+ if k == key
+ then pure (Just idx)
+ else findSlot (steps + 1) ((idx + 1) .&. mask)
+
+ goShift !steps !cur !nxt
+ | steps >= cap = error "MutableHashTable.delete: backshift did not terminate"
+ | otherwise = do
+ occ <- isOccupied ht nxt
+ if not occ
+ then pure ()
+ else do
+ k <- readKey ht nxt
+ let ideal = hashKey ht k
+ distCur = (cur - ideal) .&. mask
+ distNxt = (nxt - ideal) .&. mask
+ if distCur < distNxt
+ then do
+ v <- readVal ht nxt
+ writeKey ht cur k
+ writeVal ht cur v
+ setOccupied ht cur
+ clearOccupied ht nxt
+ goShift (steps + 1) nxt ((nxt + 1) .&. mask)
+ else goShift (steps + 1) cur ((nxt + 1) .&. mask)
+
+-- | Verify the table's representation invariants (for testing); returns a
+-- description of the first violation, or 'Nothing' when well-formed:
+--
+-- * the occupancy bitset's population equals the 'size' counter;
+-- * no key occupies two slots;
+-- * every occupied key lies on an unbroken probe run from its home slot — no
+-- empty slot between a key's home index and where it is stored, the invariant
+-- linear-probing 'lookup' and backward-shift 'delete' rely on.
+checkInvariants :: PrimMonad m => MutableHashTable (PrimState m) -> m (Maybe String)
+checkInvariants ht = do
+ sz <- size ht
+ flags <- mapM (isOccupied ht) [0 .. cap - 1]
+ let occupied = [i | (i, True) <- zip [0 .. cap - 1] flags]
+ keys <- mapM (readKey ht) occupied
+ gap <- firstGap (zip occupied keys)
+ pure $
+ if length occupied /= sz
+ then Just ("occupancy " ++ show (length occupied) ++ " /= size " ++ show sz)
+ else case firstDup Set.empty keys of
+ Just k -> Just ("key occupies two slots: " ++ show k)
+ Nothing -> gap
+ where
+ cap = mhtCap ht
+ mask = mhtMask ht
+
+ firstDup _ [] = Nothing
+ firstDup seen (k : ks)
+ | k `Set.member` seen = Just k
+ | otherwise = firstDup (Set.insert k seen) ks
+
+ firstGap [] = pure Nothing
+ firstGap ((i, k) : rest) = walk home
+ where
+ home = hashKey ht k
+ walk j
+ | j == i = firstGap rest
+ | otherwise = do
+ o <- isOccupied ht j
+ if o
+ then walk ((j + 1) .&. mask)
+ else
+ pure . Just $
+ "probe-chain gap: slot "
+ ++ show i
+ ++ " (home "
+ ++ show home
+ ++ ") has empty slot "
+ ++ show j
+ ++ " before it"
diff --git a/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Reference.hs b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Reference.hs
new file mode 100644
index 0000000000..7b1f881ded
--- /dev/null
+++ b/ouroboros-consensus/src/ouroboros-consensus/LeiosTxCache/Reference.hs
@@ -0,0 +1,392 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE LambdaCase #-}
+
+-- | The reference implementation of the LeiosTxCache: a bounded, in-memory index
+-- over the recently-announced Leios EBs, their bodies, and the txs those bodies
+-- reference, with reference-counted incremental eviction. Simple and obviously
+-- correct; "LeiosTxCache.Optimized" is validated for observational equivalence
+-- to it.
+--
+-- This is deliberately /independent/ of the on-disk LeiosDb: the two are
+-- separate caches with different eviction policies. This index retains only
+-- what is referenced by the @'maxAnnouncementCount'@ freshest EB announcements
+-- it has processed, so it stays small enough to keep resident in memory (a
+-- lookup is disk-latency-free); the LeiosDb retains far more (everything
+-- referenced by the acquired EBs younger than the immutable tip). Because they
+-- are independent stores, sometimes the node will re-fetch and re-validate a tx
+-- that has been evicted from the LeiosTxCache even while the LeiosDb still holds
+-- it; that is acceptable.
+--
+-- The type parameters are payloads the index does not interpret:
+--
+-- * @a@ is what we record for a tx that has been inserted (fetched) but not
+-- yet applied; @()@ in a proper node, or the tx's bytes in a
+-- test\/prototype.
+--
+-- * @v@ is what we record for a tx that has been applied. Kept polymorphic so
+-- the index is not coupled to any ledger type.
+--
+-- * @b@ (per body) cannot be trivial: it must at least carry the body's
+-- 'TxHash'es, via 'ReferencesTxsByHash', so eviction can decrement their
+-- refcounts without touching storage. It need not carry the whole body — a
+-- minimal @b@ of just the hashes suffices; storing more (e.g. the serialized
+-- body, which could ancillarily answer a MsgLeiosBodyRequest on a hit) is an
+-- implementation choice.
+module LeiosTxCache.Reference
+ ( -- * Index
+ LeiosTxCacheIndex (..)
+ , emptyLeiosTxCacheIndex
+
+ -- * Operations
+ , insertAnnouncement
+ , evictOlderThan
+ , insertBody
+ , insertUnappliedTx
+ , insertAppliedTx
+ , lookupTx
+ , lookupBody
+
+ -- * Internal state (exposed for testing)
+ , TxState (..)
+
+ -- * Shared types (re-exported from "LeiosTxCache.API")
+ , ReferencesTxsByHash (..)
+ , RefCount (..)
+ , BodyState (..)
+ , maxAnnouncementCount
+ ) where
+
+import Cardano.Slotting.Slot (SlotNo (..))
+import Data.Map.NonEmpty (NEMap)
+import qualified Data.Map.NonEmpty as NEMap
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe.Strict (StrictMaybe (..))
+import Data.Set (Set)
+import qualified Data.Set as Set
+import LeiosDemoTypes (EbHash, RbHash, TxHash)
+import LeiosTxCache.API
+ ( BodyState (..)
+ , InsertBodySummary
+ , RefCount (..)
+ , ReferencesTxsByHash (..)
+ , maxAnnouncementCount
+ , mkInsertBodySummary
+ )
+import qualified Lens.Micro as L
+import qualified Lens.Micro.Extras as L
+
+data TxState a v
+ = -- | An inserted body refers to this tx, but the tx itself is not inserted.
+ TxNotYetInserted {-# UNPACK #-} !RefCount
+ | -- | The tx is inserted (fetched) but not yet applied, neither by the
+ -- Mempool nor the LeiosVoting thread.
+ TxAlreadyInserted {-# UNPACK #-} !RefCount !a
+ | -- | The tx has been applied.
+ TxAlreadyValidated {-# UNPACK #-} !RefCount !v
+
+-- | The index. The 'txState' entries are the payload; the rest is maintained for
+-- incremental eviction.
+data LeiosTxCacheIndex a v b = MkLeiosTxCacheIndex
+ { announcementState :: !(Map SlotNo (NEMap RbHash EbHash))
+ -- ^ The retained EB announcements, keyed by slot then announcing RB header.
+ , announcementCount :: !Int
+ -- ^ INVARIANT: @= sum (fmap NEMap.size announcementState)@.
+ --
+ -- INVARIANT: @0 <= announcementCount <= maxAnnouncementCount@.
+ , bodyState :: !(Map EbHash (BodyState b))
+ -- ^ INVARIANT: each 'RefCount' equals the number of announcements in
+ -- 'announcementState' whose 'EbHash' is this one.
+ , txState :: !(Map TxHash (TxState a v))
+ -- ^ INVARIANT: each 'RefCount' equals the number of 'BodyAlreadyInserted's in
+ -- 'bodyState' that reference this tx.
+ , prunedSlot :: !SlotNo
+ -- ^ The greatest slot 'evictOlderThan' has pruned to (monotonically
+ -- non-decreasing; 'SlotNo' @0@ until the first prune)
+ }
+
+emptyLeiosTxCacheIndex :: LeiosTxCacheIndex a v b
+emptyLeiosTxCacheIndex =
+ MkLeiosTxCacheIndex
+ { announcementState = Map.empty
+ , announcementCount = 0
+ , bodyState = Map.empty
+ , txState = Map.empty
+ , prunedSlot = SlotNo 0
+ }
+
+{-------------------------------------------------------------------------------
+ Refcount lenses
+
+ Record syntax handles the 'LeiosTxCacheIndex' fields; these are only for the
+ refcount that is common to every constructor of the 'BodyState' \/ 'TxState'
+ sums, where record syntax does not suffice.
+-------------------------------------------------------------------------------}
+
+-- | The refcount of a body, regardless of whether the body itself is inserted.
+bodyRefCountL :: L.Lens' (BodyState b) RefCount
+bodyRefCountL = L.lens getIt setIt
+ where
+ getIt = \case
+ BodyNotYetInserted rc -> rc
+ BodyAlreadyInserted rc _ -> rc
+ setIt s rc = case s of
+ BodyNotYetInserted _ -> BodyNotYetInserted rc
+ BodyAlreadyInserted _ b -> BodyAlreadyInserted rc b
+
+-- | The refcount of a tx, regardless of its insertion\/application state.
+txRefCountL :: L.Lens' (TxState a v) RefCount
+txRefCountL = L.lens getIt setIt
+ where
+ getIt = \case
+ TxNotYetInserted rc -> rc
+ TxAlreadyInserted rc _ -> rc
+ TxAlreadyValidated rc _ -> rc
+ setIt s rc = case s of
+ TxNotYetInserted _ -> TxNotYetInserted rc
+ TxAlreadyInserted _ a -> TxAlreadyInserted rc a
+ TxAlreadyValidated _ v -> TxAlreadyValidated rc v
+
+{-------------------------------------------------------------------------------
+ RefCount helpers
+-------------------------------------------------------------------------------}
+
+incRefCount :: RefCount -> RefCount
+incRefCount (MkRefCount n) = MkRefCount (n + 1)
+
+-- | Decrement, or 'SNothing' if it would reach zero (i.e. the entry is now
+-- unreferenced and should be evicted).
+decRefCount :: RefCount -> StrictMaybe RefCount
+decRefCount (MkRefCount n)
+ | n <= 1 = SNothing
+ | otherwise = SJust $ MkRefCount $ n - 1
+
+{-------------------------------------------------------------------------------
+ Operations
+-------------------------------------------------------------------------------}
+
+-- | Insert an EB announcement (identified by its slot and announcing RB header),
+-- bumping the announced EB's body refcount. Re-inserting the same announcement
+-- is a no-op, as is inserting an EB whose slot is strictly older than
+-- 'prunedSlot' (one the cache has already been pruned past; see 'evictOlderThan').
+--
+-- If this pushes 'announcementCount' past 'maxAnnouncementCount', the oldest
+-- announcement (least slot, then least RB header) is evicted; that cascades
+-- through the body and, if the body was inserted, its txs. Returns the bodies
+-- and txs that were evicted.
+insertAnnouncement ::
+ ReferencesTxsByHash b =>
+ SlotNo ->
+ RbHash ->
+ EbHash ->
+ LeiosTxCacheIndex a v b ->
+ (LeiosTxCacheIndex a v b, Set EbHash, Set TxHash)
+insertAnnouncement slot rbh ebh idx
+ | slot < prunedSlot idx = (idx, Set.empty, Set.empty)
+ | alreadyPresent = (idx, Set.empty, Set.empty)
+ | otherwise = evictIfNeeded inserted
+ where
+ alreadyPresent = case Map.lookup slot (announcementState idx) of
+ Nothing -> False
+ Just nem -> NEMap.member rbh nem
+
+ inserted =
+ MkLeiosTxCacheIndex
+ { announcementState =
+ Map.alter
+ (Just . maybe (NEMap.singleton rbh ebh) (NEMap.insert rbh ebh))
+ slot
+ (announcementState idx)
+ , announcementCount = announcementCount idx + 1
+ , bodyState =
+ Map.alter
+ (Just . maybe (BodyNotYetInserted (MkRefCount 1)) (L.over bodyRefCountL incRefCount))
+ ebh
+ (bodyState idx)
+ , txState = txState idx
+ , prunedSlot = prunedSlot idx
+ }
+
+-- | Repeatedly 'evictOldest' while @shouldEvict@ holds of the index: the shared
+-- core of the two eviction entrypoints ('evictIfNeeded' and 'evictOlderThan').
+-- Strict accumulators avoid building up '<>' thunks.
+evictWhile ::
+ ReferencesTxsByHash b =>
+ (LeiosTxCacheIndex a v b -> Bool) ->
+ LeiosTxCacheIndex a v b ->
+ (LeiosTxCacheIndex a v b, Set EbHash, Set TxHash)
+evictWhile shouldEvict = go Set.empty Set.empty
+ where
+ go !evEbs !evTxs !idx
+ | shouldEvict idx =
+ let (idx', evEbs', evTxs') = evictOldest idx
+ in go (evEbs <> evEbs') (evTxs <> evTxs') idx'
+ | otherwise = (idx, evEbs, evTxs)
+
+-- | Evict oldest announcements until within 'maxAnnouncementCount'. In practice
+-- a single 'insertAnnouncement' overshoots by at most one, but the loop is robust
+-- regardless.
+evictIfNeeded ::
+ ReferencesTxsByHash b =>
+ LeiosTxCacheIndex a v b ->
+ (LeiosTxCacheIndex a v b, Set EbHash, Set TxHash)
+evictIfNeeded = evictWhile ((> maxAnnouncementCount) . announcementCount)
+
+-- | Evict every retained announcement whose slot is strictly older than the
+-- boundary, cascading through the bodies and txs like 'insertAnnouncement'.
+-- Returns the evicted bodies and txs. Advances 'prunedSlot' to the boundary
+-- (monotonically), after which 'insertAnnouncement' refuses any EB that old.
+evictOlderThan ::
+ ReferencesTxsByHash b =>
+ SlotNo ->
+ LeiosTxCacheIndex a v b ->
+ (LeiosTxCacheIndex a v b, Set EbHash, Set TxHash)
+evictOlderThan boundary idx =
+ evictWhile oldestIsStale idx'
+ where
+ idx' = idx{prunedSlot = max (prunedSlot idx) boundary}
+ oldestIsStale i = case Map.lookupMin (announcementState i) of
+ Just (slotMin, _) -> slotMin < prunedSlot idx'
+ Nothing -> False
+
+evictOldest ::
+ ReferencesTxsByHash b =>
+ LeiosTxCacheIndex a v b ->
+ (LeiosTxCacheIndex a v b, Set EbHash, Set TxHash)
+evictOldest idx =
+ ( MkLeiosTxCacheIndex
+ { announcementState = announcementState'
+ , announcementCount = announcementCount idx - 1
+ , bodyState = bodyState'
+ , txState = txState'
+ , prunedSlot = prunedSlot idx
+ }
+ , evEbs
+ , evTxs
+ )
+ where
+ (slotMin, nem) = Map.findMin (announcementState idx)
+ (rbhMin, ebhEvicted) = NEMap.findMin nem
+
+ announcementState' = case NEMap.nonEmptyMap (NEMap.delete rbhMin nem) of
+ Nothing -> Map.delete slotMin (announcementState idx)
+ Just nem' -> Map.insert slotMin nem' (announcementState idx)
+
+ (bodyState', txState', evEbs, evTxs) =
+ decBody ebhEvicted (bodyState idx) (txState idx)
+
+-- | Decrement a body's refcount; if it reaches zero, remove it and (if it had
+-- been inserted) decrement each of its referenced txs.
+decBody ::
+ ReferencesTxsByHash b =>
+ EbHash ->
+ Map EbHash (BodyState b) ->
+ Map TxHash (TxState a v) ->
+ (Map EbHash (BodyState b), Map TxHash (TxState a v), Set EbHash, Set TxHash)
+decBody ebh bs ts = case Map.lookup ebh bs of
+ Nothing -> (bs, ts, Set.empty, Set.empty)
+ Just b -> case decRefCount (L.view bodyRefCountL b) of
+ SJust rc -> (Map.insert ebh (L.set bodyRefCountL rc b) bs, ts, Set.empty, Set.empty)
+ SNothing ->
+ let (ts', evTxs) = case b of
+ BodyNotYetInserted _ -> (ts, Set.empty)
+ BodyAlreadyInserted _ body -> foldTxReferences decTx (ts, Set.empty) body
+ in (Map.delete ebh bs, ts', Set.singleton ebh, evTxs)
+
+decTx ::
+ (Map TxHash (TxState a v), Set TxHash) ->
+ TxHash ->
+ (Map TxHash (TxState a v), Set TxHash)
+decTx (ts, evTxs) txh =
+ let (ev, ts') = Map.alterF upd txh ts
+ in (ts', evTxs <> ev)
+ where
+ upd Nothing = (Set.empty, Nothing)
+ upd (Just tx) = case decRefCount (L.view txRefCountL tx) of
+ SJust rc -> (Set.empty, Just (L.set txRefCountL rc tx))
+ SNothing -> (Set.singleton txh, Nothing)
+
+-- | Record that we now hold the body of this EB, bumping the refcount of each tx
+-- it references. Idempotent, and a no-op if no announcement references this EB
+-- (its refcount would be zero).
+insertBody ::
+ ReferencesTxsByHash b =>
+ EbHash ->
+ b ->
+ LeiosTxCacheIndex a v b ->
+ (LeiosTxCacheIndex a v b, Maybe InsertBodySummary)
+insertBody ebh body idx = case Map.lookup ebh (bodyState idx) of
+ Nothing -> (idx, Nothing)
+ Just BodyAlreadyInserted{} -> (idx, Nothing)
+ Just (BodyNotYetInserted rc) ->
+ let ((n, tracked, acquired, validated), txState') =
+ foldTxReferences bumpTx ((0, 0, 0, 0), txState idx) body
+ idx' =
+ MkLeiosTxCacheIndex
+ { announcementState = announcementState idx
+ , announcementCount = announcementCount idx
+ , bodyState = Map.insert ebh (BodyAlreadyInserted rc body) (bodyState idx)
+ , txState = txState'
+ , prunedSlot = prunedSlot idx
+ }
+ in (idx', Just (mkInsertBodySummary n tracked acquired validated (Map.size txState')))
+ where
+ -- Bump each tx's refcount and, in the same pass, classify its /prior/ state so
+ -- the summary needs no second traversal.
+ bumpTx ((!nn, !tt, !aa, !vv), ts) txh =
+ let (dt, da, dv) = case Map.lookup txh ts of
+ Nothing -> (0, 0, 0) -- new: not yet tracked
+ Just (TxNotYetInserted _) -> (1, 0, 0) -- tracked, not acquired
+ Just (TxAlreadyInserted _ _) -> (1, 1, 0) -- acquired, not validated
+ Just (TxAlreadyValidated _ _) -> (1, 1, 1) -- acquired and validated
+ ts' =
+ Map.alter
+ (Just . maybe (TxNotYetInserted (MkRefCount 1)) (L.over txRefCountL incRefCount))
+ txh
+ ts
+ in ((nn + 1, tt + dt, aa + da, vv + dv), ts')
+
+-- | Record the payload of a fetched-but-not-yet-applied tx, without changing its
+-- refcount. A no-op if no inserted body references this tx.
+insertUnappliedTx :: TxHash -> a -> LeiosTxCacheIndex a v b -> LeiosTxCacheIndex a v b
+insertUnappliedTx txh a idx =
+ MkLeiosTxCacheIndex
+ { announcementState = announcementState idx
+ , announcementCount = announcementCount idx
+ , bodyState = bodyState idx
+ , txState = Map.alter upd txh (txState idx)
+ , prunedSlot = prunedSlot idx
+ }
+ where
+ upd Nothing = Nothing
+ upd (Just tx) = Just (TxAlreadyInserted (L.view txRefCountL tx) a)
+
+-- | Record the payload of an applied tx, without changing its refcount. A no-op
+-- if no inserted body references this tx.
+insertAppliedTx :: TxHash -> v -> LeiosTxCacheIndex a v b -> LeiosTxCacheIndex a v b
+insertAppliedTx txh v idx =
+ MkLeiosTxCacheIndex
+ { announcementState = announcementState idx
+ , announcementCount = announcementCount idx
+ , bodyState = bodyState idx
+ , txState = Map.alter upd txh (txState idx)
+ , prunedSlot = prunedSlot idx
+ }
+ where
+ upd Nothing = Nothing
+ upd (Just tx) = Just (TxAlreadyValidated (L.view txRefCountL tx) v)
+
+-- | The tx's recorded payload, if we hold it: @Left@ when inserted but not yet
+-- applied, @Right@ when applied. 'Nothing' if the tx is absent or merely
+-- referenced-but-not-yet-inserted.
+lookupTx :: TxHash -> LeiosTxCacheIndex a v b -> Maybe (Either a v)
+lookupTx txh idx = case Map.lookup txh (txState idx) of
+ Nothing -> Nothing
+ Just (TxNotYetInserted _) -> Nothing
+ Just (TxAlreadyInserted _ a) -> Just (Left a)
+ Just (TxAlreadyValidated _ v) -> Just (Right v)
+
+lookupBody :: EbHash -> LeiosTxCacheIndex a v b -> Maybe b
+lookupBody ebh idx = case Map.lookup ebh (bodyState idx) of
+ Just (BodyAlreadyInserted _ b) -> Just b
+ _ -> Nothing
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/Forging.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/Forging.hs
index b3b6797106..14fab49b34 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/Forging.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/Forging.hs
@@ -29,8 +29,7 @@ import Control.Tracer (Tracer, traceWith)
import Data.Kind (Type)
import Data.Text (Text)
import GHC.Stack
-import LeiosDemoDb (LeiosDbConnection)
-import LeiosDemoTypes (LeiosCert, TraceLeiosKernel)
+import LeiosDemoTypes (ForgedLeiosEb, LeiosCert, TraceLeiosKernel)
import LeiosVoteState (LeiosVoteState)
import Ouroboros.Consensus.Block.Abstract
import Ouroboros.Consensus.Config
@@ -125,9 +124,14 @@ data BlockForging m blk = BlockForging
-- When 'CannotForge' is returned, we don't call 'forgeBlock'.
, forgeBlock ::
ForgeBlockArgs m blk ->
- m blk
+ m (blk, Maybe ForgedLeiosEb)
-- ^ Forge a block
--
+ -- The 'Maybe' 'ForgedLeiosEb' is the Endorser Block this block announces, if
+ -- any (only Leios-enabled eras produce one, and only when there were EB txs).
+ -- The caller persists it and registers it in the cache /after/ relaying
+ -- the announcement, so the body offer never precedes the announcement.
+ --
-- The function is passed the prefix of the mempool that will fit within
-- a valid block; this is a set of transactions that is guaranteed to be
-- consistent with the ledger state (also provided as an argument) and
@@ -266,7 +270,6 @@ data ForgeBlockArgs m blk = ForgeBlockArgs
-- ^ The Leios certificate this block should embed, if it certifies a
-- previously-announced EB. 'Nothing' for non-Leios eras and for
-- blocks that don't certify anything.
- , fbLeiosDb :: LeiosDbConnection m
, fbLeiosTracer :: Tracer m TraceLeiosKernel
, fbLeiosVoteState :: LeiosVoteState m
}
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Embed/Unary.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Embed/Unary.hs
index 90ecc3b9ff..19fcbce28c 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Embed/Unary.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Embed/Unary.hs
@@ -456,7 +456,7 @@ instance Functor m => Isomorphic (BlockForging m) where
(inject' (Proxy @(WrapIsLeader blk)) isLeader)
(inject' (Proxy @(WrapForgeStateInfo blk)) forgeStateInfo)
, forgeBlock = \fbArgs ->
- project' (Proxy @(I blk))
+ first (project' (Proxy @(I blk)))
<$> forgeBlock
ForgeBlockArgs
{ fbConfig = inject (fbConfig fbArgs)
@@ -469,7 +469,6 @@ instance Functor m => Isomorphic (BlockForging m) where
, fbEbTxs = inject' (Proxy @(WrapValidatedGenTx blk)) <$> fbEbTxs fbArgs
, fbIsLeader = inject' (Proxy @(WrapIsLeader blk)) (fbIsLeader fbArgs)
, fbChainDepState = Nothing
- , fbLeiosDb = fbLeiosDb fbArgs
, fbLeiosTracer = fbLeiosTracer fbArgs
, fbLeiosVoteState = fbLeiosVoteState fbArgs
, fbMayLeiosCert = fbMayLeiosCert fbArgs
@@ -512,7 +511,7 @@ instance Functor m => Isomorphic (BlockForging m) where
(project' (Proxy @(WrapIsLeader blk)) isLeader)
(project' (Proxy @(WrapForgeStateInfo blk)) forgeStateInfo)
, forgeBlock = \fbArgs ->
- inject' (Proxy @(I blk))
+ first (inject' (Proxy @(I blk)))
<$> forgeBlock
ForgeBlockArgs
{ fbConfig = project (fbConfig fbArgs)
@@ -525,7 +524,6 @@ instance Functor m => Isomorphic (BlockForging m) where
, fbEbTxs = project' (Proxy @(WrapValidatedGenTx blk)) <$> fbEbTxs fbArgs
, fbIsLeader = project' (Proxy @(WrapIsLeader blk)) (fbIsLeader fbArgs)
, fbChainDepState = Nothing
- , fbLeiosDb = fbLeiosDb fbArgs
, fbLeiosTracer = fbLeiosTracer fbArgs
, fbLeiosVoteState = fbLeiosVoteState fbArgs
, fbMayLeiosCert = fbMayLeiosCert fbArgs
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Forging.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Forging.hs
index d85f0ea50f..5727ccefcc 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Forging.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/HardFork/Combinator/Forging.hs
@@ -30,6 +30,7 @@ import Data.SOP.OptNP (NonEmptyOptNP, OptNP, ViewOptNP (..))
import qualified Data.SOP.OptNP as OptNP
import Data.SOP.Strict
import Data.Text (Text)
+import LeiosDemoTypes (ForgedLeiosEb)
import Ouroboros.Consensus.Block
import Ouroboros.Consensus.Config
import Ouroboros.Consensus.HardFork.Combinator.Abstract
@@ -311,7 +312,7 @@ hardForkForgeBlock ::
(CanHardFork xs, Monad m) =>
OptNP empty (BlockForging m) xs ->
ForgeBlockArgs m (HardForkBlock xs) ->
- m (HardForkBlock xs)
+ m (HardForkBlock xs, Maybe ForgedLeiosEb)
hardForkForgeBlock
blockForging
ForgeBlockArgs
@@ -323,13 +324,17 @@ hardForkForgeBlock
, fbEbTxs
, fbIsLeader
, fbChainDepState
- , fbLeiosDb
, fbLeiosTracer
, fbLeiosVoteState
, fbMayLeiosCert
} =
- fmap (HardForkBlock . OneEraBlock)
- $ hsequence
+ fmap
+ ( \ns ->
+ ( HardForkBlock $ OneEraBlock $ hmap (\(Pair blk _) -> blk) ns
+ , hcollapse $ hmap (\(Pair _ mEb) -> mEb) ns
+ )
+ )
+ $ hsequence'
$ hizipWith3
forgeBlockOne
cfgs
@@ -413,7 +418,7 @@ hardForkForgeBlock
)
(Product ([] :.: WrapValidatedGenTx) ([] :.: WrapValidatedGenTx))
blk ->
- m blk
+ (m :.: Product I (K (Maybe ForgedLeiosEb))) blk
forgeBlockOne
index
cfg'
@@ -425,22 +430,23 @@ hardForkForgeBlock
)
(Pair (Comp rbTxs') (Comp ebTxs'))
) =
- forgeBlock
- ( fromMaybe
- (error (missingBlockForgingImpossible (eraIndexFromIndex index)))
- mBlockForging'
- )
- ForgeBlockArgs
- { fbConfig = cfg'
- , fbCurrentBlockNo
- , fbCurrentSlotNo
- , fbCurrentTickedLedgerState = ledgerState'
- , fbRbTxs = map unwrapValidatedGenTx rbTxs'
- , fbEbTxs = map unwrapValidatedGenTx ebTxs'
- , fbIsLeader = isLeader'
- , fbChainDepState = unwrapChainDepState <$> mChainDepState'
- , fbLeiosDb
- , fbLeiosTracer
- , fbLeiosVoteState
- , fbMayLeiosCert
- }
+ Comp $
+ (\(blk, mEb) -> Pair (I blk) (K mEb))
+ <$> forgeBlock
+ ( fromMaybe
+ (error (missingBlockForgingImpossible (eraIndexFromIndex index)))
+ mBlockForging'
+ )
+ ForgeBlockArgs
+ { fbConfig = cfg'
+ , fbCurrentBlockNo
+ , fbCurrentSlotNo
+ , fbCurrentTickedLedgerState = ledgerState'
+ , fbRbTxs = map unwrapValidatedGenTx rbTxs'
+ , fbEbTxs = map unwrapValidatedGenTx ebTxs'
+ , fbIsLeader = isLeader'
+ , fbChainDepState = unwrapChainDepState <$> mChainDepState'
+ , fbLeiosTracer
+ , fbLeiosVoteState
+ , fbMayLeiosCert
+ }
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/MiniProtocol/ChainSync/Client.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/MiniProtocol/ChainSync/Client.hs
index fb9af8e67f..f014e6123b 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/MiniProtocol/ChainSync/Client.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/MiniProtocol/ChainSync/Client.hs
@@ -782,17 +782,17 @@ data ConfigEnv m blk = ConfigEnv
, chainDbView :: ChainDbView m blk
, getDiffusionPipeliningSupport ::
DiffusionPipeliningSupport
- , leiosCertRbCallback ::
+ , leiosMsgRollForwardCallback ::
Header blk ->
+ RelativeTime ->
ChainDepState (BlockProtocol blk) ->
m ()
- -- ^ Invoked on each accepted 'MsgRollForward' with the just-arrived header
- -- and the chain-dep state as of its predecessor. For Leios: when the new
- -- header is a CertRB (its @headerContainsLeiosCert@ bit is set), the predecessor's
- -- chain-dep state still records (in @chainDepStateLeiosAnnouncement@) the EB the
- -- CertRB certifies — the CertRB's own transition would overwrite it — so this
- -- lets the same peer's LeiosFetch state be updated as if its LeiosNotify
- -- client had offered that EB. A no-op for non-Leios setups.
+ -- ^ Invoked on each accepted 'MsgRollForward' with the just-arrived header,
+ -- its slot's wall-clock onset, and the chain-dep state as of its predecessor.
+ -- For Leios the wiring uses this to both register any EB the header
+ -- /certifies/ as an offer from this peer, and feed any EB the header
+ -- /announces/ into the central announcement state — dating that announcement
+ -- from the slot onset. A no-op for non-Leios setups.
}
-- | Arguments determined dynamically
@@ -1531,8 +1531,9 @@ knownIntersectionStateTop cfgEnv dynEnv intEnv =
traceWith headerMetricsTracer (slotNo, arrivalTime)
-- Pass the predecessor's chain-dep state (the pre-validation state,
- -- whose history tip is still the predecessor); see 'leiosCertRbCallback'.
- leiosCertRbCallback cfgEnv hdr $
+ -- whose history tip is still the predecessor); see
+ -- 'leiosMsgRollForwardCallback'.
+ leiosMsgRollForwardCallback cfgEnv hdr hdrSlotTime $
headerStateChainDep $
hswtHeaderState $
HeaderStateHistory.current (theirHeaderStateHistory kis')
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl.hs
index e3065a6b62..b4a9bad0a2 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl.hs
@@ -294,6 +294,7 @@ openDBInternal args launchBgTasks = runWithTempRegistry $ do
, cdbLoE = Args.cdbsLoE cdbSpecificArgs
, cdbAcquiredLeiosEbs = varAcquiredLeiosEbs
, cdbLeiosDb = Args.cdbsLeiosDb cdbSpecificArgs
+ , cdbLeiosEvictTxCache = Args.cdbsLeiosEvictTxCache cdbSpecificArgs
, cdbChainSelStarvation = varChainSelStarvation
, cdbPerasCertDB = perasCertDB
}
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Args.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Args.hs
index 6b34bff3a6..a709a375c0 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Args.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Args.hs
@@ -96,6 +96,14 @@ data ChainDbSpecificArgs f m blk = ChainDbSpecificArgs
, cdbsLeiosDb :: HKD f (LeiosDemoDb.Common.LeiosDbHandle m)
-- ^ Handle for the Leios demo DB. Each downstream consumer should 'open'
-- its own per-thread 'LeiosDbConnection' from this handle.
+ , cdbsLeiosEvictTxCache :: HKD f (SlotNo -> m ())
+ -- ^ Prune the LeiosTxCache to the given slot. Invoked immediately BEFORE
+ -- 'LeiosDemoDb.Common.leiosDbGarbageCollect' at the same slot (see
+ -- 'Ouroboros.Consensus.Storage.ChainDB.Impl.Background.garbageCollectBlocks'):
+ -- the cache is an in-memory index of the LeiosDb, so pruning it first is what
+ -- keeps it from ever reporting a hit for a tx the LeiosDb has already dropped.
+ -- Mandatory (no default): the node wires it to the cache's 'evictOlderThan';
+ -- callers without a cache pass an explicit no-op.
}
-- | Default arguments
@@ -131,6 +139,7 @@ defaultSpecificArgs =
, cdbsTopLevelConfig = noDefault
, cdbsLoE = pure LoEDisabled
, cdbsLeiosDb = noDefault
+ , cdbsLeiosEvictTxCache = noDefault
}
-- | Default arguments
@@ -186,6 +195,9 @@ completeChainDbArgs ::
LedgerDbBackendArgs m blk ->
-- | Leios demo DB handle
LeiosDemoDb.Common.LeiosDbHandle m ->
+ -- | Prune the LeiosTxCache to a slot; run before 'leiosDbGarbageCollect' at the
+ -- same slot (the "LeiosTxCache" ordering contract).
+ (SlotNo -> m ()) ->
-- | A set of incomplete arguments, possibly modified wrt @defaultArgs@
Incomplete ChainDbArgs m blk ->
Complete ChainDbArgs m blk
@@ -199,6 +211,7 @@ completeChainDbArgs
mkVolFS
flavorArgs
leiosDb
+ leiosEvictTxCache
defArgs =
defArgs
{ cdbImmDbArgs =
@@ -236,6 +249,7 @@ completeChainDbArgs
, cdbsTopLevelConfig
, cdbsHasFSGsmDB = mkVolFS $ RelativeMountPoint "gsm"
, cdbsLeiosDb = leiosDb
+ , cdbsLeiosEvictTxCache = leiosEvictTxCache
}
}
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Background.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Background.hs
index 143499bc4a..812e2b3e27 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Background.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Background.hs
@@ -484,7 +484,10 @@ garbageCollectBlocks CDB{..} slotNo = do
PerasCertDB.garbageCollect cdbPerasCertDB slotNo
-- Evict LeiosDb EB bodies and closures no longer needed now that everything
-- up to 'slotNo' is immutable. Driven by the same scheduled slot as the other
- -- stores; currently a no-op (see 'leiosDbGarbageCollect').
+ -- stores; currently a no-op (see 'leiosDbGarbageCollect'). The LeiosTxCache is
+ -- an in-memory index of the LeiosDb, so it MUST be pruned to 'slotNo' first --
+ -- otherwise it could report a hit for a tx this GC is about to drop.
+ cdbLeiosEvictTxCache slotNo
leiosDbGarbageCollect cdbLeiosDb slotNo
traceWith cdbTracer $ TraceGCEvent $ PerformedGC slotNo
diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Types.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Types.hs
index 649a337dcd..3f2732fb5c 100644
--- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Types.hs
+++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Storage/ChainDB/Impl/Types.hs
@@ -393,6 +393,9 @@ data ChainDbEnv m blk = CDB
-- notifications (which also enqueue a 'ChainSelReprocessLeiosEb'), and pruned
-- by age as a GC is scheduled.
, cdbLeiosDb :: !(LeiosDbHandle m)
+ , cdbLeiosEvictTxCache :: !(SlotNo -> m ())
+ -- ^ Prune the LeiosTxCache to a slot; run just before 'leiosDbGarbageCollect'
+ -- at the same slot. See 'Args.cdbsLeiosEvictTxCache'.
-- ^ The LeiosDb handle. The LeiosDb is one of the stores the ChainDB owns and
-- orchestrates -- alongside the ImmutableDB, VolatileDB, LedgerDB and
-- PerasCertDB -- so, like them, the ChainDB drives its lifecycle. Concretely
diff --git a/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/ChainDB.hs b/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/ChainDB.hs
index 473ef408b9..f83d4fc566 100644
--- a/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/ChainDB.hs
+++ b/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/ChainDB.hs
@@ -156,5 +156,6 @@ fromMinimalChainDbArgs MinimalChainDbArgs{..} =
, cdbsTopLevelConfig = mcdbTopLevelConfig
, cdbsLoE = pure LoEDisabled
, cdbsLeiosDb = mcdbLeiosDb
+ , cdbsLeiosEvictTxCache = \_slot -> pure ()
}
}
diff --git a/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node.hs b/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node.hs
index dbaca4fbc5..2c19da28ae 100644
--- a/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node.hs
+++ b/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node.hs
@@ -103,14 +103,15 @@ simpleBlockForging aCanBeLeader aForgeExt =
, checkCanForge = \_ _ _ _ _ -> return ()
, forgeBlock = \ForgeBlockArgs{..} ->
return $
- forgeSimple
- aForgeExt
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- (map txForgetValidated fbRbTxs)
- fbIsLeader
+ flip (,) Nothing $
+ forgeSimple
+ aForgeExt
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ (map txForgetValidated fbRbTxs)
+ fbIsLeader
, finalize = pure ()
}
where
diff --git a/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/PBFT.hs b/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/PBFT.hs
index b20b55789c..c928d28914 100644
--- a/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/PBFT.hs
+++ b/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/PBFT.hs
@@ -112,13 +112,14 @@ pbftBlockForging canBeLeader =
tickedPBftState
, forgeBlock = \ForgeBlockArgs{..} ->
return $
- forgeSimple
- forgePBftExt
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- (map txForgetValidated fbRbTxs)
- fbIsLeader
+ flip (,) Nothing $
+ forgeSimple
+ forgePBftExt
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ (map txForgetValidated fbRbTxs)
+ fbIsLeader
, finalize = pure ()
}
diff --git a/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/Praos.hs b/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/Praos.hs
index 15a5805624..b78880c9e4 100644
--- a/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/Praos.hs
+++ b/ouroboros-consensus/src/unstable-mock-block/Ouroboros/Consensus/Mock/Node/Praos.hs
@@ -139,13 +139,14 @@ praosBlockForging cid initHotKey = do
, forgeBlock = \ForgeBlockArgs{..} -> do
hotKey <- readMVar varHotKey
return $
- forgeSimple
- (forgePraosExt hotKey)
- fbConfig
- fbCurrentBlockNo
- fbCurrentSlotNo
- fbCurrentTickedLedgerState
- (map txForgetValidated fbRbTxs)
- fbIsLeader
+ flip (,) Nothing $
+ forgeSimple
+ (forgePraosExt hotKey)
+ fbConfig
+ fbCurrentBlockNo
+ fbCurrentSlotNo
+ fbCurrentTickedLedgerState
+ (map txForgetValidated fbRbTxs)
+ fbIsLeader
, finalize = pure ()
}
diff --git a/ouroboros-consensus/test/consensus-test/Main.hs b/ouroboros-consensus/test/consensus-test/Main.hs
index 9c23fa3ba5..68278c6e34 100644
--- a/ouroboros-consensus/test/consensus-test/Main.hs
+++ b/ouroboros-consensus/test/consensus-test/Main.hs
@@ -27,6 +27,9 @@ import qualified Test.LeiosDemoDb (tests)
import qualified Test.LeiosDemoLogic (tests)
import qualified Test.LeiosDemoLogic.Announcements (tests)
import qualified Test.LeiosDemoTypes (tests)
+import qualified Test.LeiosTxCache.Optimized (tests)
+import qualified Test.LeiosTxCache.Optimized.MutableHashTable (tests)
+import qualified Test.LeiosTxCache.Reference (tests)
import qualified Test.LeiosUtils.CallTrace (tests)
import qualified Test.LeiosVoteState (tests)
import Test.Tasty
@@ -84,6 +87,9 @@ tests =
, Test.LeiosDemoDb.tests
, Test.LeiosDemoLogic.tests
, Test.LeiosDemoLogic.Announcements.tests
+ , Test.LeiosTxCache.Optimized.tests
+ , Test.LeiosTxCache.Optimized.MutableHashTable.tests
+ , Test.LeiosTxCache.Reference.tests
, Test.LeiosVoteState.tests
, Test.LeiosUtils.CallTrace.tests
]
diff --git a/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/CSJ.hs b/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/CSJ.hs
index 0f1c3a5c29..f61c7009ec 100644
--- a/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/CSJ.hs
+++ b/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/CSJ.hs
@@ -182,7 +182,7 @@ runTest TestSetup = withRegistry $ \registry -> do
, historicityCheck = HistoricityCheck.noCheck
, mkPipelineDecision0 = pipelineDecisionLowHighMark 10 20
, getDiffusionPipeliningSupport = diffusionPipelining
- , leiosCertRbCallback = \_ _ -> pure ()
+ , leiosMsgRollForwardCallback = \_ _ _ -> pure ()
}
DynamicEnv
{ version
diff --git a/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/Client.hs b/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/Client.hs
index 0a2eeb4ceb..3a38161b22 100644
--- a/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/Client.hs
+++ b/ouroboros-consensus/test/consensus-test/Test/Consensus/MiniProtocol/ChainSync/Client.hs
@@ -509,7 +509,7 @@ runChainSync
pipelineDecisionLowHighMark 10 20
, getDiffusionPipeliningSupport =
diffusionPipelining
- , leiosCertRbCallback = \_ _ -> pure ()
+ , leiosMsgRollForwardCallback = \_ _ _ -> pure ()
}
DynamicEnv
{ version = maxBound :: NodeToNodeVersion
diff --git a/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Optimized.hs b/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Optimized.hs
new file mode 100644
index 0000000000..2e1617fc92
--- /dev/null
+++ b/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Optimized.hs
@@ -0,0 +1,166 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- | Observational-equivalence test for the mutable 'LeiosTxCache' handle: run
+-- the same random op sequence (announcements, bodies, tx inserts) through both
+-- 'newPureLeiosTxCache' and 'newHashTableLeiosTxCache' and require that they
+-- return the same eviction sets from every announcement and agree on a final
+-- full-domain lookup sweep. Op ranges make the ~128-announcement eviction
+-- cascade fire in the longer sequences.
+--
+-- Each run also picks a table size and a tx-key domain sized to a target load
+-- factor — spanning sparse to /exactly full/ — so the hash table's probing and
+-- backward-shift deletion are exercised at high occupancy, not only when nearly
+-- empty. The tx domain never exceeds the table capacity (and stays within
+-- 'Word8'), so the underlying 'HT.insert' cannot hit its full-table guard.
+module Test.LeiosTxCache.Optimized (tests) where
+
+import Cardano.Slotting.Slot (SlotNo (..))
+import Control.Monad (foldM)
+import qualified Data.ByteString as BS
+import qualified Data.List as List
+import Data.Set (Set)
+import Data.Word (Word64, Word8)
+import LeiosDemoTypes (EbHash (..), RbHash (..), TxHash (..))
+import LeiosTxCache (LeiosTxCache (..), ReferencesTxsByHash (..), newPureLeiosTxCache)
+import LeiosTxCache.Optimized (newHashTableLeiosTxCache)
+import Test.LeiosTxCache.Optimized.MutableHashTable (Config (..), genConfig, salt0, salt1)
+import Test.Tasty (TestTree, adjustOption, testGroup)
+import Test.Tasty.QuickCheck
+ ( Gen
+ , Property
+ , QuickCheckTests (..)
+ , chooseInt
+ , forAll
+ , forAllShrink
+ , frequency
+ , ioProperty
+ , listOf
+ , shrinkList
+ , shuffle
+ , testProperty
+ , vectorOf
+ , (.&&.)
+ , (===)
+ )
+
+tests :: TestTree
+tests =
+ testGroup
+ "LeiosTxCache.Optimized"
+ [ adjustOption (\(QuickCheckTests n) -> QuickCheckTests (n * 10)) $
+ testProperty "hash-table handle == pure handle" prop_equiv
+ , adjustOption (\(QuickCheckTests n) -> QuickCheckTests (n * 10)) $
+ testProperty "hash-table handle == pure handle, with evictOlderThan" prop_equivEvict
+ ]
+
+type H = LeiosTxCache IO () () TestBody
+
+-- | A body carrying its tx hashes.
+newtype TestBody = TestBody [TxHash]
+ deriving (Eq, Show)
+
+instance ReferencesTxsByHash TestBody where
+ foldTxReferences f z (TestBody hs) = List.foldl' f z hs
+
+-- A 32-byte tx hash (the mutable table reads exactly 32 bytes).
+txhOf :: Word8 -> TxHash
+txhOf n = MkTxHash (BS.pack (n : replicate 31 0))
+
+ebhOf :: Word8 -> EbHash
+ebhOf n = MkEbHash (BS.pack [n])
+
+rbhOf :: Word8 -> RbHash
+rbhOf n = MkRbHash (BS.pack [n])
+
+data Op
+ = OpAnnounce !Word64 !Word8 !Word8
+ | OpBody !Word8 ![Word8]
+ | OpUnapplied ![Word8]
+ | OpApplied ![Word8]
+ | OpEvict !Word64
+ deriving Show
+
+-- | Apply an op, returning the eviction sets (the only observable output of an
+-- op) when it is an announcement or an 'evictOlderThan'.
+applyOp :: H -> Op -> IO (Maybe (Set EbHash, Set TxHash))
+applyOp h op = case op of
+ OpAnnounce s r e -> Just <$> insertAnnouncement h (SlotNo s) (rbhOf r) (ebhOf e)
+ OpEvict boundary -> Just <$> evictOlderThan h (SlotNo boundary)
+ OpBody e ts -> insertBody h (ebhOf e) (TestBody (map txhOf ts)) >> pure Nothing
+ OpUnapplied ts ->
+ withLockedInsertUnappliedTx h (\z step -> foldM (\acc t -> step acc (txhOf t) ()) z ts)
+ >> pure Nothing
+ OpApplied ts ->
+ withLockedInsertAppliedTx h (\z step -> foldM (\acc t -> step acc (txhOf t) ()) z ts)
+ >> pure Nothing
+
+sweepLookup :: H -> [Word8] -> IO [Maybe (Either () ())]
+sweepLookup h txs = withLookupTx h (\look -> mapM (look . txhOf) txs)
+
+sweepBody :: H -> [Word8] -> IO [Maybe TestBody]
+sweepBody h ebs = mapM (lookupBody h . ebhOf) ebs
+
+-- | The EB-hash domain the generators draw from (see 'genOps'): announcements
+-- and bodies use ebs @1..20@.
+ebDomain :: [Word8]
+ebDomain = [1 .. 20]
+
+genOps :: Int -> Gen [Op]
+genOps txDomain = do
+ n <- chooseInt (0, 400)
+ vectorOf n genOp
+ where
+ genOp =
+ frequency
+ [ (3, OpAnnounce <$> gen 1 300 <*> gen 1 3 <*> gen 1 20)
+ , (2, OpBody <$> gen 1 20 <*> listOf genTx)
+ , (2, OpUnapplied <$> listOf genTx)
+ , (2, OpApplied <$> listOf genTx)
+ ]
+ genTx :: Gen Word8
+ genTx = fromIntegral <$> chooseInt (0, txDomain - 1)
+ gen :: Num a => Int -> Int -> Gen a
+ gen lo hi = fromIntegral <$> chooseInt (lo, hi)
+
+prop_equiv :: Property
+prop_equiv =
+ forAll (genConfig (6, 8)) $ \cfg ->
+ forAllShrink (genOps (cfgDomain cfg)) (shrinkList (const [])) $ \ops -> ioProperty $ do
+ hp <- newPureLeiosTxCache
+ hm <- newHashTableLeiosTxCache (cfgShift cfg) salt0 salt1
+ resP <- mapM (applyOp hp) ops
+ resM <- mapM (applyOp hm) ops
+ sweepP <- sweepLookup hp (allTxs (cfgDomain cfg))
+ sweepM <- sweepLookup hm (allTxs (cfgDomain cfg))
+ sweepBodyP <- sweepBody hp ebDomain
+ sweepBodyM <- sweepBody hm ebDomain
+ pure (resP === resM .&&. sweepP === sweepM .&&. sweepBodyP === sweepBodyM)
+ where
+ allTxs txDomain = [0 .. fromIntegral (txDomain - 1)]
+
+-- | 'evictOlderThan' equivalence, kept out of 'prop_equiv': its draining of the
+-- cache would defeat that property's load-factor targeting, so this one runs at a
+-- fixed, generous table size where occupancy is not the point. Both handles must
+-- still agree on every op's eviction set and on the final lookup sweep.
+prop_equivEvict :: Property
+prop_equivEvict =
+ forAllShrink genEvictOps (shrinkList (const [])) $ \ops -> ioProperty $ do
+ hp <- newPureLeiosTxCache
+ hm <- newHashTableLeiosTxCache tableShift salt0 salt1
+ resP <- mapM (applyOp hp) ops
+ resM <- mapM (applyOp hm) ops
+ sweepP <- sweepLookup hp allTxs
+ sweepM <- sweepLookup hm allTxs
+ sweepBodyP <- sweepBody hp ebDomain
+ sweepBodyM <- sweepBody hm ebDomain
+ pure (resP === resM .&&. sweepP === sweepM .&&. sweepBodyP === sweepBodyM)
+ where
+ tableShift = 8 :: Int -- 256 slots; load factor is deliberately not the point here
+ txDomain = 40 :: Int -- << 256, so the table never fills
+ allTxs = [0 .. fromIntegral (txDomain - 1)]
+ -- Reuse the ordinary op stream, then sprinkle in evictOlderThan boundaries over
+ -- the same slot range and shuffle, so evictions interleave with the rest.
+ genEvictOps = do
+ base <- genOps txDomain
+ evicts <- listOf (OpEvict . fromIntegral <$> chooseInt (1, 300))
+ shuffle (base ++ evicts)
diff --git a/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Optimized/MutableHashTable.hs b/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Optimized/MutableHashTable.hs
new file mode 100644
index 0000000000..87e47a7533
--- /dev/null
+++ b/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Optimized/MutableHashTable.hs
@@ -0,0 +1,404 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- | Model-based test for "LeiosTxCache.Optimized.MutableHashTable": a random
+-- sequence of insert\/delete\/lookup, run (purely, in 'ST') against the table and
+-- against a 'Data.Map.Strict' oracle, must agree on every lookup and on a final
+-- full-domain sweep. This exercises the probing and the backward-shift deletion
+-- under churn.
+--
+-- Crucially it runs across a spread of load factors, /up to one slot shy of
+-- full/: with a good hash and few keys no probe clusters form, so the interesting
+-- code (long probes, wraparound, mid-cluster deletion) is only stressed when the
+-- table is dense. Each config pairs a capacity (@2 ^ shift@) with a key domain
+-- sized to a target load; the domain is kept below capacity (see 'Config'), so at
+-- least one slot always stays free — which open-addressing linear probing needs
+-- to terminate, and which the production sizing guarantees anyway.
+--
+-- The load-factor 'Config' generator and the salt are also used by the handle
+-- equivalence test "Test.LeiosTxCache.Optimized", so they are exported here (they
+-- are hash-table concepts — capacity, occupancy, SipHash salt) rather than
+-- duplicated.
+--
+-- This source file is compiled into two different test-suites: consensus-test
+-- and leios-txcache-bounds-checked. The latter adds the @-fcheck-prim-bounds@
+-- compiler flag, so that out-of-bounds errors raise an error instead of cause
+-- silent corruption. It's therefore important to run both test suites.
+module Test.LeiosTxCache.Optimized.MutableHashTable
+ ( tests
+
+ -- * Load-factor fixtures (shared with "Test.LeiosTxCache.Optimized")
+ , Config (..)
+ , genConfig
+ , salt0
+ , salt1
+ ) where
+
+import Control.Monad.ST (runST)
+import Data.Bits (shiftL, shiftR, xor, (.&.), (.|.))
+import qualified Data.List as List
+import qualified Data.Map.Strict as Map
+import Data.Word (Word64, Word8)
+import qualified LeiosTxCache.Optimized.MutableHashTable as HT
+import Test.Tasty (TestTree, adjustOption, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+import Test.Tasty.QuickCheck
+ ( Gen
+ , Property
+ , QuickCheckTests (..)
+ , arbitrary
+ , chooseInt
+ , forAll
+ , forAllShrink
+ , frequency
+ , shrinkList
+ , shuffle
+ , tabulate
+ , testProperty
+ , vectorOf
+ , (.&&.)
+ , (===)
+ )
+
+tests :: TestTree
+tests =
+ testGroup
+ "LeiosTxCache.Optimized.MutableHashTable"
+ [ adjustOption (\(QuickCheckTests n) -> QuickCheckTests (n * 10)) $
+ testProperty "agrees with Data.Map across load factors" prop_matchesMap
+ , adjustOption (\(QuickCheckTests n) -> QuickCheckTests (n * 10)) $
+ testProperty
+ "agrees with Data.Map on a long isolated chain (keys share a home slot)"
+ prop_adversarialCluster
+ , testGroup
+ "SipHash-2-4"
+ [ testCase "reference matches all 64 official vectors" $
+ [refSipHash24 refK0 refK1 (take len [0 :: Word8 ..]) | len <- [0 .. 63]]
+ @?= sip64Vectors
+ , testProperty "ported core matches the reference" prop_siphashMatchesReference
+ ]
+ ]
+
+-- | A table capacity (@2 ^ cfgShift@) paired with a key\/tx domain sized to a
+-- target load factor.
+--
+-- INVARIANT: @1 <= cfgDomain <= 2 ^ cfgShift - 1@ — at least one slot stays free.
+-- Open-addressing linear probing needs an empty slot to terminate; in particular
+-- the backward-shift 'HT.delete' loops forever on a completely full table (the
+-- lone hole gets chased around with no gap to stop at). So the sweep reaches one
+-- slot shy of full — the densest /supported/ occupancy — never a literally
+-- 100%-full table, which production never reaches either (it sizes for ~46%).
+data Config = Config
+ { cfgShift :: !Int
+ , cfgDomain :: !Int
+ }
+ deriving Show
+
+-- | Pick a capacity within the given (inclusive) @nshift@ range and a domain
+-- sized to a target load, biased to include the extremes: a sparse table and an
+-- exactly-full one.
+genConfig :: (Int, Int) -> Gen Config
+genConfig shiftRange = do
+ shift <- chooseInt shiftRange
+ let cap = 2 ^ shift :: Int
+ loadPct <-
+ frequency
+ [ (1, pure 100) -- exactly full
+ , (2, chooseInt (85, 100)) -- near-full
+ , (3, chooseInt (10, 100)) -- the whole range
+ ]
+ pure Config{cfgShift = shift, cfgDomain = max 1 (min (cap - 1) ((cap * loadPct) `div` 100))}
+
+-- | A fixed 128-bit SipHash salt. Production feeds a securely-random pair; the
+-- tests use a constant so runs are deterministic.
+salt0, salt1 :: Word64
+salt0 = 0xD1CED00DFEEDFACE
+salt1 = 0x0123456789ABCDEF
+
+data Op = Ins !Int !Word64 | Del !Int | Look !Int
+ deriving Show
+
+genOp :: Int -> Gen Op
+genOp domain = do
+ k <- chooseInt (0, domain - 1)
+ frequency
+ [ (3, Ins k <$> arbitrary) -- insert-biased, so occupancy climbs to the target
+ , (1, pure (Del k))
+ , (2, pure (Look k))
+ ]
+
+genOps :: Config -> Gen [Op]
+genOps cfg = do
+ let dom = cfgDomain cfg
+ -- Fill phase: insert every key once, in random order, so occupancy actually
+ -- reaches the target load. Random churn alone rarely fills a dense table
+ -- (coupon-collector), so without this the high load factors would never be hit.
+ fills <- mapM (\k -> Ins k <$> arbitrary) =<< shuffle [0 .. dom - 1]
+ -- Churn phase: random ins\/del\/look at that density, to stir the dense table.
+ m <- chooseInt (0, 4 * dom)
+ churn <- vectorOf m (genOp dom)
+ pure (fills ++ churn)
+
+-- | A well-mixed 32-byte key from a domain index.
+keyOf :: Int -> HT.Key
+keyOf n0 =
+ HT.Key (mix (4 * n)) (mix (4 * n + 1)) (mix (4 * n + 2)) (mix (4 * n + 3))
+ where
+ n = fromIntegral n0 :: Word64
+ mix z0 =
+ let z1 = (z0 `xor` (z0 `shiftR` 30)) * 0xBF58476D1CE4E5B9
+ z2 = (z1 `xor` (z1 `shiftR` 27)) * 0x94D049BB133111EB
+ in z2 `xor` (z2 `shiftR` 31)
+
+-- | Lookup results in op order, plus a final sweep over the whole domain.
+runMutable :: (Int -> HT.Key) -> Config -> [Op] -> (([Maybe Word64], [Maybe Word64]), Maybe String)
+runMutable key cfg ops = runST $ do
+ ht <- HT.new (cfgShift cfg) salt0 salt1
+ looks <- go ht ops
+ sweep <- mapM (HT.lookup ht . key) [0 .. cfgDomain cfg - 1]
+ inv <- HT.checkInvariants ht
+ pure ((looks, sweep), inv)
+ where
+ go _ [] = pure []
+ go ht (op : rest) = case op of
+ Ins k v -> HT.insert ht (key k) v >> go ht rest
+ Del k -> HT.delete ht (key k) >> go ht rest
+ Look k -> (:) <$> HT.lookup ht (key k) <*> go ht rest
+
+runModel :: (Int -> HT.Key) -> Config -> [Op] -> ([Maybe Word64], [Maybe Word64])
+runModel key cfg ops = (looks, sweep)
+ where
+ (looks, final) = go ops Map.empty
+ sweep = [Map.lookup (key i) final | i <- [0 .. cfgDomain cfg - 1]]
+ go [] m = ([], m)
+ go (op : rest) m = case op of
+ Ins k v -> go rest (Map.insert (key k) v m)
+ Del k -> go rest (Map.delete (key k) m)
+ Look k -> let (rs, m') = go rest m in (Map.lookup (key k) m : rs, m')
+
+-- | The peak occupancy the run reaches, in twentieths of capacity (one twentieth
+-- = 5%), rounded to the nearest twentieth — so a run that fills the table reads as
+-- 20. Reported by the property (via 'tabulate') so the load-factor distribution is
+-- visible, in particular that the extremes are actually hit.
+--
+-- Since the mutable table and the 'Data.Map.Strict' oracle agree (that is the
+-- property), the oracle's peak size equals the table's, so it is computed here
+-- purely from the same op replay.
+peakLoadTwentieths :: Config -> [Op] -> Int
+peakLoadTwentieths cfg ops = (20 * peakSize ops + cap `div` 2) `div` cap
+ where
+ cap = 2 ^ cfgShift cfg :: Int
+
+peakSize :: [Op] -> Int
+peakSize = go 0 Map.empty
+ where
+ go !mx _ [] = mx
+ go !mx m (op : rest) = case op of
+ Ins k v -> let m' = Map.insert (keyOf k) v m in go (max mx (Map.size m')) m' rest
+ Del k -> go mx (Map.delete (keyOf k) m) rest
+ Look _ -> go mx m rest
+
+prop_matchesMap :: Property
+prop_matchesMap =
+ forAll (genConfig (6, 9)) $ \cfg ->
+ forAllShrink (genOps cfg) (shrinkList (const [])) $ \ops ->
+ tabulate "peak load" [show (peakLoadTwentieths cfg ops) ++ " twentieths"] $
+ let (result, inv) = runMutable keyOf cfg ops
+ in inv === Nothing .&&. result === runModel keyOf cfg ops
+
+{-------------------------------------------------------------------------------
+ Adversarial clustering
+
+ The load sweep already covers dense tables, and at near-full load even a good
+ hash yields ~cap/2-length chains — so a full table is not the distinctive case.
+ What a good hash /never/ produces is a long probe chain in a table that is
+ otherwise mostly empty. We force exactly that: a cluster of keys all sharing one
+ home slot, dropped into a much larger table (~25% load), so inserting them
+ builds one long chain surrounded by free slots. Run through the same oracle +
+ invariant machinery, it stresses long probes and the all-same-home
+ backward-shift at an occupancy the sweep only ever reaches with short chains.
+-------------------------------------------------------------------------------}
+
+advShift :: Int
+advShift = 8 -- capacity 256
+
+-- | The colliding-cluster length (~25% of capacity): long enough to be a genuine
+-- long chain, yet far from filling the table (the full case is the sweep's job).
+advClusterSize :: Int
+advClusterSize = 64
+
+-- | 'advClusterSize' keys that all hash to a single home slot for the test salt,
+-- found by grouping 'keyOf' images by 'HT.homeSlot' and taking the fullest slot.
+-- (The 20001 candidates over 256 slots put far more than 'advClusterSize' in the
+-- fullest slot, by pigeonhole.)
+colKeys :: [HT.Key]
+colKeys =
+ take advClusterSize
+ . List.maximumBy (\a b -> compare (length a) (length b))
+ . Map.elems
+ $ Map.fromListWith
+ (++)
+ [(HT.homeSlot (2 ^ advShift - 1) salt0 salt1 k, [k]) | i <- [0 .. 20000], let k = keyOf i]
+
+colKeyMap :: Map.Map Int HT.Key
+colKeyMap = Map.fromList (zip [0 ..] colKeys)
+
+colKeyOf :: Int -> HT.Key
+colKeyOf j = colKeyMap Map.! j
+
+prop_adversarialCluster :: Property
+prop_adversarialCluster =
+ forAllShrink (genOps cfg) (shrinkList (const [])) $ \ops ->
+ let (result, inv) = runMutable colKeyOf cfg ops
+ in inv === Nothing .&&. result === runModel colKeyOf cfg ops
+ where
+ cfg = Config advShift (length colKeys)
+
+{-------------------------------------------------------------------------------
+ SipHash-2-4 validation
+
+ 'HT.siphash24' is the anti-flooding primitive, and a wrong-but-deterministic
+ hash would sail through the model test above while silently defeating the salt.
+ So we anchor a spec-following reference to the published test vectors, then check
+ the ported core against it over random inputs.
+-------------------------------------------------------------------------------}
+
+-- | The published SipHash test key: bytes @0x00 .. 0x0f@ as two little-endian
+-- words.
+refK0, refK1 :: Word64
+refK0 = 0x0706050403020100
+refK1 = 0x0f0e0d0c0b0a0908
+
+-- | Element @i@ is the digest under the 'refK0' \/ 'refK1' key of the @i@-byte
+-- message @[0x00 .. i-1]@, for @i@ in @0 .. 63@ (each 8-byte little-endian row
+-- folded into a 'Word64').
+--
+-- The SipHash announcement at
+--
+-- lists as its reference
+-- implementation. These vectors are copied from the @vectors_sip64@ array in
+-- the @vectors.h@ file there, which is multi-licensed by Jean-Philippe
+-- Aumasson, Daniel J. Bernstein, including CC0 and MIT.
+sip64Vectors :: [Word64]
+sip64Vectors =
+ [ 0x726fdb47dd0e0e31
+ , 0x74f839c593dc67fd
+ , 0x0d6c8009d9a94f5a
+ , 0x85676696d7fb7e2d
+ , 0xcf2794e0277187b7
+ , 0x18765564cd99a68d
+ , 0xcbc9466e58fee3ce
+ , 0xab0200f58b01d137
+ , 0x93f5f5799a932462
+ , 0x9e0082df0ba9e4b0
+ , 0x7a5dbbc594ddb9f3
+ , 0xf4b32f46226bada7
+ , 0x751e8fbc860ee5fb
+ , 0x14ea5627c0843d90
+ , 0xf723ca908e7af2ee
+ , 0xa129ca6149be45e5
+ , 0x3f2acc7f57c29bdb
+ , 0x699ae9f52cbe4794
+ , 0x4bc1b3f0968dd39c
+ , 0xbb6dc91da77961bd
+ , 0xbed65cf21aa2ee98
+ , 0xd0f2cbb02e3b67c7
+ , 0x93536795e3a33e88
+ , 0xa80c038ccd5ccec8
+ , 0xb8ad50c6f649af94
+ , 0xbce192de8a85b8ea
+ , 0x17d835b85bbb15f3
+ , 0x2f2e6163076bcfad
+ , 0xde4daaaca71dc9a5
+ , 0xa6a2506687956571
+ , 0xad87a3535c49ef28
+ , 0x32d892fad841c342
+ , 0x7127512f72f27cce
+ , 0xa7f32346f95978e3
+ , 0x12e0b01abb051238
+ , 0x15e034d40fa197ae
+ , 0x314dffbe0815a3b4
+ , 0x027990f029623981
+ , 0xcadcd4e59ef40c4d
+ , 0x9abfd8766a33735c
+ , 0x0e3ea96b5304a7d0
+ , 0xad0c42d6fc585992
+ , 0x187306c89bc215a9
+ , 0xd4a60abcf3792b95
+ , 0xf935451de4f21df2
+ , 0xa9538f0419755787
+ , 0xdb9acddff56ca510
+ , 0xd06c98cd5c0975eb
+ , 0xe612a3cb9ecba951
+ , 0xc766e62cfcadaf96
+ , 0xee64435a9752fe72
+ , 0xa192d576b245165a
+ , 0x0a8787bf8ecb74b2
+ , 0x81b3e73d20b49b6f
+ , 0x7fa8220ba3b2ecea
+ , 0x245731c13ca42499
+ , 0xb78dbfaf3a8d83bd
+ , 0xea1ad565322a1a0b
+ , 0x60e61c23a3795013
+ , 0x6606d7e446282b93
+ , 0x6ca4ecb15c5f91e1
+ , 0x9f626da15c9625f3
+ , 0xe51b38608ef25f57
+ , 0x958a324ceb064572
+ ]
+
+-- | The ported 4-word core agrees with the byte-oriented reference on any salt
+-- and any 32-byte key. (Both read the four words the same way: the reference's
+-- little-endian byte encoding round-trips through 'word64ToLE'.)
+prop_siphashMatchesReference ::
+ Word64 -> Word64 -> Word64 -> Word64 -> Word64 -> Word64 -> Property
+prop_siphashMatchesReference k0 k1 m0 m1 m2 m3 =
+ HT.siphash24 k0 k1 (HT.Key m0 m1 m2 m3)
+ === refSipHash24 k0 k1 (concatMap word64ToLE [m0, m1, m2, m3])
+ where
+ word64ToLE w = [fromIntegral (w `shiftR` (8 * i)) | i <- [0 .. 7]] :: [Word8]
+
+-- | A spec-following SipHash-2-4 over a byte string, used only to validate
+-- 'HT.siphash24'; anchored to the published vectors in 'tests'.
+refSipHash24 :: Word64 -> Word64 -> [Word8] -> Word64
+refSipHash24 k0 k1 msg = final (List.foldl' compress' initial (fullWords ++ [finalWord]))
+ where
+ initial =
+ ( k0 `xor` 0x736f6d6570736575
+ , k1 `xor` 0x646f72616e646f6d
+ , k0 `xor` 0x6c7967656e657261
+ , k1 `xor` 0x7465646279746573
+ )
+ n = length msg
+ nFull = n `div` 8
+ fullWords = [leWord (take 8 (drop (8 * i) msg)) | i <- [0 .. nFull - 1]]
+ leftover = drop (8 * nFull) msg
+ finalWord = leWord leftover .|. (fromIntegral (n .&. 0xff) `shiftL` 56)
+
+ leWord :: [Word8] -> Word64
+ leWord = foldr (\b acc -> (acc `shiftL` 8) .|. fromIntegral b) 0
+
+ -- c = 2 compression rounds per message word
+ compress' (v0, v1, v2, v3) m =
+ let (a0, a1, a2, a3) = sipround' (v0, v1, v2, v3 `xor` m)
+ (b0, b1, b2, b3) = sipround' (a0, a1, a2, a3)
+ in (b0 `xor` m, b1, b2, b3)
+
+ -- d = 4 finalization rounds after flipping v2
+ final (v0, v1, v2, v3) =
+ let (w0, w1, w2, w3) = iterate sipround' (v0, v1, v2 `xor` 0xff, v3) !! 4
+ in w0 `xor` w1 `xor` w2 `xor` w3
+
+ sipround' (v0, v1, v2, v3) =
+ let a = v0 + v1
+ b = rotl v1 13 `xor` a
+ a' = rotl a 32
+ c = v2 + v3
+ d = rotl v3 16 `xor` c
+ a'' = a' + d
+ d' = rotl d 21 `xor` a''
+ c' = c + b
+ b' = rotl b 17 `xor` c'
+ c'' = rotl c' 32
+ in (a'', b', c'', d')
+
+ rotl x r = (x `shiftL` r) .|. (x `shiftR` (64 - r))
diff --git a/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Reference.hs b/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Reference.hs
new file mode 100644
index 0000000000..53bbebb57d
--- /dev/null
+++ b/ouroboros-consensus/test/consensus-test/Test/LeiosTxCache/Reference.hs
@@ -0,0 +1,411 @@
+{-# LANGUAGE TypeApplications #-}
+
+-- | Tests for the pure 'LeiosTxCacheIndex': announcement/body/tx reference
+-- counting and the 'maxAnnouncementCount' eviction cascade.
+--
+-- The pure module is the reference model: the forthcoming mutable-hashtable
+-- implementation will be tested for observational equivalence to it, not against
+-- these unit tests. These exercise observable behavior only (the exported ops
+-- and the internal state constructors), not any representation detail.
+module Test.LeiosTxCache.Reference (tests) where
+
+import Cardano.Slotting.Slot (SlotNo (..))
+import qualified Data.ByteString as BS
+import Data.Foldable (toList)
+import qualified Data.List as List
+import qualified Data.Map.NonEmpty as NEMap
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Word (Word64, Word8)
+import LeiosDemoTypes (EbHash (..), RbHash (..), TxHash (..))
+import LeiosTxCache.Reference
+import Test.Tasty (TestTree, adjustOption, testGroup)
+import Test.Tasty.HUnit (Assertion, testCase, (@?=))
+import Test.Tasty.QuickCheck
+ ( Gen
+ , Property
+ , QuickCheckTests (..)
+ , chooseInt
+ , conjoin
+ , counterexample
+ , forAll
+ , forAllShrink
+ , listOf
+ , oneof
+ , shrinkList
+ , shuffle
+ , testProperty
+ , vectorOf
+ , (===)
+ )
+
+tests :: TestTree
+tests =
+ testGroup
+ "LeiosTxCache.Reference"
+ [ testGroup
+ "announcements"
+ [ testCase "one announcement -> BodyNotYetInserted rc=1" test_annOne
+ , testCase "re-announcing the same (slot, rb) is a no-op" test_annDup
+ , testCase "two announcements of one EB -> body rc=2" test_annTwo
+ ]
+ , testGroup
+ "bodies"
+ [ testCase "insertBody references its txs (NotYetInserted rc=1)" test_body
+ , testCase "insertBody on an unannounced EB is a no-op" test_bodyUnannounced
+ , testCase "insertBody is idempotent" test_bodyIdempotent
+ , testCase "lookupBody on an untracked EB is Nothing" test_lookupBodyUntracked
+ , testCase "lookupBody on an announced-only EB is Nothing" test_lookupBodyAnnouncedOnly
+ , testCase "lookupBody after insertBody returns the body" test_lookupBodyInserted
+ , testCase "lookupBody after eviction is Nothing" test_lookupBodyEvicted
+ ]
+ , testGroup
+ "txs"
+ [ testCase "insertUnappliedTx -> lookupTx Left" test_unapplied
+ , testCase "insertAppliedTx -> lookupTx Right" test_applied
+ , testCase "insert on an unreferenced tx is a no-op" test_txUnreferenced
+ , testCase "insertUnappliedTx preserves the refcount" test_preserveRc
+ ]
+ , testGroup
+ "eviction"
+ [ testCase "over-cap insert evicts the oldest body and its txs" test_evict
+ , testCase "evicting a body-less EB evicts no txs" test_evictBodyless
+ , testCase "a shared tx survives one referrer's eviction" test_evictShared
+ , testCase "evictOlderThan drops EBs below the boundary, cascading txs" test_evictOlderThan
+ , testCase "evictOlderThan keeps EBs at the boundary slot (exclusive)" test_evictOlderThanExclusive
+ , testCase "evictOlderThan below every slot evicts nothing" test_evictOlderThanNone
+ , testCase "evictOlderThan clears every EB in a stale slot" test_evictOlderThanWholeSlot
+ , testCase "insert below the pruned slot is ignored" test_prunedIgnoresOlderInsert
+ , testCase "insert at the pruned slot is admitted (exclusive)" test_prunedAllowsBoundaryInsert
+ , testCase "a lower evictOlderThan boundary does not lower the pruned slot" test_prunedSlotMonotone
+ ]
+ , testProperty "announcementCount = sum of per-slot sizes" prop_countInvariant
+ , adjustOption (\(QuickCheckTests n) -> QuickCheckTests (n * 100)) $
+ testProperty "refcounts match a recomputed model, through eviction" prop_refcounts
+ ]
+
+{-------------------------------------------------------------------------------
+ Fixtures
+-------------------------------------------------------------------------------}
+
+-- | The tx payloads are 'Int' so 'lookupTx' results are distinguishable.
+type Idx = LeiosTxCacheIndex Int Int TestBody
+
+-- | A mock EB body: just the list of tx hashes it references.
+newtype TestBody = TestBody [TxHash]
+ deriving (Eq, Show)
+
+instance ReferencesTxsByHash TestBody where
+ foldTxReferences f z (TestBody hs) = List.foldl' f z hs
+
+empty :: Idx
+empty = emptyLeiosTxCacheIndex
+
+mkTxHash :: Word8 -> TxHash
+mkTxHash w = MkTxHash (BS.pack [w])
+
+mkEbHash :: Word8 -> EbHash
+mkEbHash w = MkEbHash (BS.pack [w])
+
+mkRbHash :: Word8 -> RbHash
+mkRbHash w = MkRbHash (BS.pack [w])
+
+-- | Insert an announcement, discarding the evicted sets.
+ann :: Word64 -> Word8 -> Word8 -> Idx -> Idx
+ann s r e idx = let (idx', _, _) = insertAnnouncement (SlotNo s) (mkRbHash r) (mkEbHash e) idx in idx'
+
+body :: Word8 -> [Word8] -> Idx -> Idx
+body e ts idx = fst (insertBody (mkEbHash e) (TestBody (map mkTxHash ts)) idx)
+
+-- | Announce EBs 1..n, each at its own slot and with its own RB hash.
+annN :: Int -> Idx -> Idx
+annN n idx0 =
+ List.foldl' (\idx i -> ann (fromIntegral i) (fromIntegral i) (fromIntegral i) idx) idx0 [1 .. n]
+
+bodyRC :: Word8 -> Idx -> Maybe RefCount
+bodyRC e idx = rc <$> Map.lookup (mkEbHash e) (bodyState idx)
+ where
+ rc (BodyNotYetInserted r) = r
+ rc (BodyAlreadyInserted r _) = r
+
+txRC :: Word8 -> Idx -> Maybe RefCount
+txRC t idx = rc <$> Map.lookup (mkTxHash t) (txState idx)
+ where
+ rc (TxNotYetInserted r) = r
+ rc (TxAlreadyInserted r _) = r
+ rc (TxAlreadyValidated r _) = r
+
+{-------------------------------------------------------------------------------
+ Announcements
+-------------------------------------------------------------------------------}
+
+test_annOne :: Assertion
+test_annOne = bodyRC 1 (ann 1 1 1 empty) @?= Just (MkRefCount 1)
+
+test_annDup :: Assertion
+test_annDup = do
+ let (idx1, _, _) = insertAnnouncement (SlotNo 1) (mkRbHash 1) (mkEbHash 1) empty
+ (idx2, evEbs, evTxs) = insertAnnouncement (SlotNo 1) (mkRbHash 1) (mkEbHash 1) idx1
+ (bodyRC 1 idx2, evEbs, evTxs) @?= (Just (MkRefCount 1), Set.empty, Set.empty)
+
+test_annTwo :: Assertion
+test_annTwo = bodyRC 1 (ann 2 2 1 (ann 1 1 1 empty)) @?= Just (MkRefCount 2)
+
+{-------------------------------------------------------------------------------
+ Bodies
+-------------------------------------------------------------------------------}
+
+test_body :: Assertion
+test_body = do
+ let idx = body 1 [10, 11] (ann 1 1 1 empty)
+ (txRC 10 idx, txRC 11 idx, lookupTx (mkTxHash 10) idx)
+ @?= (Just (MkRefCount 1), Just (MkRefCount 1), Nothing)
+
+test_bodyUnannounced :: Assertion
+test_bodyUnannounced = txRC 10 (body 1 [10] empty) @?= Nothing
+
+test_bodyIdempotent :: Assertion
+test_bodyIdempotent = txRC 10 (body 1 [10] (body 1 [10] (ann 1 1 1 empty))) @?= Just (MkRefCount 1)
+
+test_lookupBodyUntracked :: Assertion
+test_lookupBodyUntracked = lookupBody (mkEbHash 1) empty @?= Nothing
+
+-- | Announced but body not inserted ('BodyNotYetInserted') reads as 'Nothing'.
+test_lookupBodyAnnouncedOnly :: Assertion
+test_lookupBodyAnnouncedOnly = lookupBody (mkEbHash 1) (ann 1 1 1 empty) @?= Nothing
+
+test_lookupBodyInserted :: Assertion
+test_lookupBodyInserted =
+ lookupBody (mkEbHash 1) (body 1 [10, 11] (ann 1 1 1 empty))
+ @?= Just (TestBody [mkTxHash 10, mkTxHash 11])
+
+-- | Evicting the EB (its slot falls below the boundary) drops its body too.
+test_lookupBodyEvicted :: Assertion
+test_lookupBodyEvicted = do
+ let base = body 1 [10] (ann 1 1 1 empty)
+ (idx', _, _) = evictOlderThan (SlotNo 2) base
+ lookupBody (mkEbHash 1) idx' @?= Nothing
+
+{-------------------------------------------------------------------------------
+ Txs
+-------------------------------------------------------------------------------}
+
+test_unapplied :: Assertion
+test_unapplied =
+ lookupTx (mkTxHash 10) (insertUnappliedTx (mkTxHash 10) 7 (body 1 [10] (ann 1 1 1 empty)))
+ @?= Just (Left 7)
+
+test_applied :: Assertion
+test_applied =
+ lookupTx (mkTxHash 10) (insertAppliedTx (mkTxHash 10) 9 (body 1 [10] (ann 1 1 1 empty)))
+ @?= Just (Right 9)
+
+test_txUnreferenced :: Assertion
+test_txUnreferenced =
+ lookupTx (mkTxHash 10) (insertUnappliedTx (mkTxHash 10) 7 empty) @?= Nothing
+
+test_preserveRc :: Assertion
+test_preserveRc = do
+ let idx0 = body 2 [10] (body 1 [10] (ann 2 2 2 (ann 1 1 1 empty)))
+ idx = insertUnappliedTx (mkTxHash 10) 7 idx0
+ (txRC 10 idx, lookupTx (mkTxHash 10) idx) @?= (Just (MkRefCount 2), Just (Left 7))
+
+{-------------------------------------------------------------------------------
+ Eviction (at maxAnnouncementCount = 128)
+-------------------------------------------------------------------------------}
+
+test_evict :: Assertion
+test_evict = do
+ let base = body 1 [200] (annN maxAnnouncementCount empty)
+ (idx', evEbs, evTxs) =
+ insertAnnouncement (SlotNo 129) (mkRbHash 129) (mkEbHash 129) base
+ (evEbs, evTxs, bodyRC 1 idx', txRC 200 idx')
+ @?= (Set.singleton (mkEbHash 1), Set.singleton (mkTxHash 200), Nothing, Nothing)
+
+test_evictBodyless :: Assertion
+test_evictBodyless = do
+ let (_, evEbs, evTxs) =
+ insertAnnouncement (SlotNo 129) (mkRbHash 129) (mkEbHash 129) (annN maxAnnouncementCount empty)
+ (evEbs, evTxs) @?= (Set.singleton (mkEbHash 1), Set.empty)
+
+test_evictShared :: Assertion
+test_evictShared = do
+ let base = body 2 [200] (body 1 [200] (annN maxAnnouncementCount empty))
+ (idx', evEbs, evTxs) =
+ insertAnnouncement (SlotNo 129) (mkRbHash 129) (mkEbHash 129) base
+ (evEbs, evTxs, txRC 200 idx')
+ @?= (Set.singleton (mkEbHash 1), Set.empty, Just (MkRefCount 1))
+
+-- | EBs 1\/2\/3 at slots 1\/2\/3, each with a body; 'evictOlderThan' 3 drops the
+-- two below slot 3, cascading their txs, and keeps the one at slot 3.
+test_evictOlderThan :: Assertion
+test_evictOlderThan = do
+ let base = body 3 [30] (body 2 [20] (body 1 [10] (annN 3 empty)))
+ (idx', evEbs, evTxs) = evictOlderThan (SlotNo 3) base
+ ( evEbs
+ , evTxs
+ , bodyRC 1 idx'
+ , bodyRC 3 idx'
+ , txRC 10 idx'
+ , txRC 30 idx'
+ )
+ @?= ( Set.fromList [mkEbHash 1, mkEbHash 2]
+ , Set.fromList [mkTxHash 10, mkTxHash 20]
+ , Nothing
+ , Just (MkRefCount 1)
+ , Nothing
+ , Just (MkRefCount 1)
+ )
+
+-- | The boundary is exclusive: an EB /at/ the boundary slot survives.
+test_evictOlderThanExclusive :: Assertion
+test_evictOlderThanExclusive = do
+ let base = body 3 [30] (body 2 [20] (body 1 [10] (annN 3 empty)))
+ (_, evEbs, evTxs) = evictOlderThan (SlotNo 2) base
+ (evEbs, evTxs) @?= (Set.singleton (mkEbHash 1), Set.singleton (mkTxHash 10))
+
+-- | A boundary at or below the oldest slot evicts nothing.
+test_evictOlderThanNone :: Assertion
+test_evictOlderThanNone = do
+ let base = body 1 [10] (annN 3 empty)
+ (idx', evEbs, evTxs) = evictOlderThan (SlotNo 1) base
+ (evEbs, evTxs, announcementCount idx') @?= (Set.empty, Set.empty, 3)
+
+-- | Two EBs share the oldest slot; both are evicted together when that slot falls
+-- below the boundary (the loop chews through the whole slot).
+test_evictOlderThanWholeSlot :: Assertion
+test_evictOlderThanWholeSlot = do
+ let base = ann 2 3 3 (ann 1 2 2 (ann 1 1 1 empty))
+ (idx', evEbs, _) = evictOlderThan (SlotNo 2) base
+ (evEbs, bodyRC 3 idx') @?= (Set.fromList [mkEbHash 1, mkEbHash 2], Just (MkRefCount 1))
+
+-- | 'evictOlderThan' records the boundary; a later announcement strictly below it
+-- is silently ignored (the cache has already been pruned past that slot).
+test_prunedIgnoresOlderInsert :: Assertion
+test_prunedIgnoresOlderInsert = do
+ let (pruned, _, _) = evictOlderThan (SlotNo 5) (annN 10 empty)
+ (idx', evEbs, evTxs) = insertAnnouncement (SlotNo 4) (mkRbHash 40) (mkEbHash 40) pruned
+ (announcementCount idx', bodyRC 40 idx', evEbs, evTxs)
+ @?= (announcementCount pruned, Nothing, Set.empty, Set.empty)
+
+-- | The boundary is exclusive on insertion too: an announcement /at/ the pruned
+-- slot is admitted, matching 'evictOlderThan' retaining EBs at the boundary slot.
+test_prunedAllowsBoundaryInsert :: Assertion
+test_prunedAllowsBoundaryInsert = do
+ let (pruned, _, _) = evictOlderThan (SlotNo 5) (annN 10 empty)
+ (idx', _, _) = insertAnnouncement (SlotNo 5) (mkRbHash 55) (mkEbHash 55) pruned
+ bodyRC 55 idx' @?= Just (MkRefCount 1)
+
+-- | The pruned slot is monotone: a later, lower 'evictOlderThan' boundary does not
+-- lower it, so an insert below the earlier (higher) boundary stays ignored.
+test_prunedSlotMonotone :: Assertion
+test_prunedSlotMonotone = do
+ let (p1, _, _) = evictOlderThan (SlotNo 5) (annN 10 empty)
+ (p2, _, _) = evictOlderThan (SlotNo 3) p1
+ (idx', evEbs, evTxs) = insertAnnouncement (SlotNo 4) (mkRbHash 40) (mkEbHash 40) p2
+ (announcementCount idx', bodyRC 40 idx', evEbs, evTxs)
+ @?= (announcementCount p2, Nothing, Set.empty, Set.empty)
+
+{-------------------------------------------------------------------------------
+ Invariant
+-------------------------------------------------------------------------------}
+
+-- | 'announcementCount' always equals the total number of retained announcements
+-- (the sum of the per-slot map sizes). Ranges are kept below the cap so eviction
+-- doesn't enter into it.
+prop_countInvariant :: Property
+prop_countInvariant = forAll genStream $ \ops ->
+ let idx = List.foldl' (\i (s, r, e) -> ann s r e i) empty ops
+ in announcementCount idx === sum (NEMap.size <$> Map.elems (announcementState idx))
+ where
+ genStream = listOf ((,,) <$> gen 1 20 <*> gen 1 5 <*> gen 1 10)
+ gen lo hi = fromIntegral <$> chooseInt (lo, hi)
+
+{-------------------------------------------------------------------------------
+ Model-based refcount properties
+-------------------------------------------------------------------------------}
+
+data Op
+ = OpAnn Word64 Word8 Word8
+ | OpBody Word8 [Word8]
+ | OpUnappliedTx Word8
+ | OpAppliedTx Word8
+ deriving Show
+
+applyOp :: Op -> Idx -> Idx
+applyOp op = case op of
+ OpAnn s r e -> ann s r e
+ OpBody e ts -> body e ts
+ OpUnappliedTx t -> insertUnappliedTx (mkTxHash t) 0
+ OpAppliedTx t -> insertAppliedTx (mkTxHash t) 0
+
+genW :: Num a => Int -> Int -> Gen a
+genW lo hi = fromIntegral <$> chooseInt (lo, hi)
+
+genAnn :: Gen Op
+genAnn = OpAnn <$> genW 1 1000 <*> genW 1 5 <*> genW 1 8
+
+genOp :: Gen Op
+genOp =
+ oneof
+ [ genAnn
+ , OpBody <$> genW 1 8 <*> listOf (genW 1 20)
+ , OpUnappliedTx <$> genW 1 20
+ , OpAppliedTx <$> genW 1 20
+ ]
+
+-- | Every sequence carries a block of ~200 distinct announcements so the
+-- 128-cap eviction is exercised in every run, interleaved with other ops.
+genOps :: Gen [Op]
+genOps = do
+ anns <- vectorOf 200 genAnn
+ others <- listOf genOp
+ shuffle (anns ++ others)
+
+bodyRefCountOf :: BodyState b -> RefCount
+bodyRefCountOf (BodyNotYetInserted r) = r
+bodyRefCountOf (BodyAlreadyInserted r _) = r
+
+txRefCountOf :: TxState a v -> RefCount
+txRefCountOf (TxNotYetInserted r) = r
+txRefCountOf (TxAlreadyInserted r _) = r
+txRefCountOf (TxAlreadyValidated r _) = r
+
+rcInt :: RefCount -> Int
+rcInt (MkRefCount w) = fromIntegral w
+
+txHashesOf :: ReferencesTxsByHash b => b -> [TxHash]
+txHashesOf = foldTxReferences (flip (:)) []
+
+-- | After any sequence of ops the maintained refcounts agree with the refcounts
+-- recomputed from first principles: a body's refcount is the number of retained
+-- announcements of that EB, and a tx's refcount is the number of inserted bodies
+-- referencing it. Also confirms the count invariant and the cap survive eviction.
+prop_refcounts :: Property
+prop_refcounts = forAllShrink genOps (shrinkList (const [])) $ \ops ->
+ let idx = List.foldl' (flip applyOp) empty ops
+ in conjoin
+ [ counterexample "cap exceeded" $
+ announcementCount idx <= maxAnnouncementCount
+ , counterexample "announcementCount /= sum of per-slot sizes" $
+ announcementCount idx === sum (NEMap.size <$> Map.elems (announcementState idx))
+ , counterexample "body refcounts disagree with announcements" $
+ Map.map (rcInt . bodyRefCountOf) (bodyState idx) === expectedBodyRC idx
+ , counterexample "tx refcounts disagree with inserted bodies" $
+ Map.map (rcInt . txRefCountOf) (txState idx) === expectedTxRC idx
+ ]
+ where
+ expectedBodyRC ix =
+ Map.fromListWith
+ (+)
+ [ (ebh, 1 :: Int)
+ | nem <- Map.elems (announcementState ix)
+ , ebh <- toList nem
+ ]
+ expectedTxRC ix =
+ Map.fromListWith
+ (+)
+ [ (txh, 1 :: Int)
+ | BodyAlreadyInserted _ b <- Map.elems (bodyState ix)
+ , txh <- txHashesOf b
+ ]
diff --git a/ouroboros-consensus/test/leios-txcache-bounds-checked/Main.hs b/ouroboros-consensus/test/leios-txcache-bounds-checked/Main.hs
new file mode 100644
index 0000000000..5aad559e9e
--- /dev/null
+++ b/ouroboros-consensus/test/leios-txcache-bounds-checked/Main.hs
@@ -0,0 +1,12 @@
+module Main (main) where
+
+import Test.LeiosTxCache.Optimized.MutableHashTable (tests)
+import Test.Tasty (defaultMain)
+
+-- | Runs the hash-table property suite against a copy of
+-- "LeiosTxCache.Optimized.MutableHashTable" recompiled with
+-- @-fcheck-prim-bounds@ (see the @leios-txcache-bounds-checked@ cabal stanza), so
+-- an out-of-bounds 'Data.Primitive.MutableByteArray' access fails loudly rather
+-- than silently corrupting memory.
+main :: IO ()
+main = defaultMain tests