| // 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. | |
| //! Transport-to-consensus session bridge for server. | |
| //! | |
| //! Maps ephemeral transport connections to durable consensus sessions. | |
| //! Connections bind to a registered session after credential verification. | |
| //! | |
| //! The [`SessionManager`] is the server-side counterpart of the SDK's | |
| //! session lifecycle. It does **not** own the `ClientTable`. That lives | |
| //! in the consensus layer. This module tracks the binding between a | |
| //! transport connection and the consensus-level `(client_id, session)` pair. | |
| use crate::cluster_meta::ClusterRoster; | |
| use ahash::AHashMap; | |
| use consensus::client_table::SessionAttachment; | |
| use iggy_binary_protocol::ConsumerSession; | |
| use iggy_common::IggyError; | |
| use message_bus::installer::conn_info::ClientTransportKind; | |
| use shard::ConnectedClientInfo; | |
| use std::net::SocketAddr; | |
| use std::rc::Rc; | |
| use std::time::{Duration, Instant}; | |
| /// What the request funnel resolves from one `connections` lookup per frame. | |
| /// | |
| /// The bound consensus session, the acting user, the transport peer address | |
| /// and the read-your-writes floor. `Default` (everything absent, no address, | |
| /// floor `0`) stands for a connection neither this map nor the bus knows. | |
| #[derive(Debug, Clone, Copy, Default)] | |
| pub struct ConnectionContext { | |
| /// `(client_id, session)` once register committed, `None` before. | |
| pub bound: Option<(u128, u64)>, | |
| /// Acting user from `login`, `None` while still `Connected`. | |
| pub user_id: Option<u32>, | |
| /// Peer address recorded by [`SessionManager::ensure_connection`]; the | |
| /// non-replicated reads pick the advertised address from it, and | |
| /// `None` degrades to the catch-all address. | |
| pub address: Option<SocketAddr>, | |
| /// Read-your-writes floor: the metadata commit this connection's own | |
| /// writes have reached, which its reads must not be served below. | |
| /// | |
| /// A connection this map does not know reads as `0` -- it was promised | |
| /// nothing, so its reads wait for nothing. | |
| pub metadata_watermark: u64, | |
| } | |
| /// A binding is local to a connection; several connections can share a session. | |
| #[derive(Debug, Clone)] | |
| pub enum ConnectionState { | |
| /// Connection established, not yet authenticated. | |
| Connected, | |
| /// Register committed through consensus. Connection is bound to a | |
| /// `(client_id, session)` pair. Requests on this connection use | |
| /// these values to populate `RoutedRequestHeader.client` and | |
| /// `RoutedRequestHeader.session`. | |
| Bound { | |
| user_id: u32, | |
| client_id: u128, | |
| session: u64, | |
| }, | |
| } | |
| /// SDK identity reported in the login-register version prefix. | |
| #[derive(Debug, Clone)] | |
| pub struct ClientSdkInfo { | |
| pub sdk_name: String, | |
| pub sdk_version: String, | |
| /// Packed protocol version, see `iggy_binary_protocol::ProtocolVersion`. | |
| pub protocol_version: u32, | |
| } | |
| /// Per-connection metadata tracked by the session manager. | |
| #[derive(Debug, Clone)] | |
| pub struct Connection { | |
| pub address: SocketAddr, | |
| pub transport: ClientTransportKind, | |
| pub state: ConnectionState, | |
| /// Last time the client proved liveness (a `ping`). The heartbeat | |
| /// verifier evicts connections stale past the configured threshold. | |
| pub last_heartbeat: Instant, | |
| /// Recorded at login; `None` until the connection authenticates. | |
| pub sdk: Option<ClientSdkInfo>, | |
| /// Highest metadata op this connection has been told committed. | |
| /// | |
| /// Seeded from the bound session (the register's own commit op, which | |
| /// floors everything the client committed before it re-homed) and raised by | |
| /// every committed reply relayed on this connection. The read gate holds a | |
| /// local read until the node's applied frontier covers it, so a client | |
| /// cannot be served state older than a write it already saw acked. | |
| /// | |
| /// Per-connection rather than per-client: the number only has to cover what | |
| /// THIS socket was told, and a client that reconnects re-seeds from the | |
| /// session it binds. | |
| pub metadata_watermark: u64, | |
| consumer_session: Option<(u128, SessionAttachment)>, | |
| } | |
| /// Bridges transport connections to consensus sessions. | |
| /// | |
| /// NOT thread-safe: each shard owns one `SessionManager` on its | |
| /// single-threaded compio runtime, the same way the rest of server | |
| /// is structured. All mutators take `&mut self`; the type carries no | |
| /// internal locking. | |
| /// | |
| /// ## Invariants | |
| /// | |
| /// - A `connection_id` appears in at most one of `connections`. | |
| /// - Every bound connection holds an attachment invalidated by session end. | |
| /// - Disconnecting one connection leaves the shared session and its peers live. | |
| pub struct SessionManager { | |
| connections: AHashMap<u128, Connection>, | |
| /// This shard's copy of the configured cluster roster, served by the | |
| /// `GetClusterMetadata` read. Lives here because it is the | |
| /// per-shard context already threaded to the non-replicated read path; | |
| /// installed once at bootstrap, disabled until then. | |
| cluster_roster: Rc<ClusterRoster>, | |
| } | |
| impl SessionManager { | |
| #[must_use] | |
| pub fn new() -> Self { | |
| Self { | |
| connections: AHashMap::new(), | |
| cluster_roster: Rc::new(ClusterRoster::disabled()), | |
| } | |
| } | |
| /// Install this shard's configured cluster roster (once, at bootstrap). | |
| pub fn set_cluster_roster(&mut self, roster: Rc<ClusterRoster>) { | |
| self.cluster_roster = roster; | |
| } | |
| /// The configured cluster roster for the `GetClusterMetadata` read. | |
| #[must_use] | |
| pub fn cluster_roster(&self) -> Rc<ClusterRoster> { | |
| Rc::clone(&self.cluster_roster) | |
| } | |
| pub fn ensure_connection( | |
| &mut self, | |
| connection_id: u128, | |
| address: SocketAddr, | |
| transport: ClientTransportKind, | |
| ) { | |
| self.connections | |
| .entry(connection_id) | |
| .or_insert_with(|| Connection { | |
| address, | |
| transport, | |
| state: ConnectionState::Connected, | |
| last_heartbeat: Instant::now(), | |
| sdk: None, | |
| metadata_watermark: 0, | |
| consumer_session: None, | |
| }); | |
| } | |
| /// The request funnel's per-frame view of a connection: stamp the | |
| /// liveness clock and read back everything the dispatch arms resolve | |
| /// from it, in ONE map lookup. | |
| /// | |
| /// `None` means the connection is not registered yet, which happens | |
| /// only on a transport's first frame; the caller installs it from the | |
| /// bus metadata ([`Self::ensure_connection`]) and asks again. | |
| pub(crate) fn touch_connection(&mut self, connection_id: u128) -> Option<ConnectionContext> { | |
| let conn = self.connections.get_mut(&connection_id)?; | |
| if conn | |
| .consumer_session | |
| .as_ref() | |
| .is_some_and(|(_, attachment)| !attachment.is_valid()) | |
| { | |
| conn.state = ConnectionState::Connected; | |
| conn.consumer_session = None; | |
| conn.metadata_watermark = 0; | |
| } | |
| conn.last_heartbeat = Instant::now(); | |
| let (bound, user_id) = match conn.state { | |
| ConnectionState::Bound { | |
| user_id, | |
| client_id, | |
| session, | |
| } => (Some((client_id, session)), Some(user_id)), | |
| ConnectionState::Connected => (None, None), | |
| }; | |
| Some(ConnectionContext { | |
| bound, | |
| user_id, | |
| address: Some(conn.address), | |
| metadata_watermark: conn.metadata_watermark, | |
| }) | |
| } | |
| /// Connection ids whose last heartbeat is older than `max_age` -- the | |
| /// stale set the heartbeat verifier evicts. Only `Bound` | |
| /// connections are considered (a freshly-`Connected` socket mid-handshake | |
| /// is left alone until it authenticates). | |
| #[must_use] | |
| pub fn collect_stale(&self, max_age: Duration, now: Instant) -> Vec<u128> { | |
| self.connections | |
| .iter() | |
| .filter(|(_, conn)| !matches!(conn.state, ConnectionState::Connected)) | |
| .filter(|(_, conn)| now.duration_since(conn.last_heartbeat) > max_age) | |
| .map(|(&id, _)| id) | |
| .collect() | |
| } | |
| /// The consensus client id a connection is bound to, if any. The heartbeat | |
| /// verifier reads it to look up consumer-group membership before deciding | |
| /// whether an eviction would actually release anything. | |
| #[must_use] | |
| pub fn bound_client_id(&self, connection_id: u128) -> Option<u128> { | |
| match self.connections.get(&connection_id)?.state { | |
| ConnectionState::Bound { client_id, .. } => Some(client_id), | |
| ConnectionState::Connected => None, | |
| } | |
| } | |
| /// Record (or refresh on re-login) the SDK identity for a connection. | |
| /// No state-machine constraint: the gate already validated the version | |
| /// and a missing connection just drops the record. | |
| pub fn record_sdk_info(&mut self, connection_id: u128, info: ClientSdkInfo) { | |
| if let Some(conn) = self.connections.get_mut(&connection_id) { | |
| conn.sdk = Some(info); | |
| } | |
| } | |
| /// Remove a transport connection without ending its durable session. | |
| pub fn remove_connection(&mut self, connection_id: u128) -> Option<(u128, u64)> { | |
| if let Some(conn) = self.connections.remove(&connection_id) | |
| && let ConnectionState::Bound { | |
| client_id, session, .. | |
| } = conn.state | |
| { | |
| return Some((client_id, session)); | |
| } | |
| None | |
| } | |
| pub fn bind_authenticated_connection( | |
| &mut self, | |
| connection_id: u128, | |
| client_id: u128, | |
| session: u64, | |
| user_id: u32, | |
| attachment: SessionAttachment, | |
| metadata_watermark: u64, | |
| ) -> Result<(), IggyError> { | |
| if !attachment.is_valid() { | |
| return Err(IggyError::Unauthenticated); | |
| } | |
| let connection = self | |
| .connections | |
| .get_mut(&connection_id) | |
| .ok_or(IggyError::Unauthenticated)?; | |
| if let ConnectionState::Bound { | |
| user_id: bound_user, | |
| client_id: bound_client, | |
| session: bound_session, | |
| } = connection.state | |
| && (bound_user != user_id || bound_client != client_id || bound_session != session) | |
| { | |
| return Err(IggyError::AlreadyAuthenticated); | |
| } | |
| connection.state = ConnectionState::Bound { | |
| user_id, | |
| client_id, | |
| session, | |
| }; | |
| connection.consumer_session = Some((client_id, attachment)); | |
| connection.metadata_watermark = connection | |
| .metadata_watermark | |
| .max(metadata_watermark) | |
| .max(session); | |
| connection.last_heartbeat = Instant::now(); | |
| Ok(()) | |
| } | |
| /// Raise this connection's metadata watermark to `commit`. Monotone, so a | |
| /// late or out-of-order reply cannot lower it; no-op for an unknown | |
| /// connection. | |
| /// | |
| /// Only committed replies belong here. A pre-consensus rejection stamps the | |
| /// primary's `commit_max`, which is an op this connection was never | |
| /// promised and, on a backup-homed connection, one it would then wait for. | |
| pub fn record_metadata_watermark(&mut self, connection_id: u128, commit: u64) { | |
| if let Some(conn) = self.connections.get_mut(&connection_id) { | |
| conn.metadata_watermark = conn.metadata_watermark.max(commit); | |
| } | |
| } | |
| /// Resolve the attached group identity without extending its lifetime. | |
| /// | |
| /// # Errors | |
| /// Returns `Unauthenticated` without an authenticated attachment, or | |
| /// `StaleClient` when the parent epoch has ended. | |
| pub fn consumer_session( | |
| &self, | |
| connection_id: u128, | |
| ) -> Result<(u128, SessionAttachment), IggyError> { | |
| self.attached_consumer_session(connection_id)? | |
| .ok_or(IggyError::Unauthenticated) | |
| } | |
| /// # Errors | |
| /// Returns `Unauthenticated` for an unbound connection and `StaleClient` | |
| /// when its attached parent session has ended. | |
| pub fn attached_consumer_session( | |
| &self, | |
| connection_id: u128, | |
| ) -> Result<Option<(u128, SessionAttachment)>, IggyError> { | |
| let connection = self | |
| .connections | |
| .get(&connection_id) | |
| .ok_or(IggyError::Unauthenticated)?; | |
| if !matches!(connection.state, ConnectionState::Bound { .. }) { | |
| return Err(IggyError::Unauthenticated); | |
| } | |
| let (client_id, attachment) = connection | |
| .consumer_session | |
| .as_ref() | |
| .ok_or(IggyError::Unauthenticated)?; | |
| if !attachment.is_valid() { | |
| return Err(IggyError::StaleClient); | |
| } | |
| Ok(Some((*client_id, attachment.clone()))) | |
| } | |
| /// The highest metadata op this connection was told committed, or `0` when | |
| /// it was told none (an unknown or still-unbound connection, which has no | |
| /// write to read back). | |
| #[must_use] | |
| pub fn metadata_watermark(&self, connection_id: u128) -> u64 { | |
| self.connections | |
| .get(&connection_id) | |
| .map_or(0, |conn| conn.metadata_watermark) | |
| } | |
| /// Look up the consensus session for a connection. | |
| /// | |
| /// Returns `(client_id, session)` if the connection is `Bound`, `None` | |
| /// otherwise. The request funnel reads it through the crate-private | |
| /// `touch_connection` instead, which resolves it in the same lookup as | |
| /// the heartbeat; this is for the session-op paths that only need the | |
| /// binding. | |
| #[must_use] | |
| pub fn get_session(&self, connection_id: u128) -> Option<(u128, u64)> { | |
| let conn = self.connections.get(&connection_id)?; | |
| if conn | |
| .consumer_session | |
| .as_ref() | |
| .is_some_and(|(_, attachment)| !attachment.is_valid()) | |
| { | |
| return None; | |
| } | |
| match conn.state { | |
| ConnectionState::Bound { | |
| client_id, session, .. | |
| } => Some((client_id, session)), | |
| ConnectionState::Connected => None, | |
| } | |
| } | |
| /// Look up the authenticated user id for a connection. | |
| #[must_use] | |
| pub fn get_user_id(&self, connection_id: u128) -> Option<u32> { | |
| let conn = self.connections.get(&connection_id)?; | |
| if conn | |
| .consumer_session | |
| .as_ref() | |
| .is_some_and(|(_, attachment)| !attachment.is_valid()) | |
| { | |
| return None; | |
| } | |
| match conn.state { | |
| ConnectionState::Bound { user_id, .. } => Some(user_id), | |
| ConnectionState::Connected => None, | |
| } | |
| } | |
| /// Flatten one connection into a [`ConnectedClientInfo`] for `get_me`. | |
| /// | |
| /// This is the single per-shard source for the client-info reads: | |
| /// `user_id`, `transport`, and `address` all come from the local | |
| /// `SessionManager`, so the caller no longer consults the message | |
| /// bus's `client_meta`. | |
| #[must_use] | |
| pub fn client_record(&self, connection_id: u128) -> Option<ConnectedClientInfo> { | |
| let conn = self.connections.get(&connection_id)?; | |
| Some(record_from(connection_id, conn)) | |
| } | |
| /// Iterate every locally-homed connected client as a | |
| /// [`ConnectedClientInfo`]. The per-shard half of the `get_clients` | |
| /// scatter-gather. | |
| pub fn iter_clients(&self) -> impl Iterator<Item = ConnectedClientInfo> + '_ { | |
| self.connections | |
| .iter() | |
| .map(|(&id, conn)| record_from(id, conn)) | |
| } | |
| /// The number of locally-homed connected clients, which | |
| /// [`Self::iter_clients`] would yield, without building their records. | |
| #[must_use] | |
| pub fn client_count(&self) -> usize { | |
| self.connections.len() | |
| } | |
| pub fn iter_consumer_sessions( | |
| &self, | |
| timeout: std::time::Duration, | |
| ) -> impl Iterator<Item = ConsumerSession> + '_ { | |
| let now = std::time::Instant::now(); | |
| self.connections.values().filter_map(move |connection| { | |
| if now.saturating_duration_since(connection.last_heartbeat) >= timeout | |
| || connection | |
| .consumer_session | |
| .as_ref() | |
| .is_none_or(|(_, attachment)| !attachment.is_valid()) | |
| { | |
| return None; | |
| } | |
| if let ConnectionState::Bound { | |
| client_id, session, .. | |
| } = connection.state | |
| { | |
| Some(ConsumerSession { client_id, session }) | |
| } else { | |
| None | |
| } | |
| }) | |
| } | |
| } | |
| impl Default for SessionManager { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| /// Flatten a connection + its id into a [`ConnectedClientInfo`]. | |
| fn record_from(connection_id: u128, conn: &Connection) -> ConnectedClientInfo { | |
| let (user_id, vsr_client_id) = match conn.state { | |
| ConnectionState::Bound { | |
| user_id, client_id, .. | |
| } => (Some(user_id), Some(client_id)), | |
| ConnectionState::Connected => (None, None), | |
| }; | |
| ConnectedClientInfo { | |
| client_id: connection_id, | |
| vsr_client_id, | |
| user_id, | |
| transport: conn.transport, | |
| address: conn.address, | |
| sdk_name: conn.sdk.as_ref().map(|sdk| sdk.sdk_name.clone()), | |
| sdk_version: conn.sdk.as_ref().map(|sdk| sdk.sdk_version.clone()), | |
| protocol_version: conn.sdk.as_ref().map(|sdk| sdk.protocol_version), | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use crate::reply_frame::build_empty_reply; | |
| use consensus::ClientTable; | |
| use iggy_binary_protocol::{Command, Operation, RoutedRequestHeader}; | |
| use std::net::{IpAddr, Ipv4Addr}; | |
| const CLIENT: u128 = 7; | |
| const USER: u32 = 1; | |
| const EPOCH: u64 = 11; | |
| const TIMEOUT: Duration = Duration::from_secs(30); | |
| fn addr(port: u16) -> SocketAddr { | |
| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) | |
| } | |
| fn registry(client: u128, user: u32, epoch: u64) -> ClientTable { | |
| let mut table = ClientTable::new(2); | |
| let header = RoutedRequestHeader { | |
| command: Command::Request, | |
| operation: Operation::Register, | |
| client, | |
| size: u32::try_from(size_of::<RoutedRequestHeader>()).unwrap(), | |
| ..Default::default() | |
| }; | |
| table | |
| .commit_register( | |
| client, | |
| user, | |
| [0x5a; 32], | |
| build_empty_reply(&header, client, epoch, epoch), | |
| ) | |
| .unwrap(); | |
| table | |
| } | |
| fn bind(sessions: &mut SessionManager, table: &mut ClientTable, connection: u128) { | |
| sessions.ensure_connection(connection, addr(5000), ClientTransportKind::Tcp); | |
| let attachment = table.attach_session(CLIENT, EPOCH, USER).unwrap(); | |
| sessions | |
| .bind_authenticated_connection(connection, CLIENT, EPOCH, USER, attachment, EPOCH) | |
| .unwrap(); | |
| } | |
| #[test] | |
| fn shared_bindings_survive_disconnect_and_end_together_on_logout() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| for connection in [1, 2, 3] { | |
| bind(&mut sessions, &mut table, connection); | |
| } | |
| assert_eq!(sessions.remove_connection(1), Some((CLIENT, EPOCH))); | |
| assert_eq!(table.get_epoch(CLIENT), Some(EPOCH)); | |
| assert_eq!(sessions.get_session(2), Some((CLIENT, EPOCH))); | |
| assert!(table.end_session(CLIENT, USER, EPOCH, EPOCH + 1)); | |
| for connection in [2, 3] { | |
| assert_eq!(sessions.get_session(connection), None); | |
| assert_eq!(sessions.get_user_id(connection), None); | |
| assert!(matches!( | |
| sessions.consumer_session(connection), | |
| Err(IggyError::StaleClient) | |
| )); | |
| } | |
| assert_eq!(sessions.iter_consumer_sessions(TIMEOUT).count(), 0); | |
| } | |
| #[test] | |
| fn record_sdk_info_exposed_via_iter_clients() { | |
| let mut mgr = SessionManager::new(); | |
| mgr.ensure_connection(1, addr(5000), ClientTransportKind::Tcp); | |
| // Pre-login: no SDK identity. | |
| let info = mgr.iter_clients().next().unwrap(); | |
| assert!(info.sdk_name.is_none()); | |
| mgr.record_sdk_info( | |
| 1, | |
| ClientSdkInfo { | |
| sdk_name: "rust-sdk".to_string(), | |
| sdk_version: "1.0.0".to_string(), | |
| protocol_version: 42, | |
| }, | |
| ); | |
| let info = mgr.iter_clients().next().unwrap(); | |
| assert_eq!(info.sdk_name.as_deref(), Some("rust-sdk")); | |
| assert_eq!(info.sdk_version.as_deref(), Some("1.0.0")); | |
| assert_eq!(info.protocol_version, Some(42)); | |
| // Re-login overwrites (client may reconnect after an upgrade). | |
| mgr.record_sdk_info( | |
| 1, | |
| ClientSdkInfo { | |
| sdk_name: "rust-sdk".to_string(), | |
| sdk_version: "2.0.0".to_string(), | |
| protocol_version: 43, | |
| }, | |
| ); | |
| let info = mgr.iter_clients().next().unwrap(); | |
| assert_eq!(info.sdk_version.as_deref(), Some("2.0.0")); | |
| // Unknown connection: record is dropped, no panic. | |
| mgr.record_sdk_info( | |
| 999, | |
| ClientSdkInfo { | |
| sdk_name: "go-sdk".to_string(), | |
| sdk_version: "0.1.0".to_string(), | |
| protocol_version: 1, | |
| }, | |
| ); | |
| assert_eq!(mgr.iter_clients().count(), 1); | |
| } | |
| #[test] | |
| fn activity_reports_include_only_recent_valid_bindings() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| sessions.ensure_connection(9, addr(5000), ClientTransportKind::Tcp); | |
| assert_eq!(sessions.iter_consumer_sessions(TIMEOUT).count(), 0); | |
| for connection in [1, 2] { | |
| bind(&mut sessions, &mut table, connection); | |
| } | |
| sessions.connections.get_mut(&1).unwrap().last_heartbeat = | |
| Instant::now().checked_sub(TIMEOUT).unwrap(); | |
| assert_eq!( | |
| sessions.iter_consumer_sessions(TIMEOUT).collect::<Vec<_>>(), | |
| [ConsumerSession { | |
| client_id: CLIENT, | |
| session: EPOCH | |
| }] | |
| ); | |
| sessions.touch_connection(1).unwrap(); | |
| assert_eq!(sessions.iter_consumer_sessions(TIMEOUT).count(), 2); | |
| sessions.remove_connection(2); | |
| assert_eq!(sessions.iter_consumer_sessions(TIMEOUT).count(), 1); | |
| table.end_session(CLIENT, USER, EPOCH, EPOCH + 1); | |
| assert_eq!(sessions.iter_consumer_sessions(TIMEOUT).count(), 0); | |
| } | |
| #[test] | |
| fn repeated_binding_is_idempotent_and_cannot_replace_a_live_binding() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| bind(&mut sessions, &mut table, 1); | |
| let attachment = table.attach_session(CLIENT, EPOCH, USER).unwrap(); | |
| sessions | |
| .bind_authenticated_connection(1, CLIENT, EPOCH, USER, attachment.clone(), EPOCH + 5) | |
| .unwrap(); | |
| assert!(matches!( | |
| sessions.bind_authenticated_connection(1, CLIENT + 1, EPOCH, USER, attachment, EPOCH), | |
| Err(IggyError::AlreadyAuthenticated) | |
| )); | |
| assert_eq!(sessions.get_session(1), Some((CLIENT, EPOCH))); | |
| assert_eq!(sessions.metadata_watermark(1), EPOCH + 5); | |
| } | |
| #[test] | |
| fn missing_connection_or_ended_attachment_cannot_bind() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| let attachment = table.attach_session(CLIENT, EPOCH, USER).unwrap(); | |
| assert!(matches!( | |
| sessions.bind_authenticated_connection( | |
| 1, | |
| CLIENT, | |
| EPOCH, | |
| USER, | |
| attachment.clone(), | |
| EPOCH | |
| ), | |
| Err(IggyError::Unauthenticated) | |
| )); | |
| sessions.ensure_connection(1, addr(5000), ClientTransportKind::Tcp); | |
| table.end_session(CLIENT, USER, EPOCH, EPOCH + 1); | |
| assert!(matches!( | |
| sessions.bind_authenticated_connection(1, CLIENT, EPOCH, USER, attachment, EPOCH), | |
| Err(IggyError::Unauthenticated) | |
| )); | |
| assert_eq!(sessions.get_session(1), None); | |
| } | |
| #[test] | |
| fn disconnect_is_idempotent_and_does_not_remove_other_bindings() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| bind(&mut sessions, &mut table, 1); | |
| bind(&mut sessions, &mut table, 2); | |
| assert_eq!(sessions.remove_connection(1), Some((CLIENT, EPOCH))); | |
| assert_eq!(sessions.remove_connection(1), None); | |
| assert_eq!(sessions.get_session(2), Some((CLIENT, EPOCH))); | |
| assert_eq!(sessions.client_count(), 1); | |
| } | |
| #[test] | |
| fn replies_and_rebinding_preserve_the_metadata_floor() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| bind(&mut sessions, &mut table, 1); | |
| assert_eq!(sessions.metadata_watermark(1), EPOCH); | |
| sessions.record_metadata_watermark(1, 50); | |
| sessions.record_metadata_watermark(1, 7); | |
| bind(&mut sessions, &mut table, 1); | |
| assert_eq!(sessions.metadata_watermark(1), 50); | |
| } | |
| #[test] | |
| fn ending_a_session_clears_the_connection_floor_before_another_user_binds() { | |
| let mut table = registry(CLIENT, USER, EPOCH); | |
| let mut sessions = SessionManager::new(); | |
| bind(&mut sessions, &mut table, 1); | |
| sessions.record_metadata_watermark(1, 50); | |
| table.end_session(CLIENT, USER, EPOCH, EPOCH + 1); | |
| let context = sessions.touch_connection(1).unwrap(); | |
| assert_eq!(context.bound, None); | |
| assert_eq!(context.user_id, None); | |
| assert_eq!(context.metadata_watermark, 0); | |
| let mut replacement = registry(CLIENT + 1, USER + 1, EPOCH + 2); | |
| let attachment = replacement | |
| .attach_session(CLIENT + 1, EPOCH + 2, USER + 1) | |
| .unwrap(); | |
| sessions | |
| .bind_authenticated_connection( | |
| 1, | |
| CLIENT + 1, | |
| EPOCH + 2, | |
| USER + 1, | |
| attachment, | |
| EPOCH + 2, | |
| ) | |
| .unwrap(); | |
| assert_eq!(sessions.metadata_watermark(1), EPOCH + 2); | |
| } | |
| #[test] | |
| fn a_reply_after_disconnect_does_not_recreate_connection_state() { | |
| let mut sessions = SessionManager::new(); | |
| sessions.record_metadata_watermark(9, 5); | |
| assert_eq!(sessions.metadata_watermark(9), 0); | |
| assert_eq!(sessions.client_count(), 0); | |
| } | |
| } |