Skip to main content

daemon/grpc_local_impl/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local-mode gRPC services for `heddle agent serve`.
3//!
4//! These services implement the gRPC contract over a single local
5//! [`Repository`]. They are distinct from `grpc_hosted_impl/` because they
6//! - don't require Postgres, Biscuit auth, or the multi-tenant registry,
7//! - are reachable over a Unix-domain socket from the same user,
8//! - share the dedup/idempotency middleware with the hosted variant via
9//!   [`repo::operation_dedup::OperationDedupStore`].
10//!
11//! Each service has its own file. The shared scaffolding (the
12//! [`GrpcLocalService`] struct, idempotency helpers) lives here.
13
14mod 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/// Shared state for the local gRPC services. Handlers borrow the repository
27/// for the duration of a single RPC; the dedup store is consulted on every
28/// state-changing call.
29#[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
49/// Idempotency wrapper. Centralises the `check → execute → record` pattern
50/// so every state-changing handler folds the same dedup-store flow.
51///
52/// `client_operation_id` may be empty (caller didn't supply one) — in that
53/// case we don't dedup at all and just execute. When supplied, the body
54/// must be a deterministic byte representation of the request (typically
55/// the protobuf-encoded request).
56pub(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    // The eager reservation atomically claims the (op_id, verb) slot before
79    // we run the mutation. Two concurrent retries with the same operation_id
80    // can no longer both observe "Fresh" and both apply side effects: the
81    // second sees `InFlight` and surfaces a transient `Aborted` to the client.
82    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            // Reservation is held until we either record (success) or
96            // cancel (failure). Without the cancel-on-error path, a failed
97            // execution would leave a permanent tombstone that all retries
98            // would see as `Conflict`/`InFlight` until compaction.
99            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                    // Best-effort: if cancel itself fails (disk error etc.)
109                    // we still want to surface the original status to the
110                    // caller. Compaction will eventually clean a stranded
111                    // reservation up.
112                    let _ = dedup.cancel(op_id, verb);
113                    Err(status)
114                }
115            }
116        }
117    }
118}
119
120/// Helper for translating a [`HeddleError`](objects::error::HeddleError) into
121/// a [`tonic::Status`] with consistent codes across the local services.
122pub(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    //! End-to-end tests for [`with_idempotency`] that exercise the
140    //! `Reserved` / `InFlight` / `Replay` / `Conflict` outcomes through the
141    //! same wrapper every gRPC handler calls.
142
143    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    /// A distinguishable prost response payload for the idempotency-flow
165    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        // First call executes and records.
179        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        // Second call must replay without re-executing — proven by the
187        // execute closure panicking if invoked.
188        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        // The original race window: caller A enters with `Fresh`, awaits
201        // execute(), and caller B enters with `Fresh` before A records.
202        // Both used to apply side effects. With reservation, B must see
203        // `InFlight` and surface `Aborted`.
204
205        let (_t, service) = make_service();
206        let op_id = OperationId::new().to_string();
207        let body = b"req";
208
209        // We gate the first execution on a oneshot so caller B starts
210        // while A is still pending.
211        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        // Give A a moment to claim the reservation. The wrapper writes the
223        // pending entry synchronously inside the dedup mutex before it
224        // awaits, so once we yield the entry is visible.
225        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        // B sees the in-flight reservation and aborts.
236        let err = b_result.expect_err("B should be aborted");
237        assert_eq!(err.code(), tonic::Code::Aborted);
238
239        // Now release A.
240        tx.send(()).unwrap();
241        let a_result = a_handle.await.unwrap().unwrap();
242        assert_eq!(a_result.old_value, "7");
243
244        // After A finishes, the entry is finalised: a third call with the
245        // same body replays.
246        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        // If execute returns Err, the reservation must be released so a
259        // retry isn't permanently blocked. Without `cancel`, a transient
260        // failure during the first attempt would leave the slot held and
261        // every subsequent retry would see Conflict/InFlight until
262        // compaction.
263
264        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        // Retry must succeed — the reservation was released.
276        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}