Framework for Notifications

This commit is contained in:
Gregor Kleen 2018-02-19 11:12:43 +01:00 committed by Gregor Kleen
parent ed1b3df2d9
commit 93c96ae620
10 changed files with 164 additions and 5 deletions

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ uniworx.nix
src/Handler/Assist.bak src/Handler/Assist.bak
src/Handler/Course.SnapCustom.hs src/Handler/Course.SnapCustom.hs
*.orig *.orig
/instance

View File

@ -41,6 +41,7 @@ ldap:
userDefaultFavourites: 12 userDefaultFavourites: 12
cryptoid-keyfile: "_env:CRYPTOID_KEYFILE:cryptoid_key.bf" cryptoid-keyfile: "_env:CRYPTOID_KEYFILE:cryptoid_key.bf"
instance-idfile: "_env:INSTANCEID_FILE:instance"
copyright: ©Institute for Informatics, LMU Munich copyright: ©Institute for Informatics, LMU Munich
#analytics: UA-YOURCODE #analytics: UA-YOURCODE

7
models
View File

@ -212,3 +212,10 @@ Exam
-- -- CONTINUE HERE: Include rating in this table or separately? -- -- CONTINUE HERE: Include rating in this table or separately?
-- UniqueExamUser userId examId -- UniqueExamUser userId examId
-- By default this file is used in Model.hs (which is imported by Foundation.hs) -- By default this file is used in Model.hs (which is imported by Foundation.hs)
QueuedNotification
recipient UserId
content Value
created UTCTime
lockInstance UUID Maybe
lockTime UTCTime Maybe
deriving Eq Read Show Generic Typeable

View File

@ -79,6 +79,7 @@ dependencies:
- parsec - parsec
- uuid - uuid
- exceptions - exceptions
- stm-conduit
# The library contains all of our application code. The executable # The library contains all of our application code. The executable
# defined below is just a thin wrapper. # defined below is just a thin wrapper.

View File

@ -13,6 +13,7 @@ module Application
, makeFoundation , makeFoundation
, makeLogWare , makeLogWare
-- * for DevelMain -- * for DevelMain
, foundationStoreNum
, getApplicationRepl , getApplicationRepl
, shutdownApp , shutdownApp
-- * for GHCI -- * for GHCI
@ -37,6 +38,16 @@ import Network.Wai.Middleware.RequestLogger (Destination (Logger),
import System.Log.FastLogger (defaultBufSize, newStdoutLoggerSet, import System.Log.FastLogger (defaultBufSize, newStdoutLoggerSet,
toLogStr) toLogStr)
import Foreign.Store
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as UUID
import System.Directory
import System.FilePath
import Notifications
-- Import all relevant handler modules here. -- Import all relevant handler modules here.
-- (HPack takes care to add new modules to our cabal file nowadays.) -- (HPack takes care to add new modules to our cabal file nowadays.)
import Handler.Common import Handler.Common
@ -70,6 +81,12 @@ makeFoundation appSettings@(AppSettings{..}) = do
appStaticDir appStaticDir
appCryptoIDKey <- readKeyFile appCryptoIDKeyFile appCryptoIDKey <- readKeyFile appCryptoIDKeyFile
appInstanceID <- maybe UUID.nextRandom readInstanceIDFile appInstanceIDFile
(appNotificationCtl, recvChan) <- atomically $ do
chan <- newBroadcastTMChan
recvChan <- dupTMChan chan
return (chan, recvChan)
-- We need a log function to create a connection pool. We need a connection -- We need a log function to create a connection pool. We need a connection
-- pool to create our foundation. And we need our foundation to get a -- pool to create our foundation. And we need our foundation to get a
@ -83,17 +100,35 @@ makeFoundation appSettings@(AppSettings{..}) = do
tempFoundation = mkFoundation $ error "connPool forced in tempFoundation" tempFoundation = mkFoundation $ error "connPool forced in tempFoundation"
logFunc = messageLoggerSource tempFoundation appLogger logFunc = messageLoggerSource tempFoundation appLogger
flip runLoggingT logFunc . $(logDebugS) "InstanceID" $ UUID.toText appInstanceID
-- Create the database connection pool -- Create the database connection pool
pool <- flip runLoggingT logFunc $ createPostgresqlPool pool <- flip runLoggingT logFunc $ createPostgresqlPool
(pgConnStr appDatabaseConf) (pgConnStr appDatabaseConf)
(pgPoolSize appDatabaseConf) (pgPoolSize appDatabaseConf)
-- Perform database migration using our application's logging settings. -- Perform database migration using our application's logging settings.
runLoggingT (runSqlPool (runMigration migrateAll) pool) logFunc flip runLoggingT logFunc $ runSqlPool (runMigration migrateAll) pool
void . fork . handleNotifications $ (mkFoundation pool) { appNotificationCtl = recvChan }
-- Return the foundation -- Return the foundation
return $ mkFoundation pool return $ mkFoundation pool
readInstanceIDFile :: FilePath -> IO UUID
readInstanceIDFile idFile = handle generateInstead $ readFileUtf8 idFile >>= parseText
where
parseText :: Text -> IO UUID
parseText = maybe (throwString "appInstanceIDFile does not contain an UUID") return . UUID.fromText
generateInstead :: IOException -> IO UUID
generateInstead e
| isDoesNotExistError e = do
createDirectoryIfMissing True $ takeDirectory idFile
instanceId <- UUID.nextRandom
writeFileUtf8 idFile $ UUID.toText instanceId
return instanceId
| otherwise = throw e
-- | Convert our foundation to a WAI Application by calling @toWaiAppPlain@ and -- | Convert our foundation to a WAI Application by calling @toWaiAppPlain@ and
-- applying some additional middlewares. -- applying some additional middlewares.
makeApplication :: UniWorX -> IO Application makeApplication :: UniWorX -> IO Application
@ -172,16 +207,25 @@ appMain = do
-------------------------------------------------------------- --------------------------------------------------------------
-- Functions for DevelMain.hs (a way to run the app from GHCi) -- Functions for DevelMain.hs (a way to run the app from GHCi)
-------------------------------------------------------------- --------------------------------------------------------------
foundationStoreNum :: Word32
foundationStoreNum = 2
getApplicationRepl :: IO (Int, UniWorX, Application) getApplicationRepl :: IO (Int, UniWorX, Application)
getApplicationRepl = do getApplicationRepl = do
settings <- getAppDevSettings settings <- getAppDevSettings
foundation <- makeFoundation settings foundation <- makeFoundation settings
wsettings <- getDevSettings $ warpSettings foundation wsettings <- getDevSettings $ warpSettings foundation
app1 <- makeApplication foundation app1 <- makeApplication foundation
let foundationStore = Store foundationStoreNum
deleteStore foundationStore
writeStore foundationStore foundation
return (getPort wsettings, foundation, app1) return (getPort wsettings, foundation, app1)
shutdownApp :: UniWorX -> IO () shutdownApp :: UniWorX -> IO ()
shutdownApp _ = return () shutdownApp UniWorX{..} = do
atomically $ closeTMChan appNotificationCtl
--------------------------------------------- ---------------------------------------------

View File

@ -73,8 +73,13 @@ data UniWorX = UniWorX
, appHttpManager :: Manager , appHttpManager :: Manager
, appLogger :: Logger , appLogger :: Logger
, appCryptoIDKey :: CryptoIDKey , appCryptoIDKey :: CryptoIDKey
, appInstanceID :: UUID
, appNotificationCtl :: TMChan NotificationCtl
} }
data NotificationCtl = NCtlFlush
| NCtlSend QueuedNotificationId
-- This is where we define all of the routes in our application. For a full -- This is where we define all of the routes in our application. For a full
-- explanation of the syntax, please see: -- explanation of the syntax, please see:
-- http://www.yesodweb.com/book/routing-and-handlers -- http://www.yesodweb.com/book/routing-and-handlers

View File

@ -7,6 +7,8 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
module Model module Model
( module Model ( module Model
, module Model.Types , module Model.Types
@ -17,6 +19,9 @@ import Database.Persist.Quasi
-- import Data.Time -- import Data.Time
-- import Data.ByteString -- import Data.ByteString
import Model.Types import Model.Types
import Data.UUID
import Data.Aeson (Value)
import Data.Aeson.TH (deriveJSON, defaultOptions)
-- You can define all of your database entities in the entities file. -- You can define all of your database entities in the entities file.
-- You can find more information on persistent and how to declare entities -- You can find more information on persistent and how to declare entities
@ -25,5 +30,7 @@ import Model.Types
share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate "migrateAll"] share [mkPersist sqlSettings, mkDeleteCascade sqlSettings, mkMigrate "migrateAll"]
$(persistFileWith lowerCaseSettings "models") $(persistFileWith lowerCaseSettings "models")
data Notification = SubmissionRated SubmissionId UTCTime
deriving (Eq, Ord, Show, Read)
deriveJSON defaultOptions ''Notification

View File

@ -28,8 +28,10 @@ import Text.Read (readMaybe)
-- import Data.CaseInsensitive (CI) -- import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import Data.UUID
import Yesod.Core.Dispatch (PathPiece(..)) import Yesod.Core.Dispatch (PathPiece(..))
import Data.Aeson (FromJSON(..), ToJSON(..), withText, Value(..)) import Data.Aeson (FromJSON(..), ToJSON(..), withText, Value(..), encode, eitherDecode)
import Data.Aeson.TH (deriveJSON, defaultOptions) import Data.Aeson.TH (deriveJSON, defaultOptions)
import GHC.Generics (Generic) import GHC.Generics (Generic)
@ -158,3 +160,14 @@ time `withinTerm` term = timeYear `mod` 100 == termYear `mod` 100
data StudyFieldType = FieldPrimary | FieldSecondary data StudyFieldType = FieldPrimary | FieldSecondary
deriving (Eq, Ord, Enum, Show, Read, Bounded) deriving (Eq, Ord, Enum, Show, Read, Bounded)
derivePersistField "StudyFieldType" derivePersistField "StudyFieldType"
derivePersistField "UUID"
instance PersistField Value where
toPersistValue = PersistDbSpecific . toStrict . encode
fromPersistValue (PersistDbSpecific t) = first pack . eitherDecode $ fromStrict t
fromPersistValue _ = Left "JSON values must be converted from PersistDbSpecific"
instance PersistFieldSql Value where
sqlType _ = SqlOther "json"

78
src/Notifications.hs Normal file
View File

@ -0,0 +1,78 @@
{-# LANGUAGE NoImplicitPrelude
, RecordWildCards
, TemplateHaskell
, OverloadedStrings
, FlexibleContexts
, ViewPatterns
, TypeFamilies
, DeriveGeneric
, DeriveDataTypeable
#-}
module Notifications
( handleNotifications
) where
import Import
import Data.Conduit.TMChan
import qualified Data.Conduit.List as C
import Data.Aeson (fromJSON, Result(..))
import Database.Persist.Sql (rawExecute, fromSqlKey)
data NotificationQueueException = QNInvalid QueuedNotification
| QNLocked QueuedNotificationId UUID UTCTime
| QNNonexistant QueuedNotificationId
deriving (Read, Show, Eq, Generic, Typeable)
instance Exception NotificationQueueException
handleNotifications :: UniWorX -> IO ()
handleNotifications foundation@UniWorX{..} = unsafeHandler foundation . bracket_ logStart logStop . runConduit $ sourceTMChan appNotificationCtl .| handleNotifications'
where
logStart = $(logDebugS) "Notifications" "Started"
logStop = $(logDebugS) "Notifications" "Shutting down"
handleNotifications' :: Sink NotificationCtl Handler ()
handleNotifications' = C.mapM_ $ void . handleAny ($(logErrorS) "Notifications" . tshow) . handleCmd
where
handleQueueException :: MonadLogger m => NotificationQueueException -> m ()
handleQueueException (QNInvalid qn) = $(logWarnS) "Notifications" $ "Invalid QueuedNotification: " ++ tshow qn
handleQueueException (QNNonexistant qnId) = $(logInfoS) "Notifications" $ "Saw nonexistant queue id: " ++ tshow (fromSqlKey qnId)
handleQueueException (QNLocked qnId lInstance lTime) = $(logDebugS) "Notifications" $ "Saw locked QueuedNotification: " ++ tshow (qnId, lInstance, lTime)
handleCmd NCtlFlush = void . fork . runDB . runConduit $ selectKeys [] [ Asc QueuedNotificationCreated ] .| C.mapM_ cmdSend
handleCmd (NCtlSend qnId) = handle handleQueueException $ do
qn@QueuedNotification{..} <- qnLock qnId
let
content :: Notification
Success content = fromJSON queuedNotificationContent
$(logDebugS) "Notifications" $ "Would send: " <> tshow (queuedNotificationRecipient, content) -- FIXME
runDB $ delete qnId
qnLock :: QueuedNotificationId -> Handler QueuedNotification
qnLock qnId = runDB $ do
rawExecute "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" []
qn@QueuedNotification{..} <- maybe (throwM $ QNNonexistant qnId) return =<< get qnId
maybe (return ()) throwM $ QNLocked <$> pure qnId <*> queuedNotificationLockInstance <*> queuedNotificationLockTime
unless ((fromJSON queuedNotificationContent :: Result Notification) /= mempty) . throwM $ QNInvalid qn
instanceID <- getsYesod appInstanceID
now <- liftIO getCurrentTime
updateGet qnId [ QueuedNotificationLockInstance =. Just instanceID
, QueuedNotificationLockTime =. Just now
]
cmdSend :: ( MonadHandler m
, HandlerSite m ~ UniWorX
) => QueuedNotificationId -> m ()
cmdSend (NCtlSend -> cmd) = do
chan <- getsYesod appNotificationCtl
liftIO . atomically $ writeTMChan chan cmd

View File

@ -68,6 +68,7 @@ data AppSettings = AppSettings
, appAnalytics :: Maybe Text , appAnalytics :: Maybe Text
-- ^ Google Analytics code -- ^ Google Analytics code
, appCryptoIDKeyFile :: FilePath , appCryptoIDKeyFile :: FilePath
, appInstanceIDFile :: Maybe FilePath
, appAuthDummyLogin :: Bool , appAuthDummyLogin :: Bool
-- ^ Indicate if auth dummy login should be enabled. -- ^ Indicate if auth dummy login should be enabled.
@ -102,6 +103,7 @@ instance FromJSON AppSettings where
appCopyright <- o .: "copyright" appCopyright <- o .: "copyright"
appAnalytics <- o .:? "analytics" appAnalytics <- o .:? "analytics"
appCryptoIDKeyFile <- o .: "cryptoid-keyfile" appCryptoIDKeyFile <- o .: "cryptoid-keyfile"
appInstanceIDFile <- o .:? "instance-idfile"
appAuthDummyLogin <- o .:? "auth-dummy-login" .!= defaultDev appAuthDummyLogin <- o .:? "auth-dummy-login" .!= defaultDev