1mod 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#[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
111pub(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 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 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 let _ = dedup.cancel(op_id, verb);
193 Err(status)
194 }
195 }
196 }
197 }
198}
199
200pub(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 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 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 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 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 let (_t, service) = make_service();
287 let op_id = OperationId::new().to_string();
288 let body = b"req";
289
290 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 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 let err = b_result.expect_err("B should be aborted");
318 assert_eq!(err.code(), LocalReviewCode::Aborted);
319
320 tx.send(()).unwrap();
322 let a_result = a_handle.await.unwrap().unwrap();
323 assert_eq!(a_result.marker, "7");
324
325 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 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 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}