packages feed

launchdarkly-server-sdk-4.0.0: src/LaunchDarkly/Server/Store/Internal.hs

{-# LANGUAGE NamedFieldPuns #-}

module LaunchDarkly.Server.Store.Internal
    ( isInitialized
    , getAllFlags
    , getFlag
    , getSegment
    , upsertFlag
    , upsertSegment
    , initialize
    , StoreResult
    , StoreResultM
    , PersistentDataStore (..)
    , SerializedItemDescriptor (..)
    , StoreHandle (..)
    , LaunchDarklyStoreRead (..)
    , LaunchDarklyStoreWrite (..)
    , ItemDescriptor (..)
    , makeStoreIO
    , insertFlag
    , deleteFlag
    , insertSegment
    , deleteSegment
    , initializeStore
    , createSerializedItemDescriptor
    , FeatureKey
    , FeatureNamespace
    , serializeWithPlaceholder
    , byteStringToVersionedData
    ) where

import Control.Lens (Lens', (%~), (^.))
import Control.Monad (void)
import Data.Aeson (FromJSON, ToJSON (toJSON), decode, encode)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy (fromStrict, toStrict)
import Data.Function ((&))
import Data.Generics.Product (HasField', field, getField, setField)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Maybe (isJust)
import Data.Text (Text)
import GHC.Generics (Generic)
import GHC.Natural (Natural)
import System.Clock (Clock (Monotonic), TimeSpec, getTime)

import Data.Aeson.Types (Value (Bool))
import Data.Either.Extra (eitherToMaybe)
import LaunchDarkly.AesonCompat (KeyMap, deleteKey, emptyObject, insertKey, lookupKey, mapMaybeValues, mapValues, singleton)
import LaunchDarkly.Server.Features (Flag, Segment)

-- Store result not defined in terms of StoreResultM so we dont have to export.
type StoreResultM m a = m (Either Text a)

-- |
-- The result type for every `PersistentDataStore` function. Instead of throwing
-- an exception, any store related error should return `Left`. Exceptions
-- unrelated to the store should not be caught.
type StoreResult a = IO (Either Text a)

class LaunchDarklyStoreRead store m where
    getFlagC :: store -> Text -> StoreResultM m (Maybe Flag)
    getSegmentC :: store -> Text -> StoreResultM m (Maybe Segment)
    getAllFlagsC :: store -> StoreResultM m (KeyMap Flag)
    getInitializedC :: store -> StoreResultM m Bool

class LaunchDarklyStoreWrite store m where
    storeInitializeC :: store -> KeyMap (ItemDescriptor Flag) -> KeyMap (ItemDescriptor Segment) -> StoreResultM m ()
    upsertSegmentC :: store -> Text -> ItemDescriptor (Maybe Segment) -> StoreResultM m ()
    upsertFlagC :: store -> Text -> ItemDescriptor (Maybe Flag) -> StoreResultM m ()

data StoreHandle m = StoreHandle
    { storeHandleGetFlag :: !(Text -> StoreResultM m (Maybe Flag))
    , storeHandleGetSegment :: !(Text -> StoreResultM m (Maybe Segment))
    , storeHandleAllFlags :: !(StoreResultM m (KeyMap Flag))
    , storeHandleInitialized :: !(StoreResultM m Bool)
    , storeHandleInitialize :: !(KeyMap (ItemDescriptor Flag) -> KeyMap (ItemDescriptor Segment) -> StoreResultM m ())
    , storeHandleUpsertSegment :: !(Text -> ItemDescriptor (Maybe Segment) -> StoreResultM m ())
    , storeHandleUpsertFlag :: !(Text -> ItemDescriptor (Maybe Flag) -> StoreResultM m ())
    , storeHandleExpireAll :: !(StoreResultM m ())
    }
    deriving (Generic)

instance Monad m => LaunchDarklyStoreRead (StoreHandle m) m where
    getFlagC = storeHandleGetFlag
    getSegmentC = storeHandleGetSegment
    getAllFlagsC = storeHandleAllFlags
    getInitializedC = storeHandleInitialized

instance Monad m => LaunchDarklyStoreWrite (StoreHandle m) m where
    storeInitializeC = storeHandleInitialize
    upsertSegmentC = storeHandleUpsertSegment
    upsertFlagC = storeHandleUpsertFlag

initializeStore ::
    (LaunchDarklyStoreWrite store m, Monad m) =>
    store ->
    KeyMap Flag ->
    KeyMap Segment ->
    StoreResultM m ()
initializeStore store flags segments = storeInitializeC store (makeVersioned flags) (makeVersioned segments)
  where
    makeVersioned = mapValues (\f -> ItemDescriptor f (getField @"version" f))

insertFlag :: (LaunchDarklyStoreWrite store m, Monad m) => store -> Flag -> StoreResultM m ()
insertFlag store flag = upsertFlagC store (getField @"key" flag) $ ItemDescriptor (pure flag) (getField @"version" flag)

deleteFlag :: (LaunchDarklyStoreWrite store m, Monad m) => store -> Text -> Natural -> StoreResultM m ()
deleteFlag store key version = upsertFlagC store key $ ItemDescriptor Nothing version

insertSegment :: (LaunchDarklyStoreWrite store m, Monad m) => store -> Segment -> StoreResultM m ()
insertSegment store segment = upsertSegmentC store (getField @"key" segment) $ ItemDescriptor (pure segment) (getField @"version" segment)

deleteSegment :: (LaunchDarklyStoreWrite store m, Monad m) => store -> Text -> Natural -> StoreResultM m ()
deleteSegment store key version = upsertSegmentC store key $ ItemDescriptor Nothing version

makeStoreIO :: Maybe PersistentDataStore -> TimeSpec -> IO (StoreHandle IO)
makeStoreIO backend ttl = do
    state <-
        newIORef
            State
                { allFlags = Expirable emptyObject True 0
                , features = emptyObject
                , segments = emptyObject
                , initialized = Expirable False True 0
                }
    let store = Store state backend ttl
    pure
        StoreHandle
            { storeHandleGetFlag = getFlag store
            , storeHandleGetSegment = getSegment store
            , storeHandleAllFlags = getAllFlags store
            , storeHandleInitialized = isInitialized store
            , storeHandleInitialize = initialize store
            , storeHandleUpsertSegment = upsertSegment store
            , storeHandleUpsertFlag = upsertFlag store
            , storeHandleExpireAll = expireAllItems store >> (pure $ Right ())
            }

data Expirable a = Expirable
    { value :: !a
    , forceExpire :: !Bool
    , updatedOn :: !TimeSpec
    }
    deriving (Generic)

data ItemDescriptor a = ItemDescriptor
    { value :: !a
    , version :: !Natural
    }
    deriving (Generic)

-- The CacheableItem is used to store results from a persistent store.
--
-- The type is a Maybe because it is possible that a persistent store will not
-- have a record of a flag requested. We can store that result as a Nothing and
-- prevent subsequent evaluations from reaching across the network.
type CacheableItem a = Maybe (ItemDescriptor (Maybe a))

data State = State
    { allFlags :: !(Expirable (KeyMap Flag))
    , features :: !(KeyMap (Expirable (CacheableItem Flag)))
    , segments :: !(KeyMap (Expirable (CacheableItem Segment)))
    , initialized :: !(Expirable Bool)
    }
    deriving (Generic)

-- | Represents the key for a given feature.
type FeatureKey = Text

-- | Represents a namespace such as features or segments
type FeatureNamespace = Text

-- | The interface implemented by external stores for use by the SDK.
data PersistentDataStore = PersistentDataStore
    { persistentDataStoreAllFeatures :: !(FeatureNamespace -> StoreResult (KeyMap SerializedItemDescriptor))
    -- ^ A map of all features in a given namespace including deleted.
    , persistentDataStoreGetFeature :: !(FeatureNamespace -> FeatureKey -> StoreResult (Maybe SerializedItemDescriptor))
    -- ^ Return the value of a key in a namespace.
    , persistentDataStoreUpsertFeature :: !(FeatureNamespace -> FeatureKey -> SerializedItemDescriptor -> StoreResult Bool)
    -- ^ Upsert a given feature. Versions should be compared before upsert.
    -- The result should indicate if the feature was replaced or not.
    , persistentDataStoreIsInitialized :: !(StoreResult Bool)
    -- ^ Checks if the external store has been initialized, which may
    -- have been done by another instance of the SDK.
    , persistentDataStoreInitialize :: !(KeyMap (KeyMap SerializedItemDescriptor) -> StoreResult ())
    -- ^ A map of namespaces, and items in namespaces. The entire store state
    -- should be replaced with these values.
    }

-- | A record representing an object that can be persisted in an external store.
data SerializedItemDescriptor = SerializedItemDescriptor
    { item :: !(Maybe ByteString)
    -- ^ A serialized item. If the item is deleted or does not exist this
    -- should be `Nothing`.
    , version :: !Natural
    -- ^ The version of a given item. If the item does not exist this should
    -- be zero.
    , deleted :: !Bool
    -- ^ True if this is a placeholder (tombstone) for a deleted item.
    }
    deriving (Generic, Eq, Show)

-- |
-- Generate a 'ByteString' representation of the 'SerializedItemDescriptor'.
--
-- If the 'SerializedItemDescriptor' has either a 'Nothing' value, or is marked
-- as deleted, the ByteString representation will be a tombstone marker containing the version and deletion status.
--
-- Otherwise, the internal item representation is returned.
serializeWithPlaceholder :: SerializedItemDescriptor -> ByteString
serializeWithPlaceholder SerializedItemDescriptor {item = Nothing, version = version} = tombstonePlaceholder version
serializeWithPlaceholder SerializedItemDescriptor {deleted = True, version = version} = tombstonePlaceholder version
serializeWithPlaceholder SerializedItemDescriptor {item = Just item} = item

-- Generate the tombstone placeholder ByteString representation.
tombstonePlaceholder :: Natural -> ByteString
tombstonePlaceholder version = singleton "deleted" (Bool True) & insertKey "version" (toJSON version) & encode & toStrict

-- |
-- Partially decode the provided ByteString into a 'VersionedData' struct.
--
-- This is useful for persistent stores who need to perform version comparsions
-- before persisting data.
byteStringToVersionedData :: ByteString -> Maybe VersionedData
byteStringToVersionedData byteString = decode $ fromStrict byteString

data VersionedData = VersionedData
    { version :: !Natural
    , deleted :: !Bool
    }
    deriving (Generic, ToJSON, FromJSON)

data Store = Store
    { state :: !(IORef State)
    , backend :: !(Maybe PersistentDataStore)
    , timeToLive :: !TimeSpec
    }
    deriving (Generic)

expireAllItems :: Store -> IO ()
expireAllItems store = atomicModifyIORef' (getField @"state" store) $ \state ->
    (,()) $
        state
            & field @"allFlags" %~ expire
            & field @"initialized" %~ expire
            & field @"features" %~ mapValues expire
            & field @"segments" %~ mapValues expire
  where
    expire = setField @"forceExpire" True

isExpired :: Store -> TimeSpec -> Expirable a -> Bool
isExpired store now item =
    (isJust $ getField @"backend" store)
        && ( (getField @"forceExpire" item)
                || (getField @"timeToLive" store) + (getField @"updatedOn" item) < now
           )

getMonotonicTime :: IO TimeSpec
getMonotonicTime = getTime Monotonic

initialize :: Store -> KeyMap (ItemDescriptor Flag) -> KeyMap (ItemDescriptor Segment) -> StoreResult ()
initialize store flags segments = case getField @"backend" store of
    Nothing -> do
        atomicModifyIORef' (getField @"state" store) $ \state ->
            (,()) $
                state
                    & setField @"features" (mapValues (\f -> Expirable (Just f) True 0) $ c flags)
                    & setField @"segments" (mapValues (\f -> Expirable (Just f) True 0) $ c segments)
                    & setField @"allFlags" (Expirable (mapValues (getField @"value") flags) True 0)
                    & setField @"initialized" (Expirable True False 0)
        pure $ Right ()
    Just backend ->
        (persistentDataStoreInitialize backend) serializedItemMap >>= \case
            Left err -> pure $ Left err
            Right () -> expireAllItems store >> pure (Right ())
  where
    serializedItemMap =
        emptyObject
            & insertKey "features" (mapValues createSerializedItemDescriptor $ c flags)
            & insertKey "segments" (mapValues createSerializedItemDescriptor $ c segments)
    c x = mapValues (\f -> f & field @"value" %~ Just) x

serializedToItemDescriptor :: (FromJSON a, HasField' "version" a Natural) => SerializedItemDescriptor -> Either Text (ItemDescriptor (Maybe a))
serializedToItemDescriptor serializedItem = case getField @"item" serializedItem of
    Nothing -> pure $ ItemDescriptor Nothing (getField @"version" serializedItem)
    Just buffer -> do
        let versionedData = byteStringToVersionedData buffer
         in case versionedData of
                Nothing -> Left "failed decoding into VersionedData"
                Just VersionedData {deleted = True, version = version} -> pure $ ItemDescriptor Nothing version
                Just _ ->
                    let decodeResult = decode $ fromStrict buffer
                     in case decodeResult of
                            Nothing -> Left "failed decoding into ItemDescriptor"
                            Just decoded -> pure $ ItemDescriptor (Just decoded) (getField @"version" decoded)

createSerializedItemDescriptor :: (ToJSON a) => ItemDescriptor (Maybe a) -> SerializedItemDescriptor
createSerializedItemDescriptor ItemDescriptor {value = Nothing, version} = SerializedItemDescriptor {item = Nothing, version, deleted = True}
createSerializedItemDescriptor ItemDescriptor {value = Just item, version} = SerializedItemDescriptor {item = Just $ toStrict $ encode item, version, deleted = False}

tryGetBackend :: (FromJSON a, HasField' "version" a Natural) => PersistentDataStore -> Text -> Text -> StoreResult (Maybe (ItemDescriptor (Maybe a)))
tryGetBackend backend namespace key =
    ((persistentDataStoreGetFeature backend) namespace key) >>= \case
        Left err -> pure $ Left err
        Right Nothing -> pure $ Right Nothing
        Right (Just serializedItem) -> case serializedToItemDescriptor serializedItem of
            Left err -> pure $ Left err
            Right versioned -> pure $ Right $ Just versioned

getGeneric ::
    (FromJSON a, HasField' "version" a Natural) =>
    Store ->
    Text ->
    Text ->
    Lens' State (KeyMap (Expirable (CacheableItem a))) ->
    StoreResult (Maybe a)
getGeneric store namespace key lens = do
    state <- readIORef $ getField @"state" store
    case getField @"backend" store of
        Nothing -> case lookupKey key (state ^. lens) of
            Nothing -> pure $ Right Nothing
            Just cacheItem -> pure $ Right $ (getField @"value") =<< (getField @"value" cacheItem)
        Just backend -> do
            now <- getMonotonicTime
            case lookupKey key (state ^. lens) of
                Nothing -> updateFromBackend backend now
                Just cacheItem ->
                    if isExpired store now cacheItem
                        then updateFromBackend backend now
                        else pure $ Right $ (getField @"value") =<< (getField @"value" cacheItem)
  where
    updateFromBackend backend now =
        tryGetBackend backend namespace key >>= \case
            Left err -> pure $ Left err
            Right Nothing -> do
                atomicModifyIORef' (getField @"state" store) $ \stateRef ->
                    (,()) $
                        stateRef
                            & lens
                                %~ (insertKey key (Expirable Nothing False now))
                pure $ Right Nothing
            Right (Just v) -> do
                atomicModifyIORef' (getField @"state" store) $ \stateRef ->
                    (,()) $
                        stateRef
                            & lens
                                %~ (insertKey key (Expirable (Just v) False now))
                pure $ Right $ getField @"value" v

getFlag :: Store -> Text -> StoreResult (Maybe Flag)
getFlag store key = getGeneric store "features" key (field @"features")

getSegment :: Store -> Text -> StoreResult (Maybe Segment)
getSegment store key = getGeneric store "segments" key (field @"segments")

upsertGeneric ::
    (ToJSON a) =>
    Store ->
    Text ->
    Text ->
    ItemDescriptor (Maybe a) ->
    Lens' State (KeyMap (Expirable (CacheableItem a))) ->
    (Bool -> State -> State) ->
    StoreResult ()
upsertGeneric store namespace key versioned lens action = do
    case getField @"backend" store of
        Nothing -> do
            void $ atomicModifyIORef' (getField @"state" store) $ \stateRef -> (,()) $ upsertMemory stateRef
            pure $ Right ()
        Just backend -> do
            result <- (persistentDataStoreUpsertFeature backend) namespace key (createSerializedItemDescriptor versioned)
            case result of
                Left err -> pure $ Left err
                Right updated ->
                    if not updated
                        then pure (Right ())
                        else do
                            now <- getMonotonicTime
                            void $ atomicModifyIORef' (getField @"state" store) $ \stateRef ->
                                (,()) $
                                    stateRef
                                        & lens %~ (insertKey key (Expirable (Just versioned) False now))
                                        & action True
                            pure $ Right ()
  where
    upsertMemory state = case lookupKey key (state ^. lens) of
        Nothing -> updateMemory state
        Just cacheItem -> case getField @"value" cacheItem of
            Nothing -> updateMemory state
            Just existing ->
                if (getField @"version" existing) < getField @"version" versioned
                    then updateMemory state
                    else state
    updateMemory state =
        state
            & lens %~ (insertKey key (Expirable (Just versioned) False 0))
            & action False

upsertFlag :: Store -> Text -> ItemDescriptor (Maybe Flag) -> StoreResult ()
upsertFlag store key versioned = upsertGeneric store "features" key versioned (field @"features") postAction
  where
    postAction external state =
        if external
            then state & field @"allFlags" %~ (setField @"forceExpire" True)
            else state & (field @"allFlags" . field @"value") %~ updateAllFlags
    updateAllFlags allFlags = case getField @"value" versioned of
        Nothing -> deleteKey key allFlags
        Just flag -> insertKey key flag allFlags

upsertSegment :: Store -> Text -> ItemDescriptor (Maybe Segment) -> StoreResult ()
upsertSegment store key versioned = upsertGeneric store "segments" key versioned (field @"segments") postAction
  where
    postAction _ state = state

filterAndCacheFlags :: Store -> TimeSpec -> KeyMap SerializedItemDescriptor -> IO (KeyMap Flag)
filterAndCacheFlags store now serializedMap = do
    let decoded = mapMaybeValues (eitherToMaybe . serializedToItemDescriptor) serializedMap
        allFlags = mapMaybeValues (getField @"value") decoded
    atomicModifyIORef' (getField @"state" store) $ \state ->
        (,()) $
            setField @"allFlags" (Expirable allFlags False now) $
                setField @"features" (mapValues (\x -> Expirable (Just x) False now) decoded) state
    pure allFlags

getAllFlags :: Store -> StoreResult (KeyMap Flag)
getAllFlags store = do
    state <- readIORef $ getField @"state" store
    let memoryFlags = pure $ Right $ getField @"value" $ getField @"allFlags" state
    case getField @"backend" store of
        Nothing -> memoryFlags
        Just backend -> do
            now <- getMonotonicTime
            if not (isExpired store now $ getField @"allFlags" state)
                then memoryFlags
                else do
                    result <- (persistentDataStoreAllFeatures backend) "features"
                    case result of
                        Left err -> pure (Left err)
                        Right serializedMap -> do
                            filtered <- filterAndCacheFlags store now serializedMap
                            pure (Right filtered)

isInitialized :: Store -> StoreResult Bool
isInitialized store = do
    state <- readIORef $ getField @"state" store
    let initialized = getField @"initialized" state
    if getField @"value" initialized
        then pure $ Right True
        else case getField @"backend" store of
            Nothing -> pure $ Right False
            Just backend -> do
                now <- getMonotonicTime
                if isExpired store now initialized
                    then do
                        result <- persistentDataStoreIsInitialized backend
                        case result of
                            Left err -> pure $ Left err
                            Right i -> do
                                atomicModifyIORef' (getField @"state" store) $ \stateRef ->
                                    (,()) $
                                        setField @"initialized" (Expirable i False now) stateRef
                                pure $ Right i
                    else pure $ Right False