diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index f4432f0ec..f22e3e5c8 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -71,6 +71,7 @@ "lsn_check_delay": 9223372036854775807, "lsn_check_interval": 5000, "lsn_check_timeout": 5000, + "min_lsn_primary_fallback": false, "min_pool_size": 1, "mirror_exposure": 1.0, "mirror_queue": 128, @@ -892,6 +893,11 @@ "default": 5000, "minimum": 0 }, + "min_lsn_primary_fallback": { + "description": "When a read sets `pgdog.min_lsn` and no replica has replayed that far, fall\nback to the primary instead of erroring.\n\n_Default:_ `false`\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#min_lsn_primary_fallback", + "type": "boolean", + "default": false + }, "min_pool_size": { "description": "Default minimum number of connections per database pool to keep open at all times.\n\n_Default:_ `1`\n\nhttps://docs.pgdog.dev/configuration/pgdog.toml/general/#min_pool_size", "type": "integer", diff --git a/integration/load_balancer/pgx/min_lsn_test.go b/integration/load_balancer/pgx/min_lsn_test.go new file mode 100644 index 000000000..ec4263c1c --- /dev/null +++ b/integration/load_balancer/pgx/min_lsn_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func primaryConn(t *testing.T) *pgx.Conn { + conn, err := pgx.Connect(context.Background(), "postgres://postgres:postgres@127.0.0.1:45000/postgres?sslmode=disable") + require.NoError(t, err) + return conn +} + +// `postgres` uses exclude_primary, so reads normally go to replicas. With +// pgdog.min_lsn set, reads must go only to a replica that has replayed past it, +// falling back to the primary when none has. +func pgdogConn(t *testing.T) *pgx.Conn { + conn, err := pgx.Connect(context.Background(), "postgres://postgres:postgres@127.0.0.1:6432/postgres?sslmode=disable") + require.NoError(t, err) + return conn +} + +func replicaTotal(table string) int64 { + var total int64 + for _, call := range LoadStatsForReplicas(table) { + total += call.Calls + } + return total +} + +func TestMinLsnRoutesReads(t *testing.T) { + p := primaryConn(t) + _, err := p.Exec(context.Background(), + "CREATE TABLE IF NOT EXISTS lb_minlsn_ok (id BIGINT); CREATE TABLE IF NOT EXISTS lb_minlsn_far (id BIGINT)") + require.NoError(t, err) + p.Close(context.Background()) + defer func() { + p := primaryConn(t) + p.Exec(context.Background(), "DROP TABLE IF EXISTS lb_minlsn_ok; DROP TABLE IF EXISTS lb_minlsn_far") + p.Close(context.Background()) + }() + + // Let the replicas catch up and the LSN monitor (lsn_check_interval=1s) populate + // each pool's replay LSN, so lsn_stats.valid() is true. + time.Sleep(3 * time.Second) + + const reads = 20 + + // Case A: min_lsn already satisfied -> reads served by replicas, not primary. + ResetStats() + a := pgdogConn(t) + _, err = a.Exec(context.Background(), "SET pgdog.min_lsn = '0/0'") + require.NoError(t, err) + for i := range reads { + _, err := a.Exec(context.Background(), "SELECT * FROM lb_minlsn_ok WHERE id = $1", int64(i)) + assert.NoError(t, err) + } + a.Close(context.Background()) + + okReplicas := replicaTotal("lb_minlsn_ok") + okPrimary := LoadStatsForPrimary("lb_minlsn_ok").Calls + t.Logf("min_lsn=0/0: replicas=%d primary=%d", okReplicas, okPrimary) + assert.Equal(t, int64(reads), okReplicas, "satisfied min_lsn must be served by replicas") + assert.Equal(t, int64(0), okPrimary, "satisfied min_lsn must not touch the primary") + + // Case B: min_lsn far in the future -> no replica caught up. Default behavior + // (min_lsn_primary_fallback off) is to error, not serve stale or hit the primary. + ResetStats() + b := pgdogConn(t) + _, err = b.Exec(context.Background(), "SET pgdog.min_lsn = '7FFFFFFF/FFFFFFFF'") + require.NoError(t, err) + _, readErr := b.Exec(context.Background(), "SELECT * FROM lb_minlsn_far WHERE id = $1", int64(1)) + b.Close(context.Background()) + + farReplicas := replicaTotal("lb_minlsn_far") + farPrimary := LoadStatsForPrimary("lb_minlsn_far").Calls + t.Logf("min_lsn=max: err=%v replicas=%d primary=%d", readErr, farReplicas, farPrimary) + assert.Error(t, readErr, "unmet min_lsn must error, not serve a stale read") + // Pin the wire message: clients (and the glow relation_writer gate) match on + // this exact text to recognize the rejection and defer, so it must not drift. + if readErr != nil { + assert.Contains(t, readErr.Error(), "no replica caught up to the requested min_lsn", + "unmet min_lsn must surface PgDog's NoReplicaCaughtUp message to the client") + // The message also carries a catch-up ETA (eta ~Ns) so the client can size + // its retry/defer to the real deficit instead of a fixed backoff. + assert.Regexp(t, `eta ~\d+s`, readErr.Error(), + "NoReplicaCaughtUp must report the catch-up ETA") + } + assert.Equal(t, int64(0), farReplicas, "unmet min_lsn must not be served by a behind replica") + assert.Equal(t, int64(0), farPrimary, "unmet min_lsn must not hit the primary when fallback is off") +} diff --git a/pgdog-config/src/general.rs b/pgdog-config/src/general.rs index ee0c8d307..ba495a8d2 100644 --- a/pgdog-config/src/general.rs +++ b/pgdog-config/src/general.rs @@ -221,6 +221,15 @@ pub struct General { #[serde(default)] pub read_write_split: ReadWriteSplit, + /// When a read sets `pgdog.min_lsn` and no replica has replayed that far, fall + /// back to the primary instead of erroring. + /// + /// _Default:_ `false` + /// + /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#min_lsn_primary_fallback + #[serde(default)] + pub min_lsn_primary_fallback: bool, + /// Path to the TLS certificate PgDog will use to setup TLS connections with clients. /// /// https://docs.pgdog.dev/configuration/pgdog.toml/general/#tls_certificate @@ -829,6 +838,7 @@ impl Default for General { load_balancing_strategy: Self::load_balancing_strategy(), read_write_strategy: Self::read_write_strategy(), read_write_split: Self::read_write_split(), + min_lsn_primary_fallback: false, tls_certificate: Self::tls_certificate(), tls_private_key: Self::tls_private_key(), tls_client_required: bool::default(), diff --git a/pgdog/src/backend/error.rs b/pgdog/src/backend/error.rs index 2514ca6c1..2527bfe15 100644 --- a/pgdog/src/backend/error.rs +++ b/pgdog/src/backend/error.rs @@ -160,10 +160,20 @@ impl Error { Error::Pool(PoolError::CheckoutTimeout) => true, Error::Pool(PoolError::AllReplicasDown) => true, Error::Pool(PoolError::Banned) => true, + Error::Pool(PoolError::NoReplicaCaughtUp { .. }) => true, _ => false, } } + /// Expected read-your-writes backpressure: the `pgdog.min_lsn` floor isn't + /// met yet. The client receives SQLSTATE 58000 and defers/retries, so this + /// is normal control flow, not a server fault; callers log it at debug and + /// do not count it as a pool error. + pub fn is_no_replica_caught_up(&self) -> bool { + use crate::backend::pool::Error as PoolError; + matches!(self, Error::Pool(PoolError::NoReplicaCaughtUp { .. })) + } + /// Transient network/pool fault worth retrying. pub fn is_retryable(&self) -> bool { match self { diff --git a/pgdog/src/backend/pool/error.rs b/pgdog/src/backend/pool/error.rs index 1f74de990..8e1db0e93 100644 --- a/pgdog/src/backend/pool/error.rs +++ b/pgdog/src/backend/pool/error.rs @@ -76,6 +76,13 @@ pub enum Error { #[error("replica lag")] ReplicaLag, + + // `eta_seconds`: how long until the soonest replica reaches `min_lsn`, + // derived from its replication lag in time; `0` means not estimable. Clients + // size their read-your-writes defer from it. The message text is part of the + // wire contract (clients match on it), so keep it stable. + #[error("no replica caught up to the requested min_lsn (eta ~{eta_seconds}s)")] + NoReplicaCaughtUp { eta_seconds: i64 }, } impl Error { @@ -98,6 +105,10 @@ impl Error { | Self::UntrackedConnCheckin(_) // Deliberate shutdown. | Self::FastShutdown + // Read-your-writes backpressure: no replica has reached the + // requested min_lsn. An immediate retry can't help; the client + // must wait out the reported ETA. + | Self::NoReplicaCaughtUp { .. } ) } } @@ -133,5 +144,6 @@ mod tests { assert!(!Error::PubSubDisabled.is_retryable()); assert!(!Error::FastShutdown.is_retryable()); assert!(!Error::NoShard(0).is_retryable()); + assert!(!Error::NoReplicaCaughtUp { eta_seconds: 5 }.is_retryable()); } } diff --git a/pgdog/src/backend/pool/lb/mod.rs b/pgdog/src/backend/pool/lb/mod.rs index aff9327c5..f204142fe 100644 --- a/pgdog/src/backend/pool/lb/mod.rs +++ b/pgdog/src/backend/pool/lb/mod.rs @@ -29,6 +29,10 @@ pub use ban::UnbanReason; use monitor::*; pub use target_health::*; +/// A real catch-up estimate is floored at 1s; `0` is reserved for "not +/// estimable", which tells the client to fall back to its own default backoff. +const MIN_ETA_SECONDS: i64 = 1; + #[cfg(test)] mod test; @@ -86,6 +90,9 @@ pub struct LoadBalancer { pub(super) role_detection: Arc, /// Read/write split. pub(super) rw_split: ReadWriteSplit, + /// Fall back to the primary when no replica satisfies a read's `min_lsn` + /// (instead of erroring). + pub(super) min_lsn_primary_fallback: bool, } impl LoadBalancer { @@ -126,9 +133,16 @@ impl LoadBalancer { maintenance: Arc::new(Notify::new()), role_detection: Arc::new(Notify::new()), rw_split, + min_lsn_primary_fallback: false, } } + /// Fall back to the primary when no replica satisfies a read's `min_lsn`. + pub fn with_min_lsn_primary_fallback(mut self, fallback: bool) -> Self { + self.min_lsn_primary_fallback = fallback; + self + } + /// Get the primary pool, if configured. pub fn primary(&self) -> Option<&Pool> { self.primary_target().map(|target| &target.pool) @@ -370,6 +384,41 @@ impl LoadBalancer { return Err(Error::AllReplicasDown); } + // Read-your-writes floor: keep only replicas that have replayed at least + // `min_lsn` (replay LSN is monotonic and the cached value is <= actual, so + // this never serves a stale read). If none qualify, fall back to the + // primary when `min_lsn_primary_fallback` is set, else error with an ETA. + if let Some(min_lsn) = request.min_lsn { + let caught_up: Vec<&Target> = candidates + .iter() + .copied() + .filter(|target| { + let stats = target.pool.lsn_stats(); + stats.valid() && stats.lsn.lsn >= min_lsn + }) + .collect(); + + if !caught_up.is_empty() { + candidates = caught_up; + } else if let Some(primary) = self + .min_lsn_primary_fallback + .then(|| self.primary_target()) + .flatten() + { + candidates = vec![primary]; + } else { + // ETA until the soonest replica reaches the floor, so the client + // can size its defer; `0` = not estimable (no valid LSN stats). + let eta_seconds = candidates + .iter() + .filter_map(|target| target.pool.lsn_stats().eta_to(min_lsn)) + .min() + .map(|eta| (eta.as_secs() as i64).max(MIN_ETA_SECONDS)) + .unwrap_or(0); + return Err(Error::NoReplicaCaughtUp { eta_seconds }); + } + } + match self.lb_strategy { Random => candidates.shuffle(&mut rand::rng()), RoundRobin => { diff --git a/pgdog/src/backend/pool/lb/test.rs b/pgdog/src/backend/pool/lb/test.rs index e65169feb..616924205 100644 --- a/pgdog/src/backend/pool/lb/test.rs +++ b/pgdog/src/backend/pool/lb/test.rs @@ -2031,3 +2031,23 @@ async fn test_params_returns_all_replicas_down_when_empty() { "params() should return AllReplicasDown when no targets exist" ); } + +#[tokio::test] +async fn test_min_lsn_errors_when_no_replica_caught_up() { + // Two replicas with default (invalid, lsn 0) stats: nothing is caught up. + let replicas = LoadBalancer::new( + &None, + &[ + create_test_pool_config("127.0.0.1", 5432), + create_test_pool_config("localhost", 5432), + ], + LoadBalancingStrategy::Random, + ReadWriteSplit::IncludePrimary, + ); + + // Fallback is off by default, so an unmet min_lsn must error before any checkout. + let request = Request::default().with_min_lsn(Some(100)); + let result = replicas.get(&request).await; + + assert!(matches!(result, Err(Error::NoReplicaCaughtUp { .. }))); +} diff --git a/pgdog/src/backend/pool/lsn_monitor.rs b/pgdog/src/backend/pool/lsn_monitor.rs index 2f5a56dd5..1c08cac54 100644 --- a/pgdog/src/backend/pool/lsn_monitor.rs +++ b/pgdog/src/backend/pool/lsn_monitor.rs @@ -48,7 +48,16 @@ SELECT COALESCE(pg_last_xact_replay_timestamp(), now()) ELSE now() - END AS timestamp + END AS timestamp, + CASE + WHEN pg_is_in_recovery() THEN + COALESCE( + EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::bigint, + 0 + ) + ELSE + 0 + END AS replay_lag_seconds "; static AURORA_LSN_QUERY: &str = " @@ -56,13 +65,23 @@ SELECT pg_is_in_recovery() AS replica, '0/0'::pg_lsn AS lsn, 0::bigint AS offset_bytes, - now() AS timestamp + now() AS timestamp, + 0::bigint AS replay_lag_seconds "; +/// Cap a replication-lag ETA at one day so a pathological lag value can't +/// overflow `Duration`; clients clamp further. +const MAX_REPLAY_LAG_SECONDS: i64 = 86_400; + /// LSN information. #[derive(Debug, Clone, Copy, Default)] pub struct LsnStats { inner: StatsLsnStats, + /// Replica replication lag in whole seconds: `now() - + /// pg_last_xact_replay_timestamp()`, computed DB-side (one clock, no + /// pgdog<->DB skew or timezone handling). `0` on the primary, on Aurora, or + /// when the replica has not replayed any transaction yet. + replay_lag_seconds: i64, } impl Deref for LsnStats { @@ -81,7 +100,10 @@ impl DerefMut for LsnStats { impl From for LsnStats { fn from(value: StatsLsnStats) -> Self { - Self { inner: value } + Self { + inner: value, + replay_lag_seconds: 0, + } } } @@ -109,11 +131,30 @@ impl LsnStats { duration: lag, } } + + /// Estimated time for this replica to reach `min_lsn`: its replication lag in + /// time (`now() - pg_last_xact_replay_timestamp()`, measured DB-side), used by + /// the client to size its read-your-writes defer. A stable single-sample + /// reading — a bytes/apply-rate derivative gets fooled by bursty replay into a + /// runaway ETA. Safe-biased (true wait <= lag, since `min_lsn` committed after + /// the frontier). `None` if stats are invalid; `ZERO` once at/past the floor. + pub fn eta_to(&self, min_lsn: i64) -> Option { + if !self.valid() { + return None; + } + if self.lsn.lsn >= min_lsn { + return Some(Duration::ZERO); + } + // Negative lag (clock wobble) floors at zero; clamp the top so a + // pathological value can't overflow. Clients clamp further. + let secs = self.replay_lag_seconds.clamp(0, MAX_REPLAY_LAG_SECONDS) as u64; + Some(Duration::from_secs(secs)) + } } impl LsnStats { fn from_row(value: DataRow, aurora: bool) -> Self { - StatsLsnStats { + let mut stats: LsnStats = StatsLsnStats { replica: value.get(0, Format::Text).unwrap_or_default(), lsn: value.get(1, Format::Text).unwrap_or_default(), offset_bytes: value.get(2, Format::Text).unwrap_or_default(), @@ -121,7 +162,9 @@ impl LsnStats { fetched: SystemTime::now(), aurora, } - .into() + .into(); + stats.replay_lag_seconds = value.get(4, Format::Text).unwrap_or_default(); + stats } } @@ -516,4 +559,36 @@ mod test { "Non-Aurora stats should be invalid with zero LSN" ); } + + #[test] + fn test_eta_to_uses_replay_lag() { + let mut stats: LsnStats = StatsLsnStats { + replica: true, + lsn: Lsn::from_i64(1000), + offset_bytes: 1000, + timestamp: TimestampTz::default(), + fetched: SystemTime::now(), + aurora: false, + } + .into(); + + // Behind the floor: eta is the replica's replication lag in time. + stats.replay_lag_seconds = 7; + assert_eq!(stats.eta_to(1500).map(|d| d.as_secs()), Some(7)); + + // Already at/past the floor -> zero, regardless of lag. + assert_eq!(stats.eta_to(1000), Some(Duration::ZERO)); + assert_eq!(stats.eta_to(500), Some(Duration::ZERO)); + + // Pathological lag is clamped, not overflowed. + stats.replay_lag_seconds = i64::MAX; + assert_eq!( + stats.eta_to(1500).map(|d| d.as_secs()), + Some(MAX_REPLAY_LAG_SECONDS as u64) + ); + + // Negative lag (clock wobble) floors at zero. + stats.replay_lag_seconds = -5; + assert_eq!(stats.eta_to(1500), Some(Duration::ZERO)); + } } diff --git a/pgdog/src/backend/pool/request.rs b/pgdog/src/backend/pool/request.rs index 597c3772b..ba0119576 100644 --- a/pgdog/src/backend/pool/request.rs +++ b/pgdog/src/backend/pool/request.rs @@ -8,6 +8,10 @@ pub struct Request { pub id: FrontendPid, pub created_at: Instant, pub read: bool, + /// Minimum replica replay LSN (64-bit) the read requires. Reads are routed + /// only to a replica that has replayed at least this far; `None` disables + /// the floor. + pub min_lsn: Option, } impl Request { @@ -16,6 +20,7 @@ impl Request { id, created_at: Instant::now(), read, + min_lsn: None, } } @@ -24,8 +29,15 @@ impl Request { id, created_at: Instant::now(), read: false, + min_lsn: None, } } + + /// Require the serving replica to have replayed at least `lsn` (read-your-writes). + pub fn with_min_lsn(mut self, lsn: Option) -> Self { + self.min_lsn = lsn; + self + } } impl Default for Request { diff --git a/pgdog/src/backend/pool/shard/mod.rs b/pgdog/src/backend/pool/shard/mod.rs index faec7596d..664da9bf7 100644 --- a/pgdog/src/backend/pool/shard/mod.rs +++ b/pgdog/src/backend/pool/shard/mod.rs @@ -13,7 +13,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::config::{LoadBalancingStrategy, ReadWriteSplit, Role}; +use crate::config::{LoadBalancingStrategy, ReadWriteSplit, Role, config}; use crate::net::Parameters; use crate::net::messages::FrontendPid; @@ -327,7 +327,9 @@ impl ShardInner { pub_sub_enabled, } = shard; let primary = primary.as_ref().map(Pool::new); - let lb = LoadBalancer::new(&primary, replicas, lb_strategy, rw_split); + let min_lsn_primary_fallback = config().config.general.min_lsn_primary_fallback; + let lb = LoadBalancer::new(&primary, replicas, lb_strategy, rw_split) + .with_min_lsn_primary_fallback(min_lsn_primary_fallback); let comms = Arc::new(ShardComms { shutdown: Notify::new(), lsn_check_interval, diff --git a/pgdog/src/frontend/client/mod.rs b/pgdog/src/frontend/client/mod.rs index ff7444c93..8759fb367 100644 --- a/pgdog/src/frontend/client/mod.rs +++ b/pgdog/src/frontend/client/mod.rs @@ -452,10 +452,19 @@ impl Client { .await; if config().config.general.log_disconnections { let (user, database) = user_database_from_params(&self.params); - error!( - r#"client "{}" disconnected from database "{}" with error [{}]: {}"#, - user, database, self.addr, err - ) + // A min_lsn miss is expected backpressure (client gets 58000 and + // defers), not a server fault; log at debug to avoid flooding. + if err.is_no_replica_caught_up() { + debug!( + r#"client "{}" disconnected from database "{}" with error [{}]: {}"#, + user, database, self.addr, err + ) + } else { + error!( + r#"client "{}" disconnected from database "{}" with error [{}]: {}"#, + user, database, self.addr, err + ) + } } } } diff --git a/pgdog/src/frontend/client/query_engine/connect.rs b/pgdog/src/frontend/client/query_engine/connect.rs index 7d0cbf699..ee78e5c4a 100644 --- a/pgdog/src/frontend/client/query_engine/connect.rs +++ b/pgdog/src/frontend/client/query_engine/connect.rs @@ -1,10 +1,13 @@ +use std::str::FromStr; + +use pgdog_stats::replication::Lsn; use tokio::time::timeout; use crate::frontend::router::parser::{ShardWithPriority, route::ShardSource}; use super::*; -use tracing::{error, trace}; +use tracing::{debug, error, trace}; impl QueryEngine { /// Connect to backend, if necessary. @@ -30,7 +33,17 @@ impl QueryEngine { let connect_route = connect_route.unwrap_or(context.client_request.route()); - let request = Request::new(context.id, connect_route.is_read()); + // Read-your-writes floor: a client sets `pgdog.min_lsn` (startup param or + // `SET`) to the commit LSN it must observe; the LB routes the read only to + // a replica that has replayed at least this far. + let min_lsn = context + .params + .get("pgdog.min_lsn") + .and_then(|value| value.as_str()) + .and_then(|value| Lsn::from_str(value).ok()) + .map(|lsn| lsn.lsn); + + let request = Request::new(context.id, connect_route.is_read()).with_min_lsn(min_lsn); self.stats.waiting(request.created_at); self.comms.update_stats(self.stats); @@ -58,7 +71,12 @@ impl QueryEngine { } Err(err) => { - self.stats.error(); + // A min_lsn miss is expected read-your-writes backpressure, not a + // server fault: don't inflate the pool error stat with it. + let expected_backpressure = err.is_no_replica_caught_up(); + if !expected_backpressure { + self.stats.error(); + } let can_recover = self .backend .cluster() @@ -66,7 +84,12 @@ impl QueryEngine { .unwrap_or_default(); if err.no_server() && can_recover { - error!("{} [{:?}]", err, context.stream.peer_addr()); + // Expected backpressure logs at debug; real faults at error. + if expected_backpressure { + debug!("{} [{:?}]", err, context.stream.peer_addr()); + } else { + error!("{} [{:?}]", err, context.stream.peer_addr()); + } let error = ErrorResponse::from_err(&err); diff --git a/pgdog/src/frontend/error.rs b/pgdog/src/frontend/error.rs index 7469fbf7b..bd8ab0c1a 100644 --- a/pgdog/src/frontend/error.rs +++ b/pgdog/src/frontend/error.rs @@ -100,6 +100,18 @@ impl Error { ) } + /// Expected read-your-writes backpressure: the `pgdog.min_lsn` floor isn't + /// met yet. The client receives SQLSTATE 58000 and defers, so this is normal + /// control flow; log it at debug, not error. + pub fn is_no_replica_caught_up(&self) -> bool { + use crate::backend::Error as BackendError; + use crate::backend::pool::Error as PoolError; + matches!( + self, + Error::Backend(BackendError::Pool(PoolError::NoReplicaCaughtUp { .. })) + ) + } + pub(crate) fn disconnect(&self) -> bool { if let Error::Net(crate::net::Error::Io(err)) = self && err.kind() == ErrorKind::UnexpectedEof diff --git a/pgdog/src/net/parameter.rs b/pgdog/src/net/parameter.rs index cd4583a82..69194389d 100644 --- a/pgdog/src/net/parameter.rs +++ b/pgdog/src/net/parameter.rs @@ -37,6 +37,7 @@ static UNTRACKED_PARAMS: Lazy> = Lazy::new(|| { String::from("pgdog.role"), String::from("pgdog.shard"), String::from("pgdog.sharding_key"), + String::from("pgdog.min_lsn"), ]) });