Skip to content

Commit 7c49b66

Browse files
committed
add: per request statement timeout using prefer header
Adds a feature to set `statement_timeout` using `Prefer: timeout` header. This also introduces a `PGRST129` error which is returned when the timeout preferred exceeds the per-role or global timeout. Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent b977ffb commit 7c49b66

19 files changed

Lines changed: 445 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. From versio
1212
- Log host, port and pg version of listener database connection by @mkleczek in #4617 #4618
1313
- Optimize requests with `Prefer: count=exact` that do not use ranges or `db-max-rows` by @laurenceisla in #3957
1414
+ Removed unnecessary double count when building the `Content-Range`.
15+
- Add `Prefer: timeout` header for per-request `statement_timeout` by @taimoorzaeem in #4381
1516

1617
### Changed
1718

docs/references/api/preferences.rst

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The following preferences are supported.
1515
- ``Prefer: missing``. See :ref:`prefer_missing`.
1616
- ``Prefer: max-affected``, See :ref:`prefer_max_affected`.
1717
- ``Prefer: tx``. See :ref:`prefer_tx`.
18+
- ``Prefer: timeout``. See :ref:`prefer_timeout`.
1819

1920
.. _prefer_handling:
2021

@@ -296,3 +297,62 @@ With :ref:`RPC <functions>`, the preference is honored completely on the basis o
296297
.. note::
297298
298299
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.
300+
301+
.. _prefer_timeout:
302+
303+
Timeout
304+
=======
305+
306+
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.
307+
308+
The header only accepts integer value indicating the ``seconds`` that are set as timeout value. ``0`` and negative values are ignored and not applied. To demonstrate, see the following example:
309+
310+
.. code-block:: postgres
311+
312+
CREATE FUNCTION test.sleep(seconds)
313+
RETURNS VOID AS $$
314+
SELECT pg_sleep(seconds);
315+
$$ LANGUAGE SQL;
316+
317+
.. code-block:: bash
318+
319+
curl -i "http://localhost:3000/rpc/sleep?seconds=5" \
320+
-H "Prefer: handling=strict, timeout=2"
321+
322+
.. code-block:: http
323+
324+
HTTP/1.1 500 Internal Server Error
325+
326+
.. code-block:: json
327+
328+
{
329+
"code": "57014",
330+
"details": null,
331+
"hint": null,
332+
"message": "canceling statement due to statement timeout"
333+
}
334+
335+
It is important to note the timeout value cannot exceed the ``statement_timeout`` set :ref:`per-role <impersonated_settings>` or per-database. The role's timeout setting takes precedence over the database level timeout. This restriction prevents misuse of this feature. PostgREST returns a :ref:`PGRST129 <pgrst129>` error in this case.
336+
337+
.. code-block:: postgres
338+
339+
ALTER ROLE postgrest_test_anonymous SET statement_timeout = '3s';
340+
341+
.. code-block:: bash
342+
343+
curl -i "http://localhost:3000/rpc/sleep?seconds=4" \
344+
-H "Prefer: handling=strict, timeout=5"
345+
346+
.. code-block:: http
347+
348+
HTTP/1.1 400 Bad Request
349+
Content-Type: application/json; charset=utf-8
350+
351+
.. code-block:: json
352+
353+
{
354+
"code": "PGRST129",
355+
"message": "Timeout preference exceeded maximum allowed",
356+
"details": "The maximum timeout allowed is 3s",
357+
"hint": "Reduce the timeout"
358+
}

docs/references/errors.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ Related to the HTTP request elements.
271271
| | | See :ref:`prefer_max_affected`. |
272272
| PGRST128 | | |
273273
+---------------+-------------+-------------------------------------------------------------+
274+
| .. _pgrst129: | 400 | ``timeout`` preference exceeds ``statement_timeout`` value |
275+
| | | of role. See :ref:`prefer_timeout`. |
276+
| PGRST129 | | |
277+
+---------------+-------------+-------------------------------------------------------------+
274278

275279

276280
.. _pgrst2**:

src/PostgREST/ApiRequest/Preferences.hs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
--
77
-- [1] https://datatracker.ietf.org/doc/html/rfc7240
88
--
9-
{-# LANGUAGE NamedFieldPuns #-}
9+
{-# LANGUAGE RecordWildCards #-}
1010
module PostgREST.ApiRequest.Preferences
1111
( Preferences(..)
1212
, PreferCount(..)
@@ -17,6 +17,7 @@ module PostgREST.ApiRequest.Preferences
1717
, PreferTransaction(..)
1818
, PreferTimezone(..)
1919
, PreferMaxAffected(..)
20+
, PreferTimeout(..)
2021
, fromHeaders
2122
, shouldCount
2223
, shouldExplainCount
@@ -43,6 +44,7 @@ import Protolude
4344
-- >>> deriving instance Show PreferHandling
4445
-- >>> deriving instance Show PreferTimezone
4546
-- >>> deriving instance Show PreferMaxAffected
47+
-- >>> deriving instance Show PreferTimeout
4648
-- >>> deriving instance Show Preferences
4749

4850
-- | Preferences recognized by the application.
@@ -56,6 +58,7 @@ data Preferences
5658
, preferHandling :: Maybe PreferHandling
5759
, preferTimezone :: Maybe PreferTimezone
5860
, preferMaxAffected :: Maybe PreferMaxAffected
61+
, preferTimeout :: Maybe PreferTimeout
5962
, invalidPrefs :: [ByteString]
6063
}
6164

@@ -77,12 +80,13 @@ data Preferences
7780
-- ( PreferTimezone "America/Los_Angeles" )
7881
-- , preferMaxAffected = Just
7982
-- ( PreferMaxAffected 100 )
83+
-- , preferTimeout = Nothing
8084
-- , invalidPrefs = []
8185
-- }
8286
--
8387
-- Multiple headers can also be used:
8488
--
85-
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")]
89+
-- >>> 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")]
8690
-- Preferences
8791
-- { preferResolution = Just IgnoreDuplicates
8892
-- , preferRepresentation = Nothing
@@ -93,6 +97,8 @@ data Preferences
9397
-- , preferTimezone = Nothing
9498
-- , preferMaxAffected = Just
9599
-- ( PreferMaxAffected 5999 )
100+
-- , preferTimeout = Just
101+
-- ( PreferTimeout 10 )
96102
-- , invalidPrefs = [ "invalid" ]
97103
-- }
98104
--
@@ -124,6 +130,7 @@ data Preferences
124130
-- , preferHandling = Just Strict
125131
-- , preferTimezone = Nothing
126132
-- , preferMaxAffected = Nothing
133+
-- , preferTimeout = Nothing
127134
-- , invalidPrefs = [ "anything" ]
128135
-- }
129136
--
@@ -138,7 +145,8 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
138145
, preferHandling = parsePrefs [Strict, Lenient]
139146
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
140147
, preferMaxAffected = PreferMaxAffected <$> maxAffectedPref
141-
, invalidPrefs = filter isUnacceptable prefs
148+
, preferTimeout = PreferTimeout <$> timeoutPref
149+
, invalidPrefs = filter (not . isPrefValid) prefs
142150
}
143151
where
144152
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
@@ -159,10 +167,17 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
159167
isTimezonePrefAccepted = ((S.member . decodeUtf8 <$> timezonePref) <*> pure acceptedTzNames) == Just True
160168

161169
maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack
162-
163-
isUnacceptable p = p `notElem` acceptedPrefs &&
164-
(isNothing (BS.stripPrefix "timezone=" p) || not isTimezonePrefAccepted) &&
165-
isNothing (BS.stripPrefix "max-affected=" p)
170+
timeoutPref = listStripPrefix "timeout=" prefs >>= readOnlyPositiveInt . readMaybe . BS.unpack
171+
where
172+
-- 0 and -ve values for timeout are meaningless, we handle them leniently and ignore them
173+
readOnlyPositiveInt (Just i) | i > 0 = Just i
174+
readOnlyPositiveInt _ = Nothing
175+
176+
isPrefValid p =
177+
p `elem` acceptedPrefs ||
178+
(isJust (BS.stripPrefix "timezone=" p) && isTimezonePrefAccepted) ||
179+
isJust (BS.stripPrefix "max-affected=" p) ||
180+
isJust (BS.stripPrefix "timeout=" p)
166181

167182
parsePrefs :: ToHeaderValue a => [a] -> Maybe a
168183
parsePrefs vals =
@@ -172,7 +187,7 @@ fromHeaders allowTxDbOverride acceptedTzNames headers =
172187
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))
173188

174189
prefAppliedHeader :: Preferences -> Maybe HTTP.Header
175-
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } =
190+
prefAppliedHeader Preferences{..} =
176191
if null prefsVals
177192
then Nothing
178193
else Just (HTTP.hPreferenceApplied, combined)
@@ -187,6 +202,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCou
187202
, toHeaderValue <$> preferHandling
188203
, toHeaderValue <$> preferTimezone
189204
, if preferHandling == Just Strict then toHeaderValue <$> preferMaxAffected else Nothing
205+
, if preferHandling == Just Strict then toHeaderValue <$> preferTimeout else Nothing
190206
]
191207

192208
-- |
@@ -289,3 +305,10 @@ newtype PreferMaxAffected = PreferMaxAffected Int64
289305

290306
instance ToHeaderValue PreferMaxAffected where
291307
toHeaderValue (PreferMaxAffected n) = "max-affected=" <> show n
308+
309+
-- |
310+
-- Statement Timeout per request
311+
newtype PreferTimeout = PreferTimeout Int64
312+
313+
instance ToHeaderValue PreferTimeout where
314+
toHeaderValue (PreferTimeout n) = "timeout=" <> show n

src/PostgREST/App.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe
169169
prefs = ApiRequest.userPreferences conf req timezones
170170

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

174174
let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest
175175
tx = MainTx.mainTx mainQ conf authResult apiReq plan sCache

src/PostgREST/AppState.hs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ import PostgREST.Config (AppConfig (..),
6363
readAppConfig)
6464
import PostgREST.Config.Database (queryDbSettings,
6565
queryPgVersion,
66-
queryRoleSettings)
66+
queryRoleSettings,
67+
queryRoleTimeoutSettings)
6768
import PostgREST.Config.PgVersion (PgVersion (..),
6869
minimumPgVersion)
6970
import PostgREST.SchemaCache (SchemaCache (..),
@@ -461,7 +462,17 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do
461462
Right x -> pure x
462463
else
463464
pure mempty
464-
readAppConfig dbSettings (configFilePath conf) (Just $ configDbUri conf) roleSettings roleIsolationLvl >>= \case
465+
roleTimeoutSettings <-
466+
if configDbConfig conf then do
467+
rSettings <- usePool appState (queryRoleTimeoutSettings $ configDbPreparedStatements conf)
468+
case rSettings of
469+
Left e -> do
470+
observer $ QueryRoleSettingsErrorObs e
471+
pure mempty
472+
Right x -> pure x
473+
else
474+
pure mempty
475+
readAppConfig dbSettings (configFilePath conf) (Just $ configDbUri conf) roleSettings roleTimeoutSettings roleIsolationLvl >>= \case
465476
Left err ->
466477
if startingUp then
467478
panic err -- die on invalid config if the program is starting up

src/PostgREST/CLI.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import Protolude
3030
main :: CLI -> IO ()
3131
main CLI{cliCommand, cliPath} = do
3232
conf <-
33-
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty
33+
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty mempty
3434
case cliCommand of
3535
Client adminCmd -> runClientCommand conf adminCmd
3636
Run runCmd -> runAppCommand conf runCmd

src/PostgREST/Config.hs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ import System.Environment (getEnvironment)
5757
import System.Posix.Types (FileMode)
5858

5959
import PostgREST.Config.Database (RoleIsolationLvl,
60-
RoleSettings)
60+
RoleSettings,
61+
RoleTimeoutSettings)
6162
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
6263
JSPathExp (..), dumpJSPath,
6364
pRoleClaimKey)
@@ -117,6 +118,10 @@ data AppConfig = AppConfig
117118
, configAdminServerHost :: Text
118119
, configAdminServerPort :: Maybe Int
119120
, configRoleSettings :: RoleSettings
121+
-- Cached statement timeout settings converted to number of seconds. They
122+
-- are never applied, only used to check max allowed timeout for a role
123+
-- when "Prefer: timeout=" header is used.
124+
, configRoleTimeoutSettings :: RoleTimeoutSettings
120125
, configRoleIsoLvl :: RoleIsolationLvl
121126
, configInternalSCQuerySleep :: Maybe Int32
122127
, configInternalSCLoadSleep :: Maybe Int32
@@ -227,13 +232,13 @@ instance JustIfMaybe a (Maybe a) where
227232

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

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

253-
parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
254-
parser optPath env dbSettings roleSettings roleIsolationLvl =
258+
parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleTimeoutSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig
259+
parser optPath env dbSettings roleSettings roleTimeoutSettings roleIsolationLvl =
255260
AppConfig
256261
<$> parseAppSettings "app.settings"
257262
<*> (fromMaybe False <$> optBool "db-aggregates-enabled")
@@ -305,6 +310,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
305310
(optString "server-host"))
306311
<*> parseAdminServerPort "admin-server-port"
307312
<*> pure roleSettings
313+
<*> pure roleTimeoutSettings
308314
<*> pure roleIsolationLvl
309315
<*> optInt "internal-schema-cache-query-sleep"
310316
<*> optInt "internal-schema-cache-load-sleep"

0 commit comments

Comments
 (0)