1use std::collections::BTreeMap;
4
5use crate::command::{CommandEnvelope, CommandRejectReason};
6use crate::ids::{ClientId, StationId, Tick};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub struct GatewayConfig {
11 pub max_sessions: usize,
13 pub reconnect_grace_ticks: u64,
16 pub max_commands_per_tick: usize,
18}
19
20impl Default for GatewayConfig {
21 fn default() -> Self {
22 Self {
23 max_sessions: 65_536,
24 reconnect_grace_ticks: 20 * 60,
25 max_commands_per_tick: 64,
26 }
27 }
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct GatewayRoute {
33 pub client_id: ClientId,
35 pub station_id: StationId,
37 pub generation: u64,
39 pub route_epoch: u64,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum GatewaySessionState {
46 Connected,
48 Disconnected {
50 since: Tick,
52 },
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct GatewaySession {
58 pub client_id: ClientId,
60 pub station_id: StationId,
62 pub connected_at: Tick,
64 pub last_seen: Tick,
66 pub generation: u64,
69 pub route_epoch: u64,
71 pub state: GatewaySessionState,
73 last_sequence: Option<u64>,
74 command_tick: Tick,
75 commands_this_tick: usize,
76}
77
78impl GatewaySession {
79 pub const fn route(&self) -> GatewayRoute {
81 GatewayRoute {
82 client_id: self.client_id,
83 station_id: self.station_id,
84 generation: self.generation,
85 route_epoch: self.route_epoch,
86 }
87 }
88
89 pub const fn last_sequence(&self) -> Option<u64> {
91 self.last_sequence
92 }
93
94 pub const fn commands_this_tick(&self) -> usize {
96 self.commands_this_tick
97 }
98
99 pub const fn is_connected(&self) -> bool {
101 matches!(self.state, GatewaySessionState::Connected)
102 }
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107pub enum GatewayConnectOutcome {
108 Created,
110 AlreadyConnected,
112 Reconnected {
114 disconnected_for: u64,
116 },
117 ReplacedExpired {
119 disconnected_for: u64,
121 },
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub struct GatewayConnectReport {
127 pub outcome: GatewayConnectOutcome,
129 pub route: GatewayRoute,
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct GatewayCommandAdmission {
136 pub route: GatewayRoute,
138 pub sequence: u64,
140 pub commands_this_tick: usize,
142}
143
144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub struct GatewayStats {
147 pub sessions_created: usize,
149 pub sessions_reconnected: usize,
151 pub sessions_expired: usize,
153 pub routes_changed: usize,
155 pub commands_admitted: usize,
157 pub commands_rejected_replay: usize,
159 pub commands_rejected_rate_limit: usize,
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum GatewayError {
166 CapacityFull {
168 capacity: usize,
170 },
171 MissingSession(ClientId),
173 SessionDisconnected {
175 client_id: ClientId,
177 since: Tick,
179 },
180 BadGeneration {
182 expected: u64,
184 actual: u64,
186 },
187 ReplayOrStale {
189 last_sequence: Option<u64>,
191 sequence: u64,
193 },
194 RateLimited {
196 limit: usize,
198 attempted: usize,
200 },
201}
202
203impl GatewayError {
204 pub const fn command_reject_reason(&self) -> Option<CommandRejectReason> {
206 match self {
207 Self::ReplayOrStale { .. } => Some(CommandRejectReason::ReplayOrStale),
208 Self::RateLimited { .. } => Some(CommandRejectReason::RateLimited),
209 Self::MissingSession(_)
210 | Self::SessionDisconnected { .. }
211 | Self::BadGeneration { .. }
212 | Self::CapacityFull { .. } => None,
213 }
214 }
215}
216
217impl core::fmt::Display for GatewayError {
218 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
219 match self {
220 Self::CapacityFull { capacity } => {
221 write!(f, "gateway session table is full at capacity {capacity}")
222 }
223 Self::MissingSession(client_id) => {
224 write!(
225 f,
226 "gateway session for client {} is missing",
227 client_id.get()
228 )
229 }
230 Self::SessionDisconnected { client_id, since } => write!(
231 f,
232 "gateway session for client {} disconnected at tick {}",
233 client_id.get(),
234 since.get()
235 ),
236 Self::BadGeneration { expected, actual } => write!(
237 f,
238 "gateway reconnect generation mismatch: expected {expected}, actual {actual}"
239 ),
240 Self::ReplayOrStale {
241 last_sequence,
242 sequence,
243 } => write!(
244 f,
245 "gateway command sequence {sequence} is not newer than {last_sequence:?}"
246 ),
247 Self::RateLimited { limit, attempted } => write!(
248 f,
249 "gateway command rate limited: limit {limit}, attempted {attempted}"
250 ),
251 }
252 }
253}
254
255impl std::error::Error for GatewayError {}
256
257#[derive(Clone, Debug)]
259pub struct GatewaySessionTable {
260 config: GatewayConfig,
261 sessions: BTreeMap<ClientId, GatewaySession>,
262 stats: GatewayStats,
263}
264
265impl GatewaySessionTable {
266 pub fn new(config: GatewayConfig) -> Self {
268 Self {
269 config,
270 sessions: BTreeMap::new(),
271 stats: GatewayStats::default(),
272 }
273 }
274
275 pub const fn config(&self) -> GatewayConfig {
277 self.config
278 }
279
280 pub const fn stats(&self) -> GatewayStats {
282 self.stats
283 }
284
285 pub fn len(&self) -> usize {
287 self.sessions.len()
288 }
289
290 pub fn is_empty(&self) -> bool {
292 self.sessions.is_empty()
293 }
294
295 pub fn session(&self, client_id: ClientId) -> Option<&GatewaySession> {
297 self.sessions.get(&client_id)
298 }
299
300 pub fn route(&self, client_id: ClientId) -> Result<GatewayRoute, GatewayError> {
302 let session = self
303 .sessions
304 .get(&client_id)
305 .ok_or(GatewayError::MissingSession(client_id))?;
306 match session.state {
307 GatewaySessionState::Connected => Ok(session.route()),
308 GatewaySessionState::Disconnected { since } => {
309 Err(GatewayError::SessionDisconnected { client_id, since })
310 }
311 }
312 }
313
314 pub fn connect(
316 &mut self,
317 client_id: ClientId,
318 station_id: StationId,
319 now: Tick,
320 ) -> Result<GatewayConnectReport, GatewayError> {
321 if self.sessions.contains_key(&client_id) {
322 return Ok(self.connect_existing(client_id, station_id, now));
323 }
324
325 if self.sessions.len() >= self.config.max_sessions {
326 return Err(GatewayError::CapacityFull {
327 capacity: self.config.max_sessions,
328 });
329 }
330
331 let session = GatewaySession {
332 client_id,
333 station_id,
334 connected_at: now,
335 last_seen: now,
336 generation: 1,
337 route_epoch: 1,
338 state: GatewaySessionState::Connected,
339 last_sequence: None,
340 command_tick: now,
341 commands_this_tick: 0,
342 };
343 let route = session.route();
344 self.sessions.insert(client_id, session);
345 self.stats.sessions_created = self.stats.sessions_created.saturating_add(1);
346 Ok(GatewayConnectReport {
347 outcome: GatewayConnectOutcome::Created,
348 route,
349 })
350 }
351
352 pub fn reconnect(
354 &mut self,
355 client_id: ClientId,
356 generation: u64,
357 now: Tick,
358 ) -> Result<GatewayConnectReport, GatewayError> {
359 let session = self
360 .sessions
361 .get_mut(&client_id)
362 .ok_or(GatewayError::MissingSession(client_id))?;
363 if session.generation != generation {
364 return Err(GatewayError::BadGeneration {
365 expected: session.generation,
366 actual: generation,
367 });
368 }
369
370 match session.state {
371 GatewaySessionState::Connected => {
372 session.last_seen = now;
373 Ok(GatewayConnectReport {
374 outcome: GatewayConnectOutcome::AlreadyConnected,
375 route: session.route(),
376 })
377 }
378 GatewaySessionState::Disconnected { since } => {
379 let disconnected_for = now.get().saturating_sub(since.get());
380 if disconnected_for > self.config.reconnect_grace_ticks {
381 session.connected_at = now;
382 session.last_seen = now;
383 session.generation = session.generation.saturating_add(1);
384 session.route_epoch = session.route_epoch.saturating_add(1);
385 session.state = GatewaySessionState::Connected;
386 session.last_sequence = None;
387 session.command_tick = now;
388 session.commands_this_tick = 0;
389 self.stats.sessions_expired = self.stats.sessions_expired.saturating_add(1);
390 Ok(GatewayConnectReport {
391 outcome: GatewayConnectOutcome::ReplacedExpired { disconnected_for },
392 route: session.route(),
393 })
394 } else {
395 session.last_seen = now;
396 session.state = GatewaySessionState::Connected;
397 self.stats.sessions_reconnected =
398 self.stats.sessions_reconnected.saturating_add(1);
399 Ok(GatewayConnectReport {
400 outcome: GatewayConnectOutcome::Reconnected { disconnected_for },
401 route: session.route(),
402 })
403 }
404 }
405 }
406 }
407
408 pub fn disconnect(&mut self, client_id: ClientId, now: Tick) -> Result<(), GatewayError> {
410 let session = self
411 .sessions
412 .get_mut(&client_id)
413 .ok_or(GatewayError::MissingSession(client_id))?;
414 session.last_seen = now;
415 session.state = GatewaySessionState::Disconnected { since: now };
416 Ok(())
417 }
418
419 pub fn expire_disconnected(&mut self, now: Tick) -> usize {
421 let grace = self.config.reconnect_grace_ticks;
422 let expired = self
423 .sessions
424 .iter()
425 .filter_map(|(client_id, session)| match session.state {
426 GatewaySessionState::Connected => None,
427 GatewaySessionState::Disconnected { since } => {
428 (now.get().saturating_sub(since.get()) > grace).then_some(*client_id)
429 }
430 })
431 .collect::<Vec<_>>();
432
433 for client_id in &expired {
434 self.sessions.remove(client_id);
435 }
436 self.stats.sessions_expired = self.stats.sessions_expired.saturating_add(expired.len());
437 expired.len()
438 }
439
440 pub fn reroute(
442 &mut self,
443 client_id: ClientId,
444 station_id: StationId,
445 now: Tick,
446 ) -> Result<GatewayRoute, GatewayError> {
447 let session = self
448 .sessions
449 .get_mut(&client_id)
450 .ok_or(GatewayError::MissingSession(client_id))?;
451 match session.state {
452 GatewaySessionState::Connected => {
453 session.last_seen = now;
454 if session.station_id != station_id {
455 session.station_id = station_id;
456 session.route_epoch = session.route_epoch.saturating_add(1);
457 self.stats.routes_changed = self.stats.routes_changed.saturating_add(1);
458 }
459 Ok(session.route())
460 }
461 GatewaySessionState::Disconnected { since } => {
462 Err(GatewayError::SessionDisconnected { client_id, since })
463 }
464 }
465 }
466
467 pub fn admit_command(
470 &mut self,
471 command: &CommandEnvelope,
472 ) -> Result<GatewayCommandAdmission, GatewayError> {
473 self.admit_sequence(command.client_id, command.sequence, command.received_at)
474 }
475
476 pub fn admit_sequence(
478 &mut self,
479 client_id: ClientId,
480 sequence: u64,
481 now: Tick,
482 ) -> Result<GatewayCommandAdmission, GatewayError> {
483 let session = self
484 .sessions
485 .get_mut(&client_id)
486 .ok_or(GatewayError::MissingSession(client_id))?;
487 match session.state {
488 GatewaySessionState::Connected => {}
489 GatewaySessionState::Disconnected { since } => {
490 return Err(GatewayError::SessionDisconnected { client_id, since });
491 }
492 }
493
494 if session
495 .last_sequence
496 .is_some_and(|last_sequence| sequence <= last_sequence)
497 {
498 self.stats.commands_rejected_replay =
499 self.stats.commands_rejected_replay.saturating_add(1);
500 return Err(GatewayError::ReplayOrStale {
501 last_sequence: session.last_sequence,
502 sequence,
503 });
504 }
505
506 if session.command_tick != now {
507 session.command_tick = now;
508 session.commands_this_tick = 0;
509 }
510 let attempted = session.commands_this_tick.saturating_add(1);
511 if attempted > self.config.max_commands_per_tick {
512 self.stats.commands_rejected_rate_limit =
513 self.stats.commands_rejected_rate_limit.saturating_add(1);
514 return Err(GatewayError::RateLimited {
515 limit: self.config.max_commands_per_tick,
516 attempted,
517 });
518 }
519
520 session.commands_this_tick = attempted;
521 session.last_sequence = Some(sequence);
522 session.last_seen = now;
523 self.stats.commands_admitted = self.stats.commands_admitted.saturating_add(1);
524 Ok(GatewayCommandAdmission {
525 route: session.route(),
526 sequence,
527 commands_this_tick: attempted,
528 })
529 }
530
531 fn connect_existing(
532 &mut self,
533 client_id: ClientId,
534 station_id: StationId,
535 now: Tick,
536 ) -> GatewayConnectReport {
537 let session = self
538 .sessions
539 .get_mut(&client_id)
540 .expect("session existence was checked");
541 match session.state {
542 GatewaySessionState::Connected => {
543 session.last_seen = now;
544 if session.station_id != station_id {
545 session.station_id = station_id;
546 session.route_epoch = session.route_epoch.saturating_add(1);
547 self.stats.routes_changed = self.stats.routes_changed.saturating_add(1);
548 }
549 GatewayConnectReport {
550 outcome: GatewayConnectOutcome::AlreadyConnected,
551 route: session.route(),
552 }
553 }
554 GatewaySessionState::Disconnected { since } => {
555 let disconnected_for = now.get().saturating_sub(since.get());
556 if disconnected_for > self.config.reconnect_grace_ticks {
557 session.station_id = station_id;
558 session.connected_at = now;
559 session.last_seen = now;
560 session.generation = session.generation.saturating_add(1);
561 session.route_epoch = session.route_epoch.saturating_add(1);
562 session.state = GatewaySessionState::Connected;
563 session.last_sequence = None;
564 session.command_tick = now;
565 session.commands_this_tick = 0;
566 self.stats.sessions_expired = self.stats.sessions_expired.saturating_add(1);
567 GatewayConnectReport {
568 outcome: GatewayConnectOutcome::ReplacedExpired { disconnected_for },
569 route: session.route(),
570 }
571 } else {
572 session.station_id = station_id;
573 session.last_seen = now;
574 session.state = GatewaySessionState::Connected;
575 self.stats.sessions_reconnected =
576 self.stats.sessions_reconnected.saturating_add(1);
577 GatewayConnectReport {
578 outcome: GatewayConnectOutcome::Reconnected { disconnected_for },
579 route: session.route(),
580 }
581 }
582 }
583 }
584 }
585}
586
587impl Default for GatewaySessionTable {
588 fn default() -> Self {
589 Self::new(GatewayConfig::default())
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::command::{CommandEnvelope, CommandPriority};
597 use crate::ids::{CommandId, EntityId};
598
599 fn command(client_id: ClientId, sequence: u64, tick: u64) -> CommandEnvelope {
600 CommandEnvelope {
601 id: CommandId::new(sequence),
602 client_id,
603 entity_id: EntityId::new(10),
604 sequence,
605 received_at: Tick::new(tick),
606 kind: 1,
607 priority: CommandPriority::Normal,
608 payload: Vec::new(),
609 }
610 }
611
612 #[test]
613 fn connects_routes_and_reroutes_sessions() {
614 let client_id = ClientId::new(7);
615 let mut table = GatewaySessionTable::new(GatewayConfig {
616 max_sessions: 4,
617 reconnect_grace_ticks: 3,
618 max_commands_per_tick: 4,
619 });
620
621 let connected = table
622 .connect(client_id, StationId::new(1), Tick::new(10))
623 .expect("connect should work");
624 assert_eq!(connected.outcome, GatewayConnectOutcome::Created);
625 assert_eq!(connected.route.station_id, StationId::new(1));
626 assert_eq!(connected.route.generation, 1);
627 assert_eq!(connected.route.route_epoch, 1);
628
629 let route = table
630 .reroute(client_id, StationId::new(2), Tick::new(11))
631 .expect("reroute should work");
632 assert_eq!(route.station_id, StationId::new(2));
633 assert_eq!(route.route_epoch, 2);
634 assert_eq!(table.stats().routes_changed, 1);
635 assert_eq!(
636 table
637 .route(client_id)
638 .expect("route should exist")
639 .station_id,
640 StationId::new(2)
641 );
642 }
643
644 #[test]
645 fn reconnects_with_generation_and_expires_disconnected_sessions() {
646 let client_id = ClientId::new(7);
647 let mut table = GatewaySessionTable::new(GatewayConfig {
648 max_sessions: 4,
649 reconnect_grace_ticks: 3,
650 max_commands_per_tick: 4,
651 });
652 let connected = table
653 .connect(client_id, StationId::new(1), Tick::new(10))
654 .expect("connect should work");
655 table
656 .disconnect(client_id, Tick::new(12))
657 .expect("disconnect should work");
658 assert!(matches!(
659 table.route(client_id),
660 Err(GatewayError::SessionDisconnected { .. })
661 ));
662
663 let bad = table
664 .reconnect(client_id, connected.route.generation + 1, Tick::new(13))
665 .expect_err("bad generation should fail");
666 assert_eq!(
667 bad,
668 GatewayError::BadGeneration {
669 expected: 1,
670 actual: 2
671 }
672 );
673
674 let reconnected = table
675 .reconnect(client_id, connected.route.generation, Tick::new(14))
676 .expect("reconnect should work");
677 assert_eq!(
678 reconnected.outcome,
679 GatewayConnectOutcome::Reconnected {
680 disconnected_for: 2
681 }
682 );
683 assert_eq!(reconnected.route.generation, 1);
684
685 table
686 .disconnect(client_id, Tick::new(15))
687 .expect("disconnect should work");
688 assert_eq!(table.expire_disconnected(Tick::new(19)), 1);
689 assert_eq!(table.len(), 0);
690 assert_eq!(table.stats().sessions_expired, 1);
691 }
692
693 #[test]
694 fn connect_replaces_stale_disconnected_session() {
695 let client_id = ClientId::new(7);
696 let mut table = GatewaySessionTable::new(GatewayConfig {
697 max_sessions: 4,
698 reconnect_grace_ticks: 3,
699 max_commands_per_tick: 4,
700 });
701 let connected = table
702 .connect(client_id, StationId::new(1), Tick::new(10))
703 .expect("connect should work");
704 table
705 .admit_sequence(client_id, 10, Tick::new(11))
706 .expect("first command should admit");
707 table
708 .disconnect(client_id, Tick::new(12))
709 .expect("disconnect should work");
710
711 let replaced = table
712 .connect(client_id, StationId::new(2), Tick::new(20))
713 .expect("stale reconnect should replace generation");
714 assert_eq!(
715 replaced.outcome,
716 GatewayConnectOutcome::ReplacedExpired {
717 disconnected_for: 8
718 }
719 );
720 assert_eq!(replaced.route.generation, connected.route.generation + 1);
721 assert_eq!(replaced.route.station_id, StationId::new(2));
722 assert_eq!(
723 table
724 .admit_sequence(client_id, 1, Tick::new(21))
725 .expect("new generation should reset sequence")
726 .sequence,
727 1
728 );
729 }
730
731 #[test]
732 fn command_admission_rejects_replay_and_rate_limit() {
733 let client_id = ClientId::new(7);
734 let mut table = GatewaySessionTable::new(GatewayConfig {
735 max_sessions: 4,
736 reconnect_grace_ticks: 3,
737 max_commands_per_tick: 2,
738 });
739 table
740 .connect(client_id, StationId::new(1), Tick::new(10))
741 .expect("connect should work");
742
743 let first = table
744 .admit_command(&command(client_id, 1, 10))
745 .expect("first command should admit");
746 assert_eq!(first.commands_this_tick, 1);
747 assert_eq!(first.route.station_id, StationId::new(1));
748
749 let replay = table
750 .admit_command(&command(client_id, 1, 10))
751 .expect_err("same sequence should reject");
752 assert_eq!(
753 replay,
754 GatewayError::ReplayOrStale {
755 last_sequence: Some(1),
756 sequence: 1
757 }
758 );
759 assert_eq!(
760 replay.command_reject_reason(),
761 Some(CommandRejectReason::ReplayOrStale)
762 );
763
764 table
765 .admit_command(&command(client_id, 2, 10))
766 .expect("second command should admit");
767 let limited = table
768 .admit_command(&command(client_id, 3, 10))
769 .expect_err("third same-tick command should rate limit");
770 assert_eq!(
771 limited,
772 GatewayError::RateLimited {
773 limit: 2,
774 attempted: 3
775 }
776 );
777 assert_eq!(
778 limited.command_reject_reason(),
779 Some(CommandRejectReason::RateLimited)
780 );
781
782 let next_tick = table
783 .admit_command(&command(client_id, 3, 11))
784 .expect("next tick should reset rate count");
785 assert_eq!(next_tick.commands_this_tick, 1);
786 assert_eq!(table.stats().commands_admitted, 3);
787 assert_eq!(table.stats().commands_rejected_replay, 1);
788 assert_eq!(table.stats().commands_rejected_rate_limit, 1);
789 }
790
791 #[test]
792 fn capacity_limit_is_enforced() {
793 let mut table = GatewaySessionTable::new(GatewayConfig {
794 max_sessions: 1,
795 reconnect_grace_ticks: 3,
796 max_commands_per_tick: 4,
797 });
798 table
799 .connect(ClientId::new(1), StationId::new(1), Tick::new(0))
800 .expect("first session should fit");
801 let error = table
802 .connect(ClientId::new(2), StationId::new(1), Tick::new(0))
803 .expect_err("second session should exceed capacity");
804 assert_eq!(error, GatewayError::CapacityFull { capacity: 1 });
805 }
806}