Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
15 changes: 11 additions & 4 deletions integration/go/go_pgx/pg_tests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@ import (
)

func assertShowField(t *testing.T, query string, field string, value int64, user string, database string, shard int64, role string) {
expected_value := value
actualValue := showField(t, query, field, user, database, shard, role)
assert.Equal(t, value, actualValue, fmt.Sprintf("\"%s\" in %s is not %d", field, query, value))
}

func assertShowFieldOneOf(t *testing.T, query string, field string, values []int64, user string, database string, shard int64, role string) {
actualValue := showField(t, query, field, user, database, shard, role)
assert.Contains(t, values, actualValue, fmt.Sprintf("\"%s\" in %s is not one of %v", field, query, values))
}

func showField(t *testing.T, query string, field string, user string, database string, shard int64, role string) int64 {
conn, err := pgx.Connect(context.Background(), "postgres://admin:pgdog@127.0.0.1:6432/admin")
if err != nil {
panic(err)
Expand All @@ -41,10 +49,9 @@ func assertShowField(t *testing.T, query string, field string, value int64, user
if row_db == database && row_user == user && row_shard.Int.Int64() == shard && row_role == role {
for i, description := range rows.FieldDescriptions() {
if description.Name == field {
actual_value, err := values[i].(pgtype.Numeric).Int64Value()
actualValue, err := values[i].(pgtype.Numeric).Int64Value()
assert.NoError(t, err)
assert.Equal(t, expected_value, actual_value.Int64, fmt.Sprintf("\"%s\" in %s is not %d", field, query, value))
return
return actualValue.Int64
}
}
}
Expand Down
12 changes: 8 additions & 4 deletions integration/go/go_pgx/sharded_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ func rangeCases() []shardCase {
}

// TestShardedListDeprecated tests list mapping via the legacy [[sharded_mappings]] config format.
func TestShardedListDeprecated(t *testing.T) { runShardingTest(t, "sharded_list_deprecated", listCases()) }
func TestShardedListDeprecated(t *testing.T) {
runShardingTest(t, "sharded_list_deprecated", listCases())
}

// TestShardedList tests list mapping via the inline mapping = [...] config format.
func TestShardedList(t *testing.T) { runShardingTest(t, "sharded_list", listCases()) }
Expand Down Expand Up @@ -228,11 +230,13 @@ func TestShardedTwoPc(t *testing.T) {
assert.NoError(t, err)
}

// +5 is for schema sync
assertShowField(t, "SHOW STATS", "total_xact_2pc_count", 200, "pgdog_2pc", "pgdog_sharded", 0, "primary")
assertShowField(t, "SHOW STATS", "total_xact_2pc_count", 200, "pgdog_2pc", "pgdog_sharded", 1, "primary")
assertShowField(t, "SHOW STATS", "total_xact_count", 401+5, "pgdog_2pc", "pgdog_sharded", 0, "primary") // PREPARE, COMMIT for each transaction + TRUNCATE
assertShowField(t, "SHOW STATS", "total_xact_count", 401+5, "pgdog_2pc", "pgdog_sharded", 1, "primary")
// The schema cache is shared across users. If pgdog_2pc wins the cache-fill
// race after RELOAD, the five schema queries are counted in its pool stats.
expectedXactCounts := []int64{401, 406}
assertShowFieldOneOf(t, "SHOW STATS", "total_xact_count", expectedXactCounts, "pgdog_2pc", "pgdog_sharded", 0, "primary")
assertShowFieldOneOf(t, "SHOW STATS", "total_xact_count", expectedXactCounts, "pgdog_2pc", "pgdog_sharded", 1, "primary")

for i := range 200 {
rows, err := conn.Query(
Expand Down
4 changes: 4 additions & 0 deletions pgdog/src/backend/databases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use tracing::{debug, error, info, warn};

use crate::auth::AuthResult;
use crate::backend::replication::ShardedSchemas;
use crate::backend::schema::SchemaCache;
use crate::config::PoolerMode;
use crate::frontend::PreparedStatements;
use crate::frontend::client::query_engine::two_pc::Manager;
Expand Down Expand Up @@ -57,6 +58,9 @@ pub fn databases() -> Arc<Databases> {

/// Replace databases pooler-wide.
pub fn replace_databases(new_databases: Databases, reload: bool) -> Result<(), Error> {
// Bust the schema cache before launching pools.
SchemaCache::global().clear();

// Order of operations is important
// to ensure zero downtime for clients.
//
Expand Down
31 changes: 25 additions & 6 deletions pgdog/src/backend/pool/shard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::backend::Schema;
use crate::backend::databases::User;
use crate::backend::pool::lb::ban::Ban;
use crate::backend::pub_sub::listener::Listener;
use crate::backend::schema::SchemaCache;
use crate::config::{LoadBalancingStrategy, ReadWriteSplit, Role};
use crate::net::Parameters;
use crate::net::messages::FrontendPid;
Expand All @@ -37,7 +38,7 @@ pub(super) struct ShardConfig<'a> {
pub(super) lb_strategy: LoadBalancingStrategy,
/// Primary/replica read/write split strategy.
pub(super) rw_split: ReadWriteSplit,
/// Cluster identifier (user/password).
/// Cluster identifier (user/database).
pub(super) identifier: Arc<User>,
/// LSN check interval
pub(super) lsn_check_interval: Duration,
Expand Down Expand Up @@ -120,23 +121,41 @@ impl Shard {
}
}

/// Load schema from the shard's primary.
pub async fn load_schema(&self) -> Result<bool, crate::backend::Error> {
/// Load schema from the shard's primary database.
///
/// Uses the global schema cache, so most requests will not actually touch
/// the database.
///
pub(crate) async fn load_schema(&self) -> Result<bool, crate::backend::Error> {
if self.schema.initialized() {
return Ok(false);
}

// This is syncrhonized by database/shard number, so this prevents
// a thundering herd with 100s of users, for example, all fetching
// the same schema.
let schema = SchemaCache::global().get(self).await?;
let _ = self.schema.set(schema);
self.schema_waiter.notify_one();

Ok(true)
}

/// Fetch schema from the shard. This does not use the
/// cache and returns the freshed schema available.
///
pub(crate) async fn fetch_schema(&self) -> Result<Schema, crate::backend::Error> {
let mut server = self.primary_or_replica(&Request::default()).await?;
let schema = Schema::load(&mut server).await?;

info!(
"loaded schema for {} tables on shard {} [{}]",
schema.tables().len(),
self.number(),
server.addr()
);
let _ = self.schema.set(schema);
self.schema_waiter.notify_one();
Ok(true)

Ok(schema)
}

/// Set the schema to its default value.
Expand Down
63 changes: 63 additions & 0 deletions pgdog/src/backend/schema/cache/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Cache the schema per database, so we don't have to fetch
//! it for each [`crate::backend::pool::Cluster`].

use dashmap::DashMap;
use once_cell::sync::Lazy;
use std::sync::Arc;
use tokio::sync::Mutex;

use crate::backend::{Schema, Shard};

type Entry = Arc<Mutex<Schema>>;
Comment thread
levkk marked this conversation as resolved.

static CACHE: Lazy<SchemaCache> = Lazy::new(SchemaCache::default);

/// Schema cache.
#[derive(Debug, Default)]
pub(crate) struct SchemaCache {
// Database => shard => Schema
cache: DashMap<String, DashMap<usize, Entry>>,
Comment thread
levkk marked this conversation as resolved.
Outdated
}

impl SchemaCache {
/// Get a schema entry from the cache or load it from
/// the server and store it in the cache.
///
/// The loading is synchronized with a mutex, so only one user
/// can load a schema at a time, preventing a thundering herd situtation.
///
Comment thread
levkk marked this conversation as resolved.
Outdated
pub(crate) async fn get(&self, shard: &Shard) -> Result<Schema, super::Error> {
// This is synchronized.
Comment thread
levkk marked this conversation as resolved.
let entry = self
.cache
.entry(shard.identifier().database.clone())
.or_default()
.entry(shard.number())
.or_default()
.clone();

// 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();
Comment on lines +43 to +45

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
levkk marked this conversation as resolved.

Ok(schema)
}

/// Remove all entries from the schema cache.
pub(crate) fn clear(&self) {
self.cache.clear();
}

/// Get a reference the the global schema cache.
pub(crate) fn global() -> &'static SchemaCache {
&CACHE
}
Comment thread
levkk marked this conversation as resolved.
Outdated
}
2 changes: 2 additions & 0 deletions pgdog/src/backend/schema/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Schema operations.
pub mod cache;
pub mod columns;
pub mod relation;
pub mod sync;
Expand All @@ -11,6 +12,7 @@ use std::ops::DerefMut;
use std::{collections::HashMap, ops::Deref};
use tracing::info;

pub(crate) use cache::SchemaCache;
pub use relation::Relation;

use super::{Cluster, Error, Server, pool::Request};
Expand Down
Loading