flink-statefulfun-0.4.0.0: src/Network/Flink/Internal/Stateful.hs
{-# LANGUAGE PatternSynonyms #-}
module Network.Flink.Internal.Stateful
( StatefulFunc
( insideCtx,
getCtx,
setCtx,
modifyCtx,
sendMsg,
sendMsgDelay,
sendEgressMsg
),
flinkWrapper,
createApp,
flinkServer,
flinkApi,
Address(.., Address'),
FuncType (..),
Function (..),
Serde (..),
FunctionState (..),
FlinkError (..),
FunctionTable,
Env (..),
Expiration(..),
ExpirationMode(..),
newState,
ProtoSerde (..),
JsonSerde (..),
jsonState,
protoState,
sendProtoMsg,
sendProtoMsgDelay
)
where
import Control.Monad.Except
import Control.Monad.Reader
import Control.Monad.State (MonadState, StateT (..), gets, modify)
import Data.Aeson (FromJSON, ToJSON, eitherDecode, encode)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy.Char8 as BSL
import Data.Either.Combinators (mapLeft)
import Data.Foldable (Foldable (toList))
import Data.Map (Map)
import qualified Data.Map as Map
import Data.ProtoLens (Message, defMessage, encodeMessage, messageName)
import Data.ProtoLens.Any (UnpackError)
import Data.ProtoLens.Encoding (decodeMessage)
import Data.ProtoLens.Prism
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import Data.Text (Text)
import Data.Text.Lazy (fromStrict)
import Data.Coerce ( coerce )
import qualified Data.Text.Lazy.Encoding as T
import Lens.Family2
import Lens.Micro ( traversed, filtered )
import Network.Flink.Internal.ProtoServant (Proto)
import Proto.RequestReply (FromFunction, ToFunction)
import qualified Proto.RequestReply as PR
import qualified Proto.RequestReply_Fields as PR
import Servant
import Data.Time.Clock ( NominalDiffTime )
data FuncType = FuncType Text Text deriving (Eq, Ord)
data Address = Address FuncType Text
{-# COMPLETE Address' #-}
pattern Address' :: Text -> Text -> Text -> Address
pattern Address' fnamespace fnTp fid = Address (FuncType fnamespace fnTp) fid
data FuncRes = IncompleteContext Expiration Text | UpdatedState (FunctionState PR.TypedValue) deriving Show
type FuncExec = Env -> PR.ToFunction'InvocationBatchRequest -> IO (Either FlinkError FuncRes)
--- | Table of stateful functions `(functionNamespace, functionType) -> function
type FunctionTable = Map FuncType FuncExec
data Env = Env
{ envFunctionNamespace :: Text,
envFunctionType :: Text,
envFunctionId :: Text
}
deriving (Show)
data FunctionState ctx = FunctionState
{ functionStateCtx :: ctx,
functionStateMutated :: Bool,
functionStateInvocations :: Seq PR.FromFunction'Invocation,
functionStateDelayedInvocations :: Seq PR.FromFunction'DelayedInvocation,
functionStateEgressMessages :: Seq PR.FromFunction'EgressMessage
}
deriving (Show, Functor)
newState :: a -> FunctionState a
newState initialCtx = FunctionState initialCtx False mempty mempty mempty
data ExpirationMode = NONE | AFTER_WRITE | AFTER_CALL deriving (Show, Eq)
data Expiration = Expiration {
emode :: ExpirationMode,
expireAfterMillis :: NominalDiffTime
} deriving (Show, Eq)
-- | Monad stack used for the execution of a Flink stateful function
-- Don't reference this directly in your code if possible
newtype Function s a = Function {runFunction :: ExceptT FlinkError (StateT (FunctionState s) (ReaderT Env IO)) a}
deriving (Monad, Applicative, Functor, MonadState (FunctionState s), MonadError FlinkError, MonadIO, MonadReader Env)
class Serde a where
-- | Type name
tpName :: Proxy a -> Text
-- | decodes types from strict 'ByteString's
deserializeBytes :: ByteString -> Either String a
-- | encodes types to strict 'ByteString's
serializeBytes :: a -> ByteString
newtype ProtoSerde a = ProtoSerde {getProto :: a}
deriving (Functor)
instance Message a => Serde (ProtoSerde a) where
tpName px = "type.googleapis.com/" <> messageName (unliftP px)
where unliftP :: Proxy (f a) -> Proxy a
unliftP Proxy = Proxy
deserializeBytes a = ProtoSerde <$> decodeMessage a
serializeBytes (ProtoSerde a) = encodeMessage a
type Json a = (FromJSON a, ToJSON a)
newtype JsonSerde a = JsonSerde {getJson :: a}
deriving (Functor)
instance Json a => Serde (JsonSerde a) where
tpName _ = "json/json" -- TODO: add adt name
deserializeBytes a = JsonSerde <$> eitherDecode (BSL.fromStrict a)
serializeBytes (JsonSerde a) = BSL.toStrict $ encode a
instance Serde () where
tpName _ = "ghc/Unit"
deserializeBytes _ = pure ()
serializeBytes _ = ""
instance Serde ByteString where
tpName _ = "ghc/Data.ByteString"
deserializeBytes = pure
serializeBytes = id
instance Serde BSL.ByteString where
tpName _ = "ghc/Data.ByteString.Lazy"
deserializeBytes = pure . BSL.fromStrict
serializeBytes = BSL.toStrict
-- | Used to represent all Flink stateful function capabilities.
--
-- Contexts are received from Flink and deserializeBytesd into `s`
-- all modifications to state are shipped back to Flink at the end of the
-- batch to be persisted.
--
-- Message passing is also queued up and passed back at the end of the current
-- batch.
class MonadIO m => StatefulFunc s m | m -> s where
-- Internal
setInitialCtx :: s -> m ()
-- Public
insideCtx :: (s -> a) -> m a
getCtx :: m s
setCtx :: s -> m ()
modifyCtx :: (s -> s) -> m ()
sendEgressMsg ::
Message a =>
-- | egress address (namespace, type)
(Text, Text) ->
-- | protobuf message to send (should be a Kafka or Kinesis protobuf record)
a ->
m ()
sendMsg ::
Serde a =>
-- | Function address (namespace, type, id)
Address ->
-- | message to send
a ->
m ()
sendMsgDelay ::
Serde a =>
-- | Function address (namespace, type, id)
Address ->
-- | delay before message send
NominalDiffTime ->
-- | message to send
a ->
-- | returns cancelation token with which delivery of the message could be canceled
m ()
instance StatefulFunc s (Function s) where
setInitialCtx ctx = modify (\old -> old {functionStateCtx = ctx})
insideCtx func = func <$> getCtx
getCtx = gets functionStateCtx
setCtx new = modify (\old -> old {functionStateCtx = new, functionStateMutated = True})
modifyCtx mutator = getCtx >>= setCtx . mutator
sendEgressMsg (namespace, egressType) msg = do
egresses <- gets functionStateEgressMessages
modify (\old -> old {functionStateEgressMessages = egresses Seq.:|> egressMsg})
where
wmsg = ProtoSerde msg
egressMsg :: PR.FromFunction'EgressMessage
egressMsg =
defMessage
& PR.egressNamespace .~ namespace
& PR.egressType .~ egressType
& PR.argument .~ tpValue
tpValue =
defMessage
& PR.typename .~ tpName (pure wmsg)
& PR.hasValue .~ True
& PR.value .~ serializeBytes wmsg
sendMsg (Address' namespace funcType id') msg = do
invocations <- gets functionStateInvocations
modify (\old -> old {functionStateInvocations = invocations Seq.:|> invocation})
where
target :: PR.Address
target =
defMessage
& PR.namespace .~ namespace
& PR.type' .~ funcType
& PR.id .~ id'
invocation :: PR.FromFunction'Invocation
invocation =
defMessage
& PR.target .~ target
& PR.argument .~ tpValue
tpValue =
defMessage
& PR.typename .~ tpName (pure msg)
& PR.hasValue .~ True
& PR.value .~ serializeBytes msg
sendMsgDelay (Address' namespace funcType id') delay msg = do
invocations <- gets functionStateDelayedInvocations
modify (\old -> old {functionStateDelayedInvocations = invocations Seq.:|> invocation})
where
target :: PR.Address
target =
defMessage
& PR.namespace .~ namespace
& PR.type' .~ funcType
& PR.id .~ id'
invocation :: PR.FromFunction'DelayedInvocation
invocation =
defMessage
& PR.delayInMs .~ round (delay * 1000)
& PR.target .~ target
& PR.argument .~ tpValue
tpValue =
defMessage
& PR.typename .~ tpName (pure msg)
& PR.hasValue .~ True
& PR.value .~ serializeBytes msg
-- | Convinience function to send protobuf messages
sendProtoMsg :: (StatefulFunc s m, Message a) => Address -> a -> m ()
sendProtoMsg addr = sendMsg addr . ProtoSerde
-- | Convinience function to send delayed protobuf messages
sendProtoMsgDelay :: (StatefulFunc s m, Message a) => Address -> NominalDiffTime -> a -> m ()
sendProtoMsgDelay addr delay = sendMsgDelay addr delay . ProtoSerde
data FlinkError
= MissingInvocationBatch
| ProtodeserializeBytesError String
| InvalidTypePassedError Text Text
| EmptyArgumentPassed
| StateDecodeError String
| MessageDecodeError String
| ProtoMessageDecodeError UnpackError
| NoSuchFunction (Text, Text)
deriving (Show, Eq)
-- | Convenience function for wrapping state in newtype for JSON serialization
jsonState :: Json s => Function s () -> Function (JsonSerde s) ()
jsonState = coerce
-- | Convenience function for wrapping state in newtype for Protobuf serialization
protoState :: Message s => Function s () -> Function (ProtoSerde s) ()
protoState = coerce
-- | Tries to unwrap typed value, possibly throwing FlinkError on broken input
unwrapA :: forall a m. (Serde a, MonadError FlinkError m) => PR.TypedValue -> m (Maybe a)
unwrapA arg = let
atp = arg ^. PR.typename
ctp = tpName @a Proxy
in if not (arg^. PR.hasValue) then pure Nothing else
if atp /= ctp
then throwError (InvalidTypePassedError ctp atp)
else Just <$> (liftEither . mapLeft MessageDecodeError $ deserializeBytes @a (arg ^. PR.value))
-- | Takes a function taking an arbitrary state type and converts it to take 'ByteString's.
-- This allows each function in the 'FunctionTable' to take its own individual type of state and just expose
-- a function accepting 'ByteString' to the library code.
flinkWrapper :: forall a s. (Serde a, Serde s) => s -> Expiration -> (a -> Function s ()) -> FuncExec
flinkWrapper s0 expr func env invocationBatch = runExceptT $ do
(eiRes, _) <- liftIO $ runner (newState s0)
liftEither eiRes
where
passedArgs = invocationBatch ^.. PR.invocations . traversed . PR.argument
mbInitCtx = (PR.state . traversed . filtered ((== "flink_state") . (^. PR.stateName)) . PR.stateValue)
`firstOf` invocationBatch
runWithCtx = do
case mbInitCtx of
Nothing -> return $ IncompleteContext expr (tpName @s Proxy) -- if state was not propagated to the function - shorcut to incomplete context reponse
Just tv -> do
mbCtx <- unwrapA @s tv
case mbCtx of
Nothing -> pure () -- if null state value was propagated
Just s1 -> setInitialCtx s1
mbArgs <- traverse (unwrapA @a) passedArgs
args <- traverse (maybe (throwError EmptyArgumentPassed) pure) mbArgs
mapM_ func args
gets (UpdatedState . fmap outS)
runner state = runReaderT (runStateT (runExceptT $ runFunction runWithCtx) state) env
outS fstate = defMessage
& PR.typename .~ tpName (pure fstate)
& PR.value .~ serializeBytes fstate
createFlinkResp :: FuncRes -> FromFunction
createFlinkResp (UpdatedState (FunctionState state mutated invocations delayedInvocations egresses)) =
defMessage & PR.invocationResult
.~ ( defMessage
& PR.stateMutations .~ toList stateMutations
& PR.outgoingMessages .~ toList invocations
& PR.delayedInvocations .~ toList delayedInvocations
& PR.outgoingEgresses .~ toList egresses
)
where
stateMutations :: [PR.FromFunction'PersistedValueMutation]
stateMutations =
[ defMessage
& PR.mutationType .~ PR.FromFunction'PersistedValueMutation'MODIFY
& PR.stateName .~ "flink_state"
& PR.stateValue .~ state
| mutated
]
createFlinkResp (IncompleteContext (Expiration mode expireTime) typeName) =
defMessage & PR.incompleteInvocationContext .~ (
defMessage & PR.missingValues .~ [
defMessage
& PR.stateName .~ "flink_state"
& PR.typeTypename .~ typeName
& PR.expirationSpec .~ (
defMessage
& PR.expireAfterMillis .~ round (expireTime * 1000.0)
& PR.mode .~ pbmode mode)
])
where pbmode NONE = PR.FromFunction'ExpirationSpec'NONE
pbmode AFTER_CALL = PR.FromFunction'ExpirationSpec'AFTER_INVOKE
pbmode AFTER_WRITE = PR.FromFunction'ExpirationSpec'AFTER_WRITE
type FlinkApi =
"statefun" :> ReqBody '[Proto] ToFunction :> Post '[Proto] FromFunction
flinkApi :: Proxy FlinkApi
flinkApi = Proxy
-- | Takes function table and creates a wai 'Application' to serve flink requests
createApp :: FunctionTable -> Application
createApp funcs = serve flinkApi (flinkServer funcs)
-- | Takes function table and creates a servant 'Server' to serve flink requests
flinkServer :: FunctionTable -> Server FlinkApi
flinkServer functions toFunction = do
batch <- getBatch toFunction
(function, (namespace, type', id')) <- findFunc (batch ^. PR.target)
result <- liftIO $ function (Env namespace type' id') batch
finalState <- liftEither $ mapLeft flinkErrToServant result
return $ createFlinkResp finalState
where
getBatch input = maybe (throwError $ flinkErrToServant MissingInvocationBatch) return (input ^? PR.maybe'request . _Just . PR._ToFunction'Invocation')
findFunc addr = do
res <- maybe (throwError $ flinkErrToServant $ NoSuchFunction (namespace, type')) return (Map.lookup (FuncType namespace type') functions)
return (res, address)
where
address@(namespace, type', _) = (addr ^. PR.namespace, addr ^. PR.type', addr ^. PR.id)
flinkErrToServant :: FlinkError -> ServerError
flinkErrToServant err = case err of
MissingInvocationBatch -> err400 {errBody = "Invocation batch missing"}
ProtodeserializeBytesError protoErr -> err400 {errBody = "Could not deserializeBytes protobuf " <> BSL.pack protoErr}
StateDecodeError decodeErr -> err400 {errBody = "Invalid JSON " <> BSL.pack decodeErr}
MessageDecodeError msg -> err400 {errBody = "Failed to decode message " <> BSL.pack msg}
ProtoMessageDecodeError msg -> err400 {errBody = "Failed to decode message " <> BSL.pack (show msg)}
NoSuchFunction (namespace, type') -> err400 {errBody = "No such function " <> T.encodeUtf8 (fromStrict namespace) <> T.encodeUtf8 (fromStrict type')}
InvalidTypePassedError expected passed -> err400 {errBody = "Expected type " <> T.encodeUtf8 (fromStrict expected) <> ", got " <> T.encodeUtf8 (fromStrict passed)}
EmptyArgumentPassed -> err400 {errBody = "Empty argument was passed to the function" }