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 {}