packages feed

ipedb-0.2.0.0: app-ccdb/Main.hs

module Main (main) where

import Codec.Compression.GZip qualified as GZip
import Control.Applicative (asum)
import Control.Exception (Exception (..), SomeException)
import Control.Monad (unless)
import Control.Monad.IO.Class (MonadIO (..))
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL
import Data.Default (Default (..))
import Data.Foldable (for_)
import Data.Machine ((~>))
import Data.Machine qualified as M
import Data.Maybe (fromMaybe)
import Data.String (IsString (..))
import Data.Vector qualified as V
import Data.Version qualified as V (showVersion)
import Data.Word (Word32)
import GHC.RTS.Events (Event)
import GHC.RTS.Events.Incremental (Decoder (..), decodeEventLog)
import IpeDB.Database qualified as DB
import IpeDB.Types.CostCentre qualified as CC
import Options.Applicative qualified as O
import Paths_ipedb (version)
import System.Exit (ExitCode (..), exitFailure, exitWith)
import System.IO qualified as IO

main :: IO ()
main = do
  O.execParser ccDBOptionsParserInfo >>= \case
    IndexCommand options -> runIndex options
    QueryCommand options -> runQuery options
    ListCommand options -> runList options
    CheckCommand options -> runCheck options

--------------------------------------------------------------------------------
-- Index

runIndex :: IndexOptions -> IO ()
runIndex IndexOptions{..} = do
  withEventlogSource eventlogSource $ \eventlogSourceHandle -> do
    DB.withNewSession def $ \session ->
      DB.withNewTable @CC.CostCentreId @CC.CostCentre session def $ \table -> do
        M.runT_ $
          fromHandle eventlogEncoding eventlogSourceHandle
            ~> decodeEvent
            ~> DB.indexer CC.toCostCentre def{DB.indexerBufferSize = bufferSize} table
        let saveOptions = def{DB.flatten = fromMaybe (shouldFlatten ccDBTableFormat) ccDBFlatten}
        DB.saveTable saveOptions table ccDBOutputPath ccDBTableFormat

--------------------------------------------------------------------------------
-- Query

runQuery :: QueryOptions -> IO ()
runQuery QueryOptions{..} = do
  DB.withNewSession def $ \session ->
    DB.withTableFrom @CC.CostCentreId @CC.CostCentre session ccDBPath ccDBTableFormat $ \table -> do
      infoProvs <- fmap V.toList . DB.lookups table . V.fromList $ costCentreIds
      for_ (zip costCentreIds infoProvs) $ \(ccId, cc) ->
        putStrLn $ show ccId <> ": " <> show cc

--------------------------------------------------------------------------------
-- List

runList :: ListOptions -> IO ()
runList ListOptions{..} = do
  DB.withNewSession def $ \session ->
    DB.withTableFrom @CC.CostCentreId @CC.CostCentre session ccDBPath ccDBTableFormat $ \table -> do
      DB.withIterator def{DB.iteratorBufferSize = bufferSize} table $ \iterator ->
        M.runT_ $ iterator ~> M.traversing (\(ccId, cc) -> putStrLn $ show ccId <> ": " <> show cc)

--------------------------------------------------------------------------------
-- Check

data MissingCostCentreEntry = MissingCostCentreEntry
  { table1Path :: !FilePath
  , table2Path :: !FilePath
  , costCentreId :: !CC.CostCentreId
  }
  deriving (Show)

instance Exception MissingCostCentreEntry where
  displayException :: MissingCostCentreEntry -> String
  displayException = flip showsException ""
   where
    showsException :: MissingCostCentreEntry -> ShowS
    showsException MissingCostCentreEntry{..} =
      shows costCentreId <> showString ": missing in " <> showString table2Path

data MismatchedCostCentreEntry = MismatchedCostCentreEntry
  { table1Path :: !FilePath
  , table2Path :: !FilePath
  , costCentreId :: !CC.CostCentreId
  , infoProv1 :: !CC.CostCentre
  , infoProv2 :: !CC.CostCentre
  }
  deriving (Show)

instance Exception MismatchedCostCentreEntry where
  displayException :: MismatchedCostCentreEntry -> String
  displayException = flip showsException ""
   where
    showsException :: MismatchedCostCentreEntry -> ShowS
    showsException MismatchedCostCentreEntry{..} =
      shows costCentreId <> showString ": mismatch between " <> showString table1Path <> showString " and " <> showString table2Path

runCheck :: CheckOptions -> IO ()
runCheck CheckOptions{..} = do
  DB.withNewSession def $ \session ->
    DB.withTableFrom @CC.CostCentreId @CC.CostCentre session ccDB1Path ccDB1TableFormat $ \table1 ->
      DB.withTableFrom @CC.CostCentreId @CC.CostCentre session ccDB2Path ccDB2TableFormat $ \table2 -> do
        let runCheck' = \case
              CheckSubset -> checkSubset
              CheckEqual -> checkEqual
        exceptionCount <- runCheck' check (ccDB1Path, table1) (ccDB2Path, table2)
        unless (exceptionCount == 0) $
          exitWith (ExitFailure exceptionCount)
 where
  checkEqual ::
    (FilePath, DB.Table CC.CostCentreId CC.CostCentre) ->
    (FilePath, DB.Table CC.CostCentreId CC.CostCentre) ->
    IO Int
  checkEqual table1Info table2Info = do
    exceptionCount1 <- checkSubset' True table1Info table2Info
    exceptionCount2 <- checkSubset' False table2Info table1Info
    pure (exceptionCount1 + exceptionCount2)

  checkSubset ::
    (FilePath, DB.Table CC.CostCentreId CC.CostCentre) ->
    (FilePath, DB.Table CC.CostCentreId CC.CostCentre) ->
    IO Int
  checkSubset = checkSubset' True

  checkSubset' ::
    Bool ->
    (FilePath, DB.Table CC.CostCentreId CC.CostCentre) ->
    (FilePath, DB.Table CC.CostCentreId CC.CostCentre) ->
    IO Int
  checkSubset' checkPresentAndEqual (table1Path, table1) (table2Path, table2) =
    DB.withIterator def{DB.iteratorBufferSize = bufferSize} table1 $ \iterator1 -> do
      fmap sum . M.runT $
        iterator1
          ~> M.buffered (fromIntegral bufferSize)
          ~> M.traversing resolve
          ~> M.asParts
          ~> M.mapping assertEqual
          ~> M.asParts
          ~> M.traversing (\e -> IO.hPutStr IO.stderr (displayException e) >> pure (1 :: Int))
   where
    resolve :: [(CC.CostCentreId, CC.CostCentre)] -> IO [(CC.CostCentreId, CC.CostCentre, Maybe CC.CostCentre)]
    resolve entries1 = do
      maybeCostCentres2 <- DB.lookups table2 (V.fromList (fst <$> entries1))
      let addToEntry (costCentreId, infoProv1) maybeCostCentre2 = (costCentreId, infoProv1, maybeCostCentre2)
      pure $ zipWith addToEntry entries1 (V.toList maybeCostCentres2)

    assertEqual :: (CC.CostCentreId, CC.CostCentre, Maybe CC.CostCentre) -> Maybe SomeException
    assertEqual (costCentreId, infoProv1, maybeCostCentre2) =
      case maybeCostCentre2 of
        Nothing ->
          Just $ toException MissingCostCentreEntry{..}
        Just infoProv2
          | checkPresentAndEqual && infoProv1 /= infoProv2 ->
              Just $ toException MismatchedCostCentreEntry{..}
          | otherwise -> Nothing

--------------------------------------------------------------------------------
-- Eventlog Processing
--------------------------------------------------------------------------------

{- |
Stream a file as chunks.
-}
fromHandle :: EventlogEncoding -> IO.Handle -> M.SourceT IO BS.ByteString
fromHandle eventlogEncoding h =
  M.MachineT $ do
    chunks <- liftIO (BSL.toChunks . decodeEventlog eventlogEncoding <$> BSL.hGetContents h)
    M.runMachineT $ M.source chunks

{- |
Parse t`Event`s from a stream of `BS.ByteString` chunks.
-}
decodeEvent :: M.Process BS.ByteString Event
decodeEvent = M.construct $ loop decodeEventLog
 where
  loop :: Decoder a -> M.PlanT (M.Is BS.ByteString) a m ()
  loop Done{} = pure ()
  loop (Consume k) = M.await >>= \chunk -> loop (k chunk)
  loop (Produce a d') = M.yield a >> loop d'
  loop (Error _ err) = error err

--------------------------------------------------------------------------------
-- CCDB Options
--------------------------------------------------------------------------------

data CCDBOptions
  = IndexCommand IndexOptions
  | QueryCommand QueryOptions
  | ListCommand ListOptions
  | CheckCommand CheckOptions

ccDBOptionsParserInfo :: O.ParserInfo CCDBOptions
ccDBOptionsParserInfo =
  O.info (ccDBOptionsParser O.<**> O.helper O.<**> ccDBVersioner) . mconcat $
    [ O.progDesc "Build and query cost-centre databases."
    ]

ccDBVersioner :: O.Parser (a -> a)
ccDBVersioner =
  O.simpleVersioner $! "ccdb " <> V.showVersion version

ccDBOptionsParser :: O.Parser CCDBOptions
ccDBOptionsParser =
  O.subparser . mconcat $
    [ O.command "index" (IndexCommand <$> indexOptionsParserInfo)
    , O.command "query" (QueryCommand <$> queryOptionsParserInfo)
    , O.command "list" (ListCommand <$> listOptionsParserInfo)
    , O.command "check" (CheckCommand <$> checkOptionsParserInfo)
    ]

--------------------------------------------------------------------------------
-- Index Options

data IndexOptions = IndexOptions
  { eventlogSource :: EventlogSource
  , eventlogEncoding :: EventlogEncoding
  , ccDBOutputPath :: !FilePath
  , ccDBTableFormat :: !DB.TableFormat
  , ccDBFlatten :: !(Maybe Bool)
  , bufferSize :: !Word32
  }

indexOptionsParserInfo :: O.ParserInfo IndexOptions
indexOptionsParserInfo =
  O.info (indexOptionsParser O.<**> O.helper) . mconcat $
    [ O.progDesc "Build a cost-centre database from an eventlog."
    ]

indexOptionsParser :: O.Parser IndexOptions
indexOptionsParser =
  IndexOptions
    <$> eventlogSourceParser
    <*> eventlogEncodingParser
    <*> ccDBOutputPathParser
    <*> tableFormatParser
    <*> O.optional (ccDBFlattenParser O.<|> ccDBNoFlattenParser)
    <*> indexBufferSizeParser

shouldFlatten :: DB.TableFormat -> Bool
shouldFlatten = \case
  DB.LSMTreeSnapshotV2 -> False
  DB.LSMTreeSnapshotV2Tar -> True
  DB.LSMTreeSnapshotV2TarGz -> True

ccDBFlattenParser :: O.Parser Bool
ccDBFlattenParser =
  O.flag' True . mconcat $
    [ O.long "flatten"
    , O.help "Flatten the lsm-tree database. This improves the speed of lookups. By default, this is disabled for the 'lsm' format and enabled for the 'tar' and 'tgz' formats."
    ]

ccDBNoFlattenParser :: O.Parser Bool
ccDBNoFlattenParser =
  O.flag' False . mconcat $
    [ O.long "no-flatten"
    , O.hidden
    , O.help "Do not flatten the lsm-tree database."
    ]

--------------------------------------------------------------------------------
-- Query Options

data QueryOptions = QueryOptions
  { ccDBPath :: !FilePath
  , ccDBTableFormat :: !DB.TableFormat
  , costCentreIds :: ![CC.CostCentreId]
  }

queryOptionsParserInfo :: O.ParserInfo QueryOptions
queryOptionsParserInfo =
  O.info (queryOptionsParser O.<**> O.helper) . mconcat $
    [ O.progDesc "Query a cost-centre database with cost-centre pointers."
    ]

queryOptionsParser :: O.Parser QueryOptions
queryOptionsParser =
  QueryOptions
    <$> ccDBPathParser
    <*> tableFormatParser
    <*> O.some costCentreIdParser

--------------------------------------------------------------------------------
-- List Options

data ListOptions = ListOptions
  { ccDBPath :: !FilePath
  , ccDBTableFormat :: !DB.TableFormat
  , bufferSize :: !Word32
  }

listOptionsParserInfo :: O.ParserInfo ListOptions
listOptionsParserInfo =
  O.info (listOptionsParser O.<**> O.helper) . mconcat $
    [ O.progDesc "List all cost-centre entries in a cost-centre database."
    ]

listOptionsParser :: O.Parser ListOptions
listOptionsParser =
  ListOptions
    <$> ccDBPathParser
    <*> tableFormatParser
    <*> listBufferSizeParser

--------------------------------------------------------------------------------
-- Check Options

data CheckOptions = CheckOptions
  { check :: !Check
  , ccDB1Path :: !FilePath
  , ccDB1TableFormat :: !DB.TableFormat
  , ccDB2Path :: !FilePath
  , ccDB2TableFormat :: !DB.TableFormat
  , bufferSize :: !Word32
  }

checkOptionsParserInfo :: O.ParserInfo CheckOptions
checkOptionsParserInfo =
  O.info (checkOptionsParser O.<**> O.helper) . mconcat $
    [ O.progDesc "Compare two tables."
    ]

checkOptionsParser :: O.Parser CheckOptions
checkOptionsParser =
  CheckOptions
    <$> checkParser
    <*> ccDBPathParser
    <*> table1FormatParser
    <*> ccDBPathParser
    <*> table2FormatParser
    <*> listBufferSizeParser

data Check
  = CheckSubset
  | CheckEqual

checkParser :: O.Parser Check
checkParser =
  asum
    [ O.flag' CheckSubset . mconcat $
        [ O.long "check-subset"
        , O.help "Check that the first table is a subset of the second table."
        ]
    , O.flag' CheckEqual . mconcat $
        [ O.long "check-equal"
        , O.help "Check that the first table and the second table are equal."
        ]
    , pure CheckEqual
    ]

--------------------------------------------------------------------------------
-- Eventlog Source

data EventlogSource
  = EventlogFromStdin
  | EventlogFromFile {file :: !FilePath}

instance IsString EventlogSource where
  fromString :: String -> EventlogSource
  fromString x = if x == "-" then EventlogFromStdin else EventlogFromFile x

eventlogSourceParser :: O.Parser EventlogSource
eventlogSourceParser =
  O.strArgument . mconcat $
    [ O.metavar "EVENTLOG"
    , O.completer (O.bashCompleter "file")
    ]

withEventlogSource :: EventlogSource -> (IO.Handle -> IO a) -> IO a
withEventlogSource EventlogFromStdin action = do
  stdinIsTerminalDevice <- IO.hIsTerminalDevice IO.stdin
  if stdinIsTerminalDevice
    then do
      putStrLn "Cannot read eventlog from terminal"
      exitFailure
    else do
      IO.hSetBinaryMode IO.stdin True
      action IO.stdin
withEventlogSource EventlogFromFile{..} action =
  IO.withBinaryFile file IO.ReadMode action

--------------------------------------------------------------------------------
-- Eventlog Format

data EventlogEncoding
  = EventlogEncodingNone
  | EventlogEncodingGZip

readEventlogEncoding :: String -> Either String EventlogEncoding
readEventlogEncoding = \case
  "none" -> Right EventlogEncodingNone
  "gzip" -> Right EventlogEncodingGZip
  eventlogEncodingString -> Left $! "Unknown eventlog encoding " <> eventlogEncodingString

eventlogEncodingParser :: O.Parser EventlogEncoding
eventlogEncodingParser =
  O.option (O.eitherReader readEventlogEncoding) . mconcat $
    [ O.short 'e'
    , O.long "eventlog-encoding"
    , O.metavar "FORMAT"
    , O.completeWith ["none", "gzip"]
    , O.help "The eventlog encoding (none, gzip)."
    , O.value EventlogEncodingNone
    ]

decodeEventlog :: EventlogEncoding -> BSL.ByteString -> BSL.ByteString
decodeEventlog = \case
  EventlogEncodingNone -> id
  EventlogEncodingGZip -> GZip.decompress

--------------------------------------------------------------------------------
-- CCDB

ccDBOutputPathParser :: O.Parser FilePath
ccDBOutputPathParser =
  O.strOption . mconcat $
    [ O.short 'o'
    , O.long "output"
    , O.metavar "CCDB_PATH"
    , O.completer (O.bashCompleter "file")
    , O.help "The cost-centre database output path."
    ]

ccDBPathParser :: O.Parser FilePath
ccDBPathParser =
  O.strArgument . mconcat $
    [ O.metavar "CCDB_PATH"
    , O.completer (O.bashCompleter "file")
    ]

--------------------------------------------------------------------------------
-- TableFormat

readTableFormat :: String -> Either String DB.TableFormat
readTableFormat = \case
  "lsm" -> Right DB.LSMTreeSnapshotV2
  "tar" -> Right DB.LSMTreeSnapshotV2Tar
  "tgz" -> Right DB.LSMTreeSnapshotV2TarGz
  tableFormatString -> Left $! "Unknown table format " <> tableFormatString

tableFormatParser :: O.Parser DB.TableFormat
tableFormatParser =
  genericTableFormatParser . mconcat $
    [ O.short 't'
    , O.long "table-format"
    ]

table1FormatParser :: O.Parser DB.TableFormat
table1FormatParser =
  genericTableFormatParser $
    O.long "table1-format"

table2FormatParser :: O.Parser DB.TableFormat
table2FormatParser =
  genericTableFormatParser $
    O.long "table2-format"

genericTableFormatParser :: O.Mod O.OptionFields DB.TableFormat -> O.Parser DB.TableFormat
genericTableFormatParser mods =
  O.option (O.eitherReader readTableFormat) . mconcat $
    [ O.metavar "FORMAT"
    , O.completeWith ["lsm", "tar", "tgz"]
    , O.help "The cost-centre database format (lsm, tar, tgz)."
    , O.value def
    , mods
    ]

--------------------------------------------------------------------------------
-- Buffer Size

indexBufferSizeParser :: O.Parser Word32
indexBufferSizeParser =
  bufferSizeParser . mconcat $
    [ O.help "The size of the index buffer in number of elements."
    , O.value DB.defaultIndexerOptions.indexerBufferSize
    ]

listBufferSizeParser :: O.Parser Word32
listBufferSizeParser =
  bufferSizeParser . mconcat $
    [ O.help "The size of the lookup buffer in number of elements."
    , O.value DB.defaultIteratorOptions.iteratorBufferSize
    ]

bufferSizeParser :: O.Mod O.OptionFields Word32 -> O.Parser Word32
bufferSizeParser mods =
  O.option O.auto . mconcat $
    [ O.long "buffer-size"
    , O.metavar "N"
    , mods
    ]

--------------------------------------------------------------------------------
-- CostCentreId

costCentreIdParser :: O.Parser CC.CostCentreId
costCentreIdParser =
  O.argument O.auto . mconcat $
    [ O.metavar "CCID"
    ]