Compare commits

...
4 Commits
Author SHA1 Message Date
steve-chavez 36eb72c2a0 bump version to 12.2.11 2025-04-21 17:08:00 -05:00
Taimoor ZaeemandSteve Chavez 38c596800a fix: regression with parameter charset=utf-8 in mediatype 2025-04-21 17:06:26 -05:00
steve-chavez a7f9181462 bump version to 12.2.10 2025-04-18 21:28:00 -05:00
Michal KleczekandSteve Chavez f68d5944e6 fix: purge JWT cache asynchronously in a separate thread
Otherwise performance was reduced unnecessarily.
2025-04-18 21:27:36 -05:00
6 changed files with 72 additions and 20 deletions
+12
View File
@@ -5,6 +5,18 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## Unreleased
## [12.2.11] - 2025-04-21
### Fixed
- #4030, Fix regression with parameter `charset=utf-8` in mediatype - @taimoorzaeem
## [12.2.10] - 2025-04-18
### Fixed
- #3889, Fix: JWT cache purging on every request decreases performance - @mkleczek
## [12.2.9] - 2025-04-16 ## [12.2.9] - 2025-04-16
### Fixed ### Fixed
+1 -1
View File
@@ -1,5 +1,5 @@
name: postgrest name: postgrest
version: 12.2.9 version: 12.2.11
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for tables, views, and functions, supporting all HTTP methods that security for tables, views, and functions, supporting all HTTP methods that security
+24 -7
View File
@@ -5,6 +5,7 @@
module PostgREST.AppState module PostgREST.AppState
( AppState ( AppState
, AuthResult(..) , AuthResult(..)
, JwtCacheState(..)
, destroy , destroy
, getConfig , getConfig
, getSchemaCache , getSchemaCache
@@ -13,7 +14,7 @@ module PostgREST.AppState
, getNextDelay , getNextDelay
, getNextListenerDelay , getNextListenerDelay
, getTime , getTime
, getJwtCache , getJwtCacheState
, getSocketREST , getSocketREST
, getSocketAdmin , getSocketAdmin
, init , init
@@ -83,6 +84,12 @@ data AuthResult = AuthResult
, authRole :: BS.ByteString , authRole :: BS.ByteString
} }
-- | JWT Cache and IO action that triggers purging old entries from the cache
data JwtCacheState = JwtCacheState
{ jwtCache :: C.Cache ByteString AuthResult
, purgeCache :: IO ()
}
data AppState = AppState data AppState = AppState
-- | Database connection pool -- | Database connection pool
{ statePool :: SQL.Pool { statePool :: SQL.Pool
@@ -107,7 +114,7 @@ data AppState = AppState
-- | Keeps track of the next delay for the listener -- | Keeps track of the next delay for the listener
, stateNextListenerDelay :: IORef Int , stateNextListenerDelay :: IORef Int
-- | JWT Cache -- | JWT Cache
, jwtCache :: C.Cache ByteString AuthResult , jwtCacheState :: JwtCacheState
-- | Network socket for REST API -- | Network socket for REST API
, stateSocketREST :: NS.Socket , stateSocketREST :: NS.Socket
-- | Network socket for the admin UI -- | Network socket for the admin UI
@@ -139,6 +146,16 @@ init conf@AppConfig{configLogLevel, configDbPoolSize} = do
initWithPool :: AppSockets -> SQL.Pool -> AppConfig -> Logger.LoggerState -> Metrics.MetricsState -> ObservationHandler -> IO AppState initWithPool :: AppSockets -> SQL.Pool -> AppConfig -> Logger.LoggerState -> Metrics.MetricsState -> ObservationHandler -> IO AppState
initWithPool (sock, adminSock) pool conf loggerState metricsState observer = do initWithPool (sock, adminSock) pool conf loggerState metricsState observer = do
cache <- C.newCache Nothing
-- purgeExpired has O(n^2) complexity
-- so we wrap it in debounce to make sure it:
-- 1) is executed asynchronously
-- 2) only a single purge operation is running at a time
debounce <- mkDebounce defaultDebounceSettings
-- debounceFreq is set to default 1 second
{ debounceAction = C.purgeExpired cache
, debounceEdge = leadingEdge
}
appState <- AppState pool appState <- AppState pool
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step <$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
@@ -151,7 +168,7 @@ initWithPool (sock, adminSock) pool conf loggerState metricsState observer = do
<*> myThreadId <*> myThreadId
<*> newIORef 0 <*> newIORef 0
<*> newIORef 1 <*> newIORef 1
<*> C.newCache Nothing <*> pure (JwtCacheState cache debounce)
<*> pure sock <*> pure sock
<*> pure adminSock <*> pure adminSock
<*> pure observer <*> pure observer
@@ -314,8 +331,8 @@ putConfig = atomicWriteIORef . stateConf
getTime :: AppState -> IO UTCTime getTime :: AppState -> IO UTCTime
getTime = stateGetTime getTime = stateGetTime
getJwtCache :: AppState -> C.Cache ByteString AuthResult getJwtCacheState :: AppState -> JwtCacheState
getJwtCache = jwtCache getJwtCacheState = jwtCacheState
getSocketREST :: AppState -> NS.Socket getSocketREST :: AppState -> NS.Socket
getSocketREST = stateSocketREST getSocketREST = stateSocketREST
@@ -439,7 +456,7 @@ retryingSchemaCacheLoad appState@AppState{stateObserver=observer, stateMainThrea
-- | Reads the in-db config and reads the config file again -- | Reads the in-db config and reads the config file again
-- | We don't retry reading the in-db config after it fails immediately, because it could have user errors. We just report the error and continue. -- | We don't retry reading the in-db config after it fails immediately, because it could have user errors. We just report the error and continue.
readInDbConfig :: Bool -> AppState -> IO () readInDbConfig :: Bool -> AppState -> IO ()
readInDbConfig startingUp appState@AppState{stateObserver=observer} = do readInDbConfig startingUp appState@AppState{stateObserver=observer, jwtCacheState=JwtCacheState{jwtCache}} = do
conf <- getConfig appState conf <- getConfig appState
pgVer <- getPgVersion appState pgVer <- getPgVersion appState
dbSettings <- dbSettings <-
@@ -476,7 +493,7 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do
if configJwtSecret conf == configJwtSecret newConf then if configJwtSecret conf == configJwtSecret newConf then
pass pass
else else
C.purge (getJwtCache appState) -- atomic O(1) operation C.purge jwtCache -- atomic O(1) operation
if startingUp then if startingUp then
pass pass
+10 -8
View File
@@ -44,8 +44,9 @@ import System.Clock (TimeSpec (..))
import System.IO.Unsafe (unsafePerformIO) import System.IO.Unsafe (unsafePerformIO)
import System.TimeIt (timeItT) import System.TimeIt (timeItT)
import PostgREST.AppState (AppState, AuthResult (..), getConfig, import PostgREST.AppState (AppState, AuthResult (..),
getJwtCache, getTime) JwtCacheState (..), getConfig,
getJwtCacheState, getTime)
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..)) import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
import PostgREST.Error (Error (..)) import PostgREST.Error (Error (..))
@@ -131,7 +132,8 @@ middleware appState app req respond = do
-- | Used to retrieve and insert JWT to JWT Cache -- | Used to retrieve and insert JWT to JWT Cache
getJWTFromCache :: AppState -> ByteString -> Int -> IO (Either Error AuthResult) -> UTCTime -> IO (Either Error AuthResult) getJWTFromCache :: AppState -> ByteString -> Int -> IO (Either Error AuthResult) -> UTCTime -> IO (Either Error AuthResult)
getJWTFromCache appState token maxLifetime parseJwt utc = do getJWTFromCache appState token maxLifetime parseJwt utc = do
checkCache <- C.lookup (getJwtCache appState) token let JwtCacheState{..} = getJwtCacheState appState
checkCache <- C.lookup jwtCache token
authResult <- maybe parseJwt (pure . Right) checkCache authResult <- maybe parseJwt (pure . Right) checkCache
case (authResult,checkCache) of case (authResult,checkCache) of
@@ -151,17 +153,17 @@ getJWTFromCache appState token maxLifetime parseJwt utc = do
let timeSpec = getTimeSpec res maxLifetime utc let timeSpec = getTimeSpec res maxLifetime utc
-- purge expired cache entries
C.purgeExpired jwtCache
-- insert new cache entry -- insert new cache entry
C.insert' jwtCache timeSpec token res C.insert' jwtCache timeSpec token res
-- Execute IO action to purge the cache
-- It is assumed this action returns immidiately
-- so that request processing is not blocked.
purgeCache
_ -> pure () _ -> pure ()
return authResult return authResult
where
jwtCache = getJwtCache appState
-- Used to extract JWT exp claim and add to JWT Cache -- Used to extract JWT exp claim and add to JWT Cache
getTimeSpec :: AuthResult -> Int -> UTCTime -> Maybe TimeSpec getTimeSpec :: AuthResult -> Int -> UTCTime -> Maybe TimeSpec
+4 -4
View File
@@ -183,14 +183,14 @@ decodeMediaType mt = decodeMediaType' $ decodeLatin1 mt
-- >>> P.parse tokenizeMediaType "" "application/vnd.pgrst.plan+text; for=\"text/xml\"; options=analyze|verbose|settings|buffers|wal" -- >>> P.parse tokenizeMediaType "" "application/vnd.pgrst.plan+text; for=\"text/xml\"; options=analyze|verbose|settings|buffers|wal"
-- Right ("application","vnd.pgrst.plan+text",[("for","text/xml"),("options","analyze|verbose|settings|buffers|wal")]) -- Right ("application","vnd.pgrst.plan+text",[("for","text/xml"),("options","analyze|verbose|settings|buffers|wal")])
-- TODO: Improve mediatype parser as per RFC 2045 https://datatracker.ietf.org/doc/html/rfc2045#section-5.1
tokenizeMediaType :: P.Parser (Text, Text, [(Text, Text)]) tokenizeMediaType :: P.Parser (Text, Text, [(Text, Text)])
tokenizeMediaType = do tokenizeMediaType = do
mainType <- P.many1 (P.alphaNum <|> P.oneOf ".*") mainType <- P.many1 (P.alphaNum <|> P.oneOf ".*")
P.char '/' P.char '/'
subType <- P.many1 (P.alphaNum <|> P.oneOf ".*+-") subType <- P.many1 (P.alphaNum <|> P.oneOf ".*+-")
params <- P.many pSemicolonSeparatedKeyVals params <- P.many pSemicolonSeparatedKeyVals
P.optional $ P.try $ P.spaces *> P.char ';' -- ending semicolon P.optional $ P.try $ P.spaces *> P.char ';' -- ending semicolon, discard input after that because it has already failed or we have hit EOF
P.eof
return (T.pack mainType, T.pack subType, params) return (T.pack mainType, T.pack subType, params)
where where
pSemicolonSeparatedKeyVals :: P.Parser (Text, Text) pSemicolonSeparatedKeyVals :: P.Parser (Text, Text)
@@ -198,12 +198,12 @@ tokenizeMediaType = do
where where
pKeyVal :: P.Parser (Text, Text) pKeyVal :: P.Parser (Text, Text)
pKeyVal = do pKeyVal = do
key <- P.many1 P.alphaNum key <- P.many1 (P.alphaNum <|> P.oneOf "-")
P.spaces P.spaces
P.char '=' P.char '='
P.spaces P.spaces
val <- P.try pQuoted <|> P.try pUnQuoted val <- P.try pQuoted <|> P.try pUnQuoted
return (T.pack key, T.pack val) return (T.pack key, T.pack val)
where where
pUnQuoted = P.many1 (P.alphaNum <|> P.oneOf "|") pUnQuoted = P.many1 (P.alphaNum <|> P.oneOf "|-")
pQuoted = P.char '\"' *> P.manyTill P.anyChar (P.char '\"') pQuoted = P.char '\"' *> P.manyTill P.anyChar (P.char '\"')
@@ -382,3 +382,24 @@ spec = describe "custom media types" $ do
`shouldRespondWith` `shouldRespondWith`
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: undefined"} |] [json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: undefined"} |]
{ matchStatus = 406 } { matchStatus = 406 }
context "media type parser allowed characters" $ do
it "regression test allowing charset=utf-8" $
request methodPost "/rpc/overloaded_default"
[("Content-Type", "application/json; charset=utf-8")]
[json|{"must_param":1}|]
`shouldRespondWith`
[json|{"val":1}|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
it "handle unrecognized parameters leniently" $ do
request methodPost "/rpc/overloaded_default"
[("Content-Type", "application/json; $$ unrecognized-chars=ignored $$")]
[json|{"must_param":1}|]
`shouldRespondWith`
[json|{"val":1}|]
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}