packages feed

transfer-db-0.3.1.0: src/CorrectionPlan.hs

{-|
Module          : CorrectionPlan
Description     : Given the plan and the execution log, it generates a new plan and an SQL script
                  meant to correct eventual failures
License         : GPL-3
Maintainer      : mihai.giurgeanu@gmail.com
Stability       : experimental
Portability     : GHC
-}
{-# LANGUAGE FlexibleContexts, DeriveGeneric #-}
module CorrectionPlan where

import Prelude hiding (fail, log)

import Control.Logging (log, withStderrLogging)

import Control.Monad.Fail (MonadFail, fail)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Control (MonadBaseControl)
import Control.Monad.Trans.Reader (ReaderT, asks, ask)
import Control.Monad.Trans.State (StateT, gets, modify', execStateT)

import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as B1 (hPutStr)
import Data.Csv (FromRecord)
import Data.Csv.Streaming (decode, Records(Cons, Nil), HasHeader(NoHeader))
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.List (foldl')
import Data.String(IsString(fromString))
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Yaml.Aeson (encode)

import GHC.Generics (Generic)

import TransferPlan (TransferPlan(TransferPlan, plan_Source, plan_Destination), Batch(Batch), BatchItem(BatchItem))

import System.IO (Handle, hSetBinaryMode, hPutStrLn)

data CorrectionConfig = CorrectionConfig {
  correction_Plan         :: TransferPlan, -- ^ the original plan
  correction_LogHandle    :: Handle,       -- ^ the input file handle to the analyzed execution log
  correction_ScriptHandle :: Handle,       -- ^ the output file handle to the generated correction sql script
  correction_PlanHandle   :: Handle        -- ^ the output file handle to the generated correction plan
  }
  
-- | generates the contents of 2 files: a sql script and a new yaml transfer plan
-- to be run to correct the possible errors in running a previous plan, preventing duplicated
-- data or missing data.
--
-- The 2 files are meant to correct the following errors:
--
-- 1.  some items in the original transfer plan were loaded 2 ore more times (so the data is duplicated in the destination table)
-- 2.  some items were skipped in the previous run, so data is missing in the destination table.
--
-- In the first case, the table containing duplicated records is truncated (with a statement in
-- the sql script) and one or more batches are generated to load again the data in that table.
--
-- In the second case, one ore more batches are generated in the new plan to load the
-- items that were missed at the first run.
--
-- Please note, that the original plan should not contain duplicated data transfers, that is, items with
-- the same table source name and same where condition.
generateCorrections :: (MonadIO m, MonadFail m, MonadBaseControl IO m) => ReaderT CorrectionConfig m ()
generateCorrections = withStderrLogging $ do
  logh      <- asks correction_LogHandle
  liftIO $ hSetBinaryMode logh True
  logstream <- liftIO $ B.hGetContents logh
  cfg       <- ask
  let plan = correction_Plan cfg
      itemsMap' = itemsMap plan
      tablesMap'= tablesMap plan
      initialCorrectionState = CorrectionState cfg 0 Set.empty Set.empty Set.empty (Map.keysSet itemsMap') tablesMap'
      
  s <- lift $ execStateT (processLogRecords $ decode NoHeader logstream) initialCorrectionState
  
  source      <- asks (plan_Source . correction_Plan)
  destination <- asks (plan_Destination . correction_Plan)
  batches <- fromItems (correctionState_GeneratedItems s) itemsMap'
  let newPlan = TransferPlan source destination batches
  planh     <- asks correction_PlanHandle
  liftIO $ B1.hPutStr planh $ encode newPlan

-- | generate new plan batches list using original batch names and original items
fromItems :: (MonadIO m) => Set (String, String) -> Map (String, String) (String, BatchItem) -> m [Batch]
fromItems items m = Set.foldl' insertItem (return Map.empty) items  >>= return . Map.elems
  where insertItem m' i = do
          m'' <- m'
          let originalinfo = Map.lookup i m
          case originalinfo of
            Nothing -> do liftIO $ log $ fromString $ "Error: Item " ++ (show i) ++ " not found in original plan. The item will not be included in the correction plan. This is a bug, please report it!"
                          return m''
            Just (batchName, batchItem) -> let existingBatch = Map.lookup batchName m''
                                           in case existingBatch of
                                                Nothing            -> return $ Map.insert batchName (Batch batchName [batchItem]) m''
                                                Just (Batch _ bis) -> return $ Map.insert batchName (Batch batchName (bis ++ [batchItem])) m''
  
-- | keeps the state of generating the correction scripts and batches during the processing of the log records
data CorrectionState = CorrectionState {
  correctionState_Config        :: CorrectionConfig,             -- ^ the current configuration
  correctionState_Rec           :: Int,                          -- ^ the current record number
  correctionState_Truncated     :: Set String,                   -- ^ the 'Set' of tables for which truncate script have been generated (due to duplication)
  correctionState_UniqueRecords :: Set (String, String),         -- ^ the 'Set' of (source table, condition) found in processing the logs
  correctionState_GeneratedItems:: Set (String, String),         -- ^ the 'Set' of (table name, where condition) representing items already added to the new correction plan
  correctionState_SkippedItems  :: Set (String, String),         -- ^ the 'Set' of (table name, where condition) representing items not found in the transfer log
  correctionState_TableItems    :: Map String (Set (String, String)) -- ^ the 'Map' from table name to the 'Set' of (table name, where condition) indicating the items needed to reload the table
  }
    

-- | create a map from the items in the batch to the (batch name, 'BatchItem') pair;
-- for each item, it will be considered
-- only the table name and the where condition
itemsMap :: TransferPlan -> Map (String, String) (String, BatchItem)
itemsMap (TransferPlan _ _ bs) = foldl' addBatchToItemsMap Map.empty bs

-- | adds the items inside a batch to an items map in the sense of 'itemsMap'
addBatchToItemsMap :: Map (String, String) (String, BatchItem) -> Batch -> Map (String, String) (String, BatchItem)
addBatchToItemsMap m (Batch name items) = foldl' (\ m' b@(BatchItem t _ w) -> Map.insert (makeItemKey t w) (name, b) m') m items

-- | create a map from the table names to the list of (table name, where condition) pairs from
-- all 'BatchItem' elements in the original plan used to load the given table
tablesMap :: TransferPlan -> Map String (Set (String, String))
tablesMap (TransferPlan _ _ bs) = foldl' addBatchToTablesMap Map.empty bs

-- | add the items inside the batch to the 'tablesMap' result
addBatchToTablesMap :: Map String (Set (String, String)) -> Batch -> Map String (Set (String, String)) 
addBatchToTablesMap m (Batch _ items) = foldl' (\ m' (BatchItem t _ w) -> Map.insertWith Set.union t (Set.singleton $ makeItemKey t w) m') m items

-- | make an item key, that is a (table name, where condition) pair, from the table name and
-- the where condition as found in a 'BatchItem'
makeItemKey :: String -> Maybe String -> (String, String)
makeItemKey t w = (t, maybe "(-)" (\ w' -> "(" ++ w' ++ ")") w)

-- | it sequentially traverses the log records and generates the sql correction script and
-- the correction plan
processLogRecords :: (MonadIO m, MonadFail m) => Records LogRecord -> StateT CorrectionState m ()
processLogRecords (Cons r rs) = do
  n <- gets correctionState_Rec
  modify' (\ s -> s {correctionState_Rec = n + 1})
  processLogRecord r
  processLogRecords rs
  
processLogRecords (Nil err rest) = do
  n <- gets correctionState_Rec
  let restStr = map (toEnum.fromIntegral) $ B.unpack $ B.take 50 rest
  case err of
    Nothing  -> do
      liftIO $ log $ fromString $ "All log records have been processed at record " ++ (show n) ++ ", at: '" ++ restStr ++ "'"
      generatedItems <- gets correctionState_GeneratedItems
      skippedItems   <- gets correctionState_SkippedItems
      modify' (\ s -> s { correctionState_GeneratedItems = generatedItems `Set.union` skippedItems})
    Just msg -> do
      liftIO $ log $ fromString $ "Parsing log records failed with error: " ++ msg ++ " when reading record " ++ (show $ n + 1) ++ " at: '" ++ restStr ++ "'"
      liftIO $ log $ fromString "Warning: because of the error parsing the tranfer log, no skipped items are included in the generated corrections plan."
    

-- | process a single log record; it checks if the record is duplicated and, if it is, it
-- generates the sql script and the plan to correct the duplicate processing of the record
processLogRecord :: (MonadIO m) => Either String LogRecord -> StateT CorrectionState m ()
processLogRecord (Left msg) = do
  n <- gets correctionState_Rec
  liftIO $ log $ fromString $ "Error processing log record " ++ (show n) ++ ": " ++ msg
processLogRecord (Right r@(LogRecord _ t _ _ _ w)) = do
  urs <- gets correctionState_UniqueRecords
  n   <- gets correctionState_Rec
  if Set.member (t, w) urs
    then processDuplicatedRecord r
    else do modify' (\s -> s { correctionState_UniqueRecords = Set.insert (t, w) urs})
            srs <- gets correctionState_SkippedItems
            if Set.member (t, w) srs
              then modify' (\ s -> s {correctionState_SkippedItems = Set.delete (t, w) srs})
              else liftIO $ log $ fromString $ "WARN at record " ++ (show n) ++ ": item not duplicated but not found in the skipped items (" ++ (show r) ++ ")"


-- | processes a duplicated record; generates the sql script to truncate the table
-- containing duplicated records and generates the items to reimport the table, based
-- on the original plan; updates the 'CorrectionState' structure
processDuplicatedRecord :: (MonadIO m) => LogRecord -> StateT CorrectionState m ()
processDuplicatedRecord r@(LogRecord _ t _ _ _ w) = do
  liftIO $ log $ fromString $ "Item <" ++ (show r) ++ "> is duplicated"
  truncated <- gets correctionState_Truncated
  if Set.member t truncated
    then liftIO $ log $ fromString $ "Table " ++ t ++ " already reloaded in the new plan by a previous duplicated item. Skip the table this time."
    else do sqlh <- gets (correction_ScriptHandle . correctionState_Config)
            liftIO $ hPutStrLn sqlh $ "TRUNCATE TABLE " ++ t ++ ";"
            generateItemsForDuplicatedRecord r
            modify' (\ s -> s { correctionState_Truncated = Set.insert t truncated})

-- | updates the 'CorrectionState' state by generating new plan items to reload
-- data for the table where duplicated records were found
generateItemsForDuplicatedRecord :: (MonadIO m) => LogRecord -> StateT CorrectionState m ()
generateItemsForDuplicatedRecord (LogRecord _ t _ _ _ _) = do
  tableItems <- gets correctionState_TableItems
  let items' = Map.lookup t tableItems
  case items' of
    Nothing -> liftIO $ log $ fromString $ "Error: duplicated items found in the log for table " ++ t ++ " but no items for this table could be found in the original plan. This seems to be a bug. Please report it!"
    Just items -> do generatedItems <- gets correctionState_GeneratedItems
                     modify' (\s -> s {correctionState_GeneratedItems = Set.union generatedItems items})

-- | a transfer log record; a transfer log record is generated for each transferred item
-- in a batch, after the trasfer succeeded; analyzing the log we can spot the erros, that is
-- items that have not been transferred successfully or items that have been transferred twice
-- because the plan was ran multiple times
data LogRecord = LogRecord {
  log_Batch       :: !String,
  log_SourceTable :: !String,
  log_DestTable   :: !String,
  log_Count       :: !Int,
  log_Size        :: !Int,
  log_Where       :: !String
  } deriving (Show, Generic)

instance FromRecord LogRecord