Skip to main content

hydracache_server/
upgrade.rs

1use serde::Serialize;
2use thiserror::Error;
3
4/// Listener handoff strategy used during a zero-downtime upgrade.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
6#[serde(rename_all = "snake_case")]
7pub enum UpgradeStrategy {
8    /// A newly spawned process receives the already-bound listener.
9    InheritedSocket,
10    /// Old and new processes overlap on the same address through reuse-port style binding.
11    ReusePort,
12}
13
14impl UpgradeStrategy {
15    /// Return the default strategy for the current platform.
16    pub fn platform_default() -> Self {
17        if cfg!(windows) {
18            Self::ReusePort
19        } else {
20            Self::InheritedSocket
21        }
22    }
23}
24
25/// Upgrade phases visible to readiness checks and diagnostics.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum UpgradePhase {
29    /// Handoff has been prepared, but the replacement is not ready yet.
30    Prepared,
31    /// Replacement process is ready to accept traffic.
32    NewReady,
33    /// Old process stopped accepting and is draining active work.
34    OldDraining,
35    /// Upgrade finished without dropped in-flight work.
36    Complete,
37}
38
39/// Operator-provided upgrade plan.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct UpgradePlan {
42    generation: u64,
43    member_id: String,
44    strategy: UpgradeStrategy,
45}
46
47impl UpgradePlan {
48    /// Create a plan for the next process generation.
49    pub fn new(generation: u64, member_id: impl Into<String>) -> Self {
50        Self {
51            generation,
52            member_id: member_id.into(),
53            strategy: UpgradeStrategy::platform_default(),
54        }
55    }
56
57    /// Override the listener handoff strategy.
58    pub fn with_strategy(mut self, strategy: UpgradeStrategy) -> Self {
59        self.strategy = strategy;
60        self
61    }
62
63    /// Validate and prepare the handoff.
64    pub fn prepare(self) -> Result<GracefulUpgrade, UpgradeError> {
65        if self.generation == 0 {
66            return Err(UpgradeError::InvalidGeneration);
67        }
68        if self.member_id.trim().is_empty() {
69            return Err(UpgradeError::MissingMemberId);
70        }
71        Ok(GracefulUpgrade {
72            plan: self,
73            phase: UpgradePhase::Prepared,
74            old_accepting: true,
75            new_ready: false,
76            in_flight: 0,
77            completed: 0,
78        })
79    }
80}
81
82/// Deterministic zero-downtime handoff model.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct GracefulUpgrade {
85    plan: UpgradePlan,
86    phase: UpgradePhase,
87    old_accepting: bool,
88    new_ready: bool,
89    in_flight: usize,
90    completed: usize,
91}
92
93impl GracefulUpgrade {
94    /// Mark the replacement process ready before the old process drains.
95    pub fn mark_new_ready(&mut self) {
96        self.new_ready = true;
97        self.phase = UpgradePhase::NewReady;
98    }
99
100    /// Stop the old process from accepting new work and start drain.
101    pub fn start_draining_old(&mut self) -> Result<(), UpgradeError> {
102        if !self.new_ready {
103            return Err(UpgradeError::ReplacementNotReady);
104        }
105        self.old_accepting = false;
106        self.phase = UpgradePhase::OldDraining;
107        Ok(())
108    }
109
110    /// Record work accepted by the old process while it is still serving.
111    pub fn record_request(&mut self) -> bool {
112        if !self.old_accepting {
113            return false;
114        }
115        self.in_flight = self.in_flight.saturating_add(1);
116        true
117    }
118
119    /// Mark one in-flight request as completed.
120    pub fn finish_request(&mut self) {
121        if self.in_flight > 0 {
122            self.in_flight -= 1;
123            self.completed = self.completed.saturating_add(1);
124        }
125    }
126
127    /// Finish upgrade after all in-flight work is drained.
128    pub fn complete(mut self) -> Result<UpgradeReport, UpgradeError> {
129        if !self.new_ready {
130            return Err(UpgradeError::ReplacementNotReady);
131        }
132        if self.in_flight > 0 {
133            return Err(UpgradeError::InFlightRequestsRemaining(self.in_flight));
134        }
135        self.phase = UpgradePhase::Complete;
136        Ok(UpgradeReport {
137            generation: self.plan.generation,
138            member_id: self.plan.member_id,
139            strategy: self.plan.strategy,
140            phase: self.phase,
141            completed_requests: self.completed,
142            dropped_requests: 0,
143        })
144    }
145
146    /// Return current phase.
147    pub fn phase(&self) -> UpgradePhase {
148        self.phase
149    }
150
151    /// Return whether old and replacement process keep the same member identity.
152    pub fn membership_stable(&self) -> bool {
153        !self.plan.member_id.trim().is_empty()
154    }
155
156    /// Return active work still attached to the old process.
157    pub fn in_flight(&self) -> usize {
158        self.in_flight
159    }
160
161    /// Return whether the old process still accepts new traffic.
162    pub fn old_accepting(&self) -> bool {
163        self.old_accepting
164    }
165}
166
167/// Successful upgrade result.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
169pub struct UpgradeReport {
170    /// Process generation that completed.
171    pub generation: u64,
172    /// Stable cluster member identity.
173    pub member_id: String,
174    /// Listener handoff strategy used.
175    pub strategy: UpgradeStrategy,
176    /// Final phase.
177    pub phase: UpgradePhase,
178    /// Requests completed while old process drained.
179    pub completed_requests: usize,
180    /// Requests dropped by the handoff.
181    pub dropped_requests: usize,
182}
183
184/// Fail-loud upgrade errors.
185#[derive(Debug, Error, PartialEq, Eq)]
186pub enum UpgradeError {
187    /// Generation zero is reserved for uninitialized runtimes.
188    #[error("upgrade generation must be greater than zero")]
189    InvalidGeneration,
190    /// Cluster member identity must remain explicit across handoff.
191    #[error("upgrade requires a non-empty member id")]
192    MissingMemberId,
193    /// The old process cannot drain before the replacement is ready.
194    #[error("replacement process is not ready")]
195    ReplacementNotReady,
196    /// Upgrade cannot complete while old process still owns work.
197    #[error("{0} in-flight request(s) remain during upgrade")]
198    InFlightRequestsRemaining(usize),
199}