module LaunchDarkly.Server.Store.Redis.Internal ( RedisStoreConfig , makeRedisStoreConfig , redisConfigSetNamespace , makeRedisStore ) where import Control.Exception (throwIO) import Control.Monad (forM_, void) import Control.Monad.Catch (Exception, Handler (..), catches) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Bifunctor (first) import Data.ByteString (ByteString) import Data.Functor ((<&>)) import Data.Generics.Product (getField) import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Typeable (Typeable) import Database.Redis ( Connection , ConnectionLostException , Redis , Reply , TxResult (..) , del , get , hget , hgetall , hset , multiExec , runRedis , set , watch ) import LaunchDarkly.AesonCompat (KeyMap, fromList, mapValues, objectKeys, toList) import LaunchDarkly.Server.Store (PersistentDataStore (..), SerializedItemDescriptor (..), StoreResult, byteStringToVersionedData, serializeWithPlaceholder) -- | Opaque type used to configure the Redis store integration. data RedisStoreConfig = RedisStoreConfig { namespace :: Text , connection :: Connection } -- | Create a default config from a given connection pool. makeRedisStoreConfig :: Connection -> RedisStoreConfig makeRedisStoreConfig con = RedisStoreConfig { namespace = "launchdarkly" , connection = con } -- | -- Configure the Redis key prefix. All keys are prefixed by default before -- being inserted into Redis. The default prefix is "launchdarkly". redisConfigSetNamespace :: Text -> RedisStoreConfig -> RedisStoreConfig redisConfigSetNamespace namespace' config = config {namespace = namespace'} -- | -- Construct a `PersistentDataStore` that can then be used during SDK -- configuration. makeRedisStore :: RedisStoreConfig -> IO PersistentDataStore makeRedisStore config = pure PersistentDataStore { persistentDataStoreUpsertFeature = redisUpsert config , persistentDataStoreGetFeature = redisGetFeature config , persistentDataStoreInitialize = redisInitialize config , persistentDataStoreIsInitialized = redisIsInitialized config , persistentDataStoreAllFeatures = redisGetAll config } newtype RedisError = RedisError Text deriving (Typeable, Show, Exception) makeKey :: RedisStoreConfig -> Text -> ByteString makeKey config key = encodeUtf8 $ T.concat [namespace config, ":", key] exceptOnReply :: (MonadIO m) => Either Reply a -> m a exceptOnReply = \case Left err -> liftIO $ throwIO $ RedisError $ T.pack $ show err Right x -> pure x run :: RedisStoreConfig -> Redis a -> StoreResult a run config action = catches (runRedis (connection config) action <&> pure) [ Handler $ \(e :: ConnectionLostException) -> pure $ Left $ T.pack $ show e , Handler $ \(RedisError err) -> pure $ Left err ] createSerializedItemDescriptor :: ByteString -> SerializedItemDescriptor createSerializedItemDescriptor byteString = SerializedItemDescriptor (Just byteString) 0 False redisInitialize :: RedisStoreConfig -> KeyMap (KeyMap SerializedItemDescriptor) -> StoreResult () redisInitialize config values = run config $ do del (map (makeKey config) $ objectKeys values) >>= void . exceptOnReply forM_ (toList values) $ \(kind, features) -> forM_ (toList features) $ \(key, feature) -> hset (makeKey config kind) (encodeUtf8 key) (serializeWithPlaceholder feature) >>= void . exceptOnReply set (makeKey config "$inited") "" >>= void . exceptOnReply redisUpsert :: RedisStoreConfig -> Text -> Text -> SerializedItemDescriptor -> StoreResult Bool redisUpsert = redisUpsertInternal (pure ()) redisUpsertInternal :: IO () -> RedisStoreConfig -> Text -> Text -> SerializedItemDescriptor -> StoreResult Bool redisUpsertInternal hook config kind key opaque = run config tryUpsert where tryUpsert = watch [space] >>= void . exceptOnReply >> hget space (encodeUtf8 key) >>= exceptOnReply >>= \x -> liftIO hook >> case x of Nothing -> doInsert (Just byteString) -> case byteStringToVersionedData byteString of Nothing -> pure True Just decodedVersion -> if getField @"version" decodedVersion >= getField @"version" opaque then pure False else doInsert space = makeKey config kind doInsert = multiExec (hset space (encodeUtf8 key) (serializeWithPlaceholder opaque)) >>= \case TxSuccess _ -> pure True TxError err -> liftIO $ throwIO $ RedisError $ T.pack $ show err TxAborted -> tryUpsert redisGetFeature :: RedisStoreConfig -> Text -> Text -> StoreResult (Maybe SerializedItemDescriptor) redisGetFeature config kind key = run config $ hget (makeKey config kind) (encodeUtf8 key) >>= exceptOnReply >>= \result -> pure $ createSerializedItemDescriptor <$> result redisIsInitialized :: RedisStoreConfig -> StoreResult Bool redisIsInitialized config = run config $ (get (makeKey config "$inited") >>= exceptOnReply) <&> isJust redisGetAll :: RedisStoreConfig -> Text -> StoreResult (KeyMap SerializedItemDescriptor) redisGetAll config kind = run config $ (hgetall (makeKey config kind) >>= exceptOnReply) <&> (mapValues createSerializedItemDescriptor . fromList . map (first decodeUtf8))