Skip to main content

verbs/review/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local typed review implementation used directly by the CLI.
3//!
4//! These operations implement the governed contract over a single local
5//! [`Repository`]. They are distinct from hosted operations because they
6//! - don't require Postgres, Biscuit auth, or the multi-tenant registry,
7//! - are invoked directly by the local CLI process,
8//! - share the dedup/idempotency middleware with the hosted variant via
9//!   [`repo::operation_dedup::OperationDedupStore`].
10//!
11//! Shared repository and idempotency scaffolding lives here.
12
13mod signal;
14mod state_review;
15
16use std::sync::Arc;
17
18use repo::{
19    Repository,
20    operation_dedup::{OperationDedupStore, reserve_operation_id_eager},
21};
22pub use signal::{SignalHealthEntry, SignalHealthReport, get_repo_signal_health};
23pub use state_review::{
24    LocalStateReview, ReviewPayload, ReviewSignal, ReviewSignalKind, ReviewSignalVisibility,
25    ReviewSummary, SignReviewRequest, SignReviewResult, StoredReviewSignature,
26};
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum LocalReviewCode {
30    InvalidArgument,
31    NotFound,
32    FailedPrecondition,
33    Aborted,
34    Internal,
35}
36
37#[derive(Debug)]
38pub struct LocalReviewError {
39    code: LocalReviewCode,
40    message: String,
41}
42
43impl LocalReviewError {
44    fn new(code: LocalReviewCode, message: impl Into<String>) -> Self {
45        Self {
46            code,
47            message: message.into(),
48        }
49    }
50
51    pub fn invalid_argument(message: impl Into<String>) -> Self {
52        Self::new(LocalReviewCode::InvalidArgument, message)
53    }
54
55    pub fn not_found(message: impl Into<String>) -> Self {
56        Self::new(LocalReviewCode::NotFound, message)
57    }
58
59    pub fn failed_precondition(message: impl Into<String>) -> Self {
60        Self::new(LocalReviewCode::FailedPrecondition, message)
61    }
62
63    pub fn aborted(message: impl Into<String>) -> Self {
64        Self::new(LocalReviewCode::Aborted, message)
65    }
66
67    pub fn internal(message: impl Into<String>) -> Self {
68        Self::new(LocalReviewCode::Internal, message)
69    }
70
71    pub fn code(&self) -> LocalReviewCode {
72        self.code
73    }
74
75    pub fn message(&self) -> &str {
76        &self.message
77    }
78}
79
80impl std::fmt::Display for LocalReviewError {
81    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        formatter.write_str(&self.message)
83    }
84}
85
86impl std::error::Error for LocalReviewError {}
87
88/// Shared state for the local review operations. Handlers borrow the repository
89/// for the duration of a single operation; the dedup store is consulted on every
90/// state-changing call.
91#[derive(Clone)]
92pub struct LocalReviewContext {
93    pub(super) repo: Arc<Repository>,
94    pub(super) dedup: Arc<OperationDedupStore>,
95}
96
97impl LocalReviewContext {
98    pub fn new(repo: Arc<Repository>, dedup: Arc<OperationDedupStore>) -> Self {
99        Self { repo, dedup }
100    }
101
102    pub fn repo(&self) -> &Repository {
103        &self.repo
104    }
105
106    pub fn dedup(&self) -> &OperationDedupStore {
107        &self.dedup
108    }
109}
110
111/// Idempotency wrapper. Centralises the `check → execute → record` pattern
112/// so every state-changing handler folds the same dedup-store flow.
113///
114/// `client_operation_id` may be empty (caller didn't supply one) — in that
115/// case we don't dedup at all and just execute. When supplied, the body
116/// must be a deterministic byte representation of the request.
117pub(super) async fn with_idempotency<F, Fut, T>(
118    service: &LocalReviewContext,
119    client_operation_id: &str,
120    verb: &'static str,
121    request_body: &[u8],
122    execute: F,
123) -> Result<T, LocalReviewError>
124where
125    F: FnOnce() -> Fut,
126    Fut: std::future::Future<Output = Result<T, LocalReviewError>>,
127    T: serde::Serialize + serde::de::DeserializeOwned,
128{
129    use objects::object::OperationId;
130    use repo::operation_dedup::{DedupOutcome, hash_request_body};
131
132    if client_operation_id.is_empty() {
133        return execute().await;
134    }
135    let op_id: OperationId = client_operation_id.parse().map_err(|err| {
136        LocalReviewError::invalid_argument(format!("invalid client_operation_id: {err}"))
137    })?;
138    let hash = hash_request_body(request_body);
139    // The eager reservation atomically claims the (op_id, verb) slot before
140    // we run the mutation. Two concurrent retries with the same operation_id
141    // can no longer both observe "Fresh" and both apply side effects: the
142    // second sees `InFlight` and surfaces a transient `Aborted` to the client.
143    let dedup = Arc::clone(&service.dedup);
144    let outcome = reserve_operation_id_eager(service.repo(), Arc::clone(&dedup), op_id, verb, hash)
145        .map_err(|err| LocalReviewError::internal(format!("dedup reserve failed: {err}")))?;
146    match outcome {
147        DedupOutcome::Replay { response } => serde_json::from_slice(response.as_slice())
148            .map_err(|err| LocalReviewError::internal(format!("decode replay failed: {err}"))),
149        DedupOutcome::Conflict => {
150            let message = service
151                .dedup
152                .metadata_for(op_id, verb)
153                .map_err(|err| LocalReviewError::internal(format!("dedup metadata failed: {err}")))?
154                .filter(|existing| existing.verb != verb)
155                .map_or(
156                    "client_operation_id reused with a different request body",
157                    |_| {
158                        "client_operation_id belongs to a different operation or replay encoding; use a new client_operation_id"
159                    },
160                );
161            Err(LocalReviewError::failed_precondition(message))
162        }
163        DedupOutcome::InFlight => Err(LocalReviewError::aborted(
164            "client_operation_id is in flight from another caller; retry once it completes",
165        )),
166        DedupOutcome::Reserved => {
167            // Reservation is held until we either record (success) or
168            // cancel (failure). Without the cancel-on-error path, a failed
169            // execution would leave a permanent tombstone that all retries
170            // would see as `Conflict`/`InFlight` until compaction.
171            match execute().await {
172                Ok(result) => {
173                    let encoded = match serde_json::to_vec(&result) {
174                        Ok(encoded) => encoded,
175                        Err(err) => {
176                            let _ = dedup.cancel(op_id, verb);
177                            return Err(LocalReviewError::internal(format!(
178                                "encode replay failed: {err}"
179                            )));
180                        }
181                    };
182                    dedup.record(op_id, verb, hash, encoded).map_err(|err| {
183                        LocalReviewError::internal(format!("dedup record failed: {err}"))
184                    })?;
185                    Ok(result)
186                }
187                Err(status) => {
188                    // Best-effort: if cancel itself fails (disk error etc.)
189                    // we still want to surface the original status to the
190                    // caller. Compaction will eventually clean a stranded
191                    // reservation up.
192                    let _ = dedup.cancel(op_id, verb);
193                    Err(status)
194                }
195            }
196        }
197    }
198}
199
200/// Helper for translating a [`HeddleError`](objects::error::HeddleError) into
201/// a [`LocalReviewError`] with consistent codes across the local services.
202pub(super) fn map_repository_error(err: objects::error::HeddleError) -> LocalReviewError {
203    use objects::error::HeddleError;
204    match err {
205        HeddleError::NotFound(msg) => LocalReviewError::not_found(msg),
206        HeddleError::StateNotFound(id) => {
207            LocalReviewError::not_found(format!("state {id} not found"))
208        }
209        HeddleError::RepositoryNotFound(path) => {
210            LocalReviewError::not_found(format!("repository not found at {}", path.display()))
211        }
212        HeddleError::InvalidObject(msg) => LocalReviewError::invalid_argument(msg),
213        HeddleError::Conflict(msg) => LocalReviewError::failed_precondition(msg),
214        HeddleError::Io(io) => LocalReviewError::internal(format!("io error: {io}")),
215        other => LocalReviewError::internal(other.to_string()),
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    //! End-to-end tests for [`with_idempotency`] that exercise the
222    //! `Reserved` / `InFlight` / `Replay` / `Conflict` outcomes through the
223    //! same wrapper every local review operation calls.
224
225    use std::{sync::Arc, time::Duration};
226
227    #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
228    struct ReplayMarker {
229        marker: String,
230    }
231    use objects::object::OperationId;
232    use repo::{Repository, operation_dedup::OperationDedupStore};
233    use tempfile::TempDir;
234    use tokio::sync::oneshot;
235
236    use super::{LocalReviewCode, LocalReviewContext, LocalReviewError, with_idempotency};
237
238    fn make_service() -> (TempDir, LocalReviewContext) {
239        let temp = TempDir::new().unwrap();
240        let repo = Arc::new(Repository::init_default(temp.path()).unwrap());
241        let store = Arc::new(OperationDedupStore::open(repo.heddle_dir()).unwrap());
242        (temp, LocalReviewContext::new(repo, store))
243    }
244
245    /// A distinguishable response payload for the idempotency flow.
246    fn marker_response(marker: &str) -> ReplayMarker {
247        ReplayMarker {
248            marker: marker.to_string(),
249        }
250    }
251
252    #[tokio::test]
253    #[serial_test::serial(process_global)]
254    async fn replays_recorded_response() {
255        let (_t, service) = make_service();
256        let op_id = OperationId::new().to_string();
257        let body = b"req";
258
259        // First call executes and records.
260        let first = with_idempotency(&service, &op_id, "verb", body, || async {
261            Ok::<ReplayMarker, LocalReviewError>(marker_response("42"))
262        })
263        .await
264        .unwrap();
265        assert_eq!(first.marker, "42");
266
267        // Second call must replay without re-executing — proven by the
268        // execute closure panicking if invoked.
269        let second = with_idempotency(&service, &op_id, "verb", body, || async {
270            #[allow(unreachable_code)]
271            Ok::<ReplayMarker, LocalReviewError>(panic!("execute must not be called on replay"))
272        })
273        .await
274        .unwrap();
275        assert_eq!(second.marker, "42");
276    }
277
278    #[tokio::test]
279    #[serial_test::serial(process_global)]
280    async fn concurrent_calls_with_same_op_id_run_execute_only_once() {
281        // The original race window: caller A enters with `Fresh`, awaits
282        // execute(), and caller B enters with `Fresh` before A records.
283        // Both used to apply side effects. With reservation, B must see
284        // `InFlight` and surface `Aborted`.
285
286        let (_t, service) = make_service();
287        let op_id = OperationId::new().to_string();
288        let body = b"req";
289
290        // We gate the first execution on a oneshot so caller B starts
291        // while A is still pending.
292        let (tx, rx) = oneshot::channel::<()>();
293        let service_a = service.clone();
294        let op_a = op_id.clone();
295        let a_handle = tokio::spawn(async move {
296            with_idempotency(&service_a, &op_a, "verb", body, || async move {
297                rx.await.expect("recv gate");
298                Ok::<ReplayMarker, LocalReviewError>(marker_response("7"))
299            })
300            .await
301        });
302
303        // Give A a moment to claim the reservation. The wrapper writes the
304        // pending entry synchronously inside the dedup mutex before it
305        // awaits, so once we yield the entry is visible.
306        tokio::time::sleep(Duration::from_millis(50)).await;
307
308        let service_b = service.clone();
309        let op_b = op_id.clone();
310        let b_result: Result<ReplayMarker, LocalReviewError> =
311            with_idempotency(&service_b, &op_b, "verb", body, || async {
312                panic!("B's execute must not run while A holds the reservation");
313            })
314            .await;
315
316        // B sees the in-flight reservation and aborts.
317        let err = b_result.expect_err("B should be aborted");
318        assert_eq!(err.code(), LocalReviewCode::Aborted);
319
320        // Now release A.
321        tx.send(()).unwrap();
322        let a_result = a_handle.await.unwrap().unwrap();
323        assert_eq!(a_result.marker, "7");
324
325        // After A finishes, the entry is finalised: a third call with the
326        // same body replays.
327        let third = with_idempotency(&service, &op_id, "verb", body, || async {
328            #[allow(unreachable_code)]
329            Ok::<ReplayMarker, LocalReviewError>(panic!("execute must not run on replay"))
330        })
331        .await
332        .unwrap();
333        assert_eq!(third.marker, "7");
334    }
335
336    #[tokio::test]
337    #[serial_test::serial(process_global)]
338    async fn cancels_reservation_on_execute_failure() {
339        // If execute returns Err, the reservation must be released so a
340        // retry isn't permanently blocked. Without `cancel`, a transient
341        // failure during the first attempt would leave the slot held and
342        // every subsequent retry would see Conflict/InFlight until
343        // compaction.
344
345        let (_t, service) = make_service();
346        let op_id = OperationId::new().to_string();
347        let body = b"req";
348
349        let first: Result<ReplayMarker, LocalReviewError> =
350            with_idempotency(&service, &op_id, "verb", body, || async {
351                Err(LocalReviewError::internal("transient"))
352            })
353            .await;
354        assert!(first.is_err());
355
356        // Retry must succeed — the reservation was released.
357        let second = with_idempotency(&service, &op_id, "verb", body, || async {
358            Ok::<ReplayMarker, LocalReviewError>(marker_response("11"))
359        })
360        .await
361        .unwrap();
362        assert_eq!(second.marker, "11");
363    }
364}