Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. From versio
- Log host, port and pg version of listener database connection by @mkleczek in #4617 #4618
- Optimize requests with `Prefer: count=exact` that do not use ranges or `db-max-rows` by @laurenceisla in #3957
+ Removed unnecessary double count when building the `Content-Range`.
- Add `Prefer: timeout` header for per-request `statement_timeout` by @taimoorzaeem in #4381

### Fixed

Expand Down
60 changes: 60 additions & 0 deletions docs/references/api/preferences.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The following preferences are supported.
- ``Prefer: missing``. See :ref:`prefer_missing`.
- ``Prefer: max-affected``, See :ref:`prefer_max_affected`.
- ``Prefer: tx``. See :ref:`prefer_tx`.
- ``Prefer: timeout``. See :ref:`prefer_timeout`.

.. _prefer_handling:

Expand Down Expand Up @@ -296,3 +297,62 @@ With :ref:`RPC <functions>`, the preference is honored completely on the basis o
.. note::

It is important for functions to return ``SETOF`` or ``TABLE`` when called with ``max-affected`` preference. A violation of this would cause a :ref:`PGRST128 <pgrst128>` error.

.. _prefer_timeout:

Timeout
=======

You can set `statement_timeout <https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT>`_ for the request using this preference. This works in combination with ``handling=strict`` preference in the same header.

The header only accepts integer value indicating the ``seconds`` that are set as timeout value. To demonstrate, see the following example:

.. code-block:: postgres

CREATE FUNCTION test.sleep(seconds)
RETURNS VOID AS $$
SELECT pg_sleep(seconds);
$$ LANGUAGE SQL;

.. code-block:: bash

curl -i "http://localhost:3000/rpc/sleep?seconds=5" \
-H "Prefer: handling=strict, timeout=2"

.. code-block:: http

HTTP/1.1 500 Internal Server Error

.. code-block:: json

{
"code": "57014",
"details": null,
"hint": null,
"message": "canceling statement due to statement timeout"
}

It is important to note the timeout value cannot exceed the ``statement_timeout`` set :ref:`per-role <impersonated_settings>`. This restriction prevents misuse of this feature. PostgREST returns a :ref:`PGRST129 <pgrst129>` error in this case.

.. code-block:: postgres

ALTER ROLE postgrest_test_anonymous SET statement_timeout = '3s';

.. code-block:: bash

curl -i "http://localhost:3000/rpc/sleep?seconds=4" \
-H "Prefer: handling=strict, timeout=5"

.. code-block:: http

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

.. code-block:: json

{
"code": "PGRST129",
"message": "Timeout preference value cannot exceed statement_timeout of role",
"details": "Timeout preferred: 5s, statement_timeout of role 'postgrest_test_anonymous': 3s",
"hint": null
}
4 changes: 4 additions & 0 deletions docs/references/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ Related to the HTTP request elements.
| | | See :ref:`prefer_max_affected`. |
| PGRST128 | | |
+---------------+-------------+-------------------------------------------------------------+
| .. _pgrst129: | 400 | ``timeout`` preference exceeds ``statement_timeout`` value |
| | | of role. See :ref:`prefer_timeout`. |
| PGRST129 | | |
+---------------+-------------+-------------------------------------------------------------+


.. _pgrst2**:
Expand Down
34 changes: 27 additions & 7 deletions src/PostgREST/ApiRequest/Preferences.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
--
-- [1] https://datatracker.ietf.org/doc/html/rfc7240
--
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module PostgREST.ApiRequest.Preferences
( Preferences(..)
, PreferCount(..)
Expand All @@ -17,6 +17,7 @@ module PostgREST.ApiRequest.Preferences
, PreferTransaction(..)
, PreferTimezone(..)
, PreferMaxAffected(..)
, PreferTimeout(..)
, fromHeaders
, shouldCount
, shouldExplainCount
Expand All @@ -43,6 +44,7 @@ import Protolude
-- >>> deriving instance Show PreferHandling
-- >>> deriving instance Show PreferTimezone
-- >>> deriving instance Show PreferMaxAffected
-- >>> deriving instance Show PreferTimeout
-- >>> deriving instance Show Preferences

-- | Preferences recognized by the application.
Expand All @@ -56,6 +58,7 @@ data Preferences
, preferHandling :: Maybe PreferHandling
, preferTimezone :: Maybe PreferTimezone
, preferMaxAffected :: Maybe PreferMaxAffected
, preferTimeout :: Maybe PreferTimeout
, invalidPrefs :: [ByteString]
}

Expand All @@ -77,12 +80,13 @@ data Preferences
-- ( PreferTimezone "America/Los_Angeles" )
-- , preferMaxAffected = Just
-- ( PreferMaxAffected 100 )
-- , preferTimeout = Nothing
-- , invalidPrefs = []
-- }
--
-- Multiple headers can also be used:
--
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")]
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999"), ("Prefer", "timeout=10")]
-- Preferences
-- { preferResolution = Just IgnoreDuplicates
-- , preferRepresentation = Nothing
Expand All @@ -93,6 +97,8 @@ data Preferences
-- , preferTimezone = Nothing
-- , preferMaxAffected = Just
-- ( PreferMaxAffected 5999 )
-- , preferTimeout = Just
-- ( PreferTimeout 10 )
-- , invalidPrefs = [ "invalid" ]
-- }
--
Expand Down Expand Up @@ -124,6 +130,7 @@ data Preferences
-- , preferHandling = Just Strict
-- , preferTimezone = Nothing
-- , preferMaxAffected = Nothing
-- , preferTimeout = Nothing
-- , invalidPrefs = [ "anything" ]
-- }
--
Expand All @@ -138,7 +145,8 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
, preferHandling = parsePrefs [Strict, Lenient]
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
, preferMaxAffected = PreferMaxAffected <$> maxAffectedPref
, invalidPrefs = filter isUnacceptable prefs
, preferTimeout = PreferTimeout <$> timeoutPref
, invalidPrefs = filter (not . isPrefValid) prefs
}
where
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
Expand All @@ -159,10 +167,13 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
isTimezonePrefAccepted = ((S.member . decodeUtf8 <$> timezonePref) <*> pure acceptedTzNames) == Just True

maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack
timeoutPref = listStripPrefix "timeout=" prefs >>= readMaybe . BS.unpack

isUnacceptable p = p `notElem` acceptedPrefs &&
(isNothing (BS.stripPrefix "timezone=" p) || not isTimezonePrefAccepted) &&
isNothing (BS.stripPrefix "max-affected=" p)
isPrefValid p =
p `elem` acceptedPrefs ||
(isJust (BS.stripPrefix "timezone=" p) && isTimezonePrefAccepted) ||
isJust (BS.stripPrefix "max-affected=" p) ||
isJust (BS.stripPrefix "timeout=" p)

parsePrefs :: ToHeaderValue a => [a] -> Maybe a
parsePrefs vals =
Expand All @@ -171,8 +182,9 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))


prefAppliedHeader :: Preferences -> Maybe HTTP.Header
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } =
prefAppliedHeader Preferences{..} =
if null prefsVals
then Nothing
else Just (HTTP.hPreferenceApplied, combined)
Expand All @@ -187,6 +199,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCou
, toHeaderValue <$> preferHandling
, toHeaderValue <$> preferTimezone
, if preferHandling == Just Strict then toHeaderValue <$> preferMaxAffected else Nothing
, if preferHandling == Just Strict then toHeaderValue <$> preferTimeout else Nothing
]

-- |
Expand Down Expand Up @@ -289,3 +302,10 @@ newtype PreferMaxAffected = PreferMaxAffected Int64

instance ToHeaderValue PreferMaxAffected where
toHeaderValue (PreferMaxAffected n) = "max-affected=" <> show n

-- |
-- Statement Timeout per request
newtype PreferTimeout = PreferTimeout Int64

instance ToHeaderValue PreferTimeout where
toHeaderValue (PreferTimeout n) = "timeout=" <> show n
2 changes: 1 addition & 1 deletion src/PostgREST/App.hs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe
prefs = ApiRequest.userPreferences conf req timezones

(parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestError $ ApiRequest.userApiRequest conf prefs req body
(planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq sCache
(planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq authResult sCache

let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest
tx = MainTx.mainTx mainQ conf authResult apiReq plan sCache
Expand Down
6 changes: 3 additions & 3 deletions src/PostgREST/AppState.hs
Original file line number Diff line number Diff line change
Expand Up @@ -451,17 +451,17 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do
Right x -> pure x
else
pure mempty
(roleSettings, roleIsolationLvl) <-
(roleSettings, roleTimeoutSettings, roleIsolationLvl) <-
if configDbConfig conf then do
rSettings <- usePool appState (queryRoleSettings pgVer (configDbPreparedStatements conf))
case rSettings of
Left e -> do
observer $ QueryRoleSettingsErrorObs e
pure (mempty, mempty)
pure (mempty, mempty, mempty)
Right x -> pure x
else
pure mempty
readAppConfig dbSettings (configFilePath conf) (Just $ configDbUri conf) roleSettings roleIsolationLvl >>= \case
readAppConfig dbSettings (configFilePath conf) (Just $ configDbUri conf) roleSettings roleTimeoutSettings roleIsolationLvl >>= \case
Left err ->
if startingUp then
panic err -- die on invalid config if the program is starting up
Expand Down
2 changes: 1 addition & 1 deletion src/PostgREST/CLI.hs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import Protolude
main :: CLI -> IO ()
main CLI{cliCommand, cliPath} = do
conf <-
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty mempty
case cliCommand of
Client adminCmd -> runClientCommand conf adminCmd
Run runCmd -> runAppCommand conf runCmd
Expand Down
18 changes: 12 additions & 6 deletions src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ import System.Environment (getEnvironment)
import System.Posix.Types (FileMode)

import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings)
RoleSettings,
RoleTimeoutSettings)
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
JSPathExp (..), dumpJSPath,
pRoleClaimKey)
Expand Down Expand Up @@ -117,6 +118,10 @@ data AppConfig = AppConfig
, configAdminServerHost :: Text
, configAdminServerPort :: Maybe Int
, configRoleSettings :: RoleSettings
-- Cached statement timeout settings converted to number of seconds. They
-- are never applied, only used to check max allowed timeout for a role
-- when "Prefer: timeout=" header is used.
, configRoleTimeoutSettings :: RoleTimeoutSettings
Comment on lines +121 to +124
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we should cache the pg transformed value

@steve-chavez How about an internal config to store the transformed values?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this looks much better 👍

, configRoleIsoLvl :: RoleIsolationLvl
, configInternalSCQuerySleep :: Maybe Int32
, configInternalSCLoadSleep :: Maybe Int32
Expand Down Expand Up @@ -227,13 +232,13 @@ instance JustIfMaybe a (Maybe a) where

-- | Reads and parses the config and overrides its parameters from env vars,
-- files or db settings.
readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> RoleSettings -> RoleIsolationLvl -> IO (Either Text AppConfig)
readAppConfig dbSettings optPath prevDbUri roleSettings roleIsolationLvl = do
readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> RoleSettings -> RoleTimeoutSettings -> RoleIsolationLvl -> IO (Either Text AppConfig)
readAppConfig dbSettings optPath prevDbUri roleSettings roleTimeoutSettings roleIsolationLvl = do
env <- readPGRSTEnvironment
-- if no filename provided, start with an empty map to read config from environment
conf <- maybe (return $ Right M.empty) loadConfig optPath

case C.runParser (parser optPath env dbSettings roleSettings roleIsolationLvl) =<< mapLeft show conf of
case C.runParser (parser optPath env dbSettings roleSettings roleTimeoutSettings roleIsolationLvl) =<< mapLeft show conf of
Left err ->
return . Left $ "Error in config " <> err
Right parsedConfig ->
Expand All @@ -250,8 +255,8 @@ readAppConfig dbSettings optPath prevDbUri roleSettings roleIsolationLvl = do
readSecretFile =<<
readDbUriFile prevDbUri parsedConfig

parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
parser optPath env dbSettings roleSettings roleIsolationLvl =
parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleTimeoutSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
parser optPath env dbSettings roleSettings roleTimeoutSettings roleIsolationLvl =
AppConfig
<$> parseAppSettings "app.settings"
<*> (fromMaybe False <$> optBool "db-aggregates-enabled")
Expand Down Expand Up @@ -305,6 +310,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
(optString "server-host"))
<*> parseAdminServerPort "admin-server-port"
<*> pure roleSettings
<*> pure roleTimeoutSettings
<*> pure roleIsolationLvl
<*> optInt "internal-schema-cache-query-sleep"
<*> optInt "internal-schema-cache-load-sleep"
Expand Down
31 changes: 23 additions & 8 deletions src/PostgREST/Config/Database.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module PostgREST.Config.Database
, queryPgVersion
, queryRoleSettings
, RoleSettings
, RoleTimeoutSettings
, RoleIsolationLvl
, TimezoneNames
, toIsolationLevel
Expand All @@ -29,6 +30,7 @@ import NeatInterpolation (trimming)
import Protolude

type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString))
type RoleTimeoutSettings = HM.HashMap ByteString Int64
type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel
type TimezoneNames = Set Text -- cache timezone names for prefer timezone=

Expand Down Expand Up @@ -131,7 +133,7 @@ queryDbSettings preConfFunc prepared =
|]::Text
decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text

queryRoleSettings :: PgVersion -> Bool -> Session (RoleSettings, RoleIsolationLvl)
queryRoleSettings :: PgVersion -> Bool -> Session (RoleSettings, RoleTimeoutSettings, RoleIsolationLvl)
queryRoleSettings pgVer prepared =
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
transaction SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) prepared
Expand All @@ -155,33 +157,46 @@ queryRoleSettings pgVer prepared =
SELECT rolname, value
FROM kv_settings
WHERE key = 'default_transaction_isolation'
),
role_timeout_setting AS (
SELECT rolname, extract(epoch from value::interval)::int as value -- transform to seconds
FROM kv_settings
WHERE key = 'statement_timeout'
)
select
kv.rolname,
i.value as iso_lvl,
coalesce(array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transaction_isolation'), '{}') as role_settings
coalesce(array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transaction_isolation'), '{}') as role_settings,
r.value as role_timeout
from kv_settings kv
join pg_settings ps on ps.name = kv.key and (ps.context = 'user' ${hasParameterPrivilege})
left join iso_setting i on i.rolname = kv.rolname
group by kv.rolname, i.value;
left join role_timeout_setting r on r.rolname = kv.rolname
group by kv.rolname, i.value, r.value;
|]

hasParameterPrivilege
| pgVer >= pgVersion150 = "or has_parameter_privilege(current_user::regrole::oid, ps.name, 'set')"
| otherwise = ""

processRows :: [(Text, Maybe Text, [(Text, Text)])] -> (RoleSettings, RoleIsolationLvl)
processRows :: [(Text, Maybe Text, [(Text, Text)], Maybe Int64)] -> (RoleSettings, RoleTimeoutSettings, RoleIsolationLvl)
processRows rs =
let
rowsWRoleSettings = [ (x, z) | (x, _, z) <- rs ]
rowsWIsolation = [ (x, y) | (x, Just y, _) <- rs ]
rowsWRoleSettings = [ (x, z) | (x, _, z, _) <- rs ]
rowsWRoleTimeoutSettings = [ (x, z') | (x, _, _, Just z') <- rs ]
rowsWIsolation = [ (x, y) | (x, Just y, _, _) <- rs ]
in
( HM.fromList $ bimap encodeUtf8 (HM.fromList . ((encodeUtf8 *** encodeUtf8) <$>)) <$> rowsWRoleSettings
, HM.fromList $ first encodeUtf8 <$> rowsWRoleTimeoutSettings
, HM.fromList $ (encodeUtf8 *** toIsolationLevel) <$> rowsWIsolation
)

rows :: HD.Result [(Text, Maybe Text, [(Text, Text)])]
rows = HD.rowList $ (,,) <$> column HD.text <*> nullableColumn HD.text <*> compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField HD.text)
rows :: HD.Result [(Text, Maybe Text, [(Text, Text)], Maybe Int64)]
rows = HD.rowList $ (,,,)
<$> column HD.text
<*> nullableColumn HD.text
<*> compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField HD.text)
<*> nullableColumn HD.int8

column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
Expand Down
Loading