packages feed

imm-2.1.2.0: src/main/Main.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UnicodeSyntax #-}

-- {{{ Imports

import Control.Concurrent.STM.TMChan
import Control.Exception.Safe
import qualified Core
import Database.Async as Database
import Database.Handle as Database
import Database.ReadOnly as Database
import Database.SQLite as SQLite
import Dhall (auto, input)
import GHC.Conc (numCapabilities)
import HTTP
import Imm
import Imm.Pretty
import Input
import Logger
import Output (putDocLn)
import qualified Output
import Pipes.ByteString hiding (filter, stdout)
import Safe
import qualified Streamly.Data.Fold as Fold
import qualified Streamly.Data.Stream.Prelude as Stream
import XML

-- }}}

main ∷ IO ()
main = do
  programInput ← parseOptions
  Output.withHandle $ \stdout → do
    withLogger programInput $ \logger → do
      handleAny (log logger Error . pretty . displayException) $ do
        withDatabase logger stdout programInput $ \database → do
          case inputCommand programInput of
            Subscribe u c → Core.subscribe logger stdout database u c
            Unsubscribe query → Core.unsubscribe logger database query
            Describe query → Core.describeFeeds stdout database query
            Reset query → Core.markAsUnprocessed logger database query
            Run f c → main2 logger stdout database f =<< resolveCallbacks c (inputCallbacksFile programInput)

          Database.commit logger database

withLogger ∷ ProgramInput → (Logger.Handle IO → IO ()) → IO ()
withLogger programInput f = withLogHandler $ \logger → do
  setLogLevel logger $ inputLogLevel programInput
  log logger Info $ "Input:" <+> pretty programInput
  f logger

withDatabase ∷ m ~ IO ⇒ Logger.Handle m → Output.Handle m → ProgramInput → (Database.Handle m → m ()) → m ()
withDatabase logger stdout programInput f = do
  database ← SQLite.mkHandle =<< SQLite.defaultDatabase
  let database' = if inputReadOnlyDatabase programInput then readOnly logger database else database
  log logger Info . ("Using database:" <++>) . indent 2 =<< _describeDatabase database'
  Database.withAsyncHandle logger stdout database' f

resolveCallbacks ∷ MonadIO m ⇒ CallbackMode → FilePath → m [Callback]
resolveCallbacks EnableCallbacks callbacksFile = io $ input auto $ fromString callbacksFile
resolveCallbacks _ _ = return mempty

main2
  ∷ Logger.Handle IO
  → Output.Handle IO
  → Database.Handle IO
  → FeedQuery
  → [Callback]
  → IO ()
main2 logger stdout database feedQuery callbacks = do
  newItemsCount ← newTVarIO (0 ∷ Int)
  errorsCount ← newTVarIO (0 ∷ Int)
  errorsChan ← newTMChanIO

  httpClient ← HTTP.mkHandle

  -- Feed record => (feed record, items)
  let fetcher = catchErrors logger errorsChan errorsCount $ \feedRecord → do
        log logger Debug "Fetch worker starts"

        let location@(FeedLocation uri _) = _feedLocation feedRecord
        (feedDefinition, items) ← Core.downloadFeed logger httpClient (toLazyM >=> parseXml xmlParser uri) location

        newFeedStatus ← touchFeed $ _feedStatus feedRecord

        let newFeedRecord = feedRecord{_feedDefinition = feedDefinition, _feedStatus = newFeedStatus}
        _updateFeedDefinition database newFeedRecord
        _updateFeedStatus database newFeedRecord
        return $ Just (newFeedRecord, items)

  -- (Feed record, items) => (feed record, items, item records)
  let itemRetriever = catchErrors logger errorsChan errorsCount $ \(feedRecord, items) → do
        itemRecords ← _fetchItems database $ _feedKey feedRecord
        return $ Just (feedRecord, items, itemRecords)

  -- (Feed record, items, item records) => multiple (feed record, item, item records)
  let itemSplitter (feedRecord, items, itemRecords) =
        Stream.fromList [(feedRecord, item, itemRecords) | item ← items]

  let itemMatcher (feedRecord, item, itemRecords) = do
        let itemRecord = itemRecords & filter (\r → _itemDefinition r `areSameItem` item) & headMay
        return (feedRecord, item, itemRecord)

  -- Filter for unread items
  let unreadSelector (feedRecord, item, itemRecord) = checkDate || checkStatus
       where
        checkDate = case (_feedLastUpdate $ _feedStatus feedRecord, _itemDate item) of
          (Nothing, _) → True
          (Just t0, Just t1) → t0 < t1
          _ → False
        checkStatus = itemRecord <&> _itemStatus <&> (not . _isProcessed) & fromMaybe True
  -- unprocessedElements <- listUnprocessedElements database entryKey

  let logNewItem (feedRecord, item, _) =
        putDocLn stdout $
          "New item:"
            <+> maybe "<unknown>" prettyTime (_itemDate item)
            <+> magenta (prettyName feedRecord)
            <+> "/"
            <+> yellow (prettyName item)

  -- New items events => execute callback => processed/error events
  let runner = catchErrors logger errorsChan errorsCount $ \(feedRecord, item, itemRecord) → do
        results ← forM callbacks $ \callback → io $ do
          runCallback logger callback $ CallbackMessage (_feedLocation feedRecord) (_feedDefinition feedRecord) item

        case lefts results of
          [] → return $ Just (feedRecord, item, itemRecord)
          e → do
            io $
              atomically $ do
                writeTMChan errorsChan (toException $ CallbackException feedRecord item e)
                modifyTVar' errorsCount (+ 1)
            return Nothing

  let storer (_, _, Just itemRecord) =
        Database.markItemAsProcessed logger database itemRecord
      storer (feedRecord, item, _) = do
        let itemRecord = mkFeedItemRecord (_feedKey feedRecord) item (FeedItemStatus True)
        void $ Database.insertItem logger database itemRecord

  entries ← case feedQuery of
    QueryAll → Database._fetchAllFeeds database
    QueryByUID uid → pure <$> Database._fetchFeed database uid

  let streamConfig = Stream.maxThreads numCapabilities . Stream.maxBuffer 100

  Stream.fromList entries
    & Stream.parMapM streamConfig fetcher
    & Stream.catMaybes
    & Stream.parMapM streamConfig itemRetriever
    & Stream.catMaybes
    & Stream.parConcatMap streamConfig itemSplitter
    & Stream.parMapM streamConfig itemMatcher
    & Stream.filter unreadSelector
    & Stream.trace logNewItem
    & Stream.trace (const $ atomically $ modifyTVar' newItemsCount (+ 1))
    & Stream.parMapM streamConfig runner
    & Stream.catMaybes
    & Stream.mapM storer
    & Stream.fold Fold.drain

  atomically $ closeTMChan errorsChan

  -- Error events => log
  handleErrors stdout errorsChan

  readTVarIO newItemsCount <&> pretty <&> bold <&> (<+> "new items") >>= putDocLn stdout
  readTVarIO errorsCount <&> pretty <&> bold <&> (<+> "errors") >>= putDocLn stdout

data CallbackException = CallbackException (FeedRecord Inserted) FeedItem [(Callback, Int, LByteString, LByteString)]
  deriving (Eq, Generic, Ord, Show, Typeable)

instance Exception CallbackException where
  displayException = show . pretty

instance Pretty CallbackException where
  pretty (CallbackException feedRecord item e) =
    "Callback error for"
      <+> prettyName (_feedDefinition feedRecord)
      <+> "/"
      <+> prettyName item
        <++> indent 2 prettyErrors
   where
    prettyErrors = vsep $ do
      (callback, i, stdout', stderr') ← e
      return $
        "When running:"
          <+> pretty callback
            <++> "Exit code:"
          <+> pretty i
            <++> "Stdout:"
            <++> indent 2 (pretty $ decodeUtf8 @Text stdout')
            <++> "Stderr:"
            <++> indent 2 (pretty $ decodeUtf8 @Text stderr')

catchErrors
  ∷ MonadIO m
  ⇒ MonadCatch m
  ⇒ Logger.Handle m
  → TMChan SomeException
  → TVar Int
  → (i → m (Maybe a))
  → i
  → m (Maybe a)
catchErrors logger errorsChan errorsCount f input_ = catchAny (f input_) $ \e → do
  log logger Error $ pretty (displayException e)
  io $
    atomically $ do
      writeTMChan errorsChan e
      modifyTVar' errorsCount (+ 1)
  return Nothing

handleErrors ∷ MonadIO m ⇒ Output.Handle m → TMChan SomeException → m ()
handleErrors stdout errorsChan = fix $ \recurse → do
  items ← atomically $ readTMChan errorsChan
  forM_ items $ \e → putDocLn stdout (red $ pretty $ displayException e) >> recurse

xmlParser ∷ XML.Handle IO
xmlParser = XML.mkHandle defaultXmlParser