Skip to content

feat: schema cache to prevent thundering herd#1258

Open
levkk wants to merge 6 commits into
mainfrom
levkk-db-global-schema-cache
Open

feat: schema cache to prevent thundering herd#1258
levkk wants to merge 6 commits into
mainfrom
levkk-db-global-schema-cache

Conversation

@levkk

@levkk levkk commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Sharded deployments with dozens / hundreds of users will stampede the servers to load the schema. With large schemas, e.g., thousands of tables, and multiple replicas, this is pretty slow and will cause issues.

The cache is using the database name and shard number, which uniquely identifies a host in pgdog.toml.

@levkk
levkk requested review from meskill and sgrif July 23, 2026 21:54
@blacksmith-sh

This comment has been minimized.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment on lines +47 to +49
let schema = shard.fetch_schema().await?;

*guard = schema.clone();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

in theory here could be a tricky data race with SchemaCache::global().clear();:

on replace_databases the following happens: clear -> new_databases.launch -> old_databases.shutdown

if for some reason we trigger replace_databases while schemas are still loading for old (like with retries due to errors) we may try to write into the cache that is basically associated with the new databases already.

The shutdown token would've be helpful, but this is called after the clear and launch, leaving this race window.

There is another race that is not important I think: lock -> fetch_schema -> reload_databases -> set to old guard. But it's not a big issue since the guard will not point to the cache map entry anymore and that'd be a separate mutex.

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.

I think I know what to do here. I'll add a global "config is reloading" signal that can cancel all of these long-running loops. I've been meaning to add something like this; making everything more deterministic.

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.

Might also have to be versioned too. Each config reload increments the config version so each Cluster knows which "epoch" it belongs to.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

*race condition, not a data race.

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.

Yeah, this one is kinda tough. We may need to refactor the config system from an ArcSwap to a watch, or something. to ensure this works across the whole app.

Right now, the race condition is real, but it may not have a real impact. i.e., if they race, they happen around the same time, so the schema is probably identical anyway. This was built specifically to busted when the user wants it to, e.g., they create a new table, then hit RELOAD in admin. Either way, the table already exists, and the schema loaded in the cache is fresh.

@levkk levkk Jul 24, 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.

I figured it out. We should attach all config-scoped "globals" to the Cluster. Duh. It's already versioned in a way that each config version gets its own Cluster, so as long as the cache is scoped to the lifetime of that Cluster, we don't have a race between config versions.

We should ban globals lol. Everything from now on should be Cluster-scoped.

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.

Added in 2fe3347

Comment thread pgdog/src/backend/schema/cache/mod.rs Outdated
#[derive(Debug, Default)]
pub(crate) struct SchemaCache {
// Database => shard => Schema
cache: DashMap<String, DashMap<usize, Entry>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's newtype these so we don't need a comment to know what they are

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.

Either it will have automatic conversion from string, which will do nothing to prevent type issues, or it won't and we have to refactor String to Database in like 100 files. Pick your poison. Comment is fine for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

newtype is still useful even with auto conversion - it has clear meaning on type levels and allows for specific functionality. And the migration could be also done partially by converting to/from String on the boundaries.

not saying do it here, but we should prefer this for new code maybe

Comment thread pgdog/src/backend/schema/cache/mod.rs Outdated
Comment thread pgdog/src/backend/schema/cache/mod.rs
Comment thread pgdog/src/backend/schema/cache/mod.rs
Comment on lines +39 to +49
// This is syncrhonized too,
// so only one shard/user can fetch the schema at a time.
let mut guard = entry.lock().await;

if guard.is_loaded() {
return Ok(guard.clone());
}

let schema = shard.fetch_schema().await?;

*guard = schema.clone();

@sgrif sgrif Jul 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we use OnceCell for this

Suggested change
// This is syncrhonized too,
// so only one shard/user can fetch the schema at a time.
let mut guard = entry.lock().await;
if guard.is_loaded() {
return Ok(guard.clone());
}
let schema = shard.fetch_schema().await?;
*guard = schema.clone();
entry.get_or_try_init(|| shard.fetch_schema())

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.

That feels obscure to me. What I have here is easier to read, at least for me and to understand its purpose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using the purpose built thing is more obscure than implementing it yourself in a worse way? I do not understand why you're being so wildly dismissive here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hey, I had to agree with Sage - the OnceCell is just built for exactly this but does it with simpler code and uses just AtomicBool instead of mutex for synchronization.

doc just for reference - https://docs.rs/tokio/latest/tokio/sync/struct.OnceCell.html

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

there is a subtle difference though - guard.is_loaded used to check the initialization instead of Option. I guess the empty schema will be refetched every time because of this 🤷🏻‍♂️

Comment thread pgdog/src/backend/schema/cache/mod.rs Outdated
levkk and others added 2 commits July 24, 2026 10:58
pub regex_parser_limit: usize,
pub pub_sub_enabled: bool,
pub identity: &'a Option<String>,
name: &'a str,

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.

@meskill Ignore this diff, just removed pub.

@levkk
levkk requested a review from meskill July 24, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants