paykit_lib/pubky_routing/
locks.rs1use std::{future::Future, time::Duration};
2
3use pubky::{PubkySession, StorageLock};
4
5use crate::{PaykitError, Result};
6
7use super::{is_not_found, read_response_revision};
8
9const WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(60);
10const LOCK_ACQUISITION_MAX_ATTEMPTS: u64 = 8;
11
12#[derive(Debug, thiserror::Error)]
13#[error("resource changed since it was read")]
14struct ResourceChanged;
15
16#[derive(Debug, thiserror::Error)]
17#[error("write lock renewal failed; the operation may have committed")]
18struct LockRenewalError(#[source] anyhow::Error);
19
20pub fn is_write_conflict(error: &PaykitError) -> bool {
22 matches!(error, PaykitError::Transport { source, .. }
23 if source.is::<ResourceChanged>() || matches!(
24 source.downcast_ref::<pubky::Error>(),
25 Some(pubky::Error::Request(pubky::errors::RequestError::Server { status, .. }))
26 if *status == pubky::StatusCode::LOCKED
27 || *status == pubky::StatusCode::PRECONDITION_FAILED
28 || *status == pubky::StatusCode::CONFLICT
29 ))
30}
31
32pub async fn put_resource_if_revision(
39 session: &PubkySession,
40 path: &str,
41 bytes: Vec<u8>,
42 expected_revision: Option<&str>,
43 max_bytes: usize,
44) -> Result<()> {
45 with_write_lock(session, path, |lock| async move {
46 let current = resource_revision(session, path, max_bytes).await?;
47 if current.as_deref() != expected_revision {
48 return Err(resource_changed());
49 }
50 session
51 .storage()
52 .put_locked(&lock, bytes)
53 .await
54 .map_err(|error| lock_error("replace Pubky resource", error))?;
55 Ok(())
56 })
57 .await
58}
59
60pub async fn delete_resource_if_revision(
65 session: &PubkySession,
66 path: &str,
67 expected_revision: &str,
68 max_bytes: usize,
69) -> Result<()> {
70 with_write_lock(session, path, |lock| async move {
71 let Some(current) = resource_revision(session, path, max_bytes).await? else {
72 return Ok(());
73 };
74 if current != expected_revision {
75 return Err(resource_changed());
76 }
77 session
78 .storage()
79 .delete_locked(&lock)
80 .await
81 .map_err(|error| lock_error("delete Pubky resource", error))?;
82 Ok(())
83 })
84 .await
85}
86
87pub(super) async fn resource_revision(
88 session: &PubkySession,
89 path: &str,
90 max_bytes: usize,
91) -> Result<Option<String>> {
92 let response = match session.storage().get(path).await {
93 Ok(response) => response,
94 Err(error) if is_not_found(&error) => return Ok(None),
95 Err(error) => return Err(lock_error("read Pubky resource revision", error)),
96 };
97 read_response_revision(response, max_bytes, "read Pubky resource revision")
98 .await
99 .map(Some)
100}
101
102fn resource_changed() -> PaykitError {
103 PaykitError::Transport {
104 context: "Pubky resource changed; reload it before writing".into(),
105 source: ResourceChanged.into(),
106 }
107}
108
109pub async fn with_write_lock<T, E, F, Fut>(
126 session: &PubkySession,
127 path: &str,
128 operation: F,
129) -> std::result::Result<T, E>
130where
131 E: From<PaykitError>,
132 F: FnOnce(StorageLock) -> Fut,
133 Fut: Future<Output = std::result::Result<T, E>>,
134{
135 with_write_lock_timeout(session, path, WRITE_LOCK_TIMEOUT, operation).await
136}
137
138pub(crate) async fn with_write_lock_timeout<T, E, F, Fut>(
139 session: &PubkySession,
140 path: &str,
141 timeout: Duration,
142 operation: F,
143) -> std::result::Result<T, E>
144where
145 E: From<PaykitError>,
146 F: FnOnce(StorageLock) -> Fut,
147 Fut: Future<Output = std::result::Result<T, E>>,
148{
149 let storage = session.storage();
150 let mut attempt = 1;
151 let (lock, acquired_at) = loop {
152 let requested_at = tokio::time::Instant::now();
153 match storage.lock(path, timeout).await {
154 Ok(lock) => break (lock, requested_at),
155 Err(pubky::Error::Request(pubky::errors::RequestError::Server { status, .. }))
156 if status == pubky::StatusCode::LOCKED
157 && attempt < LOCK_ACQUISITION_MAX_ATTEMPTS =>
158 {
159 tokio::time::sleep(Duration::from_millis(50 * attempt)).await;
160 attempt += 1;
161 }
162 Err(source) => return Err(E::from(lock_error("acquire Pubky write lock", source))),
163 }
164 };
165 let mut renewed = lock.clone();
166 let renewal = async {
167 let mut valid_until = acquired_at + renewed.timeout();
168 loop {
169 tokio::time::sleep_until(valid_until - renewed.timeout() * 2 / 3).await;
170 loop {
171 let requested_at = tokio::time::Instant::now();
172 match tokio::time::timeout_at(
173 valid_until,
174 storage.refresh_lock(&mut renewed, timeout),
175 )
176 .await
177 {
178 Ok(Ok(())) => {
179 valid_until = requested_at + renewed.timeout();
180 break;
181 }
182 Ok(Err(error))
183 if retryable_lock_refresh(&error)
184 && requested_at + Duration::from_secs(1) < valid_until =>
185 {
186 tokio::time::sleep_until(
187 (tokio::time::Instant::now() + Duration::from_secs(1)).min(valid_until),
188 )
189 .await;
190 }
191 Ok(Err(error)) => return anyhow::Error::from(error),
192 Err(_) => {
193 return anyhow::anyhow!(
194 "Pubky write lock expired before renewal was confirmed"
195 )
196 }
197 }
198 }
199 }
200 };
201 let result = tokio::select! {
202 biased;
203 error = renewal => {
204 return Err(E::from(PaykitError::Transport {
205 context: "Pubky write lock lost; operation outcome is uncertain".into(),
206 source: LockRenewalError(error).into(),
207 }));
208 }
209 result = operation(lock.clone()) => result,
210 };
211 if storage.unlock(&lock).await.is_err() {
214 tracing::warn!("Failed to release Pubky write lock; waiting for expiry");
215 }
216 result
217}
218
219fn retryable_lock_refresh(error: &pubky::Error) -> bool {
220 match error {
221 pubky::Error::Request(pubky::errors::RequestError::Transport(_)) => true,
222 pubky::Error::Request(pubky::errors::RequestError::Server { status, .. }) => {
223 status.is_server_error() || *status == pubky::StatusCode::TOO_MANY_REQUESTS
224 }
225 _ => false,
226 }
227}
228
229fn lock_error(context: &str, source: pubky::Error) -> PaykitError {
230 PaykitError::Transport {
231 context: context.into(),
232 source: source.into(),
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_generation_conflict_requires_fresh_operation() {
242 for (status, conflict) in [
243 (pubky::StatusCode::CONFLICT, true),
244 (pubky::StatusCode::PRECONDITION_FAILED, true),
245 (pubky::StatusCode::LOCKED, true),
246 (pubky::StatusCode::INTERNAL_SERVER_ERROR, false),
247 ] {
248 let source = pubky::Error::Request(pubky::errors::RequestError::Server {
249 status,
250 message: "test response".into(),
251 });
252 let error = PaykitError::Transport {
253 context: "write resource".into(),
254 source: source.into(),
255 };
256 assert_eq!(is_write_conflict(&error), conflict);
257 }
258 }
259
260 #[test]
261 fn test_lock_refresh_retries_only_transient_server_errors() {
262 for (status, retryable) in [
263 (pubky::StatusCode::SERVICE_UNAVAILABLE, true),
264 (pubky::StatusCode::TOO_MANY_REQUESTS, true),
265 (pubky::StatusCode::PRECONDITION_FAILED, false),
266 (pubky::StatusCode::LOCKED, false),
267 (pubky::StatusCode::UNAUTHORIZED, false),
268 (pubky::StatusCode::FORBIDDEN, false),
269 ] {
270 let error = pubky::Error::Request(pubky::errors::RequestError::Server {
271 status,
272 message: "test response".into(),
273 });
274 assert_eq!(retryable_lock_refresh(&error), retryable);
275 }
276 }
277}