From d82af44432cb6265582f12e747a2ee37a6a5884e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Mon, 7 Sep 2026 19:21:40 +0200 Subject: [PATCH 1/2] chore(spanner): implement dynamic channel pooling and transaction affinity Adds dynamic channel pooling to the Spanner client, replacing single-channel stubs with an elastic pool of gRPC connections. - Distributes requests across channels using the Power of Two Choices (P2C) algorithm to avoid hot connections. - Automatically spins up and warms new channels under heavy load, and quietly drains idle ones when traffic drops. - Pins multi-step transactions to the same channel so server state remains consistent. - Supports both static and dynamic configurations, and respects the `SPANNER_NUM_CHANNELS` environment variable. - Keeps the pool configuration internal (`pub(crate)`) for now while the API and behavior are finalized. --- .../src/batch_read_only_transaction.rs | 33 +- src/spanner/src/batch_write_transaction.rs | 9 +- src/spanner/src/channel_pool/affinity.rs | 241 ++++++- src/spanner/src/channel_pool/config.rs | 422 +++++++++++- src/spanner/src/channel_pool/entry.rs | 101 ++- src/spanner/src/channel_pool/mod.rs | 7 +- src/spanner/src/channel_pool/pool.rs | 400 +++++++++-- src/spanner/src/channel_pool/scaler.rs | 11 +- src/spanner/src/client.rs | 628 ++++++++++++++---- src/spanner/src/database_client.rs | 198 ++++-- .../src/partitioned_dml_transaction.rs | 8 +- src/spanner/src/read_only_transaction.rs | 83 +-- src/spanner/src/read_write_transaction.rs | 36 +- src/spanner/src/request_id.rs | 4 +- src/spanner/src/result_set.rs | 22 +- src/spanner/src/routing/cache_subscriber.rs | 5 +- src/spanner/src/routing/mock_tests.rs | 30 +- src/spanner/src/server_streaming/builder.rs | 193 +++++- src/spanner/src/server_streaming/stream.rs | 136 +++- src/spanner/src/session_maintainer.rs | 17 +- src/spanner/src/transaction_runner.rs | 3 +- src/spanner/src/write_only_transaction.rs | 34 +- 22 files changed, 2120 insertions(+), 501 deletions(-) diff --git a/src/spanner/src/batch_read_only_transaction.rs b/src/spanner/src/batch_read_only_transaction.rs index 43028e4a18..2c64876294 100644 --- a/src/spanner/src/batch_read_only_transaction.rs +++ b/src/spanner/src/batch_read_only_transaction.rs @@ -168,7 +168,7 @@ impl BatchReadOnlyTransaction { .partition_query( request, crate::RequestOptions::default(), - self.inner.context.channel_hint, + self.inner.context.affinity(), ) .await?; @@ -231,7 +231,7 @@ impl BatchReadOnlyTransaction { .partition_read( request, crate::RequestOptions::default(), - self.inner.context.channel_hint, + self.inner.context.affinity(), ) .await?; @@ -405,15 +405,11 @@ impl Partition { req: &ExecuteSqlRequest, gax_options: GaxRequestOptions, ) -> crate::Result { - let channel_hint = client.next_channel_hint(); - let gax_options = client.attach_request_id(gax_options, channel_hint); + let builder = client.execute_streaming_sql(req.clone(), gax_options, None); + let actual_gax_options = builder.options().clone(); let (stream, attempt_start_time) = - Self::execute_partition_stream(client, "ExecuteStreamingSql", || { - client - .execute_streaming_sql(req.clone(), gax_options.clone(), channel_hint) - .send() - }) - .await?; + Self::execute_partition_stream(client, "ExecuteStreamingSql", move || builder.send()) + .await?; ResultSet::create(ResultSetParams { stream, @@ -428,8 +424,7 @@ impl Partition { session_name: req.session.clone(), transaction_tag: None, operation: StreamOperation::Query(req.clone()), - channel_hint, - gax_options, + gax_options: actual_gax_options, method_name: "ExecuteStreamingSql", attempt_start_time: Some(attempt_start_time), operation_start_time: Some(attempt_start_time), @@ -443,15 +438,10 @@ impl Partition { req: &ReadRequest, gax_options: GaxRequestOptions, ) -> crate::Result { - let channel_hint = client.next_channel_hint(); - let gax_options = client.attach_request_id(gax_options, channel_hint); + let builder = client.streaming_read(req.clone(), gax_options, None); + let actual_gax_options = builder.options().clone(); let (stream, attempt_start_time) = - Self::execute_partition_stream(client, "StreamingRead", || { - client - .streaming_read(req.clone(), gax_options.clone(), channel_hint) - .send() - }) - .await?; + Self::execute_partition_stream(client, "StreamingRead", move || builder.send()).await?; ResultSet::create(ResultSetParams { stream, @@ -466,8 +456,7 @@ impl Partition { session_name: req.session.clone(), transaction_tag: None, operation: StreamOperation::Read(req.clone()), - channel_hint, - gax_options, + gax_options: actual_gax_options, method_name: "StreamingRead", attempt_start_time: Some(attempt_start_time), operation_start_time: Some(attempt_start_time), diff --git a/src/spanner/src/batch_write_transaction.rs b/src/spanner/src/batch_write_transaction.rs index 3ad9a95264..37575d95de 100644 --- a/src/spanner/src/batch_write_transaction.rs +++ b/src/spanner/src/batch_write_transaction.rs @@ -209,12 +209,10 @@ impl BatchWriteTransactionBuilder { /// ``` pub fn build(self) -> BatchWriteTransaction { let session_name = self.client.session_name(); - let channel_hint = self.client.next_channel_hint(); let gax_options = apply_defaults(self.gax_options); BatchWriteTransaction { session_name, client: self.client, - channel_hint, transaction_tag: self.transaction_tag, priority: self.priority, exclude_txn_from_change_streams: self.exclude_txn_from_change_streams, @@ -230,7 +228,6 @@ impl BatchWriteTransactionBuilder { pub struct BatchWriteTransaction { session_name: String, client: DatabaseClient, - channel_hint: usize, transaction_tag: Option, priority: Priority, exclude_txn_from_change_streams: bool, @@ -291,7 +288,6 @@ impl BatchWriteTransaction { Ok(BatchWriteResponseStream { client: self.client, session_name: self.session_name, - channel_hint: self.channel_hint, transaction_tag: self.transaction_tag, priority: self.priority, exclude_txn_from_change_streams: self.exclude_txn_from_change_streams, @@ -317,7 +313,6 @@ impl BatchWriteTransaction { pub struct BatchWriteResponseStream { client: DatabaseClient, session_name: String, - channel_hint: usize, transaction_tag: Option, priority: Priority, exclude_txn_from_change_streams: bool, @@ -436,7 +431,7 @@ impl BatchWriteResponseStream { let stream_result = self .client - .batch_write(request, self.gax_options.clone(), self.channel_hint) + .batch_write(request, self.gax_options.clone(), None) .send() .await; @@ -540,8 +535,6 @@ impl BatchWriteResponseStream { match self.check_retry(error) { Ok(()) => { self.retry_count += 1; - // Rotate channel hint only when a retry is confirmed to distribute load across healthy connections. - self.channel_hint = self.client.next_channel_hint(); if let Some(policy) = self.gax_options.backoff_policy() { let state = RetryState::new(true).set_attempt_count(self.retry_count as u32); let delay = policy.on_failure(&state); diff --git a/src/spanner/src/channel_pool/affinity.rs b/src/spanner/src/channel_pool/affinity.rs index 97186a5063..83d0a45bf3 100644 --- a/src/spanner/src/channel_pool/affinity.rs +++ b/src/spanner/src/channel_pool/affinity.rs @@ -17,6 +17,9 @@ //! Provides caller-owned handles to pin multi-statement transactions to the same physical //! channel and support both hard affinity (Read/Write transactions) and soft affinity (Read-Only transactions). +use crate::channel_pool::entry::{ChannelLease, RwTransactionAffinityGuard}; +use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; /// Caller-owned handle managing channel affinity across multi-statement transactions. @@ -24,6 +27,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; pub(crate) struct TransactionAffinity { entry_id: AtomicU64, kind: AffinityKind, + rw_guard: Mutex>, } impl Default for TransactionAffinity { @@ -33,16 +37,12 @@ impl Default for TransactionAffinity { } impl TransactionAffinity { - /// Creates a new, unpinned `TransactionAffinity` handle for Read/Write transactions (hard stickiness). - pub(crate) fn new() -> Self { - Self::new_read_write() - } - /// Creates a new, unpinned `TransactionAffinity` handle for Read/Write transactions (hard stickiness). pub(crate) fn new_read_write() -> Self { Self { entry_id: AtomicU64::new(0), kind: AffinityKind::ReadWrite, + rw_guard: Mutex::new(None), } } @@ -51,31 +51,31 @@ impl TransactionAffinity { Self { entry_id: AtomicU64::new(0), kind: AffinityKind::ReadOnly, + rw_guard: Mutex::new(None), } } + /// Returns the provided affinity handle, or creates a new default `ReadOnly` affinity if `None`. + pub(crate) fn default_read_only(existing: Option>) -> Arc { + existing.unwrap_or_else(|| Arc::new(Self::new_read_only())) + } + + /// Returns the provided affinity handle, or creates a new default `ReadWrite` affinity if `None`. + pub(crate) fn default_read_write(existing: Option>) -> Arc { + existing.unwrap_or_else(|| Arc::new(Self::new_read_write())) + } + /// Returns `true` if this handle requires hard stickiness (Read/Write transactions). pub(crate) fn is_read_write(&self) -> bool { self.kind == AffinityKind::ReadWrite } - /// Returns `true` if this handle uses soft stickiness (Read-Only transactions). - pub(crate) fn is_read_only(&self) -> bool { - self.kind == AffinityKind::ReadOnly - } - /// Returns the pinned monotonic channel entry ID, or `None` if unpinned. pub(crate) fn pinned_entry_id(&self) -> Option { let id = self.entry_id.load(Ordering::Acquire); (id != 0).then_some(id) } - /// Sets the pinned channel entry ID. - pub(crate) fn set_entry_id(&self, entry_id: u64) { - debug_assert_ne!(entry_id, 0, "entry_id must be non-zero"); - self.entry_id.store(entry_id, Ordering::Release); - } - /// Atomically sets the pinned channel entry ID if matching `current`. /// /// Returns `Ok(())` if this caller won the pin, or `Err(winner_id)` containing @@ -86,9 +86,21 @@ impl TransactionAffinity { .map(|_| ()) } - /// Clears the pinned channel entry ID, making this handle unpinned. - pub(crate) fn reset(&self) { - self.entry_id.store(0, Ordering::Release); + /// Ensures that an `RwTransactionAffinityGuard` is attached for the leased channel entry. + /// + /// If an existing guard already protects `lease.entry_id()`, this is a no-op that avoids + /// allocating a new guard or mutating active transaction atomic counters. + pub(crate) fn ensure_rw_guard(&self, lease: &ChannelLease) { + let mut slot = self + .rw_guard + .lock() + .expect("affinity rw_guard lock poisoned"); + if let Some(existing) = slot.as_ref() + && existing.entry_id() == lease.entry_id() + { + return; + } + *slot = Some(lease.rw_affinity_guard()); } } @@ -104,10 +116,41 @@ pub(crate) enum AffinityKind { ReadOnly, } +#[cfg(test)] +impl TransactionAffinity { + pub(crate) fn is_read_only(&self) -> bool { + self.kind == AffinityKind::ReadOnly + } + + pub(crate) fn reset(&self) { + self.entry_id.store(0, Ordering::Release); + let mut slot = self + .rw_guard + .lock() + .expect("affinity rw_guard lock poisoned"); + *slot = None; + } + + pub(crate) fn has_rw_guard(&self) -> bool { + self.rw_guard + .lock() + .expect("affinity rw_guard lock poisoned") + .is_some() + } +} + #[cfg(test)] mod tests { use super::*; + use crate::channel_pool::entry::{ActiveRpcGuard, ChannelEntry}; + use crate::client::Channel; + use crate::generated::gapic_dataplane::stub::Spanner as SpannerStub; use std::fmt::Debug; + use std::time::Duration; + + #[derive(Debug)] + struct DummyStub; + impl SpannerStub for DummyStub {} #[test] fn traits() { @@ -115,6 +158,24 @@ mod tests { static_assertions::assert_impl_all!(AffinityKind: Clone, Copy, Debug, PartialEq, Eq, Send, Sync); } + impl TransactionAffinity { + fn attach_rw_guard(&self, guard: RwTransactionAffinityGuard) { + let mut slot = self + .rw_guard + .lock() + .expect("affinity rw_guard lock poisoned"); + match slot.as_ref() { + Some(existing) if existing.entry_id() == guard.entry_id() => {} + _ => *slot = Some(guard), + } + } + + fn set_entry_id(&self, entry_id: u64) { + debug_assert_ne!(entry_id, 0, "entry_id must be non-zero"); + self.entry_id.store(entry_id, Ordering::Release); + } + } + #[test] fn transaction_affinity_pin_and_reset() { assert_eq!( @@ -127,11 +188,6 @@ mod tests { default_affinity.is_read_write(), "Default affinity must be ReadWrite" ); - let new_affinity = TransactionAffinity::new(); - assert!( - new_affinity.is_read_write(), - "TransactionAffinity::new must be ReadWrite" - ); let affinity = TransactionAffinity::new_read_write(); assert!( @@ -194,4 +250,141 @@ mod tests { "ReadOnly affinity is not ReadWrite" ); } + + #[test] + fn transaction_affinity_defaults() { + let default_read_only = TransactionAffinity::default_read_only(None); + assert!( + default_read_only.is_read_only(), + "default_read_only(None) must return ReadOnly affinity" + ); + + let custom_read_only = Arc::new(TransactionAffinity::new_read_only()); + let passed_read_only = + TransactionAffinity::default_read_only(Some(Arc::clone(&custom_read_only))); + assert!( + Arc::ptr_eq(&custom_read_only, &passed_read_only), + "default_read_only(Some(handle)) must return existing handle without recreating" + ); + + let default_read_write = TransactionAffinity::default_read_write(None); + assert!( + default_read_write.is_read_write(), + "default_read_write(None) must return ReadWrite affinity" + ); + + let custom_read_write = Arc::new(TransactionAffinity::new_read_write()); + let passed_read_write = + TransactionAffinity::default_read_write(Some(Arc::clone(&custom_read_write))); + assert!( + Arc::ptr_eq(&custom_read_write, &passed_read_write), + "default_read_write(Some(handle)) must return existing handle without recreating" + ); + } + + #[test] + fn attach_rw_guard_repinning_replaces_old_guard() { + let channel1 = Channel::new_for_test(DummyStub); + let channel2 = Channel::new_for_test(DummyStub); + let entry1 = Arc::new(ChannelEntry::new(1, 10, channel1)); + let entry2 = Arc::new(ChannelEntry::new(2, 20, channel2)); + + assert_eq!( + entry1.active_rw_count(), + 0, + "entry1 initial active_rw_count must be 0" + ); + assert_eq!( + entry2.active_rw_count(), + 0, + "entry2 initial active_rw_count must be 0" + ); + + let affinity = TransactionAffinity::new_read_write(); + assert!( + !affinity.has_rw_guard(), + "affinity must not have rw guard initially" + ); + + // First attach for entry 1 + affinity.attach_rw_guard(RwTransactionAffinityGuard::new(Arc::clone(&entry1))); + assert!(affinity.has_rw_guard(), "guard must be attached"); + assert_eq!( + entry1.active_rw_count(), + 1, + "entry1 active_rw_count must be 1" + ); + assert_eq!( + entry2.active_rw_count(), + 0, + "entry2 active_rw_count must be 0" + ); + + // Duplicate attach for entry 1 (same entry id) must not increment count + affinity.attach_rw_guard(RwTransactionAffinityGuard::new(Arc::clone(&entry1))); + assert_eq!( + entry1.active_rw_count(), + 1, + "duplicate attach for entry1 must keep active_rw_count at 1" + ); + + // Repinning attach for entry 2: old guard on entry 1 is dropped and replaced with guard on entry 2 + affinity.attach_rw_guard(RwTransactionAffinityGuard::new(Arc::clone(&entry2))); + assert_eq!( + entry1.active_rw_count(), + 0, + "entry1 active_rw_count must drop to 0 after repinning" + ); + assert_eq!( + entry2.active_rw_count(), + 1, + "entry2 active_rw_count must be 1 after repinning" + ); + + // Reset drops any attached guard + affinity.reset(); + assert!( + !affinity.has_rw_guard(), + "has_rw_guard must be false after reset" + ); + assert_eq!( + entry2.active_rw_count(), + 0, + "entry2 active_rw_count must drop to 0 after reset" + ); + } + + #[test] + fn ensure_rw_guard_attaches_and_is_noop_on_same_entry() { + let channel1 = Channel::new_for_test(DummyStub); + let channel2 = Channel::new_for_test(DummyStub); + let entry1 = Arc::new(ChannelEntry::new(1, 1, channel1)); + let entry2 = Arc::new(ChannelEntry::new(2, 2, channel2)); + + let affinity = TransactionAffinity::new_read_write(); + let lease1 = ChannelLease::new(ActiveRpcGuard::new( + Arc::clone(&entry1), + 0, + Duration::ZERO, + 0, + )); + affinity.ensure_rw_guard(&lease1); + assert!(affinity.has_rw_guard(), "guard must be attached"); + assert_eq!(entry1.active_rw_count(), 1, "entry1 count must be 1"); + + // Calling ensure_rw_guard again for the same entry does not increment count + affinity.ensure_rw_guard(&lease1); + assert_eq!(entry1.active_rw_count(), 1, "entry1 count must remain 1"); + + // Calling ensure_rw_guard with lease2 repins and drops old guard + let lease2 = ChannelLease::new(ActiveRpcGuard::new( + Arc::clone(&entry2), + 0, + Duration::ZERO, + 0, + )); + affinity.ensure_rw_guard(&lease2); + assert_eq!(entry1.active_rw_count(), 0, "entry1 count must drop to 0"); + assert_eq!(entry2.active_rw_count(), 1, "entry2 count must be 1"); + } } diff --git a/src/spanner/src/channel_pool/config.rs b/src/spanner/src/channel_pool/config.rs index 69164ea488..1fab8ee54a 100644 --- a/src/spanner/src/channel_pool/config.rs +++ b/src/spanner/src/channel_pool/config.rs @@ -21,8 +21,17 @@ use std::time::Duration; pub(crate) const MAX_SUPPORTED_CHANNELS: usize = 256; /// Strategy used to select channels from the active pool. -// TODO: Make public when dynamic channel pooling feature is ready for release. +/// +/// # Example +/// ```ignore +/// use google_cloud_spanner::channel_pool::ChannelSelectionStrategy; +/// +/// let strategy = ChannelSelectionStrategy::PowerOfTwoLeastBusy; +/// assert_eq!(strategy, ChannelSelectionStrategy::default()); +/// ``` #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +#[allow(dead_code)] pub(crate) enum ChannelSelectionStrategy { /// Power of Two Least Busy (samples 2 candidates, picks lower effective load, breaks ties with warmer channel). #[default] @@ -30,8 +39,17 @@ pub(crate) enum ChannelSelectionStrategy { } /// Configuration for the Spanner client channel pool. -// TODO: Make public when dynamic channel pooling feature is ready for release. +/// +/// # Example +/// ```ignore +/// use google_cloud_spanner::channel_pool::{ChannelPoolConfig, StaticChannelPoolConfig}; +/// +/// let config = ChannelPoolConfig::from(StaticChannelPoolConfig::new(8)); +/// ``` +/// +/// Supports either static fixed-size channel pooling or autonomous dynamic load-based channel scaling. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub(crate) enum ChannelPoolConfig { /// Fixed-size static channel pool (default: 4 channels). Static(StaticChannelPoolConfig), @@ -61,19 +79,19 @@ impl ChannelPoolConfig { Self::Static(_) => None, } } - - /// Returns a reference to the `StaticChannelPoolConfig` if static. - pub(crate) fn static_config(&self) -> Option<&StaticChannelPoolConfig> { - match self { - Self::Static(config) => Some(config), - Self::Dynamic(_) => None, - } - } } /// Configuration for a static (fixed-size) channel pool. -// TODO: Make public when dynamic channel pooling feature is ready for release. +/// +/// # Example +/// ```ignore +/// use google_cloud_spanner::channel_pool::StaticChannelPoolConfig; +/// +/// let config = StaticChannelPoolConfig::new(8); +/// assert_eq!(config.num_channels, 8); +/// ``` #[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] pub(crate) struct StaticChannelPoolConfig { /// Number of channels in the static pool (default: 4). pub(crate) num_channels: usize, @@ -85,7 +103,21 @@ impl Default for StaticChannelPoolConfig { } } +#[allow(dead_code)] impl StaticChannelPoolConfig { + /// Creates a new static channel pool configuration with the specified number of channels. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::StaticChannelPoolConfig; + /// + /// let config = StaticChannelPoolConfig::new(4); + /// assert_eq!(config.num_channels, 4); + /// ``` + pub(crate) fn new(num_channels: usize) -> Self { + Self { num_channels } + } + /// Validates the static pool configuration. pub(crate) fn validate(&self) -> Result<(), GaxError> { if self.num_channels == 0 { @@ -107,8 +139,22 @@ impl From for ChannelPoolConfig { } /// Configuration for a dynamically scaling channel pool. -// TODO: Make public when dynamic channel pooling feature is ready for release. +/// +/// # Example +/// ```ignore +/// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; +/// +/// let config = DynamicChannelPoolConfig::new() +/// .with_initial_channels(4) +/// .with_min_channels(2) +/// .with_max_channels(16); +/// assert_eq!(config.initial_channels, 4); +/// assert_eq!(config.max_channels, 16); +/// ``` +/// +/// Manages autonomous elastic scaling of gRPC channels based on in-flight RPC load and error feedback. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub(crate) struct DynamicChannelPoolConfig { /// Number of channels created eagerly at startup (default: 4). pub(crate) initial_channels: usize, @@ -167,7 +213,259 @@ impl Default for DynamicChannelPoolConfig { } } +#[allow(dead_code)] impl DynamicChannelPoolConfig { + /// Creates a new default dynamic channel pool configuration. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new(); + /// assert_eq!(config.initial_channels, 4); + /// ``` + pub(crate) fn new() -> Self { + Self::default() + } + + /// Sets the number of channels created eagerly at startup. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_initial_channels(6); + /// assert_eq!(config.initial_channels, 6); + /// ``` + pub(crate) fn with_initial_channels(mut self, channels: usize) -> Self { + self.initial_channels = channels; + self + } + + /// Sets the minimum number of channels retained during scale-down. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_min_channels(2); + /// assert_eq!(config.min_channels, 2); + /// ``` + pub(crate) fn with_min_channels(mut self, channels: usize) -> Self { + self.min_channels = channels; + self + } + + /// Sets the maximum number of channels allowed during scale-up. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_max_channels(16); + /// assert_eq!(config.max_channels, 16); + /// ``` + pub(crate) fn with_max_channels(mut self, channels: usize) -> Self { + self.max_channels = channels; + self + } + + /// Sets the low-load threshold (per channel) triggering scale-down evaluation. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_min_rpc_per_channel(10.0); + /// assert_eq!(config.min_rpc_per_channel, 10.0); + /// ``` + pub(crate) fn with_min_rpc_per_channel(mut self, min_rpc: f64) -> Self { + self.min_rpc_per_channel = min_rpc; + self + } + + /// Sets the high-load threshold (per channel) triggering scale-up. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_max_rpc_per_channel(30.0); + /// assert_eq!(config.max_rpc_per_channel, 30.0); + /// ``` + pub(crate) fn with_max_rpc_per_channel(mut self, max_rpc: f64) -> Self { + self.max_rpc_per_channel = max_rpc; + self + } + + /// Sets the synthetic picker load added per qualifying transport error. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_error_penalty_step(10); + /// assert_eq!(config.error_penalty_step, 10); + /// ``` + pub(crate) fn with_error_penalty_step(mut self, step: u32) -> Self { + self.error_penalty_step = step; + self + } + + /// Sets the sliding window duration for active error penalties. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// use std::time::Duration; + /// + /// let config = DynamicChannelPoolConfig::new() + /// .with_error_penalty_duration(Duration::from_secs(10)); + /// assert_eq!(config.error_penalty_duration, Duration::from_secs(10)); + /// ``` + pub(crate) fn with_error_penalty_duration(mut self, duration: Duration) -> Self { + self.error_penalty_duration = duration; + self + } + + /// Sets the interval between periodic scale-down evaluations. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// use std::time::Duration; + /// + /// let config = DynamicChannelPoolConfig::new() + /// .with_scale_down_check_interval(Duration::from_secs(120)); + /// assert_eq!(config.scale_down_check_interval, Duration::from_secs(120)); + /// ``` + pub(crate) fn with_scale_down_check_interval(mut self, interval: Duration) -> Self { + self.scale_down_check_interval = interval; + self + } + + /// Sets the cooldown period between consecutive scale-up bursts. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// use std::time::Duration; + /// + /// let config = DynamicChannelPoolConfig::new() + /// .with_scale_up_cooldown(Duration::from_secs(15)); + /// assert_eq!(config.scale_up_cooldown, Duration::from_secs(15)); + /// ``` + pub(crate) fn with_scale_up_cooldown(mut self, cooldown: Duration) -> Self { + self.scale_up_cooldown = cooldown; + self + } + + /// Sets the number of consecutive low-load checks required before scale-down. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_consecutive_low_load_checks(5); + /// assert_eq!(config.consecutive_low_load_checks, 5); + /// ``` + pub(crate) fn with_consecutive_low_load_checks(mut self, checks: usize) -> Self { + self.consecutive_low_load_checks = checks; + self + } + + /// Sets the maximum percentage of current pool size added per scale-up event. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_max_scale_up_percent(50); + /// assert_eq!(config.max_scale_up_percent, 50); + /// ``` + pub(crate) fn with_max_scale_up_percent(mut self, percent: u32) -> Self { + self.max_scale_up_percent = percent; + self + } + + /// Sets the maximum number of channels marked draining per scale-down cycle. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_max_remove_channels(4); + /// assert_eq!(config.max_remove_channels, 4); + /// ``` + pub(crate) fn with_max_remove_channels(mut self, max_channels: usize) -> Self { + self.max_remove_channels = max_channels; + self + } + + /// Sets the idle grace duration a draining channel is kept alive after load drops to zero. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// use std::time::Duration; + /// + /// let config = DynamicChannelPoolConfig::new() + /// .with_drain_idle_grace(Duration::from_secs(30)); + /// assert_eq!(config.drain_idle_grace, Duration::from_secs(30)); + /// ``` + pub(crate) fn with_drain_idle_grace(mut self, grace: Duration) -> Self { + self.drain_idle_grace = grace; + self + } + + /// Sets the timeout for executing `SELECT 1` priming on a new scaled-up channel. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// use std::time::Duration; + /// + /// let config = DynamicChannelPoolConfig::new() + /// .with_prime_timeout(Duration::from_secs(5)); + /// assert_eq!(config.prime_timeout, Duration::from_secs(5)); + /// ``` + pub(crate) fn with_prime_timeout(mut self, timeout: Duration) -> Self { + self.prime_timeout = timeout; + self + } + + /// Sets the maximum retry attempts for `SELECT 1` priming. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::DynamicChannelPoolConfig; + /// + /// let config = DynamicChannelPoolConfig::new().with_prime_max_attempts(5); + /// assert_eq!(config.prime_max_attempts, 5); + /// ``` + pub(crate) fn with_prime_max_attempts(mut self, attempts: usize) -> Self { + self.prime_max_attempts = attempts; + self + } + + /// Sets the channel selection strategy. + /// + /// # Example + /// ```ignore + /// use google_cloud_spanner::channel_pool::{ChannelSelectionStrategy, DynamicChannelPoolConfig}; + /// + /// let config = DynamicChannelPoolConfig::new() + /// .with_selection_strategy(ChannelSelectionStrategy::PowerOfTwoLeastBusy); + /// assert_eq!( + /// config.selection_strategy, + /// ChannelSelectionStrategy::PowerOfTwoLeastBusy + /// ); + /// ``` + pub(crate) fn with_selection_strategy(mut self, strategy: ChannelSelectionStrategy) -> Self { + self.selection_strategy = strategy; + self + } + /// Validates dynamic channel pool configuration boundaries and invariant relationships. pub(crate) fn validate(&self) -> Result<(), GaxError> { if self.min_channels == 0 { @@ -297,6 +595,15 @@ mod tests { ); } + impl ChannelPoolConfig { + fn static_config(&self) -> Option<&StaticChannelPoolConfig> { + match self { + Self::Static(config) => Some(config), + Self::Dynamic(_) => None, + } + } + } + #[test] fn config_defaults_and_precedence() { let static_config = StaticChannelPoolConfig::default(); @@ -738,4 +1045,95 @@ mod tests { "error_penalty_max must equal ceil(25.2) -> 26" ); } + + #[test] + fn static_channel_pool_config_new() { + let config = StaticChannelPoolConfig::new(8); + assert_eq!(config.num_channels, 8, "num_channels must be 8"); + assert!(config.validate().is_ok(), "validation must succeed"); + } + + #[test] + fn dynamic_channel_pool_config_builder() { + let config = DynamicChannelPoolConfig::new() + .with_initial_channels(5) + .with_min_channels(3) + .with_max_channels(12) + .with_min_rpc_per_channel(10.0) + .with_max_rpc_per_channel(20.0) + .with_error_penalty_step(8) + .with_error_penalty_duration(Duration::from_secs(15)) + .with_scale_down_check_interval(Duration::from_secs(120)) + .with_scale_up_cooldown(Duration::from_secs(30)) + .with_consecutive_low_load_checks(5) + .with_max_scale_up_percent(50) + .with_max_remove_channels(3) + .with_drain_idle_grace(Duration::from_secs(90)) + .with_prime_timeout(Duration::from_secs(20)) + .with_prime_max_attempts(5) + .with_selection_strategy(ChannelSelectionStrategy::PowerOfTwoLeastBusy); + + assert_eq!(config.initial_channels, 5, "initial_channels must match"); + assert_eq!(config.min_channels, 3, "min_channels must match"); + assert_eq!(config.max_channels, 12, "max_channels must match"); + assert_eq!( + config.min_rpc_per_channel, 10.0, + "min_rpc_per_channel must match" + ); + assert_eq!( + config.max_rpc_per_channel, 20.0, + "max_rpc_per_channel must match" + ); + assert_eq!( + config.error_penalty_step, 8, + "error_penalty_step must match" + ); + assert_eq!( + config.error_penalty_duration, + Duration::from_secs(15), + "error_penalty_duration must match" + ); + assert_eq!( + config.scale_down_check_interval, + Duration::from_secs(120), + "scale_down_check_interval must match" + ); + assert_eq!( + config.scale_up_cooldown, + Duration::from_secs(30), + "scale_up_cooldown must match" + ); + assert_eq!( + config.consecutive_low_load_checks, 5, + "consecutive_low_load_checks must match" + ); + assert_eq!( + config.max_scale_up_percent, 50, + "max_scale_up_percent must match" + ); + assert_eq!( + config.max_remove_channels, 3, + "max_remove_channels must match" + ); + assert_eq!( + config.drain_idle_grace, + Duration::from_secs(90), + "drain_idle_grace must match" + ); + assert_eq!( + config.prime_timeout, + Duration::from_secs(20), + "prime_timeout must match" + ); + assert_eq!( + config.prime_max_attempts, 5, + "prime_max_attempts must match" + ); + assert_eq!( + config.selection_strategy, + ChannelSelectionStrategy::PowerOfTwoLeastBusy, + "selection_strategy must match" + ); + assert!(config.validate().is_ok(), "validation must succeed"); + } } diff --git a/src/spanner/src/channel_pool/entry.rs b/src/spanner/src/channel_pool/entry.rs index 331a5ec189..ee1e71f0c5 100644 --- a/src/spanner/src/channel_pool/entry.rs +++ b/src/spanner/src/channel_pool/entry.rs @@ -15,7 +15,9 @@ //! Channel entry lifecycle, atomic accounting, and RAII drop guards. use crate::client::Channel; +use crate::server_streaming::stream::StreamGuard; use google_cloud_gax::error::rpc::Code; +use std::ops::Deref; use std::result::Result; use std::sync::Arc; use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering}; @@ -40,8 +42,6 @@ pub(crate) enum ChannelState { pub(crate) struct ChannelEntry { /// Monotonically increasing unique internal ID for transaction affinity pinning. pub(crate) id: u64, - /// Logical 1-based channel slot (1..=max_channels) passed to `x-goog-spanner-request-id`. - pub(crate) logical_channel_id: usize, /// Physical gRPC channel instance. pub(crate) channel: Channel, /// Count of active RPCs currently executing over the wire. @@ -76,7 +76,6 @@ impl ChannelEntry { channel.channel_id = logical_channel_id; Self { id, - logical_channel_id, channel, in_flight_rpcs: AtomicU32::new(0), active_rw_transactions: AtomicU32::new(0), @@ -87,6 +86,11 @@ impl ChannelEntry { } } + /// Logical 1-based channel slot (1..=max_channels) passed to `x-goog-spanner-request-id`. + pub(crate) fn logical_channel_id(&self) -> usize { + self.channel.channel_id + } + pub(crate) fn decode_penalty_state(packed: u64) -> (u32, u64) { let penalty_load = (packed >> 48) as u32; let expiry_millis = packed & 0x0000_FFFF_FFFF_FFFF; @@ -106,11 +110,6 @@ impl ChannelEntry { .fetch_max(elapsed, Ordering::Relaxed); } - /// Returns the raw activity timestamp in nanoseconds from `created_at` for warmth comparisons. - pub(crate) fn last_activity_nanos(&self) -> u64 { - self.last_activity_nanos.load(Ordering::Relaxed) - } - /// Returns the current number of in-flight RPCs on this channel. pub(crate) fn in_flight(&self) -> u32 { self.in_flight_rpcs.load(Ordering::Relaxed) @@ -200,6 +199,7 @@ impl ChannelEntry { } /// Checks if the channel entry is currently in the `Draining` state. + #[allow(dead_code)] // State query helper for Draining state; used in tests and future scale-down inspection pub(crate) fn is_draining(&self) -> bool { self.state() == ChannelState::Draining } @@ -267,6 +267,12 @@ impl ActiveRpcGuard { } } +impl StreamGuard for ActiveRpcGuard { + fn record_error_code(&self, code: Code) { + ActiveRpcGuard::record_error_code(self, code); + } +} + impl Drop for ActiveRpcGuard { fn drop(&mut self) { self.entry.in_flight_rpcs.fetch_sub(1, Ordering::Relaxed); @@ -286,6 +292,11 @@ impl RwTransactionAffinityGuard { entry.active_rw_transactions.fetch_add(1, Ordering::Relaxed); Self { entry } } + + /// Returns the monotonic entry ID of the guarded channel entry. + pub(crate) fn entry_id(&self) -> u64 { + self.entry.id + } } impl Drop for RwTransactionAffinityGuard { @@ -317,16 +328,25 @@ impl ChannelLease { Self { guard } } + /// Consumes the lease, returning the underlying active RPC guard. + pub(crate) fn into_guard(self) -> ActiveRpcGuard { + self.guard + } + + /// Records the result of an RPC call and applies an error penalty if a qualifying error occurred. + pub(crate) fn record_result( + &self, + result: &Result, + extract_code: impl Fn(&E) -> Option, + ) { + self.guard.record_result(result, extract_code); + } + /// Returns a reference to the physical `Channel`. pub(crate) fn channel(&self) -> &Channel { &self.guard.entry.channel } - /// Returns the logical 1-based channel slot (1..=max_channels) for request ID tagging. - pub(crate) fn logical_channel_id(&self) -> usize { - self.guard.entry.logical_channel_id - } - /// Returns the unique monotonic internal entry ID. pub(crate) fn entry_id(&self) -> u64 { self.guard.entry.id @@ -338,6 +358,16 @@ impl ChannelLease { } } +/// Enables `ChannelLease` to dereference transparently to the underlying `Channel`, +/// allowing callers to pass `&lease` anywhere a `&Channel` is required. +impl Deref for ChannelLease { + type Target = Channel; + + fn deref(&self) -> &Self::Target { + &self.guard.entry.channel + } +} + #[cfg(test)] mod tests { use super::*; @@ -545,7 +575,8 @@ mod tests { assert_eq!(entry.id, 10, "id must match constructor arg"); assert_eq!( - entry.logical_channel_id, 2, + entry.logical_channel_id(), + 2, "logical_channel_id must match constructor arg" ); assert!(entry.is_active(), "New channel entry must start Active"); @@ -599,11 +630,11 @@ mod tests { ); // Activity timestamps - let initial_activity = entry.last_activity_nanos(); + let initial_activity = entry.last_activity_nanos.load(Ordering::Relaxed); assert_eq!(initial_activity, 0, "Initial last_activity_nanos must be 0"); entry.touch_activity(); - let updated_activity = entry.last_activity_nanos(); + let updated_activity = entry.last_activity_nanos.load(Ordering::Relaxed); assert!( updated_activity > 0, "touch_activity() must set last_activity_nanos > 0" @@ -680,14 +711,19 @@ mod tests { "entry_id() must return entry's internal id 42" ); assert_eq!( - lease.logical_channel_id(), + lease.channel().channel_id, 3, - "logical_channel_id() must return entry's logical id 3" + "channel.channel_id must match entry's logical id 3" ); let _channel = lease.channel(); // rw_affinity_guard helper creates an RAII guard incrementing active_rw_transactions let rw_guard = lease.rw_affinity_guard(); + assert_eq!( + rw_guard.entry_id(), + 42, + "rw_affinity_guard() must report matching entry_id" + ); assert_eq!( entry.active_rw_count(), 1, @@ -700,4 +736,33 @@ mod tests { "dropping RwTransactionAffinityGuard must decrement active_rw_transactions" ); } + + #[test] + fn channel_lease_into_guard() { + let channel = create_mock_channel(); + let entry = Arc::new(ChannelEntry::new(42, 3, channel)); + + assert_eq!(entry.in_flight(), 0, "initial in-flight count must be 0"); + let guard = ActiveRpcGuard::new(Arc::clone(&entry), 0, Duration::ZERO, 0); + let lease = ChannelLease::new(guard); + assert_eq!( + entry.in_flight(), + 1, + "creating guard must increment in-flight count" + ); + + let guard = lease.into_guard(); + assert_eq!( + entry.in_flight(), + 1, + "into_guard must preserve in-flight count" + ); + + drop(guard); + assert_eq!( + entry.in_flight(), + 0, + "dropping ActiveRpcGuard must decrement in-flight count" + ); + } } diff --git a/src/spanner/src/channel_pool/mod.rs b/src/spanner/src/channel_pool/mod.rs index ab1e24b8c0..64a9264215 100644 --- a/src/spanner/src/channel_pool/mod.rs +++ b/src/spanner/src/channel_pool/mod.rs @@ -18,10 +18,6 @@ //! health-aware error penalization, caller-owned transaction affinity pinning, and background //! scaling and priming for gRPC channels. -// TODO(dynamic-channel-pooling): Remove allow(dead_code, unused_imports) once integrated into Spanner client. -#![allow(dead_code)] -#![allow(unused_imports)] - pub(crate) mod affinity; pub(crate) mod config; pub(crate) mod entry; @@ -29,8 +25,9 @@ pub(crate) mod pool; pub(crate) mod scaler; pub(crate) use affinity::TransactionAffinity; +#[allow(unused_imports)] pub(crate) use config::{ ChannelPoolConfig, ChannelSelectionStrategy, DynamicChannelPoolConfig, StaticChannelPoolConfig, }; -pub(crate) use entry::{ActiveRpcGuard, ChannelEntry, ChannelLease, RwTransactionAffinityGuard}; +pub(crate) use entry::ChannelLease; pub(crate) use pool::ChannelPool; diff --git a/src/spanner/src/channel_pool/pool.rs b/src/spanner/src/channel_pool/pool.rs index fdf2feec2f..2079b7ef43 100644 --- a/src/spanner/src/channel_pool/pool.rs +++ b/src/spanner/src/channel_pool/pool.rs @@ -26,14 +26,13 @@ use crate::channel_pool::scaler::{scale_down_monitor_loop, scale_up_worker_loop} use crate::client::Channel; use crate::routing::power_of_two_selector::PowerOfTwoSelector; use gaxi::options::ClientConfig; +use std::fmt::{Debug, Formatter, Result as FmtResult}; use std::sync::atomic::{AtomicU64, AtomicUsize}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; use tokio::spawn; use tokio::sync::Notify; -use tokio::sync::watch::{ - Receiver as WatchReceiver, Sender as WatchSender, channel as watch_channel, -}; +use tokio::sync::watch::{Sender as WatchSender, channel as watch_channel}; /// Unified channel pool managing gRPC channels for the Spanner client. /// @@ -43,6 +42,17 @@ pub(crate) struct ChannelPool { pub(crate) inner: Arc, } +impl Debug for ChannelPool { + fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult { + formatter + .debug_struct("ChannelPool") + .field("config", &self.inner.config) + .field("active_channels", &self.active_channel_count()) + .field("draining_channels", &self.draining_channel_count()) + .finish() + } +} + impl ChannelPool { /// Initializes active channel entries and monotonic ID allocator from input channels. fn initialize_entries(channels: Vec) -> (Vec>, AtomicU64) { @@ -181,7 +191,11 @@ impl ChannelPool { .iter() .find(|entry| entry.id == id && entry.is_active()) { - return Some(self.make_lease(Arc::clone(entry))); + let lease = self.make_lease(Arc::clone(entry)); + if affinity.is_read_write() { + affinity.ensure_rw_guard(&lease); + } + return Some(lease); } // 2. Draining-path: Only Read/Write transactions (hard stickiness) preserve draining affinity. @@ -192,7 +206,9 @@ impl ChannelPool { .iter() .find(|entry| entry.id == id && !entry.is_closed()) { - return Some(self.make_lease(Arc::clone(entry))); + let lease = self.make_lease(Arc::clone(entry)); + affinity.ensure_rw_guard(&lease); + return Some(lease); } } } @@ -203,25 +219,55 @@ impl ChannelPool { // Atomically attempt to pin this channel. If another concurrent thread pinned first, // use the winning channel to ensure all concurrent statements route to the same SpanFE. - match affinity.compare_and_set_entry_id(expected_id, lease.entry_id()) { - Ok(()) => Some(lease), - Err(winner_id) => { - if let Some(winner_entry) = active_guard + let final_lease = match affinity.compare_and_set_entry_id(expected_id, lease.entry_id()) { + Ok(()) => lease, + Err(winner_id) => self.resolve_cas_conflict(affinity, lease, &active_guard, winner_id), + }; + + if affinity.is_read_write() { + affinity.ensure_rw_guard(&final_lease); + } + + Some(final_lease) + } + + pub(crate) fn resolve_cas_conflict( + &self, + affinity: &TransactionAffinity, + lease: ChannelLease, + active_candidates: &[Arc], + mut winner_id: u64, + ) -> ChannelLease { + loop { + if let Some(winner_entry) = active_candidates + .iter() + .find(|entry| entry.id == winner_id && entry.is_active()) + { + return self.make_lease(Arc::clone(winner_entry)); + } + if affinity.is_read_write() { + let draining_guard = self.inner.draining_entries.read().expect("lock poisoned"); + if let Some(entry) = draining_guard .iter() - .find(|entry| entry.id == winner_id && entry.is_active()) + .find(|entry| entry.id == winner_id && !entry.is_closed()) { - return Some(self.make_lease(Arc::clone(winner_entry))); + return self.make_lease(Arc::clone(entry)); } - if affinity.is_read_write() { - let draining_guard = self.inner.draining_entries.read().expect("lock poisoned"); - if let Some(entry) = draining_guard - .iter() - .find(|entry| entry.id == winner_id && !entry.is_closed()) - { - return Some(self.make_lease(Arc::clone(entry))); + // Winner channel is closed/dead. Re-pin affinity to our active lease channel. + match affinity.compare_and_set_entry_id(winner_id, lease.entry_id()) { + Ok(()) => return lease, + Err(new_winner_id) => { + winner_id = new_winner_id; + continue; } } - Some(lease) + } + // Soft stickiness: winner is not active, re-pin affinity to our active lease channel. + match affinity.compare_and_set_entry_id(winner_id, lease.entry_id()) { + Ok(()) => return lease, + Err(new_winner_id) => { + winner_id = new_winner_id; + } } } } @@ -257,21 +303,6 @@ impl ChannelPool { self.inner.scale_up_notify.notify_one(); } - /// Clears the cached prime session name. - pub(crate) fn clear_prime_session(&self) { - let mut prime = self.inner.prime_session.write().expect("lock poisoned"); - *prime = None; - } - - /// Checks if a valid multiplexed session name is currently registered. - pub(crate) fn has_prime_session(&self) -> bool { - self.inner - .prime_session - .read() - .expect("lock poisoned") - .is_some() - } - /// Returns the total number of active channels in the pool. pub(crate) fn active_channel_count(&self) -> usize { self.inner @@ -290,10 +321,10 @@ impl ChannelPool { .len() } - /// Returns the total count of in-flight RPCs across all active channels. - pub(crate) fn total_in_flight_rpcs(&self) -> u32 { + /// Returns a clone of the first active channel in the pool, if present. + pub(crate) fn default_channel(&self) -> Option { let active_guard = self.inner.active_entries.read().expect("lock poisoned"); - active_guard.iter().map(|entry| entry.in_flight()).sum() + active_guard.first().map(|entry| entry.channel.clone()) } } @@ -305,6 +336,8 @@ pub(crate) struct ChannelPoolInner { pub(crate) draining_entries: RwLock>>, pub(crate) next_entry_id: AtomicU64, pub(crate) scale_up_notify: Arc, + #[allow(dead_code)] + // Retained for RAII drop signaling; read in scaler unit tests via subscribe() pub(crate) shutdown_sender: WatchSender<()>, pub(crate) last_scale_up_time: Mutex>, pub(crate) consecutive_low_load_checks: AtomicUsize, @@ -344,6 +377,29 @@ impl ChannelPoolInner { } } +#[cfg(test)] +impl ChannelPool { + pub(crate) fn config(&self) -> &ChannelPoolConfig { + &self.inner.config + } + + pub(crate) fn has_prime_session(&self) -> bool { + self.inner + .prime_session + .read() + .expect("lock poisoned") + .is_some() + } + + pub(crate) fn active_entries(&self) -> Vec> { + self.inner + .active_entries + .read() + .expect("lock poisoned") + .clone() + } +} + #[cfg(test)] mod tests { use super::*; @@ -380,10 +436,21 @@ mod tests { #[test] fn traits() { - static_assertions::assert_impl_all!(ChannelPool: Clone, Send, Sync); + static_assertions::assert_impl_all!(ChannelPool: Clone, Debug, Send, Sync); static_assertions::assert_impl_all!(ChannelPoolInner: Send, Sync); } + impl ChannelPool { + fn total_in_flight_rpcs(&self) -> u32 { + let active_guard = self.inner.active_entries.read().expect("lock poisoned"); + active_guard.iter().map(|entry| entry.in_flight()).sum() + } + + fn clear_prime_session(&self) { + *self.inner.prime_session.write().expect("lock poisoned") = None; + } + } + #[test] fn p2c_selection_avoids_loaded_channels_and_distributes_traffic() { let client_config = ClientConfig::default(); @@ -400,7 +467,7 @@ mod tests { let lease1 = pool.pick_channel().expect("channel pick should succeed"); assert!( - (1..=3).contains(&lease1.logical_channel_id()), + (1..=3).contains(&lease1.channel_id), "logical channel ID must be in range 1..=3" ); @@ -504,7 +571,9 @@ mod tests { // Read/Write transaction requires hard stickiness let affinity = TransactionAffinity::new_read_write(); - affinity.set_entry_id(2); // Pinned to channel 2 (which is draining) + affinity + .compare_and_set_entry_id(0, 2) + .expect("pin affinity"); // Pinned to channel 2 (which is draining) let lease = pool .resolve_affinity(&affinity) @@ -514,6 +583,27 @@ mod tests { 2, "Must preserve affinity to draining channel for Read/Write transactions" ); + assert_eq!( + channel_2.active_rw_count(), + 1, + "Draining channel must have active_rw_count = 1 while Read/Write affinity holds guard" + ); + assert!( + affinity.has_rw_guard(), + "Read/Write affinity must hold rw_guard" + ); + + // Reset clears the guard and decrements the active_rw_count + affinity.reset(); + assert_eq!( + channel_2.active_rw_count(), + 0, + "active_rw_count must decrement to 0 after affinity is reset" + ); + assert!( + !affinity.has_rw_guard(), + "affinity must not hold rw_guard after reset" + ); } #[test] @@ -535,7 +625,9 @@ mod tests { // Read-Only transaction uses soft stickiness let read_only_affinity = TransactionAffinity::new_read_only(); - read_only_affinity.set_entry_id(2); // Was pinned to channel 2 (now draining) + read_only_affinity + .compare_and_set_entry_id(0, 2) + .expect("pin affinity"); // Was pinned to channel 2 (now draining) let lease = pool .resolve_affinity(&read_only_affinity) @@ -623,7 +715,7 @@ mod tests { pool.clear_prime_session(); assert!( !pool.has_prime_session(), - "Prime session must be None after clear_prime_session" + "Prime session must be None after clearing" ); } @@ -751,7 +843,9 @@ mod tests { // 1. ReadWrite affinity pinned to a Closed draining channel -> must fallback to active channel let rw_affinity = TransactionAffinity::new_read_write(); - rw_affinity.set_entry_id(2); // Channel 2 is closed + rw_affinity + .compare_and_set_entry_id(0, 2) + .expect("pin affinity"); // Channel 2 is closed let lease = pool .resolve_affinity(&rw_affinity) .expect("must fallback to active channel when draining channel is closed"); @@ -764,7 +858,9 @@ mod tests { // 2. Affinity pinned to a non-existent channel ID -> must fallback to active channel let non_existent_affinity = TransactionAffinity::new_read_write(); - non_existent_affinity.set_entry_id(999); + non_existent_affinity + .compare_and_set_entry_id(0, 999) + .expect("pin affinity"); let lease_fallback = pool .resolve_affinity(&non_existent_affinity) .expect("must fallback to active channel for unknown channel ID"); @@ -780,6 +876,170 @@ mod tests { ); } + #[test] + fn resolve_cas_conflict_winner_in_active_candidates() { + let client_config = ClientConfig::default(); + let channel_1 = Arc::new(ChannelEntry::new(1, 1, create_mock_channel())); + let channel_2 = Arc::new(ChannelEntry::new(2, 2, create_mock_channel())); + + let pool = ChannelPool::new_static( + vec![create_mock_channel(), create_mock_channel()], + StaticChannelPoolConfig { num_channels: 2 }, + client_config, + ); + let active = vec![Arc::clone(&channel_1), Arc::clone(&channel_2)]; + *pool.inner.active_entries.write().expect("lock") = active.clone(); + + let affinity = TransactionAffinity::new_read_write(); + let my_lease = pool.make_lease(Arc::clone(&channel_1)); + + let resolved_lease = pool.resolve_cas_conflict(&affinity, my_lease, &active, 2); + assert_eq!( + resolved_lease.entry_id(), + 2, + "Resolved lease must match winner channel entry ID 2" + ); + } + + #[test] + fn resolve_cas_conflict_read_write_winner_in_draining_entries() { + let client_config = ClientConfig::default(); + let channel_1 = Arc::new(ChannelEntry::new(1, 1, create_mock_channel())); + let channel_2 = Arc::new(ChannelEntry::new(2, 2, create_mock_channel())); + channel_2.set_state(ChannelState::Draining); + + let pool = ChannelPool::new_static( + vec![create_mock_channel()], + StaticChannelPoolConfig { num_channels: 1 }, + client_config, + ); + let active = vec![Arc::clone(&channel_1)]; + *pool.inner.active_entries.write().expect("lock") = active.clone(); + *pool.inner.draining_entries.write().expect("lock") = vec![Arc::clone(&channel_2)]; + + let affinity = TransactionAffinity::new_read_write(); + let my_lease = pool.make_lease(Arc::clone(&channel_1)); + + let resolved_lease = pool.resolve_cas_conflict(&affinity, my_lease, &active, 2); + assert_eq!( + resolved_lease.entry_id(), + 2, + "ReadWrite affinity must return lease for draining winner channel 2" + ); + } + + #[test] + fn resolve_cas_conflict_read_write_winner_closed_or_unknown() { + let client_config = ClientConfig::default(); + let channel_1 = Arc::new(ChannelEntry::new(1, 1, create_mock_channel())); + let channel_2 = Arc::new(ChannelEntry::new(2, 2, create_mock_channel())); + channel_2.set_state(ChannelState::Closed); + + let pool = ChannelPool::new_static( + vec![create_mock_channel()], + StaticChannelPoolConfig { num_channels: 1 }, + client_config, + ); + let active = vec![Arc::clone(&channel_1)]; + *pool.inner.active_entries.write().expect("lock") = active.clone(); + *pool.inner.draining_entries.write().expect("lock") = vec![Arc::clone(&channel_2)]; + + let affinity = TransactionAffinity::new_read_write(); + affinity + .compare_and_set_entry_id(0, 2) + .expect("pin affinity to 2"); + + let my_lease = pool.make_lease(Arc::clone(&channel_1)); + + let resolved_lease = pool.resolve_cas_conflict(&affinity, my_lease, &active, 2); + assert_eq!( + resolved_lease.entry_id(), + 1, + "Must fallback to active lease 1 when winner channel is closed" + ); + assert_eq!( + affinity.pinned_entry_id(), + Some(1), + "Affinity must be re-pinned to active channel 1" + ); + } + + #[test] + fn resolve_cas_conflict_read_only_winner_not_active() { + let client_config = ClientConfig::default(); + let channel_1 = Arc::new(ChannelEntry::new(1, 1, create_mock_channel())); + let channel_2 = Arc::new(ChannelEntry::new(2, 2, create_mock_channel())); + channel_2.set_state(ChannelState::Draining); + + let pool = ChannelPool::new_static( + vec![create_mock_channel()], + StaticChannelPoolConfig { num_channels: 1 }, + client_config, + ); + let active = vec![Arc::clone(&channel_1)]; + *pool.inner.active_entries.write().expect("lock") = active.clone(); + *pool.inner.draining_entries.write().expect("lock") = vec![Arc::clone(&channel_2)]; + + let affinity = TransactionAffinity::new_read_only(); + affinity + .compare_and_set_entry_id(0, 2) + .expect("pin affinity to 2"); + + let my_lease = pool.make_lease(Arc::clone(&channel_1)); + + let resolved_lease = pool.resolve_cas_conflict(&affinity, my_lease, &active, 2); + assert_eq!( + resolved_lease.entry_id(), + 1, + "ReadOnly affinity must fallback to active lease 1 when winner is not active" + ); + assert_eq!( + affinity.pinned_entry_id(), + Some(1), + "Affinity must be re-pinned to active channel 1" + ); + } + + #[test] + fn resolve_cas_conflict_cas_failure_loops_and_resolves_with_new_winner() { + let client_config = ClientConfig::default(); + let channel_1 = Arc::new(ChannelEntry::new(1, 1, create_mock_channel())); + let channel_2 = Arc::new(ChannelEntry::new(2, 2, create_mock_channel())); + channel_2.set_state(ChannelState::Closed); + let channel_3 = Arc::new(ChannelEntry::new(3, 3, create_mock_channel())); + + let pool = ChannelPool::new_static( + vec![create_mock_channel(), create_mock_channel()], + StaticChannelPoolConfig { num_channels: 2 }, + client_config, + ); + let active = vec![Arc::clone(&channel_1), Arc::clone(&channel_3)]; + *pool.inner.active_entries.write().expect("lock") = active.clone(); + *pool.inner.draining_entries.write().expect("lock") = vec![Arc::clone(&channel_2)]; + + let affinity = TransactionAffinity::new_read_write(); + // Another thread already updated affinity from 2 to 3 + affinity + .compare_and_set_entry_id(0, 3) + .expect("pin affinity to 3"); + + let my_lease = pool.make_lease(Arc::clone(&channel_1)); + + // When this thread tries to resolve conflict with winner_id 2 (which is closed), + // CAS to re-pin to 1 fails because affinity is 3. The method must loop and resolve with 3! + let resolved_lease = pool.resolve_cas_conflict(&affinity, my_lease, &active, 2); + assert_eq!( + resolved_lease.entry_id(), + 3, + "Must loop and adopt winning channel 3 when CAS fails during re-pinning" + ); + assert_eq!( + affinity.pinned_entry_id(), + Some(3), + "Affinity must remain pinned to channel 3" + ); + } + #[test] fn make_guard_configurations() { let channel = create_mock_channel(); @@ -836,4 +1096,56 @@ mod tests { ); drop(dynamic_guard); } + + #[test] + fn channel_pool_helpers() { + let channels = vec![ + create_mock_channel(), + create_mock_channel(), + create_mock_channel(), + ]; + let pool = ChannelPool::new_static( + channels, + StaticChannelPoolConfig { num_channels: 3 }, + ClientConfig::default(), + ); + + assert_eq!( + pool.active_channel_count(), + 3, + "active_channel_count must match 3" + ); + assert!( + pool.default_channel().is_some(), + "default_channel must return the first channel" + ); + } + + #[test] + fn channel_pool_debug_formatting() { + let channels = vec![create_mock_channel(), create_mock_channel()]; + let pool = ChannelPool::new_static( + channels, + StaticChannelPoolConfig { num_channels: 2 }, + ClientConfig::default(), + ); + + let debug_output = format!("{pool:?}"); + assert!( + debug_output.contains("ChannelPool"), + "debug output should contain struct name: {debug_output}" + ); + assert!( + debug_output.contains("active_channels: 2"), + "debug output should contain active channel count: {debug_output}" + ); + assert!( + debug_output.contains("draining_channels: 0"), + "debug output should contain draining channel count: {debug_output}" + ); + assert!( + debug_output.contains("Static"), + "debug output should contain config details: {debug_output}" + ); + } } diff --git a/src/spanner/src/channel_pool/scaler.rs b/src/spanner/src/channel_pool/scaler.rs index 5788280a8b..57fb0cd16a 100644 --- a/src/spanner/src/channel_pool/scaler.rs +++ b/src/spanner/src/channel_pool/scaler.rs @@ -261,8 +261,8 @@ fn publish_primed_channel(inner: &ChannelPoolInner, channel: Channel, max_channe // Mark slots occupied by active channels for entry in active_write.iter() { - if entry.logical_channel_id <= MAX_SUPPORTED_CHANNELS { - occupied_slots[entry.logical_channel_id] = true; + if entry.logical_channel_id() <= MAX_SUPPORTED_CHANNELS { + occupied_slots[entry.logical_channel_id()] = true; } } @@ -270,8 +270,8 @@ fn publish_primed_channel(inner: &ChannelPoolInner, channel: Channel, max_channe { let draining_guard = inner.draining_entries.read().expect("lock poisoned"); for entry in draining_guard.iter() { - if !entry.is_closed() && entry.logical_channel_id <= MAX_SUPPORTED_CHANNELS { - occupied_slots[entry.logical_channel_id] = true; + if !entry.is_closed() && entry.logical_channel_id() <= MAX_SUPPORTED_CHANNELS { + occupied_slots[entry.logical_channel_id()] = true; } } } @@ -962,7 +962,8 @@ mod tests { "Active channel count must be 2 after publishing" ); assert_eq!( - active[1].logical_channel_id, 3, + active[1].logical_channel_id(), + 3, "New channel must receive slot 3" ); assert_eq!(active[1].id, 3, "New channel must receive monotonic ID 3"); diff --git a/src/spanner/src/client.rs b/src/spanner/src/client.rs index de7e753c4b..287d7b9838 100644 --- a/src/spanner/src/client.rs +++ b/src/spanner/src/client.rs @@ -12,13 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::ClientBuilderResult; use crate::RequestOptions; +use crate::Result; +use crate::channel_pool::{ + ChannelLease, ChannelPool, ChannelPoolConfig, StaticChannelPoolConfig, TransactionAffinity, +}; use crate::generated::gapic_dataplane::client::Spanner as GapicSpanner; use crate::model::{ - BeginTransactionRequest, CommitRequest, CommitResponse, CreateSessionRequest, - ExecuteBatchDmlRequest, ExecuteBatchDmlResponse, ExecuteSqlRequest, FetchCacheUpdateRequest, - PartitionQueryRequest, PartitionReadRequest, PartitionResponse, RollbackRequest, Session, - Transaction, + BatchWriteRequest, BeginTransactionRequest, CommitRequest, CommitResponse, + CreateSessionRequest, ExecuteBatchDmlRequest, ExecuteBatchDmlResponse, ExecuteSqlRequest, + FetchCacheUpdateRequest, PartitionQueryRequest, PartitionReadRequest, PartitionResponse, + ReadRequest, ResultSet as ModelResultSet, RollbackRequest, Session, Transaction, }; use crate::observability::Observability; #[cfg(feature = "_experimental-builtin-metrics")] @@ -42,10 +47,9 @@ use http::{ HeaderMap, header::{HeaderName, HeaderValue}, }; -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; +use std::env; +use std::sync::Arc; +use tokio::task::JoinSet; pub use crate::database_client::DatabaseClient; pub use google_cloud_spanner_admin_database_v1::client::DatabaseAdmin; @@ -58,8 +62,7 @@ pub use google_cloud_spanner_admin_instance_v1::client::InstanceAdmin; /// [Spanner]: https://docs.cloud.google.com/spanner/docs #[derive(Clone, Debug)] pub struct Spanner { - pub(crate) channels: Vec, - pub(crate) counter: Arc, + pub(crate) channel_pool: ChannelPool, pub(crate) config: ClientConfig, pub(crate) is_emulator: bool, pub(crate) instance_type: InstanceType, @@ -73,9 +76,9 @@ impl google_cloud_gax::client_builder::internal::ClientFactory for Factory { type Client = Spanner; type Credentials = Credentials; - async fn build(self, mut config: ClientConfig) -> crate::ClientBuilderResult { + async fn build(self, mut config: ClientConfig) -> ClientBuilderResult { let mut is_emulator = false; - if let Some(endpoint) = std::env::var("SPANNER_EMULATOR_HOST") + if let Some(endpoint) = env::var("SPANNER_EMULATOR_HOST") .ok() .filter(|s| !s.is_empty()) { @@ -113,21 +116,11 @@ impl google_cloud_gax::client_builder::internal::ClientFactory for Factory { config.cred = Some(anonymous::Builder::new().build()); } - let num_channels = std::env::var("SPANNER_NUM_CHANNELS") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(4); - - // TODO(channel-pool): Dial initial channels concurrently via JoinSet during client builder - // construction once ChannelPool is integrated into the Spanner client. - let mut channels = Vec::with_capacity(num_channels); - for index in 0..num_channels { - channels.push(Channel::create(&config, index + 1).await?); - } + let pool_config = resolve_pool_config(&mut config, is_emulator)?; + let channel_pool = create_channel_pool(&config, pool_config).await?; Ok(Spanner { - channels, - counter: Arc::new(AtomicUsize::new(0)), + channel_pool, config, is_emulator, instance_type, @@ -139,6 +132,19 @@ impl google_cloud_gax::client_builder::internal::ClientFactory for Factory { /// A builder for the Spanner client. pub type ClientBuilder = google_cloud_gax::client_builder::ClientBuilder; +/// Builder extension trait for channel pool configuration. +#[allow(dead_code)] +pub(crate) trait SpannerPoolBuilderExt { + /// Configures the gRPC channel pool for the Spanner client. + fn with_channel_pool>(self, pool_config: C) -> Self; +} + +impl SpannerPoolBuilderExt for ClientBuilder { + fn with_channel_pool>(self, pool_config: C) -> Self { + self.with_extension(pool_config.into()) + } +} + /// Extension trait for [`ClientBuilder`] (also exported as `SpannerBuilder`) to configure Spanner-specific options. pub trait SpannerBuilderExt { /// Sets the target [`InstanceType`] (`Cloud` vs `Omni`) for the Spanner client. @@ -187,6 +193,76 @@ impl SpannerBuilderExt for ClientBuilder { } } +fn resolve_pool_config( + config: &mut ClientConfig, + is_emulator: bool, +) -> ClientBuilderResult { + resolve_pool_config_with(config, is_emulator, || { + env::var("SPANNER_NUM_CHANNELS") + .ok() + .filter(|string| !string.trim().is_empty()) + }) +} + +fn resolve_pool_config_with( + config: &mut ClientConfig, + is_emulator: bool, + env_lookup: impl FnOnce() -> Option, +) -> ClientBuilderResult { + if let Some(config_override) = config.extensions.remove::() { + let pool_config = Arc::try_unwrap(config_override).unwrap_or_else(|arc| (*arc).clone()); + pool_config.validate().map_err(BuilderError::transport)?; + return Ok(pool_config); + } + if is_emulator { + return Ok(ChannelPoolConfig::Static(StaticChannelPoolConfig { + num_channels: 1, + })); + } + if let Some(num_channels_str) = env_lookup() { + let trimmed = num_channels_str.trim(); + if !trimmed.is_empty() { + let num_channels = trimmed.parse::().map_err(BuilderError::transport)?; + let static_config = StaticChannelPoolConfig { num_channels }; + static_config.validate().map_err(BuilderError::transport)?; + return Ok(ChannelPoolConfig::Static(static_config)); + } + } + Ok(ChannelPoolConfig::Static(StaticChannelPoolConfig::default())) +} + +async fn create_channel_pool( + config: &ClientConfig, + pool_config: ChannelPoolConfig, +) -> ClientBuilderResult { + let num_initial = match &pool_config { + ChannelPoolConfig::Static(static_config) => static_config.num_channels, + ChannelPoolConfig::Dynamic(dynamic_config) => dynamic_config.initial_channels, + }; + + let mut join_set = JoinSet::new(); + for index in 0..num_initial { + let config_clone = config.clone(); + join_set.spawn(async move { Channel::create(&config_clone, index + 1).await }); + } + + let mut channels = Vec::with_capacity(num_initial); + while let Some(join_result) = join_set.join_next().await { + let channel_result = join_result.map_err(BuilderError::transport)?; + channels.push(channel_result?); + } + channels.sort_by_key(|channel| channel.channel_id); + + Ok(match pool_config { + ChannelPoolConfig::Static(static_config) => { + ChannelPool::new_static(channels, static_config, config.clone()) + } + ChannelPoolConfig::Dynamic(dynamic_config) => { + ChannelPool::new_dynamic(channels, dynamic_config, config.clone()) + } + }) +} + fn parse_emulator_endpoint(endpoint: &str) -> String { match url::Url::parse(endpoint) { Ok(url) if url.has_host() => endpoint.to_string(), @@ -199,11 +275,11 @@ macro_rules! define_idempotent_rpc { pub(crate) async fn $method( &self, request: $request_type, - options: crate::RequestOptions, + options: RequestOptions, channel: &Channel, o11y: &Arc, - ) -> crate::Result<$response_type> { - let options = self.attach_request_id(options, channel); + ) -> Result<$response_type> { + let options = self.attach_request_id(options, channel.channel_id); #[cfg(feature = "_experimental-builtin-metrics")] let options = options.insert_extension(Arc::clone(o11y)); o11y.trace_operation( @@ -220,7 +296,7 @@ macro_rules! define_idempotent_rpc { }; } -fn apply_request_defaults(mut options: crate::RequestOptions) -> crate::RequestOptions { +fn apply_request_defaults(mut options: RequestOptions) -> RequestOptions { if options.idempotent().is_none() { options.set_idempotency(true); } @@ -363,13 +439,18 @@ impl Spanner { { // This method is primarily for testing and doesn't fully initialize grpc_client. // For production use, prefer `Spanner::builder().build()`. + let channel = Channel { + inner: GapicSpanner::from_stub(stub), + grpc_client: None, + channel_id: 1, + }; + let channel_pool = ChannelPool::new_static( + vec![channel], + StaticChannelPoolConfig { num_channels: 1 }, + ClientConfig::default(), + ); Self { - channels: vec![Channel { - inner: GapicSpanner::from_stub(stub), - grpc_client: None, - channel_id: 1, - }], - counter: Arc::new(AtomicUsize::new(0)), + channel_pool, config: ClientConfig::default(), is_emulator: false, instance_type: InstanceType::Cloud, @@ -385,25 +466,31 @@ impl Spanner { self.instance_type } - pub(crate) fn get_channel(&self, hint: usize) -> &Channel { - let idx = hint % self.channels.len(); - &self.channels[idx] + pub(crate) fn default_channel(&self) -> Option { + self.channel_pool.default_channel() + } + + pub(crate) fn channel_pool(&self) -> &ChannelPool { + &self.channel_pool } - pub(crate) fn next_channel(&self) -> &Channel { - let hint = self.counter.fetch_add(1, Ordering::Relaxed); - self.get_channel(hint) + pub(crate) fn next_channel(&self) -> ChannelLease { + self.channel_pool + .pick_channel() + .expect("channel pool must have active channels") } - pub(crate) fn next_channel_hint(&self) -> usize { - self.counter.fetch_add(1, Ordering::Relaxed) + pub(crate) fn resolve_affinity(&self, affinity: &TransactionAffinity) -> ChannelLease { + self.channel_pool + .resolve_affinity(affinity) + .expect("channel pool must have active channels") } pub(crate) fn attach_request_id( &self, - mut options: crate::RequestOptions, - channel: &Channel, - ) -> crate::RequestOptions { + mut options: RequestOptions, + channel_id: usize, + ) -> RequestOptions { if options .get_extension::() .is_some_and(|headers| headers.contains_key(&REQUEST_ID_HEADER)) @@ -411,7 +498,7 @@ impl Spanner { return options; } - let header_val_str = self.request_id_creator.next_id_prefix(channel.channel_id); + let header_val_str = self.request_id_creator.next_id_prefix(channel_id); let Ok(val) = HeaderValue::from_str(&header_val_str) else { return options; }; @@ -431,7 +518,7 @@ impl Spanner { define_idempotent_rpc!( execute_sql, ExecuteSqlRequest, - crate::model::ResultSet, + ModelResultSet, "google.spanner.v1.Spanner/ExecuteSql" ); define_idempotent_rpc!( @@ -477,8 +564,8 @@ impl Spanner { /// transport, since streaming responses are not yet auto-generated here. pub(crate) fn execute_streaming_sql( &self, - request: crate::model::ExecuteSqlRequest, - options: crate::RequestOptions, + request: ExecuteSqlRequest, + options: RequestOptions, channel: &Channel, ) -> builder::ExecuteStreamingSql { let grpc = channel @@ -487,7 +574,7 @@ impl Spanner { .expect("Streaming RPCs are not supported when using a stub client"); builder::ExecuteStreamingSql::new(grpc.clone()) .with_request(request) - .with_options(self.attach_request_id(options, channel)) + .with_options(self.attach_request_id(options, channel.channel_id)) } /// Reads rows from the database, returning a stream of results. @@ -496,8 +583,8 @@ impl Spanner { /// transport, since streaming responses are not yet auto-generated here. pub(crate) fn streaming_read( &self, - request: crate::model::ReadRequest, - options: crate::RequestOptions, + request: ReadRequest, + options: RequestOptions, channel: &Channel, ) -> builder::StreamingRead { let grpc = channel @@ -506,13 +593,13 @@ impl Spanner { .expect("Streaming RPCs are not supported when using a stub client"); builder::StreamingRead::new(grpc.clone()) .with_request(request) - .with_options(self.attach_request_id(options, channel)) + .with_options(self.attach_request_id(options, channel.channel_id)) } pub(crate) fn batch_write( &self, - request: crate::model::BatchWriteRequest, - options: crate::RequestOptions, + request: BatchWriteRequest, + options: RequestOptions, channel: &Channel, ) -> builder::BatchWrite { let grpc = channel @@ -521,7 +608,7 @@ impl Spanner { .expect("Streaming RPCs are not supported when using a stub client"); builder::BatchWrite::new(grpc.clone()) .with_request(request) - .with_options(self.attach_request_id(options, channel)) + .with_options(self.attach_request_id(options, channel.channel_id)) } pub(crate) fn fetch_cache_update( @@ -536,7 +623,7 @@ impl Spanner { .expect("Streaming RPCs are not supported when using a stub client"); builder::FetchCacheUpdate::new(grpc.clone()) .with_request(request) - .with_options(self.attach_request_id(options, channel)) + .with_options(self.attach_request_id(options, channel.channel_id)) } } @@ -612,6 +699,7 @@ mod tests { use google_cloud_gax::error::rpc::Code; use google_cloud_gax::retry_state::RetryState; use google_cloud_test_macros::tokio_test_no_panics; + use serial_test::serial; use spanner_grpc_mock::google::rpc as mock_rpc; use spanner_grpc_mock::google::spanner::v1 as mock_v1; use spanner_grpc_mock::google::spanner::v1::CommitResponse; @@ -621,6 +709,8 @@ mod tests { use spanner_grpc_mock::google::spanner::v1::result_set_stats::RowCount; use spanner_grpc_mock::{MockSpanner, start}; use static_assertions::{assert_impl_all, assert_not_impl_any}; + use std::fmt::Debug; + use std::panic::{RefUnwindSafe, UnwindSafe}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; @@ -635,11 +725,18 @@ mod tests { #[test] fn auto_traits() { - assert_impl_all!(Spanner: std::fmt::Debug, Clone, Send, Sync); - assert_not_impl_any!(Spanner: std::panic::RefUnwindSafe, std::panic::UnwindSafe); + assert_impl_all!(Spanner: Debug, Clone, Send, Sync); + assert_not_impl_any!(Spanner: RefUnwindSafe, UnwindSafe); + } + + impl Spanner { + fn channel_count(&self) -> usize { + self.channel_pool.active_channel_count() + } } #[tokio_test_no_panics] + #[serial] async fn channel_pool_default_size() { let mock = MockSpanner::new(); let (address, _server) = start("0.0.0.0:0", mock) @@ -653,7 +750,8 @@ mod tests { .await .expect("Failed to build client"); - assert_eq!(client.channels.len(), 4); + let expected_channels = if client.is_emulator() { 1 } else { 4 }; + assert_eq!(client.channel_count(), expected_channels); } #[test] @@ -683,33 +781,6 @@ mod tests { ); } - #[tokio_test_no_panics] - async fn channel_selection() { - let mock = MockSpanner::new(); - let (address, _server) = start("0.0.0.0:0", mock) - .await - .expect("Failed to start mock server"); - - let client = Spanner::builder() - .with_endpoint(address) - .with_credentials(Anonymous::new().build()) - .build() - .await - .expect("Failed to build client"); - - let hint0 = client.next_channel_hint(); - let hint1 = client.next_channel_hint(); - let hint2 = client.next_channel_hint(); - let hint3 = client.next_channel_hint(); - let hint4 = client.next_channel_hint(); - - assert_eq!(hint0 % 4, 0); - assert_eq!(hint1 % 4, 1); - assert_eq!(hint2 % 4, 2); - assert_eq!(hint3 % 4, 3); - assert_eq!(hint4 % 4, 0); - } - #[tokio_test_no_panics] async fn test_create_session() { // 1. Setup Mock Server @@ -745,7 +816,7 @@ mod tests { .create_session( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -801,7 +872,7 @@ mod tests { "projects/test-project/instances/test-instance/databases/test-db".to_string(); let session = client - .get_channel(client.next_channel_hint()) + .next_channel() .inner .create_session() .with_request(req) @@ -865,7 +936,7 @@ mod tests { .create_session( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -915,7 +986,7 @@ mod tests { .execute_sql( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -959,7 +1030,7 @@ mod tests { .execute_batch_dml( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -998,7 +1069,7 @@ mod tests { .begin_transaction( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -1041,7 +1112,7 @@ mod tests { .commit( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -1075,7 +1146,7 @@ mod tests { .rollback( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -1119,14 +1190,17 @@ mod tests { req.sql = "SELECT 1".to_string(); let mut stream = client - .execute_streaming_sql(req, crate::RequestOptions::default(), client.next_channel()) + .execute_streaming_sql(req, RequestOptions::default(), &client.next_channel()) .send() .await .expect("Failed to call execute_streaming_sql"); let result = stream.next_message().await; - assert!(result.is_some()); - assert!(result.unwrap().is_ok()); + assert!(result.is_some(), "expected stream item"); + assert!( + result.expect("stream ended prematurely").is_ok(), + "stream item should be Ok" + ); } #[tokio_test_no_panics] @@ -1167,14 +1241,17 @@ mod tests { req.columns = vec!["col1".to_string()]; let mut stream = client - .streaming_read(req, crate::RequestOptions::default(), client.next_channel()) + .streaming_read(req, RequestOptions::default(), &client.next_channel()) .send() .await .expect("Failed to call streaming_read"); let result = stream.next_message().await; - assert!(result.is_some()); - assert!(result.unwrap().is_ok()); + assert!(result.is_some(), "expected stream item"); + assert!( + result.expect("stream ended prematurely").is_ok(), + "stream item should be Ok" + ); } #[tokio_test_no_panics] @@ -1205,14 +1282,17 @@ mod tests { req.session = "test_session".to_string(); let mut stream = client - .batch_write(req, crate::RequestOptions::default(), client.next_channel()) + .batch_write(req, RequestOptions::default(), &client.next_channel()) .send() .await .expect("Failed to call batch_write"); let result = stream.next_message().await; - assert!(result.is_some()); - assert!(result.unwrap().is_ok()); + assert!(result.is_some(), "expected stream item"); + assert!( + result.expect("stream ended prematurely").is_ok(), + "stream item should be Ok" + ); } #[tokio_test_no_panics] @@ -1241,17 +1321,19 @@ mod tests { req.sql = "SELECT 1".to_string(); let mut stream = client - .execute_streaming_sql(req, crate::RequestOptions::default(), client.next_channel()) + .execute_streaming_sql(req, RequestOptions::default(), &client.next_channel()) .send() .await .expect("Failed to call execute_streaming_sql"); let result = stream.next_message().await; - assert!(result.is_some()); - let err = result.unwrap().expect_err("expected error"); + assert!(result.is_some(), "expected stream item"); + let err = result + .expect("stream ended prematurely") + .expect_err("expected error"); assert_eq!( - err.status().unwrap().code, - google_cloud_gax::error::rpc::Code::Internal + err.status().expect("status should be present").code, + Code::Internal ); } @@ -1292,7 +1374,7 @@ mod tests { .create_session( req, crate::RequestOptions::default(), - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -1339,14 +1421,14 @@ mod tests { .create_session( req, options, - client.next_channel(), + &client.next_channel(), &Observability::disabled_arc(), ) .await; // 5. Verify that it failed and did not retry assert!(result.is_err(), "Expected error, got {:?}", result); - let err = result.unwrap_err(); + let err = result.expect_err("expected error"); assert_eq!(err.status().map(|s| s.code), Some(Code::Unavailable)); Ok(()) @@ -1935,8 +2017,8 @@ mod tests { .expect("Failed to build client"); let channel = client.next_channel(); - let options = crate::RequestOptions::default(); - let options = client.attach_request_id(options, channel); + let options = RequestOptions::default(); + let options = client.attach_request_id(options, channel.channel_id); let headers = options .get_extension::() .expect("HeaderMap should be present"); @@ -1956,6 +2038,7 @@ mod tests { } #[tokio_test_no_panics] + #[serial] async fn attach_request_id_channel_id_in_range() { let mock = MockSpanner::new(); let (address, _server) = start("0.0.0.0:0", mock) @@ -1969,17 +2052,16 @@ mod tests { .await .expect("Failed to build client"); + let expected_channels = if client.is_emulator() { 1 } else { 4 }; assert_eq!( - client.channels.len(), - 4, - "default pool size should be 4 channels" + client.channel_count(), + expected_channels, + "default pool size should match expected channels" ); - // Test with a channel_hint that is larger than the pool size (e.g., hint = 7). - // get_channel(7) maps to channel at index (7 % 4 = 3), which has 1-based channel_id 4. - let channel = client.get_channel(7); - let options = crate::RequestOptions::default(); - let options = client.attach_request_id(options, channel); + let channel = client.next_channel(); + let options = RequestOptions::default(); + let options = client.attach_request_id(options, channel.channel_id); let headers = options .get_extension::() .expect("HeaderMap should be present"); @@ -1989,11 +2071,16 @@ mod tests { .to_str() .expect("should be valid ASCII"); - // With 4 channels and hint = 7: (7 % 4) + 1 = 3 + 1 = 4. - // So the prefix should contain ".4." for channel ID 4. + let channel_id = channel.channel_id; + assert!( + (1..=expected_channels).contains(&channel_id), + "Channel ID should be in range 1..={expected_channels}, got {channel_id}" + ); + let expected_segment = format!(".{}.", channel.channel_id); assert!( - val.contains(".4."), - "Request ID should contain channel ID 4 for hint 7 with pool size 4, got {val}" + val.contains(&expected_segment), + "Request ID should contain channel ID {}, got {val}", + channel.channel_id ); } @@ -2011,9 +2098,9 @@ mod tests { .await .expect("Failed to build client"); - let channel = client.get_channel(0); - let mut options = crate::RequestOptions::default(); - options = client.attach_request_id(options, channel); + let channel = client.next_channel(); + let mut options = RequestOptions::default(); + options = client.attach_request_id(options, channel.channel_id); let first_headers = options .get_extension::() .expect("HeaderMap should be present") @@ -2024,7 +2111,7 @@ mod tests { .clone(); // Calling attach_request_id a second time must NOT change the value or add duplicate headers - options = client.attach_request_id(options, channel); + options = client.attach_request_id(options, channel.channel_id); let second_headers = options .get_extension::() .expect("HeaderMap should be present"); @@ -2043,7 +2130,7 @@ mod tests { #[test] fn amend_request_options_for_lar_idempotent_no_duplicate_headers() { - let options = crate::RequestOptions::default(); + let options = RequestOptions::default(); let options = super::amend_request_options_for_lar(true, options); let options = super::amend_request_options_for_lar(true, options); @@ -2065,4 +2152,281 @@ mod tests { "LAR header should match ROUTE_TO_LEADER_VALUE" ); } + + #[tokio_test_no_panics] + async fn builder_with_static_channel_pool_config() { + use crate::channel_pool::StaticChannelPoolConfig; + + let mock = MockSpanner::new(); + let (address, _server) = start("0.0.0.0:0", mock) + .await + .expect("Failed to start mock server"); + + let client = Spanner::builder() + .with_endpoint(address) + .with_credentials(Anonymous::new().build()) + .with_channel_pool(StaticChannelPoolConfig::new(2)) + .build() + .await + .expect("Failed to build client"); + + assert_eq!( + client.channel_count(), + 2, + "Client should have exactly 2 channels configured" + ); + assert!( + client.default_channel().is_some(), + "Client should have a default channel" + ); + match client.channel_pool().config() { + ChannelPoolConfig::Static(config) => { + assert_eq!( + config.num_channels, 2, + "Configured static channel count should match" + ); + } + ChannelPoolConfig::Dynamic(_) => { + panic!("Expected static pool config, got dynamic"); + } + } + } + + #[tokio_test_no_panics] + async fn builder_with_dynamic_channel_pool_config() { + use crate::channel_pool::config::DynamicChannelPoolConfig; + + let mock = MockSpanner::new(); + let (address, _server) = start("0.0.0.0:0", mock) + .await + .expect("Failed to start mock server"); + + let dynamic_config = DynamicChannelPoolConfig::new() + .with_initial_channels(3) + .with_min_channels(2) + .with_max_channels(8); + + let client = Spanner::builder() + .with_endpoint(address) + .with_credentials(Anonymous::new().build()) + .with_channel_pool(dynamic_config) + .build() + .await + .expect("Failed to build client"); + + assert_eq!( + client.channel_count(), + 3, + "Client should have 3 initial channels configured" + ); + match client.channel_pool().config() { + ChannelPoolConfig::Dynamic(config) => { + assert_eq!(config.initial_channels, 3, "Initial channels should match"); + assert_eq!(config.min_channels, 2, "Min channels should match"); + assert_eq!(config.max_channels, 8, "Max channels should match"); + } + ChannelPoolConfig::Static(_) => { + panic!("Expected dynamic pool config, got static"); + } + } + } + + #[tokio_test_no_panics] + async fn builder_invalid_channel_pool_config_propagates_error() { + use crate::channel_pool::StaticChannelPoolConfig; + + let result = Spanner::builder() + .with_endpoint("http://localhost:9010") + .with_credentials(Anonymous::new().build()) + .with_channel_pool(StaticChannelPoolConfig { num_channels: 0 }) + .build() + .await; + + assert!( + result.is_err(), + "Builder must propagate error when channel pool validation fails" + ); + } + + #[tokio_test_no_panics] + async fn from_stub_creates_single_channel_static_pool() { + use crate::stub::Spanner as SpannerStub; + + #[derive(Debug)] + struct DummyStub; + impl SpannerStub for DummyStub {} + + let client = Spanner::from_stub(DummyStub); + + assert_eq!( + client.channel_count(), + 1, + "Client from stub should have exactly 1 channel" + ); + assert!( + client.default_channel().is_some(), + "Client from stub should have a default channel" + ); + match client.channel_pool().config() { + ChannelPoolConfig::Static(config) => { + assert_eq!( + config.num_channels, 1, + "From stub should configure exactly 1 static channel" + ); + } + ChannelPoolConfig::Dynamic(_) => { + panic!("Expected static pool config from stub"); + } + } + } + + #[test] + fn resolve_pool_config() { + use crate::channel_pool::{DynamicChannelPoolConfig, StaticChannelPoolConfig}; + + // Case 1: Default when no env var and no override + let mut config = ClientConfig::default(); + let pool_config = resolve_pool_config_with(&mut config, false, || None) + .expect("default pool config should resolve"); + assert_eq!( + pool_config, + ChannelPoolConfig::Static(StaticChannelPoolConfig { num_channels: 4 }), + "default static pool has 4 channels" + ); + + // Case 2: Emulator defaults to 1 channel + let mut config = ClientConfig::default(); + let pool_config = resolve_pool_config_with(&mut config, true, || Some("8".to_string())) + .expect("emulator pool config should resolve"); + assert_eq!( + pool_config, + ChannelPoolConfig::Static(StaticChannelPoolConfig { num_channels: 1 }), + "emulator should configure 1 channel" + ); + + // Case 3: SPANNER_NUM_CHANNELS valid integer + let mut config = ClientConfig::default(); + let pool_config = resolve_pool_config_with(&mut config, false, || Some("2".to_string())) + .expect("pool config with SPANNER_NUM_CHANNELS=2 should resolve"); + assert_eq!( + pool_config, + ChannelPoolConfig::Static(StaticChannelPoolConfig { num_channels: 2 }), + "configured static channels should be 2" + ); + + // Case 4: SPANNER_NUM_CHANNELS unparsable integer string + let mut config = ClientConfig::default(); + let err = resolve_pool_config_with(&mut config, false, || Some("not_a_number".to_string())) + .expect_err("should fail when SPANNER_NUM_CHANNELS is not a valid integer"); + let debug_err = format!("{err:?}"); + assert!( + debug_err.contains("InvalidDigit"), + "error should indicate invalid digit: {debug_err}" + ); + + // Case 5: SPANNER_NUM_CHANNELS zero (validation failure) + let mut config = ClientConfig::default(); + let err = resolve_pool_config_with(&mut config, false, || Some("0".to_string())) + .expect_err("should fail when SPANNER_NUM_CHANNELS is 0"); + let debug_err = format!("{err:?}"); + assert!( + debug_err.contains("num_channels must be at least 1"), + "error should indicate num_channels must be at least 1: {debug_err}" + ); + + // Case 6: Extension override takes precedence over SPANNER_NUM_CHANNELS + let mut config = ClientConfig::default(); + config + .extensions + .insert(ChannelPoolConfig::Static(StaticChannelPoolConfig { + num_channels: 10, + })); + let pool_config = resolve_pool_config_with(&mut config, false, || Some("2".to_string())) + .expect("extension override should resolve"); + assert_eq!( + pool_config, + ChannelPoolConfig::Static(StaticChannelPoolConfig { num_channels: 10 }), + "extension override takes precedence over env var" + ); + + // Case 7: Extension override takes precedence even if emulator is true + let mut config = ClientConfig::default(); + config + .extensions + .insert(ChannelPoolConfig::Static(StaticChannelPoolConfig { + num_channels: 8, + })); + let pool_config = resolve_pool_config_with(&mut config, true, || Some("2".to_string())) + .expect("extension override should resolve even on emulator"); + assert_eq!( + pool_config, + ChannelPoolConfig::Static(StaticChannelPoolConfig { num_channels: 8 }), + "extension override takes precedence over emulator default" + ); + + // Case 8: SPANNER_NUM_CHANNELS empty or whitespace string falls back to default + let mut config = ClientConfig::default(); + let pool_config = resolve_pool_config_with(&mut config, false, || Some(" ".to_string())) + .expect("whitespace SPANNER_NUM_CHANNELS should resolve to default"); + assert_eq!( + pool_config, + ChannelPoolConfig::Static(StaticChannelPoolConfig { num_channels: 4 }), + "whitespace SPANNER_NUM_CHANNELS should default to 4 channels" + ); + + // Case 9: Extension override with dynamic channel pool configuration + let mut config = ClientConfig::default(); + let dynamic_config = DynamicChannelPoolConfig::default(); + config + .extensions + .insert(ChannelPoolConfig::Dynamic(dynamic_config.clone())); + let pool_config = resolve_pool_config_with(&mut config, false, || Some("2".to_string())) + .expect("dynamic extension override should resolve"); + assert_eq!( + pool_config, + ChannelPoolConfig::Dynamic(dynamic_config), + "dynamic extension override takes precedence over env var" + ); + } + + #[tokio::test] + async fn client_builder_with_extension_configures_channel_pool() { + use google_cloud_auth::credentials::anonymous::Builder as AnonymousCredentialsBuilder; + use spanner_grpc_mock::{MockSpanner, start}; + + let mock = MockSpanner::new(); + let (address, _server) = start("0.0.0.0:0", mock).await.expect("start mock server"); + + // Case A: using with_extension directly + let spanner = Spanner::builder() + .with_endpoint(address.clone()) + .with_credentials(AnonymousCredentialsBuilder::new().build()) + .with_extension(ChannelPoolConfig::Static(StaticChannelPoolConfig { + num_channels: 3, + })) + .build() + .await + .expect("build client with with_extension override"); + + assert_eq!( + spanner.channel_pool.active_channel_count(), + 3, + "Channel pool must have 3 channels from with_extension override" + ); + + // Case B: using with_channel_pool helper + let spanner = Spanner::builder() + .with_endpoint(address) + .with_credentials(AnonymousCredentialsBuilder::new().build()) + .with_channel_pool(StaticChannelPoolConfig { num_channels: 2 }) + .build() + .await + .expect("build client with with_channel_pool override"); + + assert_eq!( + spanner.channel_pool.active_channel_count(), + 2, + "Channel pool must have 2 channels from with_channel_pool override" + ); + } } diff --git a/src/spanner/src/database_client.rs b/src/spanner/src/database_client.rs index a27f48bf17..83bed1b3b6 100644 --- a/src/spanner/src/database_client.rs +++ b/src/spanner/src/database_client.rs @@ -14,6 +14,7 @@ use crate::batch_read_only_transaction::BatchReadOnlyTransactionBuilder; use crate::batch_write_transaction::BatchWriteTransactionBuilder; +use crate::channel_pool::TransactionAffinity; use crate::client::Spanner; use crate::model::transaction_options::Mode; use crate::model::transaction_options::read_only::TimestampBound; @@ -80,7 +81,7 @@ use std::time::Duration; /// Cloning a `DatabaseClient` is cheap, as it shares the underlying session and channel. #[derive(Clone, Debug)] pub struct DatabaseClient { - spanner: Spanner, + pub(crate) spanner: Spanner, pub(crate) session_maintainer: Arc, pub(crate) leader_aware_routing_enabled: bool, #[allow(dead_code)] // TODO: Used by request routing interceptors in subsequent PRs @@ -101,17 +102,36 @@ macro_rules! define_db_rpc { &self, mut request: $request_type, options: RequestOptions, - channel_hint: usize, + affinity: Option<&TransactionAffinity>, ) -> Result<$response_type> { let (connection, routing_context) = $pre_route(self, &mut request); + // Route the request: + // 1. If Location-Aware Routing (LAR) resolved a direct tablet/server connection, + // dispatch directly through that connection's dedicated channel without a pool lease. + // 2. Otherwise (standard Cloud Spanner, unrouted query, or gateway fallback), + // lease a channel from the pool: + // - With affinity (e.g. within a transaction), pin or resolve the pinned channel. + // - Without affinity (single-use query, admin RPC), pick via Power of Two Least Busy (P2C). + let lease = connection.is_none().then(|| match affinity { + Some(affinity) => self.spanner.resolve_affinity(affinity), + None => self.spanner.next_channel(), + }); let channel = match &connection { Some(connection) => connection.channel(), - None => self.spanner.get_channel(channel_hint), + None => lease + .as_ref() + .expect("lease must be present when connection is None") + .channel(), }; let result = self .spanner .$method(request, options, channel, &self.o11y) .await; + // Record result on the leased pool channel to penalize transport errors (e.g. UNAVAILABLE). + // When `lease` drops, its active in-flight count automatically decrements. + if let Some(lease) = &lease { + lease.record_result(&result, |error| error.status().map(|status| status.code)); + } $post_hook(self, routing_context, connection.as_ref(), &result); let response = result?; response.observe(self); @@ -126,10 +146,14 @@ macro_rules! define_db_streaming_rpc { &self, request: $request_type, options: RequestOptions, - channel_hint: usize, + affinity: Option<&TransactionAffinity>, ) -> $builder_type { - let channel = self.spanner.get_channel(channel_hint); - self.spanner.$method(request, options, channel) + let lease = match affinity { + Some(affinity) => self.spanner.resolve_affinity(affinity), + None => self.spanner.next_channel(), + }; + let builder = self.spanner.$method(request, options, lease.channel()); + builder.with_lifetime_guard(Arc::new(lease.into_guard())) } }; ($method:ident, $expect_method:ident, $request_type:ty, $builder_type:ty, $extract_key:expr) => { @@ -137,7 +161,7 @@ macro_rules! define_db_streaming_rpc { &self, mut request: $request_type, options: RequestOptions, - channel_hint: usize, + affinity: Option<&TransactionAffinity>, ) -> $builder_type { // Step 1: When location-aware routing is disabled (standard Cloud Spanner), // `self.location_routing` is `None` so `$extract_key` is skipped immediately. @@ -158,14 +182,17 @@ macro_rules! define_db_streaming_rpc { // Step 4: Select the gRPC channel: // - If location-aware routing resolved a direct node connection (`Some(connection)`), use `connection.channel()`. - // - Otherwise (location routing disabled, unkeyed query/read, or cold cache), fall back to round-robin - // load-balancing across the client's channel pool via `self.spanner.get_channel(channel_hint)`. - // This fallback is a fast O(1) slice index without any heap allocation, cloning, or lock acquisition. - let channel = match &connection { - Some(connection) => connection.channel(), - None => self.spanner.get_channel(channel_hint), + // - Otherwise (location routing disabled, unkeyed query/read, or cold cache), fall back to + // channel pooling via `affinity` or P2C `next_channel()`. + if let Some(connection) = connection { + return self.spanner.$method(request, options, connection.channel()); + } + let lease = match affinity { + Some(affinity) => self.spanner.resolve_affinity(affinity), + None => self.spanner.next_channel(), }; - self.spanner.$method(request, options, channel) + let builder = self.spanner.$method(request, options, lease.channel()); + builder.with_lifetime_guard(Arc::new(lease.into_guard())) } }; } @@ -265,19 +292,6 @@ impl DatabaseClient { self.spanner.is_emulator() } - pub(crate) fn next_channel_hint(&self) -> usize { - self.spanner.next_channel_hint() - } - - pub(crate) fn attach_request_id( - &self, - options: RequestOptions, - channel_hint: usize, - ) -> RequestOptions { - let channel = self.spanner.get_channel(channel_hint); - self.spanner.attach_request_id(options, channel) - } - for_all_unary_db_rpcs!(define_db_rpc); /// Resolves the optimal [`ServerConnection`] for a request if location-aware routing is enabled. @@ -1173,9 +1187,7 @@ impl LocationRoutingState { .to_string(); let default_channel = spanner - .channels - .first() - .cloned() + .default_channel() .expect("Spanner client must have at least one channel"); let default_connection = ServerConnection::new(default_endpoint, default_channel); @@ -2680,9 +2692,9 @@ mod tests { assert!(!db_client.is_location_aware_routing_enabled()); - // Verify round-robin channel distribution 1..=4 for all mapped RPCs across 4 hints - for channel_hint in 0..4 { - let expected_channel_id = format!(".{}.", channel_hint + 1); + // Verify channel affinity routing across all mapped RPCs + for _ in 0..4 { + let affinity = TransactionAffinity::new_read_write(); macro_rules! call_unary_rpc { ($method:ident, $expect_method:ident, $request_type:ident, $response_type:ty $(, $extra:expr)*) => { @@ -2690,7 +2702,7 @@ mod tests { .$method( $request_type::default(), RequestOptions::default(), - channel_hint, + Some(&affinity), ) .await; }; @@ -2703,7 +2715,7 @@ mod tests { .$method( $request_type::default(), RequestOptions::default(), - channel_hint, + Some(&affinity), ) .send() .await; @@ -2716,17 +2728,91 @@ mod tests { assert_eq!( calls.len(), 10, - "each RPC method must be called once per hint" + "each RPC method must be called once per iteration" ); - for (rpc_name, request_id) in calls { - assert!( - request_id.contains(&expected_channel_id), - "RPC {rpc_name} with channel_hint {channel_hint} must use channel ID {expected_channel_id}, got {request_id}" + let expected_channel_id = calls[0] + .1 + .split('.') + .nth(3) + .expect("channel id 0") + .to_string(); + for (rpc_name, request_id) in &calls { + let actual_channel_id = request_id.split('.').nth(3).expect("channel id"); + assert_eq!( + actual_channel_id, expected_channel_id, + "RPC {rpc_name} with shared affinity must use channel ID {expected_channel_id}, got {request_id}" ); } } } + #[tokio_test_no_panics] + async fn unary_rpc_error_records_penalty_on_leased_channel() { + use crate::channel_pool::DynamicChannelPoolConfig; + use crate::client::SpannerPoolBuilderExt; + use gaxi::grpc::tonic::Status; + use google_cloud_gax::retry_policy::NeverRetry; + use std::time::Duration; + + let mut mock = create_test_mock(); + mock.expect_execute_sql().returning(|_| { + Err(Status::unavailable( + "Spanner backend temporarily unavailable", + )) + }); + + let (address, _server) = start("0.0.0.0:0", mock) + .await + .expect("Failed to start mock server"); + + let dynamic_config = DynamicChannelPoolConfig::new() + .with_initial_channels(1) + .with_min_channels(1) + .with_max_channels(2) + .with_error_penalty_step(15) + .with_error_penalty_duration(Duration::from_secs(60)); + + let spanner = Spanner::builder() + .with_endpoint(address) + .with_instance_type(InstanceType::Cloud) + .with_credentials(Anonymous::new().build()) + .with_channel_pool(dynamic_config) + .build() + .await + .expect("Failed to build client"); + + let db_client = spanner + .database_client("projects/p/instances/i/databases/d") + .build() + .await + .expect("build should succeed"); + + let active_entries = spanner.channel_pool().active_entries(); + assert_eq!(active_entries.len(), 1, "Expected single initial channel"); + let initial_penalty = active_entries[0].current_penalty(); + assert_eq!(initial_penalty, 0, "Initial penalty should be zero"); + + let request = ExecuteSqlRequest { + sql: "SELECT 1".to_string(), + ..Default::default() + }; + + let mut options = RequestOptions::default(); + options.set_retry_policy(NeverRetry); + + let result = db_client.execute_sql(request, options, None).await; + assert!( + result.is_err(), + "execute_sql should fail with unavailable error" + ); + + let penalty_after_error = active_entries[0].current_penalty(); + assert_eq!( + penalty_after_error, 15, + "Channel entry must have error penalty applied after UNAVAILABLE unary RPC" + ); + } + #[tokio_test_no_panics] async fn streaming_rpcs_round_robin_when_location_routing_enabled_without_routing_key() { use std::sync::Mutex; @@ -2776,15 +2862,15 @@ mod tests { assert!(db_client.is_location_aware_routing_enabled()); - for channel_hint in 0..4 { - let expected_channel_id = format!(".{}.", channel_hint + 1); + for _ in 0..4 { + let affinity = TransactionAffinity::new_read_write(); // execute_streaming_sql (no routing key) let _ = db_client .execute_streaming_sql( ExecuteSqlRequest::default(), RequestOptions::default(), - channel_hint, + Some(&affinity), ) .send() .await; @@ -2794,19 +2880,19 @@ mod tests { key_set.all = true; let read_request = ReadRequest::new().set_table("Users").set_key_set(key_set); let _ = db_client - .streaming_read(read_request, RequestOptions::default(), channel_hint) + .streaming_read(read_request, RequestOptions::default(), Some(&affinity)) .send() .await; let calls = captured_requests.lock().expect("lock").clone(); captured_requests.lock().expect("lock").clear(); assert_eq!(calls.len(), 2); - for (rpc_name, request_id) in calls { - assert!( - request_id.contains(&expected_channel_id), - "Even when location routing is enabled, {rpc_name} without routing key must round-robin onto channel {expected_channel_id}, got {request_id}" - ); - } + let first_channel_id = calls[0].1.split('.').nth(3).expect("channel id 1"); + let second_channel_id = calls[1].1.split('.').nth(3).expect("channel id 2"); + assert_eq!( + first_channel_id, second_channel_id, + "Both statements sharing affinity must route to the same channel" + ); } } @@ -2872,7 +2958,7 @@ mod tests { // 1. Cold start: routes to gateway, attaches discovery routing hint with operation_uid let _ = database_client - .execute_streaming_sql(request.clone(), RequestOptions::default(), 0) + .execute_streaming_sql(request.clone(), RequestOptions::default(), None) .send() .await; @@ -2947,7 +3033,7 @@ mod tests { // 4. Cache hit: routes directly to tablet mock with full routing hint let _ = database_client - .execute_streaming_sql(request, RequestOptions::default(), 0) + .execute_streaming_sql(request, RequestOptions::default(), None) .send() .await; @@ -3048,7 +3134,7 @@ mod tests { // 1. Cold start: without cached recipe or range, routes to gateway and attaches bootstrap routing hint let _ = database_client - .streaming_read(read_request.clone(), RequestOptions::default(), 0) + .streaming_read(read_request.clone(), RequestOptions::default(), None) .send() .await; @@ -3121,7 +3207,7 @@ mod tests { // 4. Cache hit: routes directly to tablet mock with full routing hint let _ = database_client - .streaming_read(read_request, RequestOptions::default(), 0) + .streaming_read(read_request, RequestOptions::default(), None) .send() .await; @@ -3426,7 +3512,7 @@ mod tests { .set_key_set(key_set.clone()); let _ = database_client - .streaming_read(read_request.clone(), RequestOptions::default(), 0) + .streaming_read(read_request.clone(), RequestOptions::default(), None) .send() .await; @@ -3483,7 +3569,7 @@ mod tests { // 3. Subsequent streaming read for table Users -> RoutingHint must be populated and attached let _ = database_client - .streaming_read(read_request, RequestOptions::default(), 0) + .streaming_read(read_request, RequestOptions::default(), None) .send() .await; diff --git a/src/spanner/src/partitioned_dml_transaction.rs b/src/spanner/src/partitioned_dml_transaction.rs index 05ebafc971..e275369e5d 100644 --- a/src/spanner/src/partitioned_dml_transaction.rs +++ b/src/spanner/src/partitioned_dml_transaction.rs @@ -27,7 +27,6 @@ use crate::transaction_retry_policy::{ }; use gaxi::prost::FromProto; use google_cloud_gax::options::RequestOptions as GaxRequestOptions; -use std::sync::Arc; /// A builder for [PartitionedDmlTransaction]. /// @@ -181,7 +180,6 @@ impl PartitionedDmlTransaction { ..Default::default() }; let base_request = statement.into_request(); - let channel_hint = self.client.next_channel_hint(); let client = self.client; let is_emulator = client.is_emulator(); @@ -193,9 +191,9 @@ impl PartitionedDmlTransaction { let client = client.clone(); async move { - let _affinity = Arc::new(TransactionAffinity::new_read_write()); + let affinity = TransactionAffinity::new_read_write(); let transaction = client - .begin_transaction(begin_request, gax_options.clone(), channel_hint) + .begin_transaction(begin_request, gax_options.clone(), Some(&affinity)) .await?; let execute_request = @@ -209,7 +207,7 @@ impl PartitionedDmlTransaction { }); let stream_builder = - client.execute_streaming_sql(execute_request, gax_options, channel_hint); + client.execute_streaming_sql(execute_request, gax_options, Some(&affinity)); let stream = stream_builder.send().await?; extract_lower_bound_update_count_from_stream(stream, &client).await diff --git a/src/spanner/src/read_only_transaction.rs b/src/spanner/src/read_only_transaction.rs index e6a8ae7716..ef3b318ef8 100644 --- a/src/spanner/src/read_only_transaction.rs +++ b/src/spanner/src/read_only_transaction.rs @@ -101,7 +101,6 @@ impl SingleUseReadOnlyTransactionBuilder { .set_single_use(TransactionOptions::default().set_read_only(read_only)); let session_name = self.client.session_name(); - let channel_hint = self.client.next_channel_hint(); SingleUseReadOnlyTransaction { context: ReadContext { session_name, @@ -112,7 +111,6 @@ impl SingleUseReadOnlyTransactionBuilder { ), precommit_token_tracker: PrecommitTokenTracker::new_noop(), transaction_tag: None, - channel_hint, begin_transaction_request_options: None, affinity: None, }, @@ -401,7 +399,7 @@ impl MultiUseReadOnlyTransactionBuilder { let options = TransactionOptions::default().set_read_only(read_only); let session_name = self.client.session_name(); - let channel_hint = self.client.next_channel_hint(); + let affinity = Some(TransactionAffinity::default_read_only(self.affinity)); let selector = match self.begin_transaction_option { BeginTransactionOption::ExplicitBegin => { let response = execute_begin_transaction( @@ -409,7 +407,7 @@ impl MultiUseReadOnlyTransactionBuilder { session_name.clone(), options, None, - channel_hint, + affinity.as_deref(), self.begin_gax_options.clone().unwrap_or_default(), None, ) @@ -425,11 +423,6 @@ impl MultiUseReadOnlyTransactionBuilder { )), }; - let affinity = Some( - self.affinity - .unwrap_or_else(|| Arc::new(TransactionAffinity::new_read_only())), - ); - Ok(MultiUseReadOnlyTransaction { context: ReadContext { session_name, @@ -437,14 +430,13 @@ impl MultiUseReadOnlyTransactionBuilder { transaction_selector: selector, precommit_token_tracker: PrecommitTokenTracker::new_noop(), transaction_tag: None, - channel_hint, begin_transaction_request_options: self.begin_gax_options, affinity, }, }) } - #[allow(dead_code)] + #[allow(dead_code)] // Allows attaching an existing affinity handle; dual to ReadWriteTransactionBuilder::with_affinity pub(crate) fn with_affinity(mut self, affinity: Arc) -> Self { self.affinity = Some(affinity); self @@ -549,7 +541,7 @@ impl MultiUseReadOnlyTransaction { } /// Returns a reference to the transaction channel affinity handle, if set. - #[allow(dead_code)] + #[allow(dead_code)] // Accessor for transaction channel affinity; used in tests and routing pub(crate) fn affinity(&self) -> Option<&TransactionAffinity> { self.context.affinity() } @@ -561,7 +553,7 @@ pub(crate) async fn execute_begin_transaction( session_name: String, options: crate::model::TransactionOptions, transaction_tag: Option, - channel_hint: usize, + affinity: Option<&TransactionAffinity>, request_options: crate::RequestOptions, mutation_key: Option, ) -> crate::Result { @@ -575,7 +567,7 @@ pub(crate) async fn execute_begin_transaction( } client - .begin_transaction(request, request_options, channel_hint) + .begin_transaction(request, request_options, affinity) .await } @@ -677,12 +669,10 @@ pub(crate) struct ExplicitBeginParams { pub(crate) client: crate::database_client::DatabaseClient, pub(crate) session_name: String, pub(crate) transaction_tag: Option, - pub(crate) channel_hint: usize, pub(crate) request_options: crate::RequestOptions, pub(crate) is_stream_fallback: bool, pub(crate) precommit_token_tracker: crate::precommit::PrecommitTokenTracker, pub(crate) mutation_key: Option, - #[allow(dead_code)] pub(crate) affinity: Option>, } @@ -757,7 +747,7 @@ impl ReadContextTransactionSelector { params.session_name, options, params.transaction_tag, - params.channel_hint, + params.affinity.as_deref(), params.request_options, params.mutation_key, ) @@ -983,7 +973,6 @@ pub(crate) struct ReadContext { pub(crate) transaction_selector: ReadContextTransactionSelector, pub(crate) precommit_token_tracker: PrecommitTokenTracker, pub(crate) transaction_tag: Option, - pub(crate) channel_hint: usize, pub(crate) begin_transaction_request_options: Option, pub(crate) affinity: Option>, } @@ -1034,7 +1023,6 @@ impl ReadContext { client: self.client.clone(), session_name: self.session_name.clone(), transaction_tag: self.transaction_tag.clone(), - channel_hint: self.channel_hint, request_options: options, is_stream_fallback, precommit_token_tracker: self.precommit_token_tracker.clone(), @@ -1046,7 +1034,6 @@ impl ReadContext { } /// Returns a reference to the transaction channel affinity handle, if set. - #[allow(dead_code)] pub(crate) fn affinity(&self) -> Option<&TransactionAffinity> { self.affinity.as_deref() } @@ -1085,12 +1072,12 @@ macro_rules! execute_stream_with_retry { ($self:expr, $request:ident, $gax_options:ident, $rpc_method:ident, $operation_variant:path, $method_name:expr) => {{ let operation_start_time = Instant::now(); let mut attempt_start_time = operation_start_time; - let stream = match $self - .client - .$rpc_method($request.clone(), $gax_options.clone(), $self.channel_hint) - .send() - .await - { + let builder = + $self + .client + .$rpc_method($request.clone(), $gax_options.clone(), $self.affinity()); + let request_options = builder.options().clone(); + let stream = match builder.send().await { Ok(s) => s, Err(e) => { let elapsed_attempt = attempt_start_time.elapsed(); @@ -1143,12 +1130,12 @@ macro_rules! execute_stream_with_retry { $request.transaction = Some(selector); // Reset attempt timestamp for the retry attempt attempt_start_time = Instant::now(); - match $self - .client - .$rpc_method($request.clone(), $gax_options.clone(), $self.channel_hint) - .send() - .await - { + let retry_builder = $self.client.$rpc_method( + $request.clone(), + request_options.clone(), + $self.affinity(), + ); + match retry_builder.send().await { Ok(s) => s, Err(retry_err) => { let elapsed_attempt = attempt_start_time.elapsed(); @@ -1173,8 +1160,7 @@ macro_rules! execute_stream_with_retry { session_name: $self.session_name.clone(), transaction_tag: $self.transaction_tag.clone(), operation: $operation_variant($request), - channel_hint: $self.channel_hint, - gax_options: $gax_options, + gax_options: request_options, method_name: $method_name, attempt_start_time: Some(attempt_start_time), operation_start_time: Some(operation_start_time), @@ -1191,9 +1177,7 @@ impl ReadContext { seqno: Option, ) -> crate::Result { let statement = statement.into(); - let gax_options = self - .client - .attach_request_id(statement.gax_options().clone(), self.channel_hint); + let gax_options = statement.gax_options().clone(); let mut request = statement .into_request() .set_session(self.session_name.clone()) @@ -1216,9 +1200,7 @@ impl ReadContext { read: T, ) -> crate::Result { let read = read.into(); - let gax_options = self - .client - .attach_request_id(read.gax_options.clone(), self.channel_hint); + let gax_options = read.gax_options.clone(); let mut request = read .into_request() .set_session(self.session_name.clone()) @@ -1239,6 +1221,7 @@ impl ReadContext { #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::Result; use crate::result_set::tests::adapt; use crate::result_set::tests::string_val; use crate::statement::Statement; @@ -3602,7 +3585,6 @@ pub(crate) mod tests { transaction_selector: selector, precommit_token_tracker: crate::read_only_transaction::PrecommitTokenTracker::new(), transaction_tag: None, - channel_hint: 0, begin_transaction_request_options: None, affinity: None, }; @@ -3749,7 +3731,7 @@ pub(crate) mod tests { } #[tokio_test_no_panics] - async fn multi_use_read_only_transaction_affinity_preserved() -> crate::Result<()> { + async fn multi_use_read_only_transaction_affinity_preserved() -> Result<()> { use crate::statement::Statement; use gaxi::grpc::tonic::Response; @@ -3786,10 +3768,11 @@ pub(crate) mod tests { transaction .affinity() .expect("affinity present") - .set_entry_id(202); + .compare_and_set_entry_id(0, 1) + .expect("pin entry"); assert_eq!( affinity.pinned_entry_id(), - Some(202), + Some(1), "Affinity handle passed to builder must observe the pinned channel ID" ); @@ -3798,7 +3781,7 @@ pub(crate) mod tests { .await?; assert_eq!( result_set.affinity().pinned_entry_id(), - Some(202), + Some(1), "ResultSet generated from MultiUse transaction must share the same pinned affinity" ); @@ -3806,7 +3789,7 @@ pub(crate) mod tests { } #[tokio_test_no_panics] - async fn multi_use_read_only_transaction_default_affinity_created() -> crate::Result<()> { + async fn multi_use_read_only_transaction_default_affinity_created() -> Result<()> { let mock = create_session_mock(); let (db_client, _server) = setup_db_client(mock).await; @@ -3832,7 +3815,7 @@ pub(crate) mod tests { } #[tokio_test_no_panics] - async fn single_use_read_only_transaction_affinity_present() -> crate::Result<()> { + async fn single_use_read_only_transaction_affinity_present() -> Result<()> { use crate::statement::Statement; use gaxi::grpc::tonic::Response; @@ -3850,7 +3833,7 @@ pub(crate) mod tests { let affinity = result_set.affinity(); assert!( - affinity.is_read_only(), + !affinity.is_read_write(), "SingleUse query ResultSet affinity must be ReadOnly" ); assert_eq!( @@ -3859,7 +3842,9 @@ pub(crate) mod tests { "Initial pinned entry ID should be None" ); - affinity.set_entry_id(505); + affinity + .compare_and_set_entry_id(0, 505) + .expect("pin entry"); assert_eq!( result_set.affinity().pinned_entry_id(), Some(505), diff --git a/src/spanner/src/read_write_transaction.rs b/src/spanner/src/read_write_transaction.rs index bbe1a66ede..ea2740bb56 100644 --- a/src/spanner/src/read_write_transaction.rs +++ b/src/spanner/src/read_write_transaction.rs @@ -171,7 +171,7 @@ impl ReadWriteTransactionBuilder { async fn begin( &self, session_name: String, - channel_hint: usize, + affinity: Option<&TransactionAffinity>, request_options: crate::RequestOptions, ) -> crate::Result { let response = crate::read_only_transaction::execute_begin_transaction( @@ -179,7 +179,7 @@ impl ReadWriteTransactionBuilder { session_name, self.options.clone(), self.transaction_tag.clone(), - channel_hint, + affinity, request_options, None, ) @@ -192,10 +192,10 @@ impl ReadWriteTransactionBuilder { } pub(crate) async fn build( - self, + mut self, deadline: Option, ) -> crate::Result { - let channel_hint = self.client.next_channel_hint(); + let affinity = TransactionAffinity::default_read_write(self.affinity.take()); let transaction_selector = match self.begin_transaction_option { BeginTransactionOption::ExplicitBegin => { let mut options = self.begin_gax_options.clone().unwrap_or_default(); @@ -205,7 +205,7 @@ impl ReadWriteTransactionBuilder { &mut options, ); - self.begin(self.session_name.clone(), channel_hint, options) + self.begin(self.session_name.clone(), Some(&affinity), options) .await? } BeginTransactionOption::InlineBegin => ReadContextTransactionSelector::Lazy(Arc::new( @@ -213,11 +213,6 @@ impl ReadWriteTransactionBuilder { )), }; - let affinity = Some( - self.affinity - .unwrap_or_else(|| Arc::new(TransactionAffinity::new_read_write())), - ); - Ok(ReadWriteTransaction { context: ReadContext { session_name: self.session_name, @@ -225,9 +220,8 @@ impl ReadWriteTransactionBuilder { transaction_selector, precommit_token_tracker: PrecommitTokenTracker::new(), transaction_tag: self.transaction_tag, - channel_hint, begin_transaction_request_options: None, - affinity, + affinity: Some(affinity), }, seqno: Arc::new(AtomicI64::new(1)), max_commit_delay: self.max_commit_delay, @@ -240,7 +234,6 @@ impl ReadWriteTransactionBuilder { }) } - #[allow(dead_code)] pub(crate) fn with_affinity(mut self, affinity: Arc) -> Self { self.affinity = Some(affinity); self @@ -334,7 +327,7 @@ macro_rules! execute_with_retry { .$rpc_method( $request.clone(), $gax_options.clone(), - $self.context.channel_hint, + $self.context.affinity(), ) .await; @@ -665,7 +658,7 @@ impl ReadWriteTransaction { let response = self .context .client - .commit(request, gax_options, self.context.channel_hint) + .commit(request, gax_options, self.context.affinity()) .await?; let response = @@ -681,7 +674,7 @@ impl ReadWriteTransaction { self.context .client - .commit(retry_commit_req, gax_options, self.context.channel_hint) + .commit(retry_commit_req, gax_options, self.context.affinity()) .await? } else { response @@ -705,7 +698,7 @@ impl ReadWriteTransaction { self.context .client - .rollback(request, gax_options, self.context.channel_hint) + .rollback(request, gax_options, self.context.affinity()) .await?; Ok(()) @@ -720,7 +713,7 @@ impl ReadWriteTransaction { } /// Returns a reference to the transaction channel affinity handle, if set. - #[allow(dead_code)] + #[allow(dead_code)] // Accessor for transaction channel affinity; used in tests and routing pub(crate) fn affinity(&self) -> Option<&TransactionAffinity> { self.context.affinity() } @@ -4304,10 +4297,11 @@ mod tests { transaction .affinity() .expect("affinity present") - .set_entry_id(101); + .compare_and_set_entry_id(0, 1) + .expect("pin entry"); assert_eq!( affinity.pinned_entry_id(), - Some(101), + Some(1), "Affinity handle passed to builder must observe the pinned channel ID" ); @@ -4316,7 +4310,7 @@ mod tests { .await?; assert_eq!( result_set.affinity().pinned_entry_id(), - Some(101), + Some(1), "ResultSet generated from ReadWrite transaction must share the same pinned affinity" ); diff --git a/src/spanner/src/request_id.rs b/src/spanner/src/request_id.rs index c680b58a7f..85014d588b 100644 --- a/src/spanner/src/request_id.rs +++ b/src/spanner/src/request_id.rs @@ -242,7 +242,7 @@ mod tests { .create_session( request.clone(), crate::RequestOptions::default(), - client.get_channel(0), + &client.next_channel(), &Observability::disabled_arc(), ) .await @@ -253,7 +253,7 @@ mod tests { .create_session( request, crate::RequestOptions::default(), - client.get_channel(0), + &client.next_channel(), &Observability::disabled_arc(), ) .await diff --git a/src/spanner/src/result_set.rs b/src/spanner/src/result_set.rs index 4cca1b2532..ec38ab14a6 100644 --- a/src/spanner/src/result_set.rs +++ b/src/spanner/src/result_set.rs @@ -85,7 +85,6 @@ pub struct ResultSet { max_buffered_partial_result_sets: usize, retry_count: usize, transaction_selector: Option, - channel_hint: usize, gax_options: GaxRequestOptions, method_name: &'static str, headers: HeaderMap, @@ -110,7 +109,6 @@ pub(crate) struct ResultSetParams { pub session_name: String, pub transaction_tag: Option, pub operation: StreamOperation, - pub channel_hint: usize, pub gax_options: GaxRequestOptions, pub method_name: &'static str, pub attempt_start_time: Option, @@ -148,7 +146,6 @@ impl ResultSet { session_name, transaction_tag, operation, - channel_hint, gax_options, method_name, attempt_start_time, @@ -160,7 +157,7 @@ impl ResultSet { let attempt_start = attempt_start_time.unwrap_or_else(Instant::now); let operation_start = operation_start_time.unwrap_or(attempt_start); let headers = stream.headers().clone(); - let affinity = affinity.unwrap_or_else(|| Arc::new(TransactionAffinity::new_read_only())); + let affinity = TransactionAffinity::default_read_only(affinity); Self { stream: Some(stream), @@ -181,7 +178,6 @@ impl ResultSet { max_buffered_partial_result_sets: MAX_BUFFERED_PARTIAL_RESULT_SETS, retry_count: 0, transaction_selector, - channel_hint, gax_options, tokio_handle: Handle::try_current().ok(), method_name, @@ -597,7 +593,6 @@ impl ResultSet { client: self.client.clone(), session_name: self.session_name.clone(), transaction_tag: self.transaction_tag.clone(), - channel_hint: self.channel_hint, request_options: self.gax_options.clone(), is_stream_fallback: true, precommit_token_tracker: self.precommit_token_tracker.clone(), @@ -759,7 +754,11 @@ impl ResultSet { .clone() .or_else(|| req.transaction.take()); self.client - .execute_streaming_sql(req.clone(), self.gax_options.clone(), self.channel_hint) + .execute_streaming_sql( + req.clone(), + self.gax_options.clone(), + Some(&self.affinity), + ) .send() .await } @@ -769,7 +768,7 @@ impl ResultSet { .clone() .or_else(|| req.transaction.take()); self.client - .streaming_read(req.clone(), self.gax_options.clone(), self.channel_hint) + .streaming_read(req.clone(), self.gax_options.clone(), Some(&self.affinity)) .send() .await } @@ -801,7 +800,7 @@ impl ResultSet { } /// Returns a reference to the transaction affinity handle attached to this result set. - #[allow(dead_code)] + #[allow(dead_code)] // Accessor for attached transaction affinity; used in tests and verification pub(crate) fn affinity(&self) -> &TransactionAffinity { &self.affinity } @@ -1970,7 +1969,7 @@ pub(crate) mod tests { .expect("Failed to build client"); let db_client: crate::database_client::DatabaseClient = - client.database_client("db").build().await.unwrap(); + client.database_client("db").build().await?; let tracker = PrecommitTokenTracker::new(); @@ -1979,7 +1978,7 @@ pub(crate) mod tests { .set_sql("SELECT 1".to_string()); let stream = db_client - .execute_streaming_sql(req.clone(), GaxRequestOptions::default(), 0) + .execute_streaming_sql(req.clone(), GaxRequestOptions::default(), None) .send() .await?; @@ -1991,7 +1990,6 @@ pub(crate) mod tests { session_name: "session".to_string(), transaction_tag: None, operation: StreamOperation::Query(req), - channel_hint: 0, gax_options: GaxRequestOptions::default(), method_name: "ExecuteStreamingSql", attempt_start_time: None, diff --git a/src/spanner/src/routing/cache_subscriber.rs b/src/spanner/src/routing/cache_subscriber.rs index fabeb68224..a5d58bb8b3 100644 --- a/src/spanner/src/routing/cache_subscriber.rs +++ b/src/spanner/src/routing/cache_subscriber.rs @@ -387,12 +387,13 @@ async fn execute_subscriber_iteration( config.max_recipe_count, config.max_range_count, ); - let channel = config.spanner.next_channel(); + let channel_lease = config.spanner.next_channel(); debug!(database = %config.database, "Connecting to FetchCacheUpdate stream"); let connect_future = config .spanner - .fetch_cache_update(request, RequestOptions::default(), channel) + .fetch_cache_update(request, RequestOptions::default(), &channel_lease) + .with_lifetime_guard(Arc::new(channel_lease.into_guard())) .send(); tokio::pin!(connect_future); diff --git a/src/spanner/src/routing/mock_tests.rs b/src/spanner/src/routing/mock_tests.rs index bc4454dbe0..da15d9a09a 100644 --- a/src/spanner/src/routing/mock_tests.rs +++ b/src/spanner/src/routing/mock_tests.rs @@ -2822,7 +2822,7 @@ async fn unary_commit_routes_to_affinity_address_and_clears_affinity() -> anyhow .set_transaction_id(Bytes::copy_from_slice(transaction_id)); let response = database_client - .commit(commit_request, RequestOptions::default(), 0) + .commit(commit_request, RequestOptions::default(), None) .await?; assert!( @@ -2901,7 +2901,7 @@ async fn unary_rollback_routes_to_affinity_address_and_clears_affinity() -> anyh .set_transaction_id(Bytes::copy_from_slice(transaction_id)); database_client - .rollback(rollback_request, RequestOptions::default(), 0) + .rollback(rollback_request, RequestOptions::default(), None) .await?; assert!( @@ -2997,7 +2997,7 @@ async fn unary_single_use_commit_routes_to_leader_tablet_replica() -> anyhow::Re .set_mutations(vec![mutation.build_proto()]); let response = database_client - .commit(commit_request, RequestOptions::default(), 0) + .commit(commit_request, RequestOptions::default(), None) .await?; assert!( @@ -3095,7 +3095,7 @@ async fn unary_begin_transaction_with_mutation_key_routes_to_leader_and_records_ .set_mutation_key(mutation.build_proto()); let response = database_client - .begin_transaction(begin_request, RequestOptions::default(), 0) + .begin_transaction(begin_request, RequestOptions::default(), None) .await?; assert!( @@ -3195,7 +3195,7 @@ async fn unary_begin_transaction_with_read_only_options_does_not_record_affinity .set_mutation_key(mutation.build_proto()); let response = database_client - .begin_transaction(begin_request, RequestOptions::default(), 0) + .begin_transaction(begin_request, RequestOptions::default(), None) .await?; assert!( @@ -3268,7 +3268,7 @@ async fn unary_execute_sql_routes_to_affinity_address() -> anyhow::Result<()> { ); let _ = database_client - .execute_sql(execute_sql_request, RequestOptions::default(), 0) + .execute_sql(execute_sql_request, RequestOptions::default(), None) .await?; assert!( @@ -3327,7 +3327,7 @@ async fn unary_execute_sql_with_inline_begin_rw_records_affinity() -> anyhow::Re ); let _ = database_client - .execute_sql(execute_sql_request, RequestOptions::default(), 0) + .execute_sql(execute_sql_request, RequestOptions::default(), None) .await?; assert!( @@ -3398,7 +3398,7 @@ async fn unary_execute_batch_dml_routes_to_affinity_address() -> anyhow::Result< .set_seqno(1); let _ = database_client - .execute_batch_dml(batch_dml_request, RequestOptions::default(), 0) + .execute_batch_dml(batch_dml_request, RequestOptions::default(), None) .await?; assert!( @@ -3463,7 +3463,7 @@ async fn unary_execute_batch_dml_with_inline_begin_rw_records_affinity() -> anyh .set_seqno(1); let _ = database_client - .execute_batch_dml(batch_dml_request, RequestOptions::default(), 0) + .execute_batch_dml(batch_dml_request, RequestOptions::default(), None) .await?; assert!( @@ -3538,7 +3538,7 @@ async fn unary_partition_read_routes_to_tablet_node() -> anyhow::Result<()> { .set_key_set(key_set.into_proto()); let _ = database_client - .partition_read(partition_read_request, RequestOptions::default(), 0) + .partition_read(partition_read_request, RequestOptions::default(), None) .await?; assert!( @@ -3602,7 +3602,7 @@ async fn unary_partition_query_with_transaction_id_routes_to_affinity_address() ); let _ = database_client - .partition_query(partition_query_request, RequestOptions::default(), 0) + .partition_query(partition_query_request, RequestOptions::default(), None) .await?; assert!( @@ -4127,7 +4127,7 @@ async fn end_to_end_unary_execute_sql_with_key_recipe_routes_to_tablet_replica() let request1 = statement.clone().into_request(); let _ = database_client - .execute_sql(request1, RequestOptions::default(), 0) + .execute_sql(request1, RequestOptions::default(), None) .await?; assert!( @@ -4163,7 +4163,7 @@ async fn end_to_end_unary_execute_sql_with_key_recipe_routes_to_tablet_replica() // 4. Second execution (cache hit): routes directly to tablet mock with attached routing hint let request2 = statement.into_request(); let _ = database_client - .execute_sql(request2, RequestOptions::default(), 0) + .execute_sql(request2, RequestOptions::default(), None) .await?; assert!( @@ -4519,7 +4519,7 @@ async fn unary_execute_sql_with_directed_read_options_and_key_recipe_routes_to_d let request1 = statement.clone().into_request(); let _ = database_client - .execute_sql(request1, RequestOptions::default(), 0) + .execute_sql(request1, RequestOptions::default(), None) .await?; assert!( @@ -4567,7 +4567,7 @@ async fn unary_execute_sql_with_directed_read_options_and_key_recipe_routes_to_d // Cache hit: routes directly to mock_east replica matching directed read options let request2 = statement.into_request(); let _ = database_client - .execute_sql(request2, RequestOptions::default(), 0) + .execute_sql(request2, RequestOptions::default(), None) .await?; assert!( diff --git a/src/spanner/src/server_streaming/builder.rs b/src/spanner/src/server_streaming/builder.rs index 154c021469..2d2adbbdfd 100644 --- a/src/spanner/src/server_streaming/builder.rs +++ b/src/spanner/src/server_streaming/builder.rs @@ -23,7 +23,8 @@ use crate::model::ReadRequest; use crate::server_streaming::stream::BatchWriteStream; use crate::server_streaming::stream::CacheUpdateStream; use crate::server_streaming::stream::PartialResultSetStream; -use gaxi::grpc::tonic; +use crate::server_streaming::stream::SpannerServerStream; +use crate::server_streaming::stream::StreamLifetimeGuard; use gaxi::grpc::tonic::Extensions; use gaxi::grpc::tonic::GrpcMethod; use gaxi::prost::ToProto; @@ -36,6 +37,7 @@ pub(crate) struct ExecuteStreamingSql { grpc_client: gaxi::grpc::Client, request: ExecuteSqlRequest, options: RequestOptions, + lifetime_guard: Option, } impl ExecuteStreamingSql { @@ -44,9 +46,16 @@ impl ExecuteStreamingSql { grpc_client, request: ExecuteSqlRequest::default(), options: RequestOptions::default(), + lifetime_guard: None, } } + /// Attaches an opaque RAII lifetime guard that remains alive for the duration of the stream. + pub(crate) fn with_lifetime_guard(mut self, guard: StreamLifetimeGuard) -> Self { + self.lifetime_guard = Some(guard); + self + } + /// Sets the full request, replacing any prior values. pub(crate) fn with_request>(mut self, v: V) -> Self { self.request = v.into(); @@ -59,23 +68,25 @@ impl ExecuteStreamingSql { self } + /// Returns a reference to the request options. + pub(crate) fn options(&self) -> &RequestOptions { + &self.options + } + /// Start the server streaming request and receive the stream. pub(crate) async fn send(self) -> Result { - let session = self.request.session.clone(); + let request_params = format!("session={}", self.request.session); let request = self.request.to_proto().map_err(Error::deser)?; - let request_params = format!("session={session}"); - let response = make_server_streaming_request( + make_server_streaming_request( &self.grpc_client, request, self.options, "ExecuteStreamingSql", "/google.spanner.v1.Spanner/ExecuteStreamingSql", &request_params, + self.lifetime_guard, ) - .await?; - let (metadata, stream, _) = response.into_parts(); - let headers = metadata.into_headers(); - Ok(PartialResultSetStream::new(stream, headers)) + .await } } @@ -91,6 +102,7 @@ pub(crate) struct StreamingRead { grpc_client: gaxi::grpc::Client, request: ReadRequest, options: RequestOptions, + lifetime_guard: Option, } impl StreamingRead { @@ -99,9 +111,16 @@ impl StreamingRead { grpc_client, request: ReadRequest::default(), options: RequestOptions::default(), + lifetime_guard: None, } } + /// Attaches an opaque RAII lifetime guard that remains alive for the duration of the stream. + pub(crate) fn with_lifetime_guard(mut self, guard: StreamLifetimeGuard) -> Self { + self.lifetime_guard = Some(guard); + self + } + /// Sets the full request, replacing any prior values. pub(crate) fn with_request>(mut self, v: V) -> Self { self.request = v.into(); @@ -114,23 +133,25 @@ impl StreamingRead { self } + /// Returns a reference to the request options. + pub(crate) fn options(&self) -> &RequestOptions { + &self.options + } + /// Start the server streaming request and receive the stream. pub(crate) async fn send(self) -> Result { - let session = self.request.session.clone(); + let request_params = format!("session={}", self.request.session); let request = self.request.to_proto().map_err(Error::deser)?; - let request_params = format!("session={session}"); - let response = make_server_streaming_request( + make_server_streaming_request( &self.grpc_client, request, self.options, "StreamingRead", "/google.spanner.v1.Spanner/StreamingRead", &request_params, + self.lifetime_guard, ) - .await?; - let (metadata, stream, _) = response.into_parts(); - let headers = metadata.into_headers(); - Ok(PartialResultSetStream::new(stream, headers)) + .await } } @@ -146,6 +167,7 @@ pub(crate) struct BatchWrite { grpc_client: gaxi::grpc::Client, request: BatchWriteRequest, options: RequestOptions, + lifetime_guard: Option, } impl BatchWrite { @@ -154,9 +176,16 @@ impl BatchWrite { grpc_client, request: BatchWriteRequest::default(), options: RequestOptions::default(), + lifetime_guard: None, } } + /// Attaches an opaque RAII lifetime guard that remains alive for the duration of the stream. + pub(crate) fn with_lifetime_guard(mut self, guard: StreamLifetimeGuard) -> Self { + self.lifetime_guard = Some(guard); + self + } + /// Sets the full request, replacing any prior values. pub(crate) fn with_request>(mut self, v: V) -> Self { self.request = v.into(); @@ -171,21 +200,18 @@ impl BatchWrite { /// Start the server streaming request and receive the stream. pub(crate) async fn send(self) -> Result { - let session = self.request.session.clone(); + let request_params = format!("session={}", self.request.session); let request = self.request.to_proto().map_err(Error::deser)?; - let request_params = format!("session={session}"); - let response = make_server_streaming_request( + make_server_streaming_request( &self.grpc_client, request, self.options, "BatchWrite", "/google.spanner.v1.Spanner/BatchWrite", &request_params, + self.lifetime_guard, ) - .await?; - let (metadata, stream, _) = response.into_parts(); - let headers = metadata.into_headers(); - Ok(BatchWriteStream::new(stream, headers)) + .await } } @@ -201,6 +227,7 @@ pub(crate) struct FetchCacheUpdate { grpc_client: gaxi::grpc::Client, request: FetchCacheUpdateRequest, options: RequestOptions, + lifetime_guard: Option, } impl FetchCacheUpdate { @@ -209,9 +236,16 @@ impl FetchCacheUpdate { grpc_client, request: FetchCacheUpdateRequest::default(), options: RequestOptions::default(), + lifetime_guard: None, } } + /// Attaches an opaque RAII lifetime guard that remains alive for the duration of the stream. + pub(crate) fn with_lifetime_guard(mut self, guard: StreamLifetimeGuard) -> Self { + self.lifetime_guard = Some(guard); + self + } + /// Sets the full request, replacing any prior values. pub(crate) fn with_request>(mut self, v: V) -> Self { self.request = v.into(); @@ -226,21 +260,18 @@ impl FetchCacheUpdate { /// Start the server streaming request and receive the stream. pub(crate) async fn send(self) -> Result { - let database = self.request.database.clone(); + let request_params = format!("database={}", self.request.database); let request = self.request.to_proto().map_err(Error::deser)?; - let request_params = format!("database={database}"); - let response = make_server_streaming_request( + make_server_streaming_request( &self.grpc_client, request, self.options, "FetchCacheUpdate", "/google.spanner.v1.Spanner/FetchCacheUpdate", &request_params, + self.lifetime_guard, ) - .await?; - let (metadata, stream, _) = response.into_parts(); - let headers = metadata.into_headers(); - Ok(CacheUpdateStream::new(stream, headers)) + .await } } @@ -266,20 +297,21 @@ async fn make_server_streaming_request( method_name: &'static str, path_str: &'static str, x_goog_request_params: &str, -) -> Result>> + lifetime_guard: Option, +) -> Result> where Req: Message + Default + Clone + 'static, Res: Message + Default + 'static, { let options = google_cloud_gax::options::internal::set_default_idempotency(options, false); let extensions = { - let mut e = Extensions::new(); - e.insert(GrpcMethod::new("google.spanner.v1.Spanner", method_name)); - e + let mut extensions = Extensions::new(); + extensions.insert(GrpcMethod::new("google.spanner.v1.Spanner", method_name)); + extensions }; let path = http::uri::PathAndQuery::from_static(path_str); - grpc_client + let response = match grpc_client .server_streaming( extensions, path, @@ -289,14 +321,43 @@ where x_goog_request_params, ) .await + { + Ok(response) => response, + Err(err) => { + if let (Some(guard), Some(status)) = (lifetime_guard, err.status()) { + guard.record_error_code(status.code); + } + return Err(err); + } + }; + let (metadata, stream, _) = response.into_parts(); + let headers = metadata.into_headers(); + let mut stream = SpannerServerStream::new(stream, headers); + if let Some(guard) = lifetime_guard { + stream = stream.with_lifetime_guard(guard); + } + Ok(stream) } #[cfg(test)] mod tests { use super::*; use crate::client::Spanner; + use crate::server_streaming::stream::StreamGuard; use google_cloud_auth::credentials::anonymous::Builder as Anonymous; + use google_cloud_gax::error::rpc::Code; use google_cloud_test_macros::tokio_test_no_panics; + use std::fmt::Debug; + use std::sync::Arc; + use std::sync::Mutex; + + #[test] + fn traits() { + static_assertions::assert_impl_all!(ExecuteStreamingSql: Clone, Debug, Send, Sync); + static_assertions::assert_impl_all!(StreamingRead: Clone, Debug, Send, Sync); + static_assertions::assert_impl_all!(BatchWrite: Clone, Debug, Send, Sync); + static_assertions::assert_impl_all!(FetchCacheUpdate: Clone, Debug, Send, Sync); + } #[tokio_test_no_panics] async fn fetch_cache_update_builder_configuration() { @@ -311,7 +372,9 @@ mod tests { .await .expect("spanner client should build"); - let grpc_client = spanner.channels[0] + let grpc_client = spanner + .default_channel() + .expect("default channel should exist") .grpc_client .clone() .expect("grpc client should exist"); @@ -329,4 +392,64 @@ mod tests { "projects/p/instances/i/databases/d" ); } + + #[derive(Debug)] + struct TestLifetimeGuard { + recorded_code: Arc>>, + } + + impl StreamGuard for TestLifetimeGuard { + fn record_error_code(&self, code: Code) { + *self.recorded_code.lock().expect("lock poisoned") = Some(code); + } + } + + #[tokio_test_no_panics] + async fn make_server_streaming_request_records_error_code_on_initial_failure() { + use gaxi::grpc::tonic::Status; + + let mut mock = spanner_grpc_mock::MockSpanner::new(); + mock.expect_execute_streaming_sql() + .once() + .returning(|_| Err(Status::unavailable("backend unavailable"))); + + let (address, _server) = spanner_grpc_mock::start("0.0.0.0:0", mock) + .await + .expect("mock server should start"); + + let spanner = Spanner::builder() + .with_endpoint(address) + .with_credentials(Anonymous::new().build()) + .build() + .await + .expect("spanner client should build"); + + let grpc_client = spanner + .default_channel() + .expect("default channel should exist") + .grpc_client + .clone() + .expect("grpc client should exist"); + + let recorded_code = Arc::new(Mutex::new(None)); + let guard = Arc::new(TestLifetimeGuard { + recorded_code: Arc::clone(&recorded_code), + }); + + let builder = ExecuteStreamingSql::new(grpc_client) + .with_lifetime_guard(guard) + .with_request( + ExecuteSqlRequest::default() + .set_session("projects/p/instances/i/databases/d/sessions/s") + .set_sql("SELECT 1"), + ); + + let result = builder.send().await; + assert!(result.is_err(), "Initial handshake failure must return Err"); + assert_eq!( + *recorded_code.lock().expect("lock poisoned"), + Some(Code::Unavailable), + "Initial handshake failure must record Code::Unavailable on lifetime guard" + ); + } } diff --git a/src/spanner/src/server_streaming/stream.rs b/src/spanner/src/server_streaming/stream.rs index 76b0c557da..b02e49ed9b 100644 --- a/src/spanner/src/server_streaming/stream.rs +++ b/src/spanner/src/server_streaming/stream.rs @@ -17,11 +17,18 @@ use crate::google::spanner::v1::CacheUpdate as ProtoCacheUpdate; use crate::google::spanner::v1::PartialResultSet; use gaxi::grpc::from_status::to_gax_error; use gaxi::grpc::tonic::Streaming; +use google_cloud_gax::error::rpc::Code; use http::HeaderMap; -use std::any::Any; +use std::fmt::Debug; +use std::sync::Arc; -/// Type alias for opaque stream lifetime drop guards. -pub(crate) type StreamLifetimeGuard = Box; +/// Trait for stream lifetime drop guards capable of recording RPC error codes for dynamic channel pooling. +pub(crate) trait StreamGuard: Debug + Send + Sync + 'static { + fn record_error_code(&self, code: Code); +} + +/// Type alias for stream lifetime drop guards. +pub(crate) type StreamLifetimeGuard = Arc; /// Generic wrapper around gRPC server-streaming responses with lifetime management. #[derive(Debug)] @@ -41,7 +48,6 @@ impl SpannerServerStream { } /// Attaches an opaque RAII lifetime guard that remains alive for the duration of the stream. - #[allow(dead_code)] pub(crate) fn with_lifetime_guard(mut self, guard: StreamLifetimeGuard) -> Self { self.lifetime_guard = Some(guard); self @@ -60,9 +66,16 @@ impl SpannerServerStream { pub(crate) async fn next_message(&mut self) -> Option> { match self.inner.message().await.map_err(to_gax_error).transpose() { Some(Ok(message)) => Some(Ok(message)), - other => { + Some(Err(err)) => { + let guard = self.lifetime_guard.take(); + if let (Some(guard), Some(status)) = (guard.as_ref(), err.status()) { + guard.record_error_code(status.code); + } + Some(Err(err)) + } + None => { self.lifetime_guard = None; - other + None } } } @@ -84,8 +97,10 @@ mod tests { use google_cloud_gax::options::RequestOptions; use google_cloud_test_macros::tokio_test_no_panics; use std::fmt::Debug; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }; #[test] fn auto_traits() { @@ -94,8 +109,16 @@ mod tests { static_assertions::assert_impl_all!(CacheUpdateStream: Send, Sync, Debug); } + #[derive(Debug)] struct TestDropGuard { dropped: Arc, + recorded_code: Arc>>, + } + + impl StreamGuard for TestDropGuard { + fn record_error_code(&self, code: Code) { + *self.recorded_code.lock().expect("lock poisoned") = Some(code); + } } impl Drop for TestDropGuard { @@ -107,8 +130,10 @@ mod tests { #[tokio_test_no_panics] async fn stream_drop_releases_lifetime_guard() -> anyhow::Result<()> { let dropped = Arc::new(AtomicBool::new(false)); - let guard = Box::new(TestDropGuard { + let recorded_code = Arc::new(Mutex::new(None)); + let guard = Arc::new(TestDropGuard { dropped: Arc::clone(&dropped), + recorded_code: Arc::clone(&recorded_code), }); let mut mock = create_session_mock(); @@ -123,7 +148,7 @@ mod tests { .set_session(db_client.session_name()) .set_sql("SELECT 1"); let stream = db_client - .execute_streaming_sql(request, RequestOptions::default(), 0) + .execute_streaming_sql(request, RequestOptions::default(), None) .send() .await? .with_lifetime_guard(guard); @@ -139,14 +164,21 @@ mod tests { dropped.load(Ordering::Relaxed), "Guard must be dropped when stream is dropped" ); + assert_eq!( + *recorded_code.lock().expect("lock poisoned"), + None, + "No error code should be recorded on normal stream drop" + ); Ok(()) } #[tokio_test_no_panics] async fn stream_eof_releases_lifetime_guard() -> anyhow::Result<()> { let dropped = Arc::new(AtomicBool::new(false)); - let guard = Box::new(TestDropGuard { + let recorded_code = Arc::new(Mutex::new(None)); + let guard = Arc::new(TestDropGuard { dropped: Arc::clone(&dropped), + recorded_code: Arc::clone(&recorded_code), }); let mut mock = create_session_mock(); @@ -160,7 +192,7 @@ mod tests { .set_session(db_client.session_name()) .set_sql("SELECT 1"); let mut stream = db_client - .execute_streaming_sql(request, RequestOptions::default(), 0) + .execute_streaming_sql(request, RequestOptions::default(), None) .send() .await? .with_lifetime_guard(guard); @@ -179,14 +211,21 @@ mod tests { dropped.load(Ordering::Relaxed), "Guard must be dropped immediately on EOF" ); + assert_eq!( + *recorded_code.lock().expect("lock poisoned"), + None, + "No error code should be recorded on normal EOF" + ); Ok(()) } #[tokio_test_no_panics] async fn stream_error_releases_lifetime_guard() -> anyhow::Result<()> { let dropped = Arc::new(AtomicBool::new(false)); - let guard = Box::new(TestDropGuard { + let recorded_code = Arc::new(Mutex::new(None)); + let guard = Arc::new(TestDropGuard { dropped: Arc::clone(&dropped), + recorded_code: Arc::clone(&recorded_code), }); let mut mock = create_session_mock(); @@ -200,7 +239,7 @@ mod tests { .set_session(db_client.session_name()) .set_sql("SELECT 1"); let mut stream = db_client - .execute_streaming_sql(request, RequestOptions::default(), 0) + .execute_streaming_sql(request, RequestOptions::default(), None) .send() .await? .with_lifetime_guard(guard); @@ -225,6 +264,75 @@ mod tests { dropped.load(Ordering::Relaxed), "Guard must be dropped immediately on stream error" ); + assert_eq!( + *recorded_code.lock().expect("lock poisoned"), + Some(Code::Unavailable), + "Stream error must record Code::Unavailable on guard" + ); + Ok(()) + } + + #[tokio_test_no_panics] + async fn stream_error_penalizes_active_rpc_guard() -> anyhow::Result<()> { + use crate::channel_pool::entry::{ActiveRpcGuard, ChannelEntry}; + use crate::model::ExecuteSqlRequest; + use std::time::Duration; + + let mut mock = create_session_mock(); + let (sender, receiver) = tokio::sync::mpsc::channel(1); + mock.expect_execute_streaming_sql() + .return_once(move |_| Ok(Response::from(receiver))); + + let (db_client, _server) = setup_db_client(mock).await; + + let channel = db_client + .spanner + .default_channel() + .expect("default channel should be present"); + let entry = Arc::new(ChannelEntry::new(1, 1, channel)); + assert_eq!(entry.in_flight(), 0, "initial in-flight should be 0"); + assert_eq!(entry.current_penalty(), 0, "initial penalty should be 0"); + + let guard: Arc = Arc::new(ActiveRpcGuard::new( + Arc::clone(&entry), + 2, + Duration::from_secs(60), + 10, + )); + assert_eq!(entry.in_flight(), 1, "guard creation increments in-flight"); + + let request = ExecuteSqlRequest::default() + .set_session(db_client.session_name()) + .set_sql("SELECT 1"); + let mut stream = db_client + .execute_streaming_sql(request, RequestOptions::default(), None) + .send() + .await? + .with_lifetime_guard(guard); + + sender + .send(Err(Status::unavailable("server unavailable"))) + .await + .expect("send error"); + + let next = stream.next_message().await; + assert!(next.is_some(), "Stream should yield Some on error"); + assert!( + next.expect("error message").is_err(), + "Stream message should be an error" + ); + + assert_eq!( + entry.in_flight(), + 0, + "Guard must be dropped and in_flight decremented on error" + ); + assert_eq!( + entry.current_penalty(), + 2, + "Stream error must apply configured penalty step (2) to ChannelEntry" + ); + Ok(()) } } diff --git a/src/spanner/src/session_maintainer.rs b/src/spanner/src/session_maintainer.rs index 5f933276c4..1688427fd8 100644 --- a/src/spanner/src/session_maintainer.rs +++ b/src/spanner/src/session_maintainer.rs @@ -65,6 +65,9 @@ impl ManagedSessionMaintainer { ) -> Result> { let session = Self::create_session(&spanner, &database_name, &database_role, &options, &o11y).await?; + spanner + .channel_pool() + .set_prime_session(session.name.clone()); let maintainer = Arc::new(ManagedSessionMaintainer { spanner, @@ -113,6 +116,10 @@ impl ManagedSessionMaintainer { ) .await?; + self.spanner + .channel_pool() + .set_prime_session(new_session.name.clone()); + let mut guard = self.session.write().expect("failed to write session"); *guard = ManagedSession { session: Arc::new(new_session), @@ -142,7 +149,7 @@ impl ManagedSessionMaintainer { let channel = spanner.next_channel(); spanner - .create_session(request, options.clone(), channel, o11y) + .create_session(request, options.clone(), &channel, o11y) .await } @@ -241,6 +248,10 @@ mod tests { session.name, "projects/test-project/instances/test-instance/databases/test-db/sessions/1" ); + assert!( + maintainer.spanner.channel_pool().has_prime_session(), + "maintainer should register prime session with channel pool" + ); } // Modify created_at to be in the past (older than 7 days) @@ -266,6 +277,10 @@ mod tests { session.name, "projects/test-project/instances/test-instance/databases/test-db/sessions/2" ); + assert!( + maintainer.spanner.channel_pool().has_prime_session(), + "maintainer should preserve prime session in channel pool after replacement" + ); } } diff --git a/src/spanner/src/transaction_runner.rs b/src/spanner/src/transaction_runner.rs index abd37257af..0780b6f4bb 100644 --- a/src/spanner/src/transaction_runner.rs +++ b/src/spanner/src/transaction_runner.rs @@ -2326,7 +2326,8 @@ mod tests { transaction .affinity() .expect("affinity present") - .set_entry_id(42); + .compare_and_set_entry_id(0, 42) + .expect("pin entry"); captured_ids.lock().expect("mutex lock").push( transaction .affinity() diff --git a/src/spanner/src/write_only_transaction.rs b/src/spanner/src/write_only_transaction.rs index da061eb113..681eafc4e7 100644 --- a/src/spanner/src/write_only_transaction.rs +++ b/src/spanner/src/write_only_transaction.rs @@ -413,8 +413,6 @@ impl WriteOnlyTransaction { let client = self.client; let session_name = self.session_name.clone(); let previous_transaction_id = Arc::new(Mutex::new(Bytes::new())); - let channel_hint = client.next_channel_hint(); - let affinity = Arc::new(TransactionAffinity::new_read_write()); let max_commit_delay = self.max_commit_delay; let return_commit_stats = self.return_commit_stats; @@ -429,10 +427,13 @@ impl WriteOnlyTransaction { let previous_transaction_id = previous_transaction_id.clone(); let begin_gax_options = begin_gax_options.clone(); let commit_gax_options = commit_gax_options.clone(); - let _affinity = Arc::clone(&affinity); async move { - let previous_id: Bytes = previous_transaction_id.lock().unwrap().clone(); + let affinity = TransactionAffinity::new_read_write(); + let previous_id: Bytes = previous_transaction_id + .lock() + .expect("previous_transaction_id mutex poisoned") + .clone(); let begin_req = BeginTransactionRequest::default() .set_session(session_name.clone()) @@ -449,23 +450,25 @@ impl WriteOnlyTransaction { .set_request_options(req_options.clone()) .set_or_clear_mutation_key(mutation_key.clone()); - let tx = client - .begin_transaction(begin_req, begin_gax_options, channel_hint) + let transaction = client + .begin_transaction(begin_req, begin_gax_options, Some(&affinity)) .await?; - *previous_transaction_id.lock().unwrap() = tx.id.clone(); + *previous_transaction_id + .lock() + .expect("previous_transaction_id mutex poisoned") = transaction.id.clone(); let commit_req = create_commit_request( session_name.clone(), - tx.id.clone(), + transaction.id.clone(), mutations_proto, - tx.precommit_token, + transaction.precommit_token, Some(req_options.clone()), max_commit_delay, return_commit_stats, ); let response = client - .commit(commit_req, commit_gax_options.clone(), channel_hint) + .commit(commit_req, commit_gax_options.clone(), Some(&affinity)) .await?; // If a commit_response with a precommit_token is returned, then we need to @@ -473,7 +476,7 @@ impl WriteOnlyTransaction { if let Some(new_token) = response.precommit_token().map(|b| *b.clone()) { let retry_commit_req = create_commit_request( session_name.clone(), - tx.id, + transaction.id, Vec::new(), Some(new_token), Some(req_options), @@ -482,7 +485,7 @@ impl WriteOnlyTransaction { ); client - .commit(retry_commit_req, commit_gax_options, channel_hint) + .commit(retry_commit_req, commit_gax_options, Some(&affinity)) .await } else { Ok(response) @@ -545,7 +548,6 @@ impl WriteOnlyTransaction { .set_or_clear_max_commit_delay(self.max_commit_delay) .set_return_commit_stats(self.return_commit_stats); let client = self.client; - let channel_hint = client.next_channel_hint(); let is_emulator = client.is_emulator(); let action = || { @@ -553,11 +555,7 @@ impl WriteOnlyTransaction { let request = request.clone(); let commit_gax_options = commit_gax_options.clone(); - async move { - client - .commit(request, commit_gax_options, channel_hint) - .await - } + async move { client.commit(request, commit_gax_options, None).await } }; retry_aborted(&*self.retry_policy, action, is_emulator).await From deb0bbbf21df829f3a05014b944854f4e7080176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Tue, 8 Sep 2026 09:37:50 +0200 Subject: [PATCH 2/2] fix(spanner): fix build error after macro update --- src/spanner/src/database_client.rs | 34 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/spanner/src/database_client.rs b/src/spanner/src/database_client.rs index 77022d07d1..b4a0c5794a 100644 --- a/src/spanner/src/database_client.rs +++ b/src/spanner/src/database_client.rs @@ -2931,18 +2931,18 @@ mod tests { assert!(database_client.is_location_aware_routing_enabled()); - // Verify across multiple channel affinities (channel_hint 2 -> slot .3., channel_hint 1 -> slot .2.): + // Verify across multiple channel affinities: // 1. Statements within a transaction remain pinned to the same channel slot. // 2. Different transactions distribute across distinct channel slots rather than // collapsing onto Channel 0 (slot .1.). - for channel_hint in [2usize, 1usize] { - let expected_channel_id = format!(".{}.", channel_hint + 1); + for _ in 0..2 { + let affinity = TransactionAffinity::new_read_write(); // 1. BeginTransaction (unkeyed read-write options) let begin_request = BeginTransactionRequest::default() .set_options(TransactionOptions::default().set_read_write(ReadWrite::default())); let transaction = database_client - .begin_transaction(begin_request, RequestOptions::default(), channel_hint) + .begin_transaction(begin_request, RequestOptions::default(), Some(&affinity)) .await .expect("begin_transaction should succeed"); @@ -2956,21 +2956,25 @@ mod tests { let selector = TransactionSelector::new().set_id(transaction_id.clone()); let sql_request = ExecuteSqlRequest::default().set_transaction(selector.clone()); database_client - .execute_sql(sql_request, RequestOptions::default(), channel_hint) + .execute_sql(sql_request, RequestOptions::default(), Some(&affinity)) .await .expect("execute_sql should succeed"); // 3. ExecuteStreamingSql with the returned transaction ID let streaming_request = ExecuteSqlRequest::default().set_transaction(selector); let _ = database_client - .execute_streaming_sql(streaming_request, RequestOptions::default(), channel_hint) + .execute_streaming_sql( + streaming_request, + RequestOptions::default(), + Some(&affinity), + ) .send() .await; // 4. Commit with the transaction ID let commit_request = CommitRequest::default().set_transaction_id(transaction_id); database_client - .commit(commit_request, RequestOptions::default(), channel_hint) + .commit(commit_request, RequestOptions::default(), Some(&affinity)) .await .expect("commit should succeed"); @@ -2988,10 +2992,16 @@ mod tests { 4, "expected 4 calls: begin_transaction, execute_sql, execute_streaming_sql, commit" ); + let channel_segment = calls[0] + .1 + .split('.') + .nth(1) + .expect("channel ID segment in request ID"); + let expected_channel_id = format!(".{channel_segment}."); for (rpc_name, request_id) in calls { assert!( request_id.contains(&expected_channel_id), - "RPC {rpc_name} for transaction with channel_hint {channel_hint} must route via channel {expected_channel_id}, got {request_id}" + "RPC {rpc_name} for transaction must route via pinned channel {expected_channel_id}, got {request_id}" ); } } @@ -5123,7 +5133,7 @@ mod tests { .set_options(TransactionOptions::new().set_read_write(ReadWrite::new())) .set_mutation_key(user_mutation.clone().build_proto()); let _ = database_client - .begin_transaction(begin_request, RequestOptions::default(), 0) + .begin_transaction(begin_request, RequestOptions::default(), None) .await .expect("begin_transaction must succeed"); @@ -5159,7 +5169,7 @@ mod tests { .set_transaction_id(Bytes::from_static(b"tx-e2e-1")) .set_mutations(vec![user_mutation.clone().build_proto()]); let _ = database_client - .commit(commit_request, RequestOptions::default(), 0) + .commit(commit_request, RequestOptions::default(), None) .await .expect("commit must succeed"); @@ -5194,7 +5204,7 @@ mod tests { .set_single_use_transaction(TransactionOptions::new().set_read_write(ReadWrite::new())) .set_mutations(vec![user_mutation.clone().build_proto()]); let _ = database_client - .commit(single_use_commit_request, RequestOptions::default(), 0) + .commit(single_use_commit_request, RequestOptions::default(), None) .await .expect("single-use commit must succeed"); @@ -5224,7 +5234,7 @@ mod tests { .set_session("projects/p/instances/i/databases/d/sessions/s1") .set_options(TransactionOptions::new().set_read_write(ReadWrite::new())); let unkeyed_response = database_client - .begin_transaction(unkeyed_begin_request, RequestOptions::default(), 0) + .begin_transaction(unkeyed_begin_request, RequestOptions::default(), None) .await .expect("unkeyed begin_transaction must succeed");