This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-gateway.git
The following commit(s) were added to refs/heads/main by this push:
new f4a8a40 [SPARK-58773] Add scg-routing crate
f4a8a40 is described below
commit f4a8a40b5a6f9eb8d323cec93c97f392e2c952f0
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Tue Aug 18 18:59:37 2026 -0700
[SPARK-58773] Add scg-routing crate
### What changes were proposed in this pull request?
Part of importing the Spark Connect Gateway reference implementation into
`apache/spark-connect-gateway` as a series of small, bottom-up PRs (tracked
in
the do-not-merge reference PR #1).
This adds the `scg-routing` crate — the routing core of the gateway. It
defines
the central traits and types that the pool and store layers implement and
that
the proxy routes through:
- `SessionKey` — the routing key derived from a request
- `Pool` — the backend-pool trait (implemented later by pool-static /
pool-k8s)
- `AffinityStore` — the session-affinity store trait (implemented later by
store-memory / store-redis)
- `Router` — ties them together
`scg-routing` is a leaf crate (no internal dependencies), so it compiles and
tests on its own. It is appended to the workspace `members` list. Subsequent
PRs add the crates that depend on it.
### Why are the changes needed?
Implements the reference gateway from the approved SPIP (SPARK-58455),
imported
one reviewable piece at a time.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
`cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D
warnings`,
`cargo build --workspace`, and `cargo test --workspace` all pass locally
(scg-routing has 15 unit tests).
### Was this patch authored or co-authored using generative AI tooling?
Yes, co-authored with Claude Code.
Closes #3 from viirya/import-02-routing.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
Cargo.lock | 69 ++++
Cargo.toml | 1 +
crates/routing/Cargo.toml | 35 +++
crates/routing/src/lib.rs | 777 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 882 insertions(+)
diff --git a/Cargo.lock b/Cargo.lock
index 5356f40..a7b6d1d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -374,6 +374,15 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.33"
@@ -421,6 +430,29 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -571,6 +603,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "regex"
version = "1.13.1"
@@ -624,6 +665,22 @@ dependencies = [
"tonic-prost-build",
]
+[[package]]
+name = "scg-routing"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "parking_lot",
+ "tokio",
+ "tonic",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -644,6 +701,16 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -716,7 +783,9 @@ dependencies = [
"bytes",
"libc",
"mio",
+ "parking_lot",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
diff --git a/Cargo.toml b/Cargo.toml
index f26c9b3..8484085 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,7 @@
resolver = "2"
members = [
"crates/genproto",
+ "crates/routing",
]
[workspace.package]
diff --git a/crates/routing/Cargo.toml b/crates/routing/Cargo.toml
new file mode 100644
index 0000000..dfa4bdf
--- /dev/null
+++ b/crates/routing/Cargo.toml
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[package]
+name = "scg-routing"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+readme.workspace = true
+keywords.workspace = true
+categories.workspace = true
+description = "Routing core for the Spark Connect Gateway: session keys, pool
and affinity-store traits, tenant-aware router."
+publish = false
+
+[dependencies]
+async-trait = { workspace = true }
+tonic = { workspace = true }
+
+[dev-dependencies]
+parking_lot = { workspace = true }
+tokio = { workspace = true }
diff --git a/crates/routing/src/lib.rs b/crates/routing/src/lib.rs
new file mode 100644
index 0000000..f29d3e2
--- /dev/null
+++ b/crates/routing/src/lib.rs
@@ -0,0 +1,777 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Routing core: SessionKey, Pool trait, AffinityStore trait,
+//! TenantRouter, and Router.
+//!
+//! Backend selection is broken into pieces so each can evolve
+//! independently:
+//!
+//! * `Pool` — *which* backend should serve a fresh session?
+//! * `AffinityStore` — *which* backend already serves an existing session?
+//! * `TenantRouter` — map a tenant string to its pool. Single-tenant
+//! deployments still work — they configure exactly one entry
+//! (often the implicit `"default"`).
+//! * `Router` — glue that asks the store first, then the pool from
+//! `TenantRouter`, and remembers the decision.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use tonic::Status;
+
+/// Identifies a Spark Connect session within a tenant. The triple
+/// `(tenant, user_id, session_id)` is the affinity routing key.
+/// Single-tenant deployments implicitly use the literal string
+/// `"default"` for the tenant component (see [`SessionKey::new`]).
+///
+/// Spark Connect itself keys `SparkSession` only on `(user_id,
+/// session_id)`. Adding the tenant prefix lets multiple tenants share
+/// a gateway without their `session_id` namespaces colliding —
+/// `(team-a, alice, sess-1)` is a different key from `(team-b,
+/// alice, sess-1)`.
+///
+/// `user_id` may be empty if the client did not set it; `session_id`
+/// must not be empty for the affinity store to route a request.
+/// `tenant` is always non-empty in production (the tenant resolver
+/// guarantees this).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct SessionKey {
+ pub tenant: String,
+ pub user_id: String,
+ pub session_id: String,
+}
+
+impl SessionKey {
+ /// Build a [`SessionKey`] without an explicit tenant — used by
+ /// tests and single-tenant call sites. The tenant is set to
+ /// `"default"`, matching the back-compat behaviour of
+ /// `TenantResolverConfig::default()`.
+ pub fn new(user_id: impl Into<String>, session_id: impl Into<String>) ->
Self {
+ Self::with_tenant("default", user_id, session_id)
+ }
+
+ /// Build a [`SessionKey`] for an explicit tenant. This is what
+ /// production handlers call after the tenant resolver yields a
+ /// tenant string.
+ pub fn with_tenant(
+ tenant: impl Into<String>,
+ user_id: impl Into<String>,
+ session_id: impl Into<String>,
+ ) -> Self {
+ Self {
+ tenant: tenant.into(),
+ user_id: user_id.into(),
+ session_id: session_id.into(),
+ }
+ }
+
+ pub(crate) fn is_zero(&self) -> bool {
+ self.session_id.is_empty()
+ }
+}
+
+/// One backend in a pool: an address plus the metadata a selection
+/// strategy may key on. Discovery sources fill what they know —
+/// static config can declare labels and weights explicitly; the K8s
+/// watcher currently supplies addresses only (pod-label surfacing is
+/// planned work).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BackendMember {
+ pub addr: String,
+ /// Free-form key/value metadata (e.g. `spark-version`), for
+ /// metadata-aware selection strategies. Empty unless the
+ /// discovery source provides it.
+ pub labels: std::collections::BTreeMap<String, String>,
+ /// Relative weight for weighted strategies. Defaults to 1;
+ /// round-robin ignores it.
+ pub weight: u32,
+}
+
+impl BackendMember {
+ /// A member with default metadata (no labels, weight 1).
+ pub fn new(addr: impl Into<String>) -> Self {
+ Self {
+ addr: addr.into(),
+ labels: std::collections::BTreeMap::new(),
+ weight: 1,
+ }
+ }
+}
+
+/// Provides pool *membership*: which backends currently exist and
+/// are believed healthy. Implementations must be safe for concurrent
+/// use.
+///
+/// Two shipping implementations: `scg-pool-static` (fixed list at
+/// startup) and `scg-pool-k8s` (Endpoints watch). The K8s pool can
+/// be empty during startup or after a flap.
+///
+/// Deliberately *not* part of this trait: choosing which member a
+/// new session is placed on. That is [`SelectionStrategy`]'s job —
+/// membership sources and placement policies vary independently.
+pub trait Pool: Send + Sync + 'static {
+ /// Snapshot of the currently-healthy members. Order is
+ /// implementation-defined; strategies must not rely on it beyond
+ /// a single call.
+ fn members(&self) -> Vec<BackendMember>;
+
+ /// Best-effort hint that `addr` is unreachable. Implementations may
+ /// remove `addr` from the rotation, decrement a health score, or
+ /// ignore the hint entirely. The K8s pool uses this for passive
+ /// failure detection alongside its active service-watch.
+ fn mark_unhealthy(&self, _addr: &str) {}
+}
+
+/// Inputs a [`SelectionStrategy`] may consult beyond the candidate
+/// list itself. Empty today; declared `#[non_exhaustive]` so fields
+/// (e.g. per-backend session counts for least-sessions placement)
+/// can be added without breaking implementors.
+#[derive(Debug, Default)]
+#[non_exhaustive]
+pub struct SelectionContext {}
+
+/// Chooses which pool member a *new* session is placed on. Invoked
+/// only on the placement path — affinity hits, the operation-id
+/// reverse index, and bind-if-absent race arbitration never consult
+/// the strategy, so a misbehaving strategy can skew new placements
+/// but cannot break live sessions.
+///
+/// Strategies may be stateful (round-robin holds a cursor); one
+/// strategy instance serves one pool, so state never mixes across
+/// tenants.
+pub trait SelectionStrategy: Send + Sync + 'static {
+ /// Return the index of the chosen candidate, or `None` to decline
+ /// (e.g. `candidates` is empty). Called with a consistent
+ /// snapshot; must be non-blocking.
+ fn select(
+ &self,
+ key: &SessionKey,
+ candidates: &[BackendMember],
+ ctx: &SelectionContext,
+ ) -> Option<usize>;
+}
+
+/// The default strategy: rotate over the candidates with an atomic
+/// cursor. Ignores labels, weights, and the session key.
+#[derive(Debug, Default)]
+pub struct RoundRobinStrategy {
+ cursor: std::sync::atomic::AtomicU64,
+}
+
+impl SelectionStrategy for RoundRobinStrategy {
+ fn select(
+ &self,
+ _key: &SessionKey,
+ candidates: &[BackendMember],
+ _ctx: &SelectionContext,
+ ) -> Option<usize> {
+ if candidates.is_empty() {
+ return None;
+ }
+ let idx = self
+ .cursor
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ Some((idx % candidates.len() as u64) as usize)
+ }
+}
+
+/// Persistence layer for sticky routing decisions. Two shipping
+/// backends: `scg-store-memory` (in-process, single-replica) and
+/// `scg-store-redis` (shared across replicas for HA).
+///
+/// The trait is `async_trait` because the Redis backing is a
+/// network call. The in-memory impl wraps its sync work in async-fn
+/// signatures with no `await` points, so callers pay only the
+/// trait-object dispatch cost.
+#[async_trait::async_trait]
+pub trait AffinityStore: Send + Sync + 'static {
+ async fn lookup_session(&self, key: &SessionKey) -> Option<String>;
+ /// Insert `(key, backend)` only if no binding for `key` exists.
+ /// Returns the *winning* binding (existing or freshly inserted).
+ async fn bind_session_if_absent(&self, key: SessionKey, backend: String)
-> String;
+ async fn forget_session(&self, key: &SessionKey);
+
+ async fn lookup_op(&self, op_id: &str) -> Option<String>;
+ async fn bind_op(&self, op_id: String, backend: String);
+ async fn forget_op(&self, op_id: &str);
+}
+
+/// What to do when a request arrives for a tenant that has no
+/// explicit pool entry. The `default` pool, if one is configured, is
+/// what `UseDefault` falls back to.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UnknownTenantPolicy {
+ /// Route the unknown tenant to the default pool. If no default
+ /// pool is configured either, the request fails as if the pool
+ /// were empty (`Unavailable`).
+ UseDefault,
+ /// Refuse to serve the unknown tenant — surface `PermissionDenied`
+ /// to the client. SaaS-style deployments that want hard isolation
+ /// between configured tenants pick this.
+ Reject,
+}
+
+/// A pool paired with the strategy that places new sessions on it.
+/// Membership and placement policy vary independently — the pairing
+/// happens here, one strategy instance per pool so strategy state
+/// (e.g. a round-robin cursor) never mixes across pools.
+#[derive(Clone)]
+pub struct PoolEntry {
+ pub pool: Arc<dyn Pool>,
+ pub strategy: Arc<dyn SelectionStrategy>,
+}
+
+impl PoolEntry {
+ /// Pair `pool` with the default round-robin strategy.
+ pub fn round_robin(pool: Arc<dyn Pool>) -> Self {
+ Self {
+ pool,
+ strategy: Arc::new(RoundRobinStrategy::default()),
+ }
+ }
+
+ /// Pair `pool` with an explicit strategy.
+ pub fn with_strategy(pool: Arc<dyn Pool>, strategy: Arc<dyn
SelectionStrategy>) -> Self {
+ Self { pool, strategy }
+ }
+
+ /// Apply this entry's strategy to its pool's current members.
+ fn select_addr(&self, key: &SessionKey) -> Option<String> {
+ let members = self.pool.members();
+ let ctx = SelectionContext::default();
+ let idx = self.strategy.select(key, &members, &ctx)?;
+ members.get(idx).map(|m| m.addr.clone())
+ }
+}
+
+/// Maps a tenant string to a [`PoolEntry`]. Construct once at
+/// startup from the operator's config. Cheap to clone (everything
+/// is `Arc`).
+///
+/// The lookup order is:
+///
+/// 1. `tenants` map (exact match on tenant string).
+/// 2. `default` entry if `policy == UseDefault`.
+/// 3. `Err(PermissionDenied)` if `policy == Reject`.
+pub struct TenantRouter {
+ tenants: HashMap<String, PoolEntry>,
+ default: Option<PoolEntry>,
+ policy: UnknownTenantPolicy,
+}
+
+impl TenantRouter {
+ /// Build a router from explicit per-tenant pool entries, an
+ /// optional shared default entry, and the unknown-tenant policy.
+ ///
+ /// `default = None` + `policy = UseDefault` is allowed but
+ /// degrades to "everything except the explicit tenants fails"
+ /// — equivalent to `policy = Reject` for unknown tenants, but
+ /// without the explicit `PermissionDenied` (you get
+ /// `Unavailable` from the empty pool path). Set the policy
+ /// explicitly if you want clean error semantics.
+ pub fn new(
+ tenants: HashMap<String, PoolEntry>,
+ default: Option<PoolEntry>,
+ policy: UnknownTenantPolicy,
+ ) -> Self {
+ Self {
+ tenants,
+ default,
+ policy,
+ }
+ }
+
+ /// Single-pool convenience: every tenant routes to the same pool
+ /// with round-robin placement. Used by single-tenant deployments
+ /// and by tests that don't care about per-tenant isolation.
+ pub fn single(pool: Arc<dyn Pool>) -> Self {
+ Self {
+ tenants: HashMap::new(),
+ default: Some(PoolEntry::round_robin(pool)),
+ policy: UnknownTenantPolicy::UseDefault,
+ }
+ }
+
+ /// Pick the pool entry for `tenant`. Returns:
+ /// * `Ok(Some(entry))` when a pool is available (explicit or default)
+ /// * `Ok(None)` when no pool exists and policy says it's OK (e.g.
+ /// `UseDefault` without a default pool — caller emits the usual
+ /// "no healthy backend" error)
+ /// * `Err(Status)` when `policy == Reject` and the tenant is unknown.
+ pub fn pool_for(&self, tenant: &str) -> Result<Option<PoolEntry>, Status> {
+ if let Some(p) = self.tenants.get(tenant) {
+ return Ok(Some(p.clone()));
+ }
+ match self.policy {
+ UnknownTenantPolicy::UseDefault => Ok(self.default.clone()),
+ UnknownTenantPolicy::Reject =>
Err(Status::permission_denied(format!(
+ "tenant {:?} has no configured pool",
+ tenant
+ ))),
+ }
+ }
+}
+
+/// Outcome of a session-resolution call. `addr` is the backend
+/// for the session; `newly_bound` distinguishes a freshly-bound
+/// session (the gateway just decided which backend it lives on)
+/// from an existing one (the affinity store already had the
+/// binding). Used by audit logging to fire `session.create`
+/// exactly once per session lifetime.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResolveOutcome {
+ pub addr: String,
+ pub newly_bound: bool,
+}
+
+/// Resolves a request to a concrete backend address.
+pub struct Router {
+ tenants: TenantRouter,
+ store: Arc<dyn AffinityStore>,
+}
+
+impl Router {
+ /// Build a Router from a [`TenantRouter`] and an affinity store.
+ pub fn new(tenants: TenantRouter, store: Arc<dyn AffinityStore>) -> Self {
+ Self { tenants, store }
+ }
+
+ /// Single-pool convenience constructor for single-tenant
+ /// deployments. Equivalent to
+ /// `Router::new(TenantRouter::single(pool), store)`.
+ pub fn single_pool(pool: Arc<dyn Pool>, store: Arc<dyn AffinityStore>) ->
Self {
+ Self::new(TenantRouter::single(pool), store)
+ }
+
+ /// Resolve a backend for `key`. If a binding exists it is returned;
+ /// otherwise a fresh backend is picked from the tenant's pool,
+ /// recorded, and returned.
+ ///
+ /// Result variants:
+ ///
+ /// * `Ok(Some(addr))` — a backend was found (existing binding or
+ /// freshly picked).
+ /// * `Ok(None)` — no binding exists *and* the tenant's pool
+ /// currently has no healthy backend (e.g. K8s service-watch
+ /// pool during startup). Caller surfaces `Unavailable`.
+ /// * `Err(Status)` — the tenant has no configured pool and the
+ /// policy is `Reject`. Caller forwards the `PermissionDenied`
+ /// directly to the client.
+ ///
+ /// A `SessionKey` with an empty `session_id` falls through to a
+ /// fresh pick, but that binding is *not* recorded — without a
+ /// stable session id we cannot honour stickiness on the next call.
+ pub async fn resolve_session(&self, key: &SessionKey) ->
Result<Option<String>, Status> {
+ Ok(self.resolve_session_detailed(key).await?.map(|r| r.addr))
+ }
+
+ /// Same as [`resolve_session`] but distinguishes a freshly-bound
+ /// session from an existing one — useful for audit logging
+ /// (`session.create` events fire only on the freshly-bound
+ /// path). Most callers should use `resolve_session` and ignore
+ /// the binding flavour.
+ pub async fn resolve_session_detailed(
+ &self,
+ key: &SessionKey,
+ ) -> Result<Option<ResolveOutcome>, Status> {
+ let Some(entry) = self.tenants.pool_for(&key.tenant)? else {
+ return Ok(None);
+ };
+ if key.is_zero() {
+ // Empty session_id is never bound — the affinity store
+ // ignores it. Counts as `newly_bound = false` for audit
+ // purposes (there's nothing to record).
+ return Ok(entry.select_addr(key).map(|addr| ResolveOutcome {
+ addr,
+ newly_bound: false,
+ }));
+ }
+ if let Some(existing) = self.store.lookup_session(key).await {
+ return Ok(Some(ResolveOutcome {
+ addr: existing,
+ newly_bound: false,
+ }));
+ }
+ let Some(chosen) = entry.select_addr(key) else {
+ return Ok(None);
+ };
+ let winner = self
+ .store
+ .bind_session_if_absent(key.clone(), chosen.clone())
+ .await;
+ Ok(Some(ResolveOutcome {
+ addr: winner.clone(),
+ // If our `bind` returned a different value than what we
+ // tried to insert, someone else won the race — we did
+ // not freshly bind this session.
+ newly_bound: winner == chosen,
+ }))
+ }
+
+ /// Resolve a backend for an operation, falling back to session
+ /// routing when the operation is unknown.
+ ///
+ /// Used by `ReattachExecute` / `ReleaseExecute` / `Interrupt`: a
+ /// client may reattach to a long-running operation that was
+ /// started on a specific backend, and the gateway must route back
+ /// to that same backend even if the affinity cache for the
+ /// session has already expired or is missing.
+ pub async fn resolve_op(
+ &self,
+ op_id: &str,
+ key: &SessionKey,
+ ) -> Result<Option<String>, Status> {
+ if !op_id.is_empty() {
+ if let Some(b) = self.store.lookup_op(op_id).await {
+ return Ok(Some(b));
+ }
+ }
+ self.resolve_session(key).await
+ }
+
+ /// Hint that `addr` is currently unreachable. Routed to the pool
+ /// owning `tenant`. Best-effort — unknown tenant is a no-op.
+ pub fn mark_unhealthy(&self, tenant: &str, addr: &str) {
+ if let Ok(Some(entry)) = self.tenants.pool_for(tenant) {
+ entry.pool.mark_unhealthy(addr);
+ }
+ }
+
+ pub async fn remember_op(&self, op_id: String, backend: String) {
+ if op_id.is_empty() {
+ return;
+ }
+ self.store.bind_op(op_id, backend).await;
+ }
+
+ /// Bind `key` to a *specific* backend, bypassing pool selection.
+ /// Used when the correct backend is dictated by protocol
+ /// semantics rather than placement policy — e.g. `CloneSession`
+ /// creates a new session whose state lives on the driver that
+ /// executed the clone, so the cloned session's key must be bound
+ /// to that same driver.
+ ///
+ /// Returns the winning binding: `backend` if this call bound the
+ /// key (or a racing call bound the same address), or the
+ /// pre-existing address if the key was already bound elsewhere.
+ /// A zero key (empty session id) is never bound and returns
+ /// `backend` unchanged.
+ pub async fn remember_session(&self, key: &SessionKey, backend: String) ->
String {
+ if key.is_zero() {
+ return backend;
+ }
+ self.store
+ .bind_session_if_absent(key.clone(), backend)
+ .await
+ }
+
+ pub async fn forget_op(&self, op_id: &str) {
+ if op_id.is_empty() {
+ return;
+ }
+ self.store.forget_op(op_id).await;
+ }
+
+ pub async fn forget_session(&self, key: &SessionKey) {
+ self.store.forget_session(key).await;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use parking_lot::Mutex as PLMutex;
+ use std::collections::HashMap;
+
+ /// Fixed three-member pool; placement order is supplied by the
+ /// entry's RoundRobinStrategy, not by the pool itself.
+ struct SeqPool;
+ impl Pool for SeqPool {
+ fn members(&self) -> Vec<BackendMember> {
+ vec![
+ BackendMember::new("a"),
+ BackendMember::new("b"),
+ BackendMember::new("c"),
+ ]
+ }
+ }
+
+ /// Pool that always reports empty — used to test the "no backend"
+ /// path through Router::resolve_session.
+ struct EmptyPool;
+ impl Pool for EmptyPool {
+ fn members(&self) -> Vec<BackendMember> {
+ Vec::new()
+ }
+ }
+
+ struct StubStore {
+ sessions: PLMutex<HashMap<SessionKey, String>>,
+ ops: PLMutex<HashMap<String, String>>,
+ }
+ impl Default for StubStore {
+ fn default() -> Self {
+ Self {
+ sessions: PLMutex::new(HashMap::new()),
+ ops: PLMutex::new(HashMap::new()),
+ }
+ }
+ }
+ #[async_trait::async_trait]
+ impl AffinityStore for StubStore {
+ async fn lookup_session(&self, k: &SessionKey) -> Option<String> {
+ self.sessions.lock().get(k).cloned()
+ }
+ async fn bind_session_if_absent(&self, k: SessionKey, v: String) ->
String {
+ let mut g = self.sessions.lock();
+ g.entry(k).or_insert(v).clone()
+ }
+ async fn forget_session(&self, k: &SessionKey) {
+ self.sessions.lock().remove(k);
+ }
+ async fn lookup_op(&self, o: &str) -> Option<String> {
+ self.ops.lock().get(o).cloned()
+ }
+ async fn bind_op(&self, o: String, v: String) {
+ self.ops.lock().insert(o, v);
+ }
+ async fn forget_op(&self, o: &str) {
+ self.ops.lock().remove(o);
+ }
+ }
+
+ fn router() -> Router {
+ Router::single_pool(Arc::new(SeqPool), Arc::new(StubStore::default()))
+ }
+
+ #[tokio::test]
+ async fn stickiness_is_honoured() {
+ let r = router();
+ let k = SessionKey::new("u1", "s1");
+ let first = r.resolve_session(&k).await.unwrap();
+ for _ in 0..10 {
+ assert_eq!(r.resolve_session(&k).await.unwrap(), first);
+ }
+ }
+
+ #[tokio::test]
+ async fn distinct_sessions_can_diverge() {
+ let r = router();
+ let a = r
+ .resolve_session(&SessionKey::new("u1", "s1"))
+ .await
+ .unwrap();
+ let b = r
+ .resolve_session(&SessionKey::new("u1", "s2"))
+ .await
+ .unwrap();
+ assert_ne!(a, b, "round-robin should diverge across sessions");
+ }
+
+ #[tokio::test]
+ async fn empty_session_does_not_bind() {
+ let store = Arc::new(StubStore::default());
+ let r = Router::single_pool(Arc::new(SeqPool), store.clone() as
Arc<dyn AffinityStore>);
+ r.resolve_session(&SessionKey::new("u", "")).await.unwrap();
+ assert!(store
+ .lookup_session(&SessionKey::new("u", "anything"))
+ .await
+ .is_none());
+ }
+
+ #[tokio::test]
+ async fn op_lookup_overrides_session() {
+ let r = router();
+ let k = SessionKey::new("u", "s");
+ let _first = r.resolve_op("op-unknown", &k).await.unwrap();
+ r.remember_op("op-1".to_string(), "explicit:1".to_string())
+ .await;
+ assert_eq!(
+ r.resolve_op("op-1", &k).await.unwrap().as_deref(),
+ Some("explicit:1")
+ );
+ }
+
+ #[tokio::test]
+ async fn empty_pool_returns_none() {
+ let r = Router::single_pool(Arc::new(EmptyPool),
Arc::new(StubStore::default()));
+ let k = SessionKey::new("u", "s");
+ assert!(r.resolve_session(&k).await.unwrap().is_none());
+ assert!(r.resolve_op("op", &k).await.unwrap().is_none());
+ }
+
+ // ---- SelectionStrategy tests ------------------------------------
+
+ #[test]
+ fn round_robin_strategy_rotates_over_candidates() {
+ let strat = RoundRobinStrategy::default();
+ let members = vec![
+ BackendMember::new("a"),
+ BackendMember::new("b"),
+ BackendMember::new("c"),
+ ];
+ let k = SessionKey::new("u", "s");
+ let ctx = SelectionContext::default();
+ let picks: Vec<usize> = (0..4)
+ .map(|_| strat.select(&k, &members, &ctx).unwrap())
+ .collect();
+ assert_eq!(picks, vec![0, 1, 2, 0]);
+ }
+
+ #[test]
+ fn round_robin_strategy_declines_empty_candidates() {
+ let strat = RoundRobinStrategy::default();
+ let k = SessionKey::new("u", "s");
+ let ctx = SelectionContext::default();
+ assert!(strat.select(&k, &[], &ctx).is_none());
+ }
+
+ #[tokio::test]
+ async fn placement_through_router_rotates_via_strategy() {
+ // The behaviour previously pinned by the pools' own
+ // round-robin tests: successive *new* sessions land on
+ // successive members.
+ let r = router();
+ let mut got = Vec::new();
+ for i in 0..4 {
+ let k = SessionKey::new("u", format!("s{}", i));
+ got.push(r.resolve_session(&k).await.unwrap().unwrap());
+ }
+ assert_eq!(got, vec!["a", "b", "c", "a"]);
+ }
+
+ // ---- TenantRouter tests -----------------------------------------
+
+ fn fixed_pool(addr: &'static str) -> PoolEntry {
+ struct FixedPool(&'static str);
+ impl Pool for FixedPool {
+ fn members(&self) -> Vec<BackendMember> {
+ vec![BackendMember::new(self.0)]
+ }
+ }
+ PoolEntry::round_robin(Arc::new(FixedPool(addr)))
+ }
+
+ #[tokio::test]
+ async fn tenant_router_picks_per_tenant_pool() {
+ let mut tenants = HashMap::new();
+ tenants.insert("team-a".to_string(), fixed_pool("a:1"));
+ tenants.insert("team-b".to_string(), fixed_pool("b:1"));
+ let tr = TenantRouter::new(tenants, None, UnknownTenantPolicy::Reject);
+ let store: Arc<dyn AffinityStore> = Arc::new(StubStore::default());
+ let r = Router::new(tr, store);
+
+ let got_a = r
+ .resolve_session(&SessionKey::with_tenant("team-a", "u", "s1"))
+ .await
+ .unwrap();
+ assert_eq!(got_a.as_deref(), Some("a:1"));
+
+ let got_b = r
+ .resolve_session(&SessionKey::with_tenant("team-b", "u", "s1"))
+ .await
+ .unwrap();
+ assert_eq!(got_b.as_deref(), Some("b:1"));
+ }
+
+ #[tokio::test]
+ async fn tenant_router_falls_back_to_default_when_use_default() {
+ let mut tenants = HashMap::new();
+ tenants.insert("team-a".to_string(), fixed_pool("a:1"));
+ let tr = TenantRouter::new(
+ tenants,
+ Some(fixed_pool("default:1")),
+ UnknownTenantPolicy::UseDefault,
+ );
+ let store: Arc<dyn AffinityStore> = Arc::new(StubStore::default());
+ let r = Router::new(tr, store);
+
+ let got = r
+ .resolve_session(&SessionKey::with_tenant("unknown-tenant", "u",
"s1"))
+ .await
+ .unwrap();
+ assert_eq!(got.as_deref(), Some("default:1"));
+ }
+
+ #[tokio::test]
+ async fn tenant_router_rejects_unknown_tenant_under_reject_policy() {
+ let mut tenants = HashMap::new();
+ tenants.insert("team-a".to_string(), fixed_pool("a:1"));
+ let tr = TenantRouter::new(tenants, None, UnknownTenantPolicy::Reject);
+ let store: Arc<dyn AffinityStore> = Arc::new(StubStore::default());
+ let r = Router::new(tr, store);
+
+ let err = r
+ .resolve_session(&SessionKey::with_tenant("unknown", "u", "s1"))
+ .await
+ .unwrap_err();
+ assert_eq!(err.code(), tonic::Code::PermissionDenied);
+ }
+
+ #[tokio::test]
+ async fn tenant_router_no_default_no_explicit_returns_none() {
+ // UseDefault policy + no default pool + unknown tenant → Ok(None).
+ // The caller surfaces this as the usual "no healthy backend" error.
+ let tr = TenantRouter::new(HashMap::new(), None,
UnknownTenantPolicy::UseDefault);
+ let store: Arc<dyn AffinityStore> = Arc::new(StubStore::default());
+ let r = Router::new(tr, store);
+ let got = r
+ .resolve_session(&SessionKey::with_tenant("nobody", "u", "s1"))
+ .await
+ .unwrap();
+ assert!(got.is_none());
+ }
+
+ #[tokio::test]
+ async fn remember_session_binds_fresh_key_to_given_backend() {
+ let r = router();
+ let k = SessionKey::new("u1", "cloned-1");
+ let winner = r.remember_session(&k, "b".into()).await;
+ assert_eq!(winner, "b");
+ // Follow-up resolution honours the explicit binding — it does
+ // NOT go through pool selection (SeqPool would have yielded
+ // "a" for a first pick).
+ assert_eq!(r.resolve_session(&k).await.unwrap().as_deref(), Some("b"));
+ }
+
+ #[tokio::test]
+ async fn remember_session_respects_existing_binding() {
+ let r = router();
+ let k = SessionKey::new("u1", "s1");
+ // Bind via normal resolution first (SeqPool starts at "a").
+ let placed = r.resolve_session(&k).await.unwrap().unwrap();
+ // An explicit remember to a different address loses the race:
+ // the pre-existing binding wins and is returned.
+ let winner = r.remember_session(&k, "z".into()).await;
+ assert_eq!(winner, placed);
+ assert_eq!(
+ r.resolve_session(&k).await.unwrap().as_deref(),
+ Some(placed.as_str())
+ );
+ }
+
+ #[tokio::test]
+ async fn remember_session_ignores_zero_key() {
+ let r = router();
+ let k = SessionKey::new("u1", "");
+ let winner = r.remember_session(&k, "b".into()).await;
+ assert_eq!(winner, "b");
+ // Zero keys are never bound; resolution still goes through
+ // pool selection.
+ let got = r.resolve_session(&k).await.unwrap();
+ assert_eq!(got.as_deref(), Some("a"));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]