pavex_session/config/
state.rs

1use serde::Deserialize;
2
3#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4#[serde(rename_all = "snake_case")]
5#[non_exhaustive]
6/// Configure the way session state is stored.
7pub struct SessionStateConfig {
8    /// The time-to-live of the server session state, i.e.
9    /// how long to keep the server-side state of a session
10    /// in the storage backend you chose.
11    ///
12    /// This value is also used to control the expiration
13    /// of the client-side session cookie if [`SessionCookieConfig::kind`]
14    /// is set to [`SessionCookieKind::Persistent`].
15    ///
16    /// # Default
17    ///
18    /// The default value is 24 hours.
19    ///
20    /// [`SessionCookieConfig::kind`]: super::SessionCookieConfig::kind
21    /// [`SessionCookieKind::Persistent`]: super::SessionCookieKind::Persistent
22    #[serde(deserialize_with = "deserialize_ttl", default = "default_ttl")]
23    pub ttl: std::time::Duration,
24    /// The event that triggers the extension of the time-to-live
25    /// of the current session.
26    #[serde(default)]
27    pub extend_ttl: TtlExtensionTrigger,
28    /// The server will skip TTL extension if the remaining TTL
29    /// is greater than this threshold.
30    /// The threshold is a ratio between 0 and 1, interpreted as
31    /// a percentage of the total TTL.
32    ///
33    /// If set to `None`, the server will never skip TTL extension.
34    ///
35    /// # Performance impact
36    ///
37    /// Setting a sensible threshold will:
38    ///
39    /// - reduce the number of requests to your storage backend
40    /// - reduce your server latency by removing a network request from the critical path
41    ///
42    /// # Default
43    ///
44    /// By default, the threshold is set to 0.8—i.e. 80% of the total TTL.
45    ///
46    /// # Example
47    ///
48    /// Let's assume that the TTL for a new session is set to 24 hours.
49    /// With the default threshold of 0.8, the server will skip TTL extension requests
50    /// if the remaining session TTL is greater than 19.2 hours.
51    /// In other words, the server expects at most ~0.2 TTL extension requests per hour for
52    /// each active session, regardless of the number of requests the server receives
53    /// for that session.
54    #[serde(default = "default_ttl_extension_threshold")]
55    pub ttl_extension_threshold: Option<TtlExtensionThreshold>,
56    /// Determines when the storage backend should be asked to create a new session state record.
57    #[serde(default)]
58    pub server_state_creation: ServerStateCreation,
59    /// Determines what happens when there is no server-side state for a pre-existing session
60    /// (e.g. only the client-side state remains or was ever created).
61    #[serde(default)]
62    pub missing_server_state: MissingServerState,
63}
64
65impl Default for SessionStateConfig {
66    fn default() -> Self {
67        Self {
68            ttl: default_ttl(),
69            extend_ttl: Default::default(),
70            ttl_extension_threshold: default_ttl_extension_threshold(),
71            server_state_creation: Default::default(),
72            missing_server_state: Default::default(),
73        }
74    }
75}
76
77fn deserialize_ttl<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
78where
79    D: serde::Deserializer<'de>,
80{
81    let span = pavex::time::Span::deserialize(deserializer)?;
82    if span.is_negative() {
83        return Err(serde::de::Error::custom(
84            "The session state TTL cannot be negative",
85        ));
86    }
87    if span.is_zero() {
88        return Err(serde::de::Error::custom(
89            "The session state TTL cannot be zero",
90        ));
91    }
92    let ttl = span.try_into().map_err(serde::de::Error::custom)?;
93    Ok(ttl)
94}
95
96fn default_ttl() -> std::time::Duration {
97    // 1 day
98    std::time::Duration::from_secs(60 * 60 * 24)
99}
100
101fn default_ttl_extension_threshold() -> Option<TtlExtensionThreshold> {
102    Some(TtlExtensionThreshold::new(0.8).unwrap())
103}
104
105#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108/// Configure when the TTL for an existing session should be extended.
109pub enum TtlExtensionTrigger {
110    /// The TTL of the current session is refreshed on every request where the
111    /// server either:
112    ///
113    /// - Modified the session state
114    /// - Loaded the server state from the storage backend
115    ///
116    /// This is the default.
117    ///
118    /// # Performance impact
119    ///
120    /// TTL refreshes are not free, as they require an additional
121    /// request to the storage backend if the state was otherwise unchanged.
122    /// This impacts both the total load on your storage backend
123    /// (i.e. number of queries it has to handle) and the latency of the requests served by your server.
124    ///
125    /// This impact can be mitigated by setting a [TTL extension threshold](SessionStateConfig::ttl_extension_threshold).
126    #[default]
127    OnStateLoadsAndChanges,
128    /// The TTL of the current session is only refreshed when the session state is modified.
129    ///
130    /// It doesn't distinguish between changes to the client-side and the server-side session state
131    /// when determining if the TTL should be refreshed.
132    ///
133    /// # Performance impact
134    ///
135    /// [`OnStateChanges`] may reduce the number of requests to the storage backend
136    /// compared to [`OnStateLoadsAndChanges`], as well as improve the latency of the requests served
137    /// by your server by removing a network request from the critical path.
138    /// It primarily depends on the
139    /// [TTL extension threshold](SessionStateConfig::ttl_extension_threshold) you set, if any.
140    ///
141    /// [`OnStateChanges`]: TtlExtensionTrigger::OnStateChanges
142    /// [`OnStateLoadsAndChanges`]: TtlExtensionTrigger::OnStateLoadsAndChanges
143    OnStateChanges,
144}
145
146#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149/// Configure when the storage backend should be asked to create a new session state record.
150///
151/// Regardless of the policy you choose, remember that no session record will be created if:
152///
153/// - There is no client-side session state (i.e. the client didn't send a session cookie)
154/// - The client-side session state is empty
155/// - The server-side session state is empty
156pub enum ServerStateCreation {
157    /// The storage backend won't be asked to create a new session state
158    /// record if the server-side state is empty.
159    SkipIfEmpty,
160    /// The storage backend will always be asked to create a server-side session state
161    /// record if a client-side session state is present.
162    ///
163    /// This is the default policy.
164    #[default]
165    NeverSkip,
166}
167
168#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
169#[serde(rename_all = "snake_case")]
170#[non_exhaustive]
171/// Configure the expected behaviour when dealing with a pre-existing session (i.e. with
172/// a valid client-side cookie) that doesn't have a corresponding record in the session store.
173pub enum MissingServerState {
174    /// The session will be treated as valid.
175    ///
176    /// The server state will be treated as empty, if interacted with.
177    Allow,
178    /// The session will be marked as invalidated.
179    #[default]
180    Reject,
181}
182
183#[derive(Debug, Clone, Copy, serde::Serialize)]
184/// A ratio between 0 and 1, interpreted as a percentage of the time-to-live of a fresh session.
185pub struct TtlExtensionThreshold(f32);
186
187impl<'de> serde::Deserialize<'de> for TtlExtensionThreshold {
188    fn deserialize<D>(deserializer: D) -> Result<TtlExtensionThreshold, D::Error>
189    where
190        D: serde::Deserializer<'de>,
191    {
192        let value = f32::deserialize(deserializer)?;
193        TtlExtensionThreshold::new(value).map_err(serde::de::Error::custom)
194    }
195}
196
197impl TtlExtensionThreshold {
198    pub fn new(value: f32) -> Result<Self, InvalidTtlExtensionThreshold> {
199        if !(0.0..=1.0).contains(&value) {
200            Err(InvalidTtlExtensionThreshold(value))
201        } else {
202            Ok(Self(value))
203        }
204    }
205
206    pub fn inner(self) -> f32 {
207        self.0
208    }
209}
210
211impl TryFrom<f32> for TtlExtensionThreshold {
212    type Error = InvalidTtlExtensionThreshold;
213
214    fn try_from(value: f32) -> Result<Self, Self::Error> {
215        Self::new(value)
216    }
217}
218
219impl TryFrom<f64> for TtlExtensionThreshold {
220    type Error = InvalidTtlExtensionThreshold;
221
222    fn try_from(value: f64) -> Result<Self, Self::Error> {
223        Self::new(value as f32)
224    }
225}
226
227#[derive(Debug)]
228/// Error raised when trying to create a [`TtlExtensionThreshold`] with an invalid value.
229pub struct InvalidTtlExtensionThreshold(f32);
230
231impl std::fmt::Display for InvalidTtlExtensionThreshold {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        write!(
234            f,
235            "TTL extension threshold must be a ratio between 0 and 1, got {}",
236            self.0
237        )
238    }
239}
240
241impl std::error::Error for InvalidTtlExtensionThreshold {}