1mod signal;
15mod state_review;
16
17use std::sync::Arc;
18
19use repo::{
20 Repository,
21 operation_dedup::{OperationDedupStore, reserve_operation_id_eager},
22};
23pub use signal::{SignalHealthEntry, SignalHealthReport, get_repo_signal_health};
24pub use state_review::LocalStateReviewService;
25
26#[derive(Clone)]
30pub struct GrpcLocalService {
31 pub(super) repo: Arc<Repository>,
32 pub(super) dedup: Arc<OperationDedupStore>,
33}
34
35impl GrpcLocalService {
36 pub fn new(repo: Arc<Repository>, dedup: Arc<OperationDedupStore>) -> Self {
37 Self { repo, dedup }
38 }
39
40 pub fn repo(&self) -> &Repository {
41 &self.repo
42 }
43
44 pub fn dedup(&self) -> &OperationDedupStore {
45 &self.dedup
46 }
47}
48
49pub(super) async fn with_idempotency<F, Fut, T>(
57 service: &GrpcLocalService,
58 client_operation_id: &str,
59 verb: &'static str,
60 request_body: &[u8],
61 execute: F,
62) -> Result<T, tonic::Status>
63where
64 F: FnOnce() -> Fut,
65 Fut: std::future::Future<Output = Result<T, tonic::Status>>,
66 T: prost::Message + Default,
67{
68 use objects::object::OperationId;
69 use repo::operation_dedup::{DedupOutcome, hash_request_body};
70
71 if client_operation_id.is_empty() {
72 return execute().await;
73 }
74 let op_id: OperationId = client_operation_id.parse().map_err(|err| {
75 tonic::Status::invalid_argument(format!("invalid client_operation_id: {err}"))
76 })?;
77 let hash = hash_request_body(request_body);
78 let dedup = Arc::clone(&service.dedup);
83 let outcome = reserve_operation_id_eager(service.repo(), Arc::clone(&dedup), op_id, verb, hash)
84 .map_err(|err| tonic::Status::internal(format!("dedup reserve failed: {err}")))?;
85 match outcome {
86 DedupOutcome::Replay { response } => T::decode(response.as_slice())
87 .map_err(|err| tonic::Status::internal(format!("decode replay failed: {err}"))),
88 DedupOutcome::Conflict => Err(tonic::Status::failed_precondition(
89 "client_operation_id reused with a different request body",
90 )),
91 DedupOutcome::InFlight => Err(tonic::Status::aborted(
92 "client_operation_id is in flight from another caller; retry once it completes",
93 )),
94 DedupOutcome::Reserved => {
95 match execute().await {
100 Ok(result) => {
101 let encoded = result.encode_to_vec();
102 dedup.record(op_id, verb, hash, encoded).map_err(|err| {
103 tonic::Status::internal(format!("dedup record failed: {err}"))
104 })?;
105 Ok(result)
106 }
107 Err(status) => {
108 let _ = dedup.cancel(op_id, verb);
113 Err(status)
114 }
115 }
116 }
117 }
118}
119
120pub(super) fn to_status(err: objects::error::HeddleError) -> tonic::Status {
123 use objects::error::HeddleError;
124 match err {
125 HeddleError::NotFound(msg) => tonic::Status::not_found(msg),
126 HeddleError::StateNotFound(id) => tonic::Status::not_found(format!("state {id} not found")),
127 HeddleError::RepositoryNotFound(path) => {
128 tonic::Status::not_found(format!("repository not found at {}", path.display()))
129 }
130 HeddleError::InvalidObject(msg) => tonic::Status::invalid_argument(msg),
131 HeddleError::Conflict(msg) => tonic::Status::failed_precondition(msg),
132 HeddleError::Io(io) => tonic::Status::internal(format!("io error: {io}")),
133 other => tonic::Status::internal(other.to_string()),
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use std::{sync::Arc, time::Duration};
144
145 #[derive(Clone, PartialEq, prost::Message)]
146 struct UpdateRefResponse {
147 #[prost(string, tag = "1")]
148 old_value: String,
149 }
150 use objects::object::OperationId;
151 use repo::{Repository, operation_dedup::OperationDedupStore};
152 use tempfile::TempDir;
153 use tokio::sync::oneshot;
154
155 use super::{GrpcLocalService, with_idempotency};
156
157 fn make_service() -> (TempDir, GrpcLocalService) {
158 let temp = TempDir::new().unwrap();
159 let repo = Arc::new(Repository::init_default(temp.path()).unwrap());
160 let store = Arc::new(OperationDedupStore::open(repo.heddle_dir()).unwrap());
161 (temp, GrpcLocalService::new(repo, store))
162 }
163
164 fn marker_response(marker: &str) -> UpdateRefResponse {
166 UpdateRefResponse {
167 old_value: marker.to_string(),
168 }
169 }
170
171 #[tokio::test]
172 #[serial_test::serial(process_global)]
173 async fn replays_recorded_response() {
174 let (_t, service) = make_service();
175 let op_id = OperationId::new().to_string();
176 let body = b"req";
177
178 let first = with_idempotency(&service, &op_id, "verb", body, || async {
180 Ok::<UpdateRefResponse, tonic::Status>(marker_response("42"))
181 })
182 .await
183 .unwrap();
184 assert_eq!(first.old_value, "42");
185
186 let second = with_idempotency(&service, &op_id, "verb", body, || async {
189 #[allow(unreachable_code)]
190 Ok::<UpdateRefResponse, tonic::Status>(panic!("execute must not be called on replay"))
191 })
192 .await
193 .unwrap();
194 assert_eq!(second.old_value, "42");
195 }
196
197 #[tokio::test]
198 #[serial_test::serial(process_global)]
199 async fn concurrent_calls_with_same_op_id_run_execute_only_once() {
200 let (_t, service) = make_service();
206 let op_id = OperationId::new().to_string();
207 let body = b"req";
208
209 let (tx, rx) = oneshot::channel::<()>();
212 let service_a = service.clone();
213 let op_a = op_id.clone();
214 let a_handle = tokio::spawn(async move {
215 with_idempotency(&service_a, &op_a, "verb", body, || async move {
216 rx.await.expect("recv gate");
217 Ok::<UpdateRefResponse, tonic::Status>(marker_response("7"))
218 })
219 .await
220 });
221
222 tokio::time::sleep(Duration::from_millis(50)).await;
226
227 let service_b = service.clone();
228 let op_b = op_id.clone();
229 let b_result: Result<UpdateRefResponse, tonic::Status> =
230 with_idempotency(&service_b, &op_b, "verb", body, || async {
231 panic!("B's execute must not run while A holds the reservation");
232 })
233 .await;
234
235 let err = b_result.expect_err("B should be aborted");
237 assert_eq!(err.code(), tonic::Code::Aborted);
238
239 tx.send(()).unwrap();
241 let a_result = a_handle.await.unwrap().unwrap();
242 assert_eq!(a_result.old_value, "7");
243
244 let third = with_idempotency(&service, &op_id, "verb", body, || async {
247 #[allow(unreachable_code)]
248 Ok::<UpdateRefResponse, tonic::Status>(panic!("execute must not run on replay"))
249 })
250 .await
251 .unwrap();
252 assert_eq!(third.old_value, "7");
253 }
254
255 #[tokio::test]
256 #[serial_test::serial(process_global)]
257 async fn cancels_reservation_on_execute_failure() {
258 let (_t, service) = make_service();
265 let op_id = OperationId::new().to_string();
266 let body = b"req";
267
268 let first: Result<UpdateRefResponse, tonic::Status> =
269 with_idempotency(&service, &op_id, "verb", body, || async {
270 Err(tonic::Status::internal("transient"))
271 })
272 .await;
273 assert!(first.is_err());
274
275 let second = with_idempotency(&service, &op_id, "verb", body, || async {
277 Ok::<UpdateRefResponse, tonic::Status>(marker_response("11"))
278 })
279 .await
280 .unwrap();
281 assert_eq!(second.old_value, "11");
282 }
283}