pg-wire-0.2.0.0: src/PgWire/Cache/Sieve.hs
-- | SIEVE cache eviction algorithm.
--
-- A simple, efficient eviction policy that outperforms FIFO and
-- approaches LRU quality with lower overhead. Based on the NSDI'24
-- paper "SIEVE is Simpler than LRU."
--
-- On cache hit: set a visited bit (no list mutation).
-- On eviction: sweep from hand pointer, skip visited entries (reset
-- their bit), evict the first unvisited entry.
--
-- This implementation uses IORef-based mutable linked list nodes,
-- suitable for single-threaded per-connection use.
module PgWire.Cache.Sieve
( SieveCache
, newSieveCache
, lookup
, insert
, delete
, size
, clear
, capacity
, foldEntries
) where
import Prelude hiding (lookup)
import Data.IORef
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
-- | A SIEVE cache with keys @k@ and values @v@.
data SieveCache k v = SieveCache
{ scCapacity :: !Int
, scSize :: !(IORef Int)
, scMap :: !(IORef (Map k (Node k v)))
, scHead :: !(IORef (Maybe (Node k v)))
-- ^ Newest entry (insertion point).
, scTail :: !(IORef (Maybe (Node k v)))
-- ^ Oldest entry.
, scHand :: !(IORef (Maybe (Node k v)))
-- ^ Eviction sweep pointer. Starts at tail, moves toward head.
}
-- | A node in the doubly-linked list.
data Node k v = Node
{ nodeKey :: !k
, nodeVal :: !v
, nodeVisited :: !(IORef Bool)
, nodePrev :: !(IORef (Maybe (Node k v)))
-- ^ Toward head (newer).
, nodeNext :: !(IORef (Maybe (Node k v)))
-- ^ Toward tail (older).
}
-- | Create an empty SIEVE cache with the given capacity.
newSieveCache :: Int -> IO (SieveCache k v)
newSieveCache cap = do
sz <- newIORef 0
m <- newIORef Map.empty
hd <- newIORef Nothing
tl <- newIORef Nothing
hand <- newIORef Nothing
pure SieveCache
{ scCapacity = cap
, scSize = sz
, scMap = m
, scHead = hd
, scTail = tl
, scHand = hand
}
-- | Look up a key. On hit, marks the entry as visited.
lookup :: (Ord k) => SieveCache k v -> k -> IO (Maybe v)
lookup sc k = do
m <- readIORef (scMap sc)
case Map.lookup k m of
Nothing -> pure Nothing
Just node -> do
writeIORef (nodeVisited node) True
pure (Just (nodeVal node))
-- | Insert a key-value pair. If the cache is full, evicts an entry first.
-- If the key already exists, updates the value and marks as visited.
insert :: (Ord k) => SieveCache k v -> k -> v -> IO (Maybe (k, v))
-- ^ Returns the evicted key and value, if any.
insert sc k v = do
m <- readIORef (scMap sc)
case Map.lookup k m of
Just node -> do
-- Key exists — update value and mark visited.
deleteNode sc node
insertFresh sc k v
Nothing -> do
sz <- readIORef (scSize sc)
if sz >= scCapacity sc
then do
evicted <- evict sc
insertNew sc k v
pure evicted
else do
insertNew sc k v
pure Nothing
-- | Delete a key from the cache.
delete :: (Ord k) => SieveCache k v -> k -> IO ()
delete sc k = do
m <- readIORef (scMap sc)
case Map.lookup k m of
Nothing -> pure ()
Just node -> deleteNode sc node
-- | Current number of entries.
size :: SieveCache k v -> IO Int
size sc = readIORef (scSize sc)
-- | The configured capacity of this cache.
capacity :: SieveCache k v -> Int
capacity = scCapacity
-- | Drop every entry. After 'clear', 'size' is 0 and the cache behaves
-- as if it had just been allocated.
clear :: SieveCache k v -> IO ()
clear sc = do
writeIORef (scSize sc) 0
writeIORef (scMap sc) Map.empty
writeIORef (scHead sc) Nothing
writeIORef (scTail sc) Nothing
writeIORef (scHand sc) Nothing
-- | Walk every (key, value) pair currently in the cache. Order is
-- newest-first (insertion order at the head). Used by callers that
-- need to release server-side resources for cached entries on shutdown.
foldEntries :: SieveCache k v -> b -> (b -> k -> v -> IO b) -> IO b
foldEntries sc z f = do
mHead <- readIORef (scHead sc)
go z mHead
where
go acc Nothing = pure acc
go acc (Just node) = do
acc' <- f acc (nodeKey node) (nodeVal node)
mNext <- readIORef (nodeNext node)
go acc' mNext
------------------------------------------------------------------------
-- Internal
------------------------------------------------------------------------
-- | Insert a new entry at the head. Does NOT check capacity.
insertNew :: (Ord k) => SieveCache k v -> k -> v -> IO ()
insertNew sc k v = do
visited <- newIORef False
prev <- newIORef Nothing
next <- newIORef Nothing
let node = Node k v visited prev next
mHead <- readIORef (scHead sc)
case mHead of
Nothing -> do
-- Empty cache: node is both head and tail.
writeIORef (scHead sc) (Just node)
writeIORef (scTail sc) (Just node)
writeIORef (scHand sc) (Just node)
Just oldHead -> do
-- Insert before old head.
writeIORef (nodeNext node) (Just oldHead)
writeIORef (nodePrev oldHead) (Just node)
writeIORef (scHead sc) (Just node)
modifyIORef' (scMap sc) (Map.insert k node)
modifyIORef' (scSize sc) (+ 1)
-- | Insert fresh after a delete (for update). Returns Nothing.
insertFresh :: (Ord k) => SieveCache k v -> k -> v -> IO (Maybe (k, v))
insertFresh sc k v = do
insertNew sc k v
-- Mark as visited since it was just accessed.
m <- readIORef (scMap sc)
case Map.lookup k m of
Just node -> writeIORef (nodeVisited node) True
Nothing -> pure ()
pure Nothing
-- | Remove a node from the linked list and map.
deleteNode :: (Ord k) => SieveCache k v -> Node k v -> IO ()
deleteNode sc node = do
-- Fix hand pointer if it points to this node.
mHand <- readIORef (scHand sc)
case mHand of
Just handNode | sameNode handNode node -> advanceHand sc node
_ -> pure ()
-- Unlink from doubly-linked list.
mPrev <- readIORef (nodePrev node)
mNext <- readIORef (nodeNext node)
case mPrev of
Just p -> writeIORef (nodeNext p) mNext
Nothing -> writeIORef (scHead sc) mNext -- node was head
case mNext of
Just n -> writeIORef (nodePrev n) mPrev
Nothing -> writeIORef (scTail sc) mPrev -- node was tail
-- Remove from map and decrement size.
modifyIORef' (scMap sc) (Map.delete (nodeKey node))
modifyIORef' (scSize sc) (subtract 1)
-- | Evict one entry using the SIEVE sweep. Returns the evicted key and value.
evict :: (Ord k) => SieveCache k v -> IO (Maybe (k, v))
evict sc = do
mHand <- readIORef (scHand sc)
case mHand of
Nothing -> pure Nothing
Just startNode -> sweep sc startNode
-- | Sweep from the hand, skipping visited nodes (resetting their bit),
-- until finding an unvisited node to evict.
sweep :: (Ord k) => SieveCache k v -> Node k v -> IO (Maybe (k, v))
sweep sc node = do
visited <- readIORef (nodeVisited node)
if visited
then do
-- Give a second chance: reset visited bit, advance hand.
writeIORef (nodeVisited node) False
advanceHand sc node
mNext <- readIORef (scHand sc)
case mNext of
Nothing -> pure Nothing -- should not happen
Just nextNode -> sweep sc nextNode
else do
-- Evict this node.
let k = nodeKey node
v = nodeVal node
advanceHand sc node
deleteNode sc node
pure (Just (k, v))
-- | Advance the hand pointer. Moves toward the tail (next pointer).
-- If at the tail, wraps to the head.
advanceHand :: SieveCache k v -> Node k v -> IO ()
advanceHand sc node = do
mNext <- readIORef (nodeNext node)
case mNext of
Just n -> writeIORef (scHand sc) (Just n)
Nothing -> do
-- At tail, wrap to head.
mHead <- readIORef (scHead sc)
writeIORef (scHand sc) mHead
-- | Check if two nodes are the same (by IORef identity of visited).
sameNode :: Node k v -> Node k v -> Bool
sameNode a b = nodeVisited a == nodeVisited b
-- IORef equality is pointer equality.