franz-0.5.2: src/Database/Franz/Internal/Reader.hs
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
module Database.Franz.Internal.Reader where
import Control.Concurrent
import Control.Concurrent.STM
import Control.Exception
import Control.Monad
import Control.Monad.State.Strict
import Control.Monad.Trans.Maybe
import Data.Hashable (Hashable)
import Data.Serialize
import Database.Franz.Internal.Protocol
import qualified Data.ByteString.Char8 as B
import qualified Data.HashMap.Strict as HM
import qualified Data.IntMap.Strict as IM
import qualified Data.Vector.Unboxed as U
import qualified Data.Vector as V
import Data.Maybe (isJust)
import GHC.Clock (getMonotonicTime)
import System.Directory
import System.FilePath
import System.IO
import qualified System.FSNotify as FS
data StreamStatus = CaughtUp | Outdated | Gone deriving Eq
data Stream = Stream
{ streamPath :: FilePath
, vOffsets :: !(TVar (IM.IntMap Int))
, indexNames :: !(V.Vector IndexName)
, indices :: !(HM.HashMap IndexName (TVar (IM.IntMap Int)))
, vCount :: !(TVar Int)
, vStatus :: !(TVar StreamStatus)
, followThread :: !ThreadId
, indexHandle :: !Handle
, payloadHandle :: !Handle
, vActivity :: !(TVar Activity)
}
type Activity = Either Double Int
addActivity :: Stream -> STM ()
addActivity str = modifyTVar' (vActivity str) $ \case
Left _ -> Right 0
Right n -> Right (n + 1)
removeActivity :: Stream -> IO ()
removeActivity str = do
now <- getMonotonicTime
atomically $ modifyTVar' (vActivity str) $ \case
Left _ -> Left now
Right n
| n <= 0 -> Left now
| otherwise -> Right (n - 1)
closeStream :: Stream -> IO ()
closeStream Stream{..} = do
killThread followThread
hClose payloadHandle
hClose indexHandle
createStream :: FS.WatchManager -> FilePath -> IO Stream
createStream man path = do
let offsetPath = path </> "offsets"
let payloadPath = path </> "payloads"
exist <- doesFileExist offsetPath
unless exist $ throwIO $ StreamNotFound offsetPath
initialOffsetsBS <- B.readFile offsetPath
payloadHandle <- openBinaryFile payloadPath ReadMode
indexNames <- V.fromList . B.lines <$> B.readFile (path </> "indices")
let icount = 1 + V.length indexNames
let count = B.length initialOffsetsBS `div` (8 * icount)
let getI = fromIntegral <$> getInt64le
initialIndices <- either (throwIO . InternalError) pure
$ runGet (V.replicateM count $ U.replicateM icount getI) initialOffsetsBS
let initialOffsets = IM.fromList $ V.toList
$ V.zip (V.enumFromN 0 count) $ V.map U.head initialIndices
vOffsets <- newTVarIO $! initialOffsets
vStatus <- newTVarIO Outdated
vCount <- newTVarIO $! IM.size initialOffsets
_ <- FS.watchDir man path (\case
FS.Modified p _ _ | p == offsetPath -> True
FS.Removed p _ _ | p == offsetPath -> True
_ -> False)
$ \case
FS.Modified _ _ _ -> atomically $ writeTVar vStatus Outdated
FS.Removed _ _ _ -> atomically $ writeTVar vStatus Gone
_ -> pure ()
vIndices <- forM [1..V.length indexNames] $ \i -> newTVarIO
$ IM.fromList $ V.toList $ V.zip (V.map (U.! i) initialIndices) (V.enumFromN 0 count)
indexHandle <- openFile offsetPath ReadMode
let final :: Either SomeException () -> IO ()
final (Left exc) | Just ThreadKilled <- fromException exc = pure ()
final (Left exc) = logFollower [path, "terminated with", show exc]
final (Right _) = logFollower [path, "has been removed"]
-- TODO broadcast an exception if it exits?
followThread <- flip forkFinally final $ do
forM_ (IM.maxViewWithKey initialOffsets) $ \((i, _), _) ->
hSeek indexHandle AbsoluteSeek $ fromIntegral $ succ i * icount * 8
fix $ \self -> do
bs <- B.hGet indexHandle (8 * icount)
if B.null bs
then do
atomically $ modifyTVar' vStatus $ \case
Outdated -> CaughtUp
CaughtUp -> CaughtUp
Gone -> Gone
continue <- atomically $ readTVar vStatus >>= \case
CaughtUp -> retry
Outdated -> pure True
Gone -> pure False
when continue self
else do
ofs : indices <- either (throwIO . InternalError) pure $ runGet (replicateM icount getI) bs
atomically $ do
i <- readTVar vCount
modifyTVar' vOffsets $ IM.insert i ofs
forM_ (zip vIndices indices) $ \(v, x) -> modifyTVar' v $ IM.insert (fromIntegral x) i
writeTVar vCount $! i + 1
self
let indices = HM.fromList $ zip (V.toList indexNames) vIndices
vActivity <- getMonotonicTime >>= newTVarIO . Left
return Stream{ streamPath = path, ..}
where
logFollower = hPutStrLn stderr . unwords . (:) "[follower]"
type QueryResult = ((Int, Int) -- SeqNo *before* the first result, byte offset
, (Int, Int)) -- SeqNo of the last result, byte offset
isEmptyResult :: QueryResult -> Bool
isEmptyResult ((i, _), (j, _)) = i >= j
range :: Int -- ^ from
-> Int -- ^ to
-> RequestType
-> IM.IntMap Int -- ^ offsets
-> (Bool, QueryResult)
range begin end rt allOffsets = (,) ready $ case rt of
AllItems -> (firstItem, maybe firstItem fst $ IM.maxViewWithKey body)
LastItem -> case IM.maxViewWithKey body of
Nothing -> (zero, zero)
Just (ofs', r) -> case IM.maxViewWithKey (IM.union left r) of
Just (ofs, _) -> (ofs, ofs')
Nothing -> (zero, ofs')
FirstItem -> case IM.minViewWithKey body of
Nothing -> (zero, zero)
Just (ofs', _) -> case IM.maxViewWithKey left of
Just (ofs, _) -> (ofs, ofs')
Nothing -> (zero, ofs')
where
------------------------------------
-- begin end
-- v v
-- | wing | lastItem | cont |
-- | left | body | |
------------------------------------
zero = (-1, 0)
ready = isJust lastItem || not (null cont)
(wing, lastItem, cont) = IM.splitLookup end allOffsets
(left, body) = splitR begin $ maybe id (IM.insert end) lastItem wing
firstItem = maybe zero fst $ IM.maxViewWithKey left
splitR :: Int -> IM.IntMap a -> (IM.IntMap a, IM.IntMap a)
splitR i m = let (l, p, r) = IM.splitLookup i m in (l, maybe id (IM.insert i) p r)
data FranzReader = FranzReader
{ watchManager :: FS.WatchManager
, vStreams :: TVar (HM.HashMap FranzDirectory (HM.HashMap StreamName Stream))
}
data ReaperState = ReaperState
{ -- | How many streams we pruned.
prunedStreams :: !Int
-- | How many streams we saw in total.
, totalStreams :: !Int
}
reaper :: Double -- interval
-> Double -- lifetime
-> FranzReader -> IO ()
reaper int life FranzReader{..} = forever $ do
now <- getMonotonicTime
-- Check if stream's activity indicates that we should prune it.
let shouldPrune (Left t) = now - t >= life
shouldPrune _ = False
-- Try prunning stream at given filepath. Checks if stream
-- should really be pruned first.
tryPrune :: FranzDirectory -> StreamName -> STM (Maybe Stream)
tryPrune mPath sPath = runMaybeT $ do
currentAllStreams <- lift $ readTVar vStreams
currentStreams <- MaybeT . pure $ HM.lookup mPath currentAllStreams
currentStream <- MaybeT . pure $ HM.lookup sPath currentStreams
currentAct <- lift $ readTVar (vActivity currentStream)
guard $ shouldPrune currentAct
let newStreams = HM.delete sPath currentStreams
lift . writeTVar vStreams $ if HM.null newStreams
-- Stream we're deleting was the
-- last one around.
then HM.delete mPath currentAllStreams
-- Still have some other streams left for this mount path.
-- Keep those only.
else HM.insert mPath newStreams currentAllStreams
pure currentStream
-- Take a snapshots of all streams.
allStreams <- readTVarIO vStreams
-- Traverse the snapshot, looking for streams that currently seem
-- out of date. If we find an out-of-date stream, take outer lock
-- too, check again, delete it if necessary. Close stream promptly
-- after deletion.
--
-- We could first traverse the whole snapshot, gather potential
-- streams for deletion, take lock and delete all these streams from
-- the map. However, on an assumption that we normally reaps streams
-- and much lower rate than we use them and in favour of locking at
-- as small time intervals as possible, we simply traverse the
-- snapshot and if we find an out of date stream in the snapshot, we
-- take the lock on the whole stream data structure and delete the
-- stream. We also check that we aren't leaving an empty map entry
-- behind and if we are, we delete it straight away. This stops us
-- from having to traverse the whole map later to clean things up as
-- well as ensuring that we never leave empty values in the map for
-- others to see.
--
-- While doing all this, keep track of how many streams we saw and
-- how many we have closed which is used later to report statistics
-- to the user: this is in contrast to doing linear-time traversals
-- just to get counts of things we've already traversed.
stats <- flip execStateT (ReaperState 0 0) $
forM_ (HM.toList allStreams) $ \(mPath, streams) ->
forM_ (HM.toList streams) $ \(sPath, stream) -> do
modify' $ \s -> s { totalStreams = totalStreams s + 1 }
snapAct <- lift $ readTVarIO (vActivity stream)
when (shouldPrune snapAct) $ do
-- The stream indicates that it's old and should be pruned. Take
-- a lock on the outer map, check again inside a transaction and
-- amend the maps as necessary.
deletedStream'm <- lift . atomically $ tryPrune mPath sPath
forM_ deletedStream'm $ \prunedStream -> do
lift $ closeStream prunedStream
modify' $ \s -> s { prunedStreams = prunedStreams s + 1 }
when (prunedStreams stats > 0) $ hPutStrLn stderr $ unwords
[ "[reaper] closed"
, show (prunedStreams stats)
, "out of"
, show (totalStreams stats)
]
threadDelay $ floor $ int * 1e6
withFranzReader :: (FranzReader -> IO ()) -> IO ()
withFranzReader = bracket newFranzReader closeFranzReader
newFranzReader :: IO FranzReader
newFranzReader = do
vStreams <- newTVarIO HM.empty
watchManager <- FS.startManager
pure FranzReader{..}
closeFranzReader :: FranzReader -> IO ()
closeFranzReader FranzReader{..} = do
FS.stopManager watchManager
readTVarIO vStreams >>= mapM_ (mapM_ closeStream)
-- | Globally-configured path which contains franz directories.
newtype FranzPrefix = FranzPrefix { unFranzPrefix :: FilePath } deriving (Eq, Hashable)
-- | Directory which contains franz streams.
-- Values of this type serve two purposes:
--
-- * Arbitrary prefix so that clients don't have to specify the full path
newtype FranzDirectory = FranzDirectory FilePath deriving (Eq, Hashable)
getFranzDirectory :: FranzPrefix -> FranzDirectory -> FilePath
getFranzDirectory (FranzPrefix prefix) (FranzDirectory dir) = prefix </> dir
getFranzStreamPath :: FranzPrefix -> FranzDirectory -> StreamName -> FilePath
getFranzStreamPath prefix dir name
= getFranzDirectory prefix dir </> streamNameToPath name
handleQuery :: FranzPrefix
-> FranzReader
-> FranzDirectory
-> Query
-> (FranzException -> IO r)
-> (Stream -> STM (Maybe QueryResult) -> IO r) -> IO r
handleQuery prefix FranzReader{..} dir (Query name begin_ end_ rt) onError cont
= bracket (try acquire) (either mempty removeActivity) $ \case
Left err -> onError err
Right stream@Stream{..} -> cont stream $ do
readTVar vStatus >>= \case
Outdated -> retry
CaughtUp -> pure ()
Gone -> throwSTM $ StreamNotFound streamPath
allOffsets <- readTVar vOffsets
let finalOffset = case IM.maxViewWithKey allOffsets of
Just ((k, _), _) -> k + 1
Nothing -> 0
let rotate i
| i < 0 = finalOffset + i
| otherwise = i
begin <- case begin_ of
BySeqNum i -> pure $ rotate i
ByIndex index val -> case HM.lookup index indices of
Nothing -> throwSTM $ IndexNotFound index $ HM.keys indices
Just v -> do
m <- readTVar v
let (_, wing) = splitR val m
return $! maybe maxBound fst $ IM.minView wing
end <- case end_ of
BySeqNum i -> pure $ rotate i
ByIndex index val -> case HM.lookup index indices of
Nothing -> throwSTM $ IndexNotFound index $ HM.keys indices
Just v -> do
m <- readTVar v
let (body, lastItem, _) = IM.splitLookup val m
let body' = maybe id (IM.insert val) lastItem body
return $! maybe minBound fst $ IM.maxView body'
return $! case range begin end rt allOffsets of
(ready, result)
-- When BySeqNum (-1) is specified, the client expects at least one item.
-- More concretely, (ByIndex "time" t, BySeqNum (-1)) should resolve ByIndex
-- until there's an item with a timestamp later than t.
-- Perhaps there should be a special constructor for ItemRef that points to
-- the latest item at transaction, and redefine "Nth-from-end" semantics
-- where the "end" is frozen at the reception of the query.
| BySeqNum i <- end_, i < 0, isEmptyResult result -> Nothing
| ready -> Just result
| otherwise -> Nothing
where
acquire = join $ atomically $ do
allStreams <- readTVar vStreams
let !streams = maybe mempty id $ HM.lookup dir allStreams
case HM.lookup name streams of
Nothing -> pure $ bracketOnError
(createStream watchManager $ getFranzStreamPath prefix dir name)
closeStream $ \s -> atomically $ do
addActivity s
modifyTVar' vStreams $ HM.insert dir $ HM.insert name s streams
pure s
Just s -> do
addActivity s
pure (pure s)