feed-gipeda-0.1.0.2: src/FeedGipeda/Master.hs
{-| The master node works as follows:
1. Maintain updated local clones of a number of configured repositories
2. Ask @gipeda@ which of those repositories have commits that need to be benchmarked
3. Notify the caller for each (Repo, SHA) pair (to delegate work to slaves)
4. Call @gipeda@ when either the repository or some benchmark result file changed
If in @Watch@ mode (as opposed to @OneShot@ mode), the configuration file is
watched for updates to the actively watched repositories, as well as
fetches all currently watched repositories at a fixed interval. Existing
clones are detected and reused, so that restarting the daemon will not do
unnecessary work.
-}
module FeedGipeda.Master
( NewCommitAction
, checkForNewCommits
) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Event (Event)
import qualified Control.Concurrent.Event as Event
import Control.Concurrent.Lock (Lock)
import qualified Control.Concurrent.Lock as Lock
import Control.Logging as Logging
import Control.Monad (forM_, forever, when)
import Control.Monad.IO.Class (liftIO)
import Data.Functor
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Set (Set)
import qualified Data.Set as Set
import qualified Data.Text as Text
import Data.Time (NominalDiffTime)
import qualified Data.Time as Time
import Debug.Trace (traceShowId)
import qualified FeedGipeda.Config as Config
import qualified FeedGipeda.Gipeda as Gipeda
import FeedGipeda.GitShell (SHA)
import qualified FeedGipeda.GitShell as GitShell
import qualified FeedGipeda.Master.File as File
import qualified FeedGipeda.Master.Finalize as Finalize
import FeedGipeda.Master.RepoDiff (RepoDiff)
import qualified FeedGipeda.Master.RepoDiff as RepoDiff
import FeedGipeda.Repo (Repo)
import qualified FeedGipeda.Repo as Repo
import FeedGipeda.Types
import Reactive.Banana ((<@), (<@>))
import qualified Reactive.Banana as Banana
import qualified Reactive.Banana.Frameworks as Banana
import System.Directory (canonicalizePath,
getCurrentDirectory)
import System.FilePath (equalFilePath, takeDirectory)
import qualified System.FSNotify as FS
-- | Handler which will be called for commits @gipeda@ requests to benchmark.
type NewCommitAction
= (String -> IO ())
-- ^ Continuation to call with the benchmark results
-> String
-- ^ The @benchmarkScript@ as determined when assembling the @gipeda.yaml@
-> Repo
-- ^ The repository of the commit to benchmark
-> SHA
-- ^ The commit to benchmark
-> IO ()
notifyOnNewCommitsInBacklog :: NewCommitAction -> (Repo, Set SHA) -> IO ()
notifyOnNewCommitsInBacklog onNewCommit (repo, backlog) = do
benchmarkScript <- Gipeda.determineBenchmarkScript repo
forM_ (Set.toList backlog) $ \commit ->
onNewCommit (File.writeBenchmarkCSV repo commit) benchmarkScript repo commit
finalizeRepos :: Lock -> Paths -> Deployment -> Set Repo -> Set Repo -> IO ()
finalizeRepos lock paths deployment activeRepos repos =
forM_ (Set.toList repos) $ \repo -> Lock.with lock $ do
backlog <- File.generateBacklog repo
Finalize.regenerateAndDeploy (gipeda paths) deployment activeRepos repo
File.writeBacklog repo backlog
readConfigFileRepos :: FS.Event -> IO (Maybe (Set Repo))
readConfigFileRepos evt =
case evt of
FS.Removed _ _ -> return (Just Set.empty)
_ ->
Config.decodeFile (FS.eventPath evt) >>= either
(\err -> Logging.warn (Text.pack err) >> return Nothing)
(return . Just . Config.repos)
accumDiff
:: Banana.Event (Set Repo)
-> Banana.MomentIO (Banana.Event RepoDiff)
accumDiff repos =
fst <$> Banana.mapAccum Set.empty ((\new old -> (RepoDiff.compute old new, new)) <$> repos)
dedupCommitsAndNotifyWhenEmpty
:: IO ()
-> Banana.Event (Repo, Set SHA)
-> Banana.MomentIO (Banana.Event (Repo, Set SHA))
dedupCommitsAndNotifyWhenEmpty notify commits = do
(events, maps) <- Banana.mapAccum Map.empty (filterDuplicates <$> commits)
Banana.mapEventIO id events
where
filterDuplicates
:: (Repo, Set SHA)
-> Map Repo (Set SHA)
-> (IO (Repo, Set SHA), Map Repo (Set SHA))
filterDuplicates (repo, commits) inProgress =
let
nonDuplicates =
Set.difference commits (fromMaybe Set.empty (Map.lookup repo inProgress))
newMap =
if Set.null commits
then Map.delete repo inProgress
else Map.insert repo commits inProgress
eventAction = do
when (Map.null newMap) notify
Logging.log (Text.pack ("Backlog for " ++ Repo.uri repo
++ " contained " ++ show (Set.size commits) ++ " commits, "
++ show (Set.size nonDuplicates) ++ " unhandled."))
return (repo, nonDuplicates)
in
(eventAction, newMap)
periodically :: NominalDiffTime -> Banana.MomentIO (Banana.Event ())
periodically dt = do
(event, fire) <- Banana.newEvent
liftIO $ forkIO $ forever $ do
begin <- Time.getCurrentTime
fire ()
end <- Time.getCurrentTime
let elapsed = Time.diffUTCTime end begin
threadDelay (ceiling ((dt - elapsed) * 1000000))
return event
singleShot :: Event -> Banana.MomentIO (Banana.Event ())
singleShot evt = do
(event, fire) <- Banana.newEvent
liftIO $ forkIO $ Event.wait evt >>= fire
return event
repoOfFileEvent
:: FilePath
-> Banana.Behavior (Set Repo)
-> Banana.Event FS.Event
-> Banana.MomentIO (Banana.Event Repo)
repoOfFileEvent cwd activeRepos fileEvents =
Banana.filterJust <$> Banana.mapEventIO
id
(File.repoOfPath cwd <$> activeRepos <@> (FS.eventPath <$> fileEvents))
{-| See the module docs. This function builds up the FRP network with primitives
from @reactive-banana@. No other module should be 'tainted' by that.
-}
checkForNewCommits
:: Paths
-> Deployment
-> BuildMode
-> NewCommitAction
-> IO ()
checkForNewCommits paths deployment mode onNewCommit = FS.withManager $ \mgr -> do
cwd <- getCurrentDirectory
exit <- Event.new
start <- Event.new
let
watchFile :: FilePath -> Banana.MomentIO (Banana.Event FS.Event)
watchFile path' = do
(event, fire) <- Banana.newEvent
path <- liftIO (canonicalizePath path')
liftIO $ FS.watchDir mgr (takeDirectory path) (equalFilePath path . FS.eventPath) $ \evt -> do
Logging.debug (Text.pack ("File changed: " ++ show evt))
fire evt
return event
watchTree :: FilePath -> (FilePath -> Bool) -> Banana.MomentIO (Banana.Event FS.Event)
watchTree path predicate = do
(event, fire) <- Banana.newEvent
liftIO $ FS.watchTree mgr path (predicate . FS.eventPath) $ \evt -> do
Logging.debug (Text.pack ("File changed: " ++ show evt))
fire evt
return event
networkDescription :: Banana.MomentIO ()
networkDescription = do
-- Source: Initial tick to read in the file
initialConfig <- (FS.Added (configFile paths) undefined <$) <$> singleShot start
-- Source: Events resulting from watching the config file
configFileChanges <-
case mode of
Once -> return initialConfig
WatchForChanges _ -> Banana.unionWith const initialConfig <$> watchFile (configFile paths)
activeRepos <- Banana.filterJust <$> Banana.mapEventIO readConfigFileRepos configFileChanges
activeReposB <- Banana.stepper Set.empty activeRepos
diffsWithoutRefresh <- accumDiff activeRepos
-- Source: When in PeriodicRefresh mode, occasionally mark all repos dirty
diffs <-
case mode of
Once -> return diffsWithoutRefresh
WatchForChanges dt -> do
ticks <- periodically dt
return (Banana.unionWith const (RepoDiff.compute Set.empty <$> activeReposB <@ ticks) diffsWithoutRefresh)
-- Fetch every added ('dirty') repository, delay until fetch is complete
-- TODO: parallelize and/or get rid of forM_ somehow
fetchedRepos <-
Banana.mapEventIO
(\added -> do
forM_ (Set.toList added) $ \repo -> do
Logging.log (Text.pack ("Syncing " ++ Repo.shortName repo))
GitShell.sync repo
return added)
(RepoDiff.added <$> diffs)
-- Source: Changed benchmark CSV files
benchmarks <- watchTree cwd (File.isBenchmarkCSV cwd)
benchmarkedRepos <- repoOfFileEvent cwd activeReposB benchmarks
-- Sink: produce the appropriate backlog and deploy
let reposToFinish = Banana.unionWith Set.union fetchedRepos (Set.singleton <$> benchmarkedRepos)
finalizeLock <- liftIO Lock.new
Banana.reactimate (finalizeRepos finalizeLock paths deployment <$> activeReposB <@> reposToFinish)
-- Source: Backlog changes
backlogs <- watchTree cwd (File.isBacklog cwd)
backlogRepos <- repoOfFileEvent cwd activeReposB backlogs
-- Sink: Backlog changes kick off workers, resp. the new commit action
backlogCommits <- Banana.mapEventIO (\repo -> (,) repo <$> File.readBacklog repo) backlogRepos
let doExit = when (mode == Once) (Event.set exit)
dedupedCommits <- dedupCommitsAndNotifyWhenEmpty doExit backlogCommits
Banana.reactimate (notifyOnNewCommitsInBacklog onNewCommit <$> dedupedCommits)
network <- Banana.compile networkDescription
Banana.actuate network
Event.set start
Event.wait exit