Skip to main content

paykit_lib/pubky_routing/
locks.rs

1use 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
20/// Whether an operation must reread its resource before retrying a write.
21pub 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
32/// Replace a resource after checking its content revision under a write lock.
33///
34/// `None` requires an absent resource. A revision comes from
35/// [`crate::content_revision`] of the exact previously read bytes, not HTTP metadata.
36/// Reads are bounded by `max_bytes`. Session capabilities, key rotation, and
37/// request timeouts remain the caller's responsibility.
38pub 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
60/// Remove a resource only if its content revision still matches under a lock.
61///
62/// An absent resource is already removed. Session capabilities, key rotation,
63/// and request timeouts remain the caller's responsibility.
64pub 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
109/// Run a Pubky read-modify-write operation under an exclusive file lock.
110///
111/// Read the current contents inside `operation` and use the supplied lock for
112/// every mutation. The lock is renewed while the operation runs and released
113/// afterward. Transient renewal failures are retried within the last confirmed
114/// lease lifetime. Cancellation leaves it to expire. Loss of the lock stops the
115/// operation with an uncertain outcome, not a retryable write conflict: a PUT
116/// may already have committed. Callers must reconcile before retrying.
117/// Contended acquisition is retried with bounded backoff before `operation`
118/// runs. The operation itself is never replayed.
119///
120/// Homeserver 0.16.2 with GCS fences stale publications by object generation.
121/// A delayed write may still land before this holder's first publication; a
122/// conflict requires a fresh read, and uncertain shared state needs a confirmed
123/// replacement before use. Session creation, capability scope, key rotation,
124/// and request timeouts remain the caller's responsibility.
125pub 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    // Releasing the lock does not undo a committed operation. A failed unlock
212    // leaves a bounded lease, not a reason to repeat a successful mutation.
213    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}