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 .filter(|existing| existing.verb != verb)
154 .map_or(
155 "client_operation_id reused with a different request body",
156 |_| {
157 "client_operation_id belongs to a different operation or replay encoding; use a new client_operation_id"
158 },
159 );
160 Err(LocalReviewError::failed_precondition(message))
161 }
162 DedupOutcome::InFlight => Err(LocalReviewError::aborted(
163 "client_operation_id is in flight from another caller; retry once it completes",
164 )),
165 DedupOutcome::Reserved => {
166 match execute().await {
171 Ok(result) => {
172 let encoded = match serde_json::to_vec(&result) {
173 Ok(encoded) => encoded,
174 Err(err) => {
175 let _ = dedup.cancel(op_id, verb);
176 return Err(LocalReviewError::internal(format!(
177 "encode replay failed: {err}"
178 )));
179 }
180 };
181 dedup.record(op_id, verb, hash, encoded).map_err(|err| {
182 LocalReviewError::internal(format!("dedup record failed: {err}"))
183 })?;
184 Ok(result)
185 }
186 Err(status) => {
187 let _ = dedup.cancel(op_id, verb);
192 Err(status)
193 }
194 }
195 }
196 }
197}
198
199pub(super) fn map_repository_error(err: objects::error::HeddleError) -> LocalReviewError {
202 use objects::error::HeddleError;
203 match err {
204 HeddleError::NotFound(msg) => LocalReviewError::not_found(msg),
205 HeddleError::StateNotFound(id) => {
206 LocalReviewError::not_found(format!("state {id} not found"))
207 }
208 HeddleError::RepositoryNotFound(path) => {
209 LocalReviewError::not_found(format!("repository not found at {}", path.display()))
210 }
211 HeddleError::InvalidObject(msg) => LocalReviewError::invalid_argument(msg),
212 HeddleError::Conflict(msg) => LocalReviewError::failed_precondition(msg),
213 HeddleError::Io(io) => LocalReviewError::internal(format!("io error: {io}")),
214 other => LocalReviewError::internal(other.to_string()),
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use std::{sync::Arc, time::Duration};
225
226 #[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
227 struct ReplayMarker {
228 marker: String,
229 }
230 use objects::object::OperationId;
231 use repo::{Repository, operation_dedup::OperationDedupStore};
232 use tempfile::TempDir;
233 use tokio::sync::oneshot;
234
235 use super::{LocalReviewCode, LocalReviewContext, LocalReviewError, with_idempotency};
236
237 fn make_service() -> (TempDir, LocalReviewContext) {
238 let temp = TempDir::new().unwrap();
239 let repo = Arc::new(Repository::init_default(temp.path()).unwrap());
240 let store = Arc::new(OperationDedupStore::open(repo.heddle_dir()).unwrap());
241 (temp, LocalReviewContext::new(repo, store))
242 }
243
244 fn marker_response(marker: &str) -> ReplayMarker {
246 ReplayMarker {
247 marker: marker.to_string(),
248 }
249 }
250
251 #[tokio::test]
252 #[serial_test::serial(process_global)]
253 async fn replays_recorded_response() {
254 let (_t, service) = make_service();
255 let op_id = OperationId::new().to_string();
256 let body = b"req";
257
258 let first = with_idempotency(&service, &op_id, "verb", body, || async {
260 Ok::<ReplayMarker, LocalReviewError>(marker_response("42"))
261 })
262 .await
263 .unwrap();
264 assert_eq!(first.marker, "42");
265
266 let second = with_idempotency(&service, &op_id, "verb", body, || async {
269 #[allow(unreachable_code)]
270 Ok::<ReplayMarker, LocalReviewError>(panic!("execute must not be called on replay"))
271 })
272 .await
273 .unwrap();
274 assert_eq!(second.marker, "42");
275 }
276
277 #[tokio::test]
278 #[serial_test::serial(process_global)]
279 async fn concurrent_calls_with_same_op_id_run_execute_only_once() {
280 let (_t, service) = make_service();
286 let op_id = OperationId::new().to_string();
287 let body = b"req";
288
289 let (tx, rx) = oneshot::channel::<()>();
292 let service_a = service.clone();
293 let op_a = op_id.clone();
294 let a_handle = tokio::spawn(async move {
295 with_idempotency(&service_a, &op_a, "verb", body, || async move {
296 rx.await.expect("recv gate");
297 Ok::<ReplayMarker, LocalReviewError>(marker_response("7"))
298 })
299 .await
300 });
301
302 tokio::time::sleep(Duration::from_millis(50)).await;
306
307 let service_b = service.clone();
308 let op_b = op_id.clone();
309 let b_result: Result<ReplayMarker, LocalReviewError> =
310 with_idempotency(&service_b, &op_b, "verb", body, || async {
311 panic!("B's execute must not run while A holds the reservation");
312 })
313 .await;
314
315 let err = b_result.expect_err("B should be aborted");
317 assert_eq!(err.code(), LocalReviewCode::Aborted);
318
319 tx.send(()).unwrap();
321 let a_result = a_handle.await.unwrap().unwrap();
322 assert_eq!(a_result.marker, "7");
323
324 let third = with_idempotency(&service, &op_id, "verb", body, || async {
327 #[allow(unreachable_code)]
328 Ok::<ReplayMarker, LocalReviewError>(panic!("execute must not run on replay"))
329 })
330 .await
331 .unwrap();
332 assert_eq!(third.marker, "7");
333 }
334
335 #[tokio::test]
336 #[serial_test::serial(process_global)]
337 async fn cancels_reservation_on_execute_failure() {
338 let (_t, service) = make_service();
345 let op_id = OperationId::new().to_string();
346 let body = b"req";
347
348 let first: Result<ReplayMarker, LocalReviewError> =
349 with_idempotency(&service, &op_id, "verb", body, || async {
350 Err(LocalReviewError::internal("transient"))
351 })
352 .await;
353 assert!(first.is_err());
354
355 let second = with_idempotency(&service, &op_id, "verb", body, || async {
357 Ok::<ReplayMarker, LocalReviewError>(marker_response("11"))
358 })
359 .await
360 .unwrap();
361 assert_eq!(second.marker, "11");
362 }
363}