Skip to content

add: load schema cache dump from URI at startup#5031

Open
mkleczek wants to merge 2 commits into
PostgREST:mainfrom
mkleczek:push-tpxzxqqnpsuv
Open

add: load schema cache dump from URI at startup#5031
mkleczek wants to merge 2 commits into
PostgREST:mainfrom
mkleczek:push-tpxzxqqnpsuv

Conversation

@mkleczek

@mkleczek mkleczek commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Resolves #4999
by making it unnecessary.

@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from ecd794f to d6968c9 Compare June 22, 2026 19:07
@mkleczek mkleczek changed the title feat(cli): load schema cache dump at startup add: load schema cache dump at startup Jun 22, 2026
@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from d6968c9 to 3541b96 Compare June 22, 2026 19:08
@mkleczek
mkleczek marked this pull request as draft June 22, 2026 19:09
@mkleczek
mkleczek requested a review from steve-chavez June 22, 2026 19:11
@steve-chavez

This comment was marked as outdated.

@steve-chavez

Copy link
Copy Markdown
Member

Helping to clarify, the motivation is to solve the same problems on #4999. This by loading the schema cache from a file.

@steve-chavez

Copy link
Copy Markdown
Member

Q: If the schema cache file is stale, we still pick it up and serve requests as "best effort"?

@mkleczek

Copy link
Copy Markdown
Collaborator Author

Helping to clarify, the motivation is to solve the same problems on #4999. This by loading the schema cache from a file.

@steve-chavez I am still working on the right design and will provide more details soon.

I believe now that we can solve in this PR all pain points we want to solve with materialized views.

@mkleczek

Copy link
Copy Markdown
Collaborator Author

Q: If the schema cache file is stale, we still pick it up and serve requests as "best effort"?

Yes the idea is to treat schema cache preloaded from the dump as stale and trigger asynchronous load from the db.

@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from 3541b96 to 6417d00 Compare July 7, 2026 09:35
@mkleczek mkleczek changed the title add: load schema cache dump at startup add: load schema cache dump from URI at startup Jul 7, 2026
@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from 6417d00 to 25e4372 Compare July 7, 2026 09:51
@mkleczek
mkleczek marked this pull request as ready for review July 7, 2026 09:51
@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch 3 times, most recently from 1d96a81 to a51ed0b Compare July 7, 2026 13:05
Comment thread src/library/PostgREST/SchemaCache.hs
Comment thread docs/references/cli.rst
Comment thread src/PostgREST/CLI.hs Outdated
Comment thread test/io/test_cli.py
Comment on lines +428 to +429
def test_slow_schema_cache_dump_uri_does_not_delay_startup(defaultenv):
"A slow schema cache dump URI should not delay database-backed startup."

@steve-chavez steve-chavez Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So if the URI is slow then we will prefer querying the schema cache.

Won't this will always be the case when doing HTTP requests? It seems like this feature will only make sense when it's obtained from a file since that'd be faster.

@mkleczek mkleczek Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

So if the URI is slow then we will prefer querying the schema cache.

Won't this will always be the case when doing HTTP requests? It seems like this feature will only make sense when it's obtained from a file since that'd be faster.

Well... given performance issues with schema cache queries we've seen reported, I wouldn't be so sure.

HTTP is needed because:

  1. In k8s environment it is rare to have a shared filesystem to read the file from.
  2. Since we provide schema cache dump from admin server it is very convenient for blue/green deployments - just configure each to point to the other one's admin endpoint and you have schema cache pre-loading for free.

Besides: why would HTTP be slow? The most important thing here is not the access protocol but the fact that we serve the dump from memory. I am sure establishing database session and then querying the catalog is going to be much slower than HTTP GET from localhost.
I am pretty sure HTTP is going to be faster than reading from a materialized view as well.

But really we don't want to decide up front which method is preferred. We need to query the database anyway because pre-loaded dump might be stale. So why wait if we are going to do it anyway? It is better to race - that way we are going to have startup as fast as possible - we really don't care which method wins. We only need to make sure the database result is not overwritten.

There is also another angle: we want to deal with thundering herd issue when querying the db. In clustered environments HTTP gives us a natural way to load balance initial schema cache load among existing PostgREST instances. Once we have #4643 or similar it will become even more important as it might affect startup time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good points. So we're always going to race here to get the fastest possible startup, got it. We should make this clear on the docs.

@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from a51ed0b to 6fd483f Compare July 7, 2026 18:58
@steve-chavez

Copy link
Copy Markdown
Member

Since this is about fast startups, I was wondering if you have considered the in-db config + role settings queries, which if used are also required for startup (done prior to the schema cache):

queryDbSettings :: Maybe Text -> Session [(Text, Text)]
queryDbSettings preConfFunc =
SQL.transactionNoRetry SQL.ReadCommitted SQL.Read $ SQL.statement dbSettingsNames $ SQL.Statement sql (arrayParam HE.text) decodeSettings True
where
sql = encodeUtf8 [trimming|
WITH
role_setting AS (
SELECT setdatabase as database,
unnest(setconfig) as setting
FROM pg_catalog.pg_db_role_setting
WHERE setrole = quote_ident(CURRENT_USER)::regrole::oid
AND setdatabase IN (0, (SELECT oid FROM pg_catalog.pg_database WHERE datname = CURRENT_CATALOG))
),
kv_settings AS (
SELECT database,
substr(setting, 1, strpos(setting, '=') - 1) as k,
substr(setting, strpos(setting, '=') + 1) as v
FROM role_setting
${preConfigF}
)
SELECT DISTINCT ON (key)
replace(k, '${prefix}', '') AS key,
v AS value
FROM kv_settings
WHERE k = ANY($$1) AND v IS NOT NULL
ORDER BY key, database DESC NULLS LAST;
|]
preConfigF = case preConfFunc of
Nothing -> mempty
Just func -> [trimming|
UNION
SELECT
null as database,
x as k,
current_setting(x, true) as v
FROM unnest($$1) x
JOIN ${func}() _ ON TRUE
|]::Text
decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text

queryRoleSettings :: PgVersion -> Session (RoleSettings, RoleIsolationLvl)
queryRoleSettings pgVer =
SQL.transactionNoRetry SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) True
where
sql = encodeUtf8 [trimming|
with
role_setting as (
select r.rolname, unnest(r.rolconfig) as setting
from pg_auth_members m
join pg_roles r on r.oid = m.roleid
where member = quote_ident(current_user)::regrole::oid
),
kv_settings AS (
SELECT
rolname,
substr(setting, 1, strpos(setting, '=') - 1) as key,
substr(setting, strpos(setting, '=') + 1) as value
FROM role_setting
),
iso_setting AS (
SELECT rolname, value
FROM kv_settings
WHERE key = 'default_transaction_isolation'
)
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
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;
|]
hasParameterPrivilege
| pgVer >= pgVersion150 = "or has_parameter_privilege(quote_ident(current_user)::regrole::oid, ps.name, 'set')"
| otherwise = ""
processRows :: [(Text, Maybe Text, [(Text, Text)])] -> (RoleSettings, RoleIsolationLvl)
processRows rs =
let
rowsWRoleSettings = [ (x, z) | (x, _, z) <- rs ]
rowsWIsolation = [ (x, y) | (x, Just y, _) <- rs ]
in
( HM.fromList $ bimap encodeUtf8 (HM.fromList . ((encodeUtf8 *** encodeUtf8) <$>)) <$> rowsWRoleSettings
, 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)

The in-db configs are not cached but the role settings are cached here:

, configRoleSettings :: RoleSettings
, configRoleIsoLvl :: RoleIsolationLvl

When adding those I wondered if they should go into schema cache or not, I decided to make them separate to save some work on filtering them for the openAPI output. But with this feature, it looks we should make them part of the schema cache to really get the faster startup?

@steve-chavez

Copy link
Copy Markdown
Member

in-db config + role settings queries

Actually I don't think the "in-db config" can be put inside the schema cache because it depends on it (db-schemas, db-extra-search-path) but the role settings could be put inside the schema cache to save some time.

@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch 4 times, most recently from 249ef2e to bfbc506 Compare July 8, 2026 18:00
Comment thread docs/references/cli.rst
Comment on lines +86 to +89
Tries to load the initial :ref:`schema_cache` from the given URI when starting PostgREST.
The load is asynchronous and does not block application startup. If the URI cannot be
loaded, PostgREST logs the failure and continues with the regular database-backed
schema cache load.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should be clear here that even if we load the schema cache from URL, this will be overridden later when the schema cache comes from the db.

Comment thread docs/references/cli.rst
Comment on lines +105 to +140
Load From a Local File
~~~~~~~~~~~~~~~~~~~~~~

First, write a schema cache dump to a file:

.. code:: bash
$ postgrest --dump-schema > /var/lib/postgrest/schema-cache.json
Then start PostgREST with a ``file:`` URI:

.. code:: bash
$ postgrest --schema-cache-uri file:///var/lib/postgrest/schema-cache.json
Load From Another PostgREST Instance
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The :ref:`admin_server` exposes the current runtime schema cache on the
``/schema_cache`` endpoint. If one PostgREST instance exposes its admin server on
``localhost:3001``, a new instance can load from it with:

.. code:: bash
$ postgrest --schema-cache-uri http://localhost:3001/schema_cache
In Kubernetes, the same pattern can use an internal Service that points at ready
PostgREST pods:

.. code:: bash
$ postgrest --schema-cache-uri http://postgrest-admin:3001/schema_cache
The admin endpoint should stay internal to your deployment or be protected by your
platform's networking controls.

@steve-chavez steve-chavez Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This section is more like a part of a how-to. I think we have two options:

  1. Remove it. Simplest option.
    • The reference is already pretty clear IMO.
  2. Or make it part of a new how-to, like "Fast cold starts for PostgREST".
    • Maybe it could fit into an explanation too.

@steve-chavez

Copy link
Copy Markdown
Member

@mkleczek Does this require #5083? What happens right now since we don't have the pdIsoLvl?

@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from bfbc506 to 053ce13 Compare July 14, 2026 16:13
<*> o .: "pdHasVariadic"
<*> pure Nothing
<*> o .: "pdFuncSettings"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Once #5083 gets merged, this can be remove by just adding:

deriving instance JSON.FromJSON SQL.IsolationLevel

and deriving the instance for Routine type like:

data Routine = ...
  ...
  deriving (Eq, Show, Generic, JSON.ToJSON, JSON.FromJSON)

@steve-chavez

Copy link
Copy Markdown
Member

@mkleczek Please address the feedback here 🙏

@mkleczek
mkleczek force-pushed the push-tpxzxqqnpsuv branch from 053ce13 to bfcfcb2 Compare July 18, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Schema cache as materialized view

3 participants