launchdarkly-server-sdk 4.6.0 → 4.6.1
raw patch · 13 files changed
+345/−43 lines, 13 filesdep +regex-pcre-builtindep −pcre-lightPVP ok
version bump matches the API change (PVP)
Dependencies added: regex-pcre-builtin
Dependencies removed: pcre-light
API changes (from Hackage documentation)
Files
- CHANGELOG.md +11/−0
- launchdarkly-server-sdk.cabal +4/−3
- src/LaunchDarkly/Server/Client.hs +2/−2
- src/LaunchDarkly/Server/Client/Internal.hs +1/−1
- src/LaunchDarkly/Server/Context/Internal.hs +11/−9
- src/LaunchDarkly/Server/Events.hs +33/−19
- src/LaunchDarkly/Server/Integrations/FileData.hs +1/−1
- src/LaunchDarkly/Server/Operators.hs +15/−4
- test/Spec.hs +3/−1
- test/Spec/Client.hs +9/−0
- test/Spec/Context.hs +105/−3
- test/Spec/Events.hs +126/−0
- test/Spec/Operators.hs +24/−0
CHANGELOG.md view
@@ -2,6 +2,17 @@ All notable changes to the LaunchDarkly Haskell Server-side SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.6.1](https://github.com/launchdarkly/haskell-server-sdk/compare/4.6.0...4.6.1) (2026-11-05)+++### Bug Fixes++* Make putIfEmptyMVar atomic to avoid blocking evaluations ([#117](https://github.com/launchdarkly/haskell-server-sdk/issues/117)) ([74e0af5](https://github.com/launchdarkly/haskell-server-sdk/commit/74e0af596ef19a53cdaebc2bd784010010a33c0c))+* Match other SDKs in the secure mode hash and canonical key for multi-kind contexts with a user ([#125](https://github.com/launchdarkly/haskell-server-sdk/issues/125)) ([3b19794](https://github.com/launchdarkly/haskell-server-sdk/commit/3b197949c86f36faab190b2b23570fd233a3a52e))+* Prevent event summary start dates from retaining memory between flushes ([#124](https://github.com/launchdarkly/haskell-server-sdk/issues/124)) ([1754765](https://github.com/launchdarkly/haskell-server-sdk/commit/1754765edd4dca7fed95b9560e9ebcbe8488e8f7))+* Prevent unbounded memory growth and lost counts in event summaries ([#121](https://github.com/launchdarkly/haskell-server-sdk/issues/121)) ([7ca8b8b](https://github.com/launchdarkly/haskell-server-sdk/commit/7ca8b8b3e710cc8c8ae1b264b2e591df2e64d588))+* Remove the need for a system PCRE library to build the SDK ([#122](https://github.com/launchdarkly/haskell-server-sdk/issues/122)) ([d5f6ee4](https://github.com/launchdarkly/haskell-server-sdk/commit/d5f6ee422f8914ab46132f10bb64afe3153d0379))+ ## [4.6.0](https://github.com/launchdarkly/haskell-server-sdk/compare/4.5.1...4.6.0) (2026-05-28)
launchdarkly-server-sdk.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: launchdarkly-server-sdk-version: 4.6.0+version: 4.6.1 synopsis: Server-side SDK for integrating with LaunchDarkly description: Please see the README on GitHub at <https://github.com/launchdarkly/haskell-server-sdk#readme> category: Web@@ -117,8 +117,8 @@ , monad-logger >=0.3.36 && <0.4 , monad-loops ==0.4.* , mtl >=2.2.2 && <2.4- , pcre-light >=0.4.1.0 && <0.5 , random >=1.1 && <1.3+ , regex-pcre-builtin >=0.95.1.3.8.43 && <0.96 , scientific >=0.3.6.2 && <0.4 , semver >=0.3.4 && <0.5 , text >=1.2.4.0 && <2.1@@ -139,6 +139,7 @@ Spec.Context Spec.DataSource Spec.Evaluate+ Spec.Events Spec.Features Spec.HttpConfiguration Spec.Integrations.FileData@@ -230,8 +231,8 @@ , monad-logger >=0.3.36 && <0.4 , monad-loops ==0.4.* , mtl >=2.2.2 && <2.4- , pcre-light >=0.4.1.0 && <0.5 , random >=1.1 && <1.3+ , regex-pcre-builtin >=0.95.1.3.8.43 && <0.96 , scientific >=0.3.6.2 && <0.4 , semver >=0.3.4 && <0.5 , text >=1.2.4.0 && <2.1
src/LaunchDarkly/Server/Client.hs view
@@ -28,7 +28,7 @@ ) where import Control.Concurrent (forkFinally, killThread)-import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar) import Control.Monad (forM_, void) import Control.Monad.Fix (mfix) import Control.Monad.IO.Class (liftIO)@@ -297,7 +297,7 @@ -- be delivered as soon as possible. Flushing is asynchronous, so this method -- will return before it is complete. flushEvents :: Client -> IO ()-flushEvents client = putMVar (getField @"flush" $ getField @"events" client) ()+flushEvents client = void $ tryPutMVar (getField @"flush" $ getField @"events" client) () -- | -- Close shuts down the LaunchDarkly client. After calling this, the
src/LaunchDarkly/Server/Client/Internal.hs view
@@ -31,7 +31,7 @@ -- | The version string for this library. clientVersion :: Text-clientVersion = "4.6.0" -- x-release-please-version+clientVersion = "4.6.1" -- x-release-please-version -- | -- Client is the LaunchDarkly client. Client instances are thread-safe.
src/LaunchDarkly/Server/Context/Internal.hs view
@@ -159,7 +159,7 @@ _ -> Multi MultiContext- { fullKey = intercalate ":" $ map (\c -> canonicalizeKey (getField @"key" c) (kind c)) sorted+ { fullKey = intercalate ":" $ map (\c -> canonicalizeKeyForMulti (getField @"key" c) (kind c)) sorted , contexts = fromList $ map (\c -> ((kind c), c)) singleContexts } @@ -251,16 +251,19 @@ | otherwise = Single $ c {privateAttributes = Just attrs} withPrivateAttributes _ c = c --- Given a key and kind, generate a canonical key.------ In a multi-kind context, each individual context should theoretically--- contain the same key. To address this restriction, we generate a canonical--- key that includes the context's kind. However, if the kind is "user", we--- omit the kind inclusion to maintain backwards compatibility.+-- Given a key and kind, generate a canonical key for a standalone single-kind+-- context. The "user" kind is omitted from the canonical form to maintain+-- backwards compatibility with the pre-context user model. canonicalizeKey :: Text -> Text -> Text canonicalizeKey key "user" = key-canonicalizeKey key kind = kind <> ":" <> (replace "%" "%25" key & replace ":" "%3A")+canonicalizeKey key kind = canonicalizeKeyForMulti key kind +-- Canonical key form used for a sub-context inside a multi-kind context. The+-- kind prefix is always included -- the user-prefix-omission rule applies only+-- to standalone single-kind contexts.+canonicalizeKeyForMulti :: Text -> Text -> Text+canonicalizeKeyForMulti key kind = kind <> ":" <> (replace "%" "%25" key & replace ":" "%3A")+ unwrapSingleContext :: Context -> Maybe SingleContext unwrapSingleContext (Single c) = Just c unwrapSingleContext _ = Nothing@@ -337,7 +340,6 @@ [ ("key", toJSON $ key) , ("kind", toJSON $ if includeKind then String kind else Null) , ("anonymous", toJSON $ if anonymous then Bool True else Null)- , ("_meta", maybe Null toJSON privateAttributes) , ( "_meta" , case privateAttributes of
src/LaunchDarkly/Server/Events.hs view
@@ -1,6 +1,6 @@ module LaunchDarkly.Server.Events where -import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newEmptyMVar, newMVar, putMVar, readMVar, swapMVar, tryTakeMVar)+import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newEmptyMVar, newMVar, readMVar, swapMVar) import Control.Lens ((%~), (&)) import Control.Monad (when) import Data.Aeson (ToJSON, Value (..), object, toJSON, (.=))@@ -43,8 +43,7 @@ { events :: !(MVar [EventType]) , lastKnownServerTime :: !(MVar Integer) , flush :: !(MVar ())- , summary :: !(MVar (KeyMap FlagSummaryContext))- , startDate :: !(MVar Natural)+ , summary :: !(MVar SummaryState) , contextKeyLRU :: !(MVar (LRU Text ())) } deriving (Generic)@@ -54,8 +53,7 @@ events <- newMVar [] lastKnownServerTime <- newMVar 0 flush <- newEmptyMVar- summary <- newMVar mempty- startDate <- newEmptyMVar+ summary <- newMVar emptySummaryState contextKeyLRU <- newMVar $ newLRU $ pure $ fromIntegral $ getField @"contextKeyLRUCapacity" config pure EventState {..} @@ -75,13 +73,22 @@ makeBaseEvent :: a -> IO (BaseEvent a) makeBaseEvent child = unixMilliseconds >>= \now -> pure $ BaseEvent {creationDate = now, event = child} +-- The summary data for the current flush window.+--+-- The start date and the counters are in one value. Thus a single MVar+-- operation changes both together. The start date is Nothing only when+-- there are no counters.+data SummaryState = SummaryState !(Maybe Natural) !(KeyMap FlagSummaryContext)++emptySummaryState :: SummaryState+emptySummaryState = SummaryState Nothing mempty+ processSummary :: Config -> EventState -> IO () processSummary config state =- tryTakeMVar (getField @"startDate" state) >>= \case- Nothing -> pure ()- (Just startDate) -> do+ swapMVar (getField @"summary" state) emptySummaryState >>= \case+ SummaryState Nothing _ -> pure ()+ SummaryState (Just startDate) features -> do endDate <- unixMilliseconds- features <- swapMVar (getField @"summary" state) mempty queueEvent config state $ EventTypeSummary $ SummaryEvent {..} class EventKind a where@@ -98,9 +105,9 @@ eventKind _ = "summary" data FlagSummaryContext = FlagSummaryContext- { defaultValue :: Maybe Value- , counters :: KeyMap CounterContext- , contextKinds :: HS.HashSet Text+ { defaultValue :: !(Maybe Value)+ , counters :: !(KeyMap CounterContext)+ , contextKinds :: !(HS.HashSet Text) } deriving (Generic, Show) @@ -341,15 +348,19 @@ , value = getField @"value" event , unknown = unknown }- result = flip (insertKey $ getField @"key" event) summaryContext $ (root & field @"counters" %~ (insertKey key leaf) & field @"contextKinds" %~ (HS.union contextKinds))--putIfEmptyMVar :: MVar a -> a -> IO ()-putIfEmptyMVar mvar value = tryTakeMVar mvar >>= \case Just x -> putMVar mvar x; Nothing -> putMVar mvar value+ root' = root & field @"counters" %~ (insertKey key leaf) & field @"contextKinds" %~ (HS.union contextKinds)+ -- The map does not evaluate its values. Evaluate them here, so that+ -- repeated updates do not keep a chain of unevaluated values in memory.+ result = leaf `seq` root' `seq` insertKey (getField @"key" event) root' summaryContext runSummary :: Natural -> EventState -> EvalEvent -> Bool -> IO () runSummary now state event unknown =- putIfEmptyMVar (getField @"startDate" state) now- >> modifyMVar_ (getField @"summary" state) (\summary -> pure $ summarizeEvent summary event unknown)+ modifyMVar_ (getField @"summary" state) $ \(SummaryState startDate features) ->+ -- Callers read the time before they get the MVar. Thus a later caller+ -- can write first. Keep the earliest time as the start date. Evaluate+ -- it now, so that repeated updates do not keep a chain of+ -- unevaluated values in memory.+ pure $! SummaryState (Just $! maybe now (min now) startDate) (summarizeEvent features event unknown) processEvalEvent :: Natural -> Config -> EventState -> Context -> Bool -> Bool -> EvalEvent -> IO () processEvalEvent now config state context includeReason unknown event = do@@ -371,7 +382,10 @@ processEvalEvents :: Config -> EventState -> Context -> Bool -> [EvalEvent] -> Bool -> IO () processEvalEvents config state context includeReason events unknown =- unixMilliseconds >>= \now -> mapM_ (processEvalEvent now config state context includeReason unknown) events+ -- Do not record any event data when the SDK does not send events.+ -- Otherwise the summary data grows without limit.+ when (shouldSendEvents config) $+ unixMilliseconds >>= \now -> mapM_ (processEvalEvent now config state context includeReason unknown) events maybeIndexContext :: Natural -> Config -> Context -> EventState -> IO () maybeIndexContext now config context state = do
src/LaunchDarkly/Server/Integrations/FileData.hs view
@@ -188,7 +188,7 @@ -- "segments": { -- "segment-key-1": { -- "key": "segment-key-1",--- "includes": [ "user-key-1" ]+-- "included": [ "user-key-1" ] -- } -- } -- }
src/LaunchDarkly/Server/Operators.hs view
@@ -20,7 +20,8 @@ import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime) import Data.Time.ISO8601 (parseISO8601) import GHC.Generics (Generic)-import Text.Regex.PCRE.Light (compileM, match)+import System.IO.Unsafe (unsafePerformIO)+import Text.Regex.PCRE.ByteString (compBlank, compile, execBlank, execute) data Op = OpIn@@ -114,10 +115,20 @@ compareSemVer :: (Version -> Version -> Bool) -> Text -> Text -> Bool compareSemVer op x y = fromRight False $ liftM2 op (parseSemVer x) (parseSemVer y) +-- | Return True if the pattern matches part of the text.+--+-- A pattern that is not valid does not match. A match error, for example a+-- match limit, also does not match. The pattern compiles without UTF mode.+-- Thus, "." and case-insensitive matches operate on bytes of the UTF-8 text.+--+-- The function uses the IO functions of regex-pcre-builtin. The pure match+-- functions throw an exception when a match error occurs. matches :: Text -> Text -> Bool-matches text pattern = case compileM (encodeUtf8 pattern) [] of- Left _ -> False- Right compiled -> isJust $ match compiled (encodeUtf8 text) []+matches text pattern = unsafePerformIO $ do+ compiled <- compile compBlank execBlank (encodeUtf8 pattern)+ case compiled of+ Left _ -> pure False+ Right regex -> either (const False) isJust <$> execute regex (encodeUtf8 text) getOperation :: Op -> (Value -> Value -> Bool) getOperation op = case op of
test/Spec.hs view
@@ -2,13 +2,14 @@ import Test.HUnit (Counts (..), Test (TestLabel, TestList), runTestTT) -import Control.Monad.Cont (when)+import Control.Monad (when) import qualified Spec.Bucket import qualified Spec.Client import qualified Spec.Config import qualified Spec.Context import qualified Spec.DataSource import qualified Spec.Evaluate+import qualified Spec.Events import qualified Spec.Features import qualified Spec.HttpConfiguration import qualified Spec.Integrations.FileData@@ -32,6 +33,7 @@ , Spec.Context.allTests , Spec.DataSource.allTests , Spec.Evaluate.allTests+ , Spec.Events.allTests , Spec.Features.allTests , Spec.HttpConfiguration.allTests , Spec.Operators.allTests
test/Spec/Client.hs view
@@ -2,6 +2,7 @@ import Data.Function ((&)) import Data.Generics.Product (getField)+import System.Timeout (timeout) import Test.HUnit import LaunchDarkly.Server.Client@@ -26,6 +27,13 @@ userContext = makeContext "Message" "user" orgContext = makeContext "Message" "org" +-- Test that flushEvents returns when no event thread takes the flush signal.+testFlushEventsDoesNotBlock :: Test+testFlushEventsDoesNotBlock = TestCase $ do+ client <- makeTestClient "secret"+ result <- timeout (5 * 1000000) $ flushEvents client >> flushEvents client+ assertEqual "flushEvents returned" (Just ()) result+ makeTestClient :: Text -> IO Client makeTestClient sdkKey = do client <- makeClient $ (makeConfig sdkKey) & configSetOffline True@@ -36,4 +44,5 @@ allTests = TestList [ testSecureModeHashIsGeneratedCorrectly+ , testFlushEventsDoesNotBlock ]
test/Spec/Context.hs view
@@ -2,8 +2,8 @@ import Test.HUnit -import Control.Monad.Cont (liftIO)-import Data.Aeson (Value (..), decode, encode)+import Control.Monad.IO.Class (liftIO)+import Data.Aeson (Value (..), decode, encode, toJSON) import Data.Function ((&)) import Data.Maybe (fromJust) import qualified Data.Set as S@@ -12,7 +12,7 @@ import LaunchDarkly.AesonCompat (lookupKey) import LaunchDarkly.Server.Config (configSetAllAttributesPrivate, makeConfig) import LaunchDarkly.Server.Context-import LaunchDarkly.Server.Context.Internal (redactContext, redactContextRedactAnonymous, withoutAnonymousContexts)+import LaunchDarkly.Server.Context.Internal (getCanonicalKey, redactContext, redactContextRedactAnonymous, withoutAnonymousContexts) import qualified LaunchDarkly.Server.Reference as R confirmInvalidContext :: Context -> Text -> Assertion@@ -67,6 +67,32 @@ where user = makeContext "user-key" "user" +canonicalKeyForSingleUserOmitsKindPrefix :: Test+canonicalKeyForSingleUserOmitsKindPrefix =+ TestCase $+ assertEqual "" "user-key" $+ getCanonicalKey (makeContext "user-key" "user")++canonicalKeyForSingleNonUserIncludesKindPrefix :: Test+canonicalKeyForSingleNonUserIncludesKindPrefix =+ TestCase $+ assertEqual "" "org:org-key" $+ getCanonicalKey (makeContext "org-key" "org")++canonicalKeyEscapesPercentAndColonInKey :: Test+canonicalKeyEscapesPercentAndColonInKey =+ TestCase $+ assertEqual "" "org:a%25b%3Ac" $+ getCanonicalKey (makeContext "a%b:c" "org")++canonicalKeyForMultiIncludesUserKindPrefix :: Test+canonicalKeyForMultiIncludesUserKindPrefix =+ TestCase $+ let user = makeContext "user-key" "user"+ org = makeContext "org-key" "org"+ multi = makeMultiContext [user, org]+ in assertEqual "" "org:org-key:user:user-key" $ getCanonicalKey multi+ multiKindCanOnlyRetrieveKindAttribute :: Test multiKindCanOnlyRetrieveKindAttribute = TestCase $@@ -416,6 +442,71 @@ assertEqual "" False $ isValid $ withoutAnonymousContexts anonMc ) +canonicalKeyForMultiSortsKindsAndEscapesKeys :: Test+canonicalKeyForMultiSortsKindsAndEscapesKeys =+ TestCase $+ let user = makeContext "a:b" "user"+ org = makeContext "c%d" "org"+ in assertEqual "" "org:c%25d:user:a%3Ab" $ getCanonicalKey (makeMultiContext [user, org])++canonicalKeyForMultiWithoutUser :: Test+canonicalKeyForMultiWithoutUser =+ TestCase $+ let org = makeContext "org-key" "org"+ device = makeContext "device-key" "device"+ in assertEqual "" "device:device-key:org:org-key" $ getCanonicalKey (makeMultiContext [org, device])++canonicalKeyForParsedMultiIncludesUserKindPrefix :: Test+canonicalKeyForParsedMultiIncludesUserKindPrefix =+ TestCase $+ assertEqual "" (Just "org:org-key:user:user-key") $+ getCanonicalKey <$> decode "{\"kind\": \"multi\", \"user\": {\"key\": \"user-key\"}, \"org\": {\"key\": \"org-key\"}}"++privateAttributesContext :: Text -> Text -> Context+privateAttributesContext key kind =+ makeContext key kind+ & withAttribute "name" "Sandy"+ & withPrivateAttributes (S.fromList [R.makeReference "name", R.makeReference "/address/city"])++metaOf :: Value -> Maybe Value+metaOf (Object o) = lookupKey "_meta" o+metaOf _ = Nothing++expectedMeta :: Maybe Value+expectedMeta = Just $ Object $ fromList [("privateAttributes", Array $ fromList ["/address/city", "name"])]++singleContextSerializesPrivateAttributesInMeta :: Test+singleContextSerializesPrivateAttributesInMeta =+ TestCase $+ assertEqual "" expectedMeta $+ metaOf (toJSON $ privateAttributesContext "user-key" "user")++singleContextWithoutPrivateAttributesOmitsMeta :: Test+singleContextWithoutPrivateAttributesOmitsMeta =+ TestCase $+ assertEqual "" Nothing $+ metaOf (toJSON $ makeContext "user-key" "user")++multiContextSerializesPrivateAttributesPerContext :: Test+multiContextSerializesPrivateAttributesPerContext =+ TestCase $ do+ assertEqual "" expectedMeta $ metaOf user+ assertEqual "" Nothing $ metaOf org+ where+ multi = makeMultiContext [privateAttributesContext "user-key" "user", makeContext "org-key" "org"]+ (user, org) = case toJSON multi of+ Object o -> (fromJust $ lookupKey "user" o, fromJust $ lookupKey "org" o)+ _ -> error "expected object"++privateAttributesSurviveRoundTrip :: Test+privateAttributesSurviveRoundTrip =+ TestCase $ do+ assertEqual "" (Just single) $ decode (encode single)+ assertEqual "" (Just multi) $ decode (encode multi)+ where+ single = privateAttributesContext "user-key" "user"+ multi = makeMultiContext [single, privateAttributesContext "org-key" "org"]+ allTests :: Test allTests = TestList@@ -425,6 +516,17 @@ , multiKindRequiresUnique , multiKindRequiresSingleContextsOnly , multiKindWithSingleKindWillReturnSingleKind+ , canonicalKeyForSingleUserOmitsKindPrefix+ , canonicalKeyForSingleNonUserIncludesKindPrefix+ , canonicalKeyEscapesPercentAndColonInKey+ , canonicalKeyForMultiIncludesUserKindPrefix+ , canonicalKeyForMultiSortsKindsAndEscapesKeys+ , canonicalKeyForMultiWithoutUser+ , canonicalKeyForParsedMultiIncludesUserKindPrefix+ , singleContextSerializesPrivateAttributesInMeta+ , singleContextWithoutPrivateAttributesOmitsMeta+ , multiContextSerializesPrivateAttributesPerContext+ , privateAttributesSurviveRoundTrip , multiKindCanOnlyRetrieveKindAttribute , canRetrievalIndividualContextsFromMultiKindContext , canRetrievalIndividualContextsFromSingleKindContext
+ test/Spec/Events.hs view
@@ -0,0 +1,126 @@+module Spec.Events (allTests) where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar, takeMVar)+import Control.Monad (forM, forM_, replicateM_)+import Data.Aeson (Value (..))+import Data.Function ((&))+import Data.Generics.Product (getField)+import GHC.Natural (Natural)+import System.Timeout (timeout)+import Test.HUnit++import LaunchDarkly.AesonCompat (objectValues)+import LaunchDarkly.Server.Config (configSetSendEvents, makeConfig)+import LaunchDarkly.Server.Config.Internal (Config)+import LaunchDarkly.Server.Context (makeContext)+import LaunchDarkly.Server.Details (EvaluationReason (EvaluationReasonOff))+import LaunchDarkly.Server.Events++enabledConfig :: Config+enabledConfig = makeConfig "sdk-key"++disabledConfig :: Config+disabledConfig = makeConfig "sdk-key" & configSetSendEvents False++evalEvent :: EvalEvent+evalEvent = newUnknownFlagEvent "flag" (Bool True) EvaluationReasonOff (makeContext "user-key" "user")++summaryEvents :: EventState -> IO [SummaryEvent]+summaryEvents state = do+ events <- readMVar $ getField @"events" state+ pure [summary | EventTypeSummary summary <- events]++totalCount :: SummaryEvent -> Natural+totalCount summary =+ sum+ [ getField @"count" counter+ | flag <- objectValues $ getField @"features" summary+ , counter <- objectValues $ getField @"counters" flag+ ]++-- Test that a flush with no evaluations does not queue a summary event.+testNoSummaryWithoutEvaluations :: Test+testNoSummaryWithoutEvaluations = TestCase $ do+ state <- makeEventState enabledConfig+ processSummary enabledConfig state+ summaries <- summaryEvents state+ assertEqual "summary event count" 0 (length summaries)++-- Test that evaluations with events disabled do not keep summary data.+-- The flush uses an enabled config to show any data that is kept.+testDisabledEventsKeepNoSummary :: Test+testDisabledEventsKeepNoSummary = TestCase $ do+ state <- makeEventState disabledConfig+ replicateM_ 100 $ processEvalEvents disabledConfig state (makeContext "user-key" "user") False [evalEvent] False+ processSummary enabledConfig state+ summaries <- summaryEvents state+ assertEqual "summary event count" 0 (length summaries)++-- Test that the start date is the earliest evaluation time, not the first one recorded.+testStartDateIsEarliestEvaluation :: Test+testStartDateIsEarliestEvaluation = TestCase $ do+ state <- makeEventState enabledConfig+ runSummary 200 state evalEvent False+ runSummary 100 state evalEvent False+ runSummary 300 state evalEvent False+ processSummary enabledConfig state+ summaries <- summaryEvents state+ case summaries of+ [summary] -> do+ assertEqual "startDate" 100 (getField @"startDate" summary)+ assertEqual "total count" 3 (totalCount summary)+ _ -> assertFailure $ "expected one summary event, got " ++ show (length summaries)++-- Test that a flush resets the summary, so the next window counts only new evaluations.+testFlushStartsNewWindow :: Test+testFlushStartsNewWindow = TestCase $ do+ state <- makeEventState enabledConfig+ runSummary 100 state evalEvent False+ runSummary 100 state evalEvent False+ processSummary enabledConfig state+ runSummary 500 state evalEvent False+ processSummary enabledConfig state+ -- The events queue holds the newest event first.+ summaries <- summaryEvents state+ case summaries of+ [second, first] -> do+ assertEqual "first startDate" 100 (getField @"startDate" first)+ assertEqual "first total count" 2 (totalCount first)+ assertEqual "second startDate" 500 (getField @"startDate" second)+ assertEqual "second total count" 1 (totalCount second)+ _ -> assertFailure $ "expected two summary events, got " ++ show (length summaries)++-- Test that concurrent evaluations finish and that the summary counts every evaluation.+testConcurrentEvaluationsAreCounted :: Test+testConcurrentEvaluationsAreCounted = TestCase $ do+ state <- makeEventState enabledConfig+ let threadCount = 32 :: Int+ callsPerThread = 1000 :: Int+ result <- timeout (30 * 1000000) $ do+ done <- forM [1 .. threadCount] $ \thread -> do+ finished <- newEmptyMVar+ _ <- forkIO $ do+ forM_ [1 .. callsPerThread] $ \call ->+ runSummary (fromIntegral $ 1000 + thread + call) state evalEvent False+ putMVar finished ()+ pure finished+ mapM_ takeMVar done+ assertEqual "all threads finished" (Just ()) result+ processSummary enabledConfig state+ summaries <- summaryEvents state+ case summaries of+ [summary] -> do+ assertEqual "startDate" 1002 (getField @"startDate" summary)+ assertEqual "total count" (fromIntegral $ threadCount * callsPerThread) (totalCount summary)+ _ -> assertFailure $ "expected one summary event, got " ++ show (length summaries)++allTests :: Test+allTests =+ TestList+ [ testNoSummaryWithoutEvaluations+ , testDisabledEventsKeepNoSummary+ , testStartDateIsEarliestEvaluation+ , testFlushStartsNewWindow+ , testConcurrentEvaluationsAreCounted+ ]
test/Spec/Operators.hs view
@@ -82,6 +82,30 @@ , (OpMatches, String "hello world", String "(world|planet)", True) , (OpMatches, String "hello world", String "aloha", False) , (OpMatches, String "hello world", String "***bad rg", False)+ , (OpMatches, String "hello world", String "(hello", False)+ , (OpMatches, String "hello world", String "[a-", False)+ , -- This pattern backtracks until it reaches the match limit. The match error gives False.+ (OpMatches, String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", String "^(a+)+$", False)+ , (OpMatches, String "hello world", String "", True)+ , (OpMatches, String "", String "", True)+ , (OpMatches, String "", String "^$", True)+ , (OpMatches, String "", String "a", False)+ , (OpMatches, String "hello world", String "^hello", True)+ , (OpMatches, String "hello world", String "^world", False)+ , (OpMatches, String "hello world", String "world$", True)+ , (OpMatches, String "hello world", String "hello$", False)+ , (OpMatches, String "hello world", String "^hello world$", True)+ , (OpMatches, String "HELLO", String "hello", False)+ , (OpMatches, String "HELLO", String "(?i)hello", True)+ , (OpMatches, String "caf\233", String "caf\233", True)+ , (OpMatches, String "\26085\26412\35486", String "\26412", True)+ , (OpMatches, String "\233", String "[\233]", True)+ , (OpMatches, String "e", String "[\233]", False)+ , (OpMatches, String "\1635", String "\\d", False)+ , (OpMatches, String "a.b", String "a\\.b", True)+ , (OpMatches, String "axb", String "a\\.b", False)+ , (OpMatches, Number $ fromInteger 5, String "5", False)+ , (OpMatches, String "5", Number $ fromInteger 5, False) , -- semver operators (OpSemVerEqual, String "2.0.0", String "2.0.0", True) , (OpSemVerEqual, String "2.0", String "2.0.0", True)