Skip to main content

orch/engine/
dispatch_guard.rs

1//! RAII guard for the per-task dispatching [`DashMap`].
2//!
3//! [`DispatchGuard`] removes a key from the shared `dispatching` map when it is
4//! dropped — whether the owning async task completes normally **or panics**.
5//!
6//! ## Problem it solves
7//!
8//! Without this guard, the cleanup code (`map.remove(&key)`) only runs when the
9//! spawned async block reaches the end of its normal execution path.  If the block
10//! panics (e.g. from an `unwrap()` inside `review_and_merge`), Tokio catches the
11//! panic and terminates the task, but the manual remove never executes.  The key
12//! leaks in the map permanently: subsequent attempts to review the task are skipped
13//! by the guard check, and stuck-task recovery keeps resetting the task to
14//! `NeedsReview` in an infinite loop until the service is restarted.
15//!
16//! ## Why DashMap instead of Mutex\<HashMap\>
17//!
18//! The dispatching map is accessed from both async contexts (tick, sync, subscribers)
19//! and synchronous `Drop` implementations.  `DashMap` is a lock-free concurrent map
20//! that works in both contexts without risk of blocking Tokio worker threads — unlike
21//! `std::sync::Mutex` which blocks the OS thread, or `tokio::sync::Mutex` which
22//! cannot be used in `Drop`.
23//!
24//! ## Why DashMap instead of DashSet
25//!
26//! `DashSet::insert` returns `false` in two distinct cases:
27//! 1. The same task is already being dispatched (intended skip).
28//! 2. A different task happens to produce the same key (should never happen, but
29//!    silently skips a valid dispatch if it does).
30//!
31//! By storing the task-id as the map value, the caller can distinguish these cases:
32//! a matching task-id means an expected duplicate; a mismatched task-id is an
33//! unexpected collision that warrants a warning log.
34//!
35//! ## Usage
36//!
37//! ```rust,ignore
38//! use dashmap::mapref::entry::Entry;
39//!
40//! // 1. Attempt to claim the dispatch slot atomically.
41//! match dispatching.entry(dispatch_key.clone()) {
42//!     Entry::Occupied(existing) => {
43//!         let existing_id = existing.get().clone();
44//!         drop(existing); // release shard lock before logging
45//!         if existing_id == task.id.0 {
46//!             tracing::debug!(task_id = %task.id.0, "task already dispatching, skipping duplicate");
47//!         } else {
48//!             tracing::warn!(
49//!                 task_id = %task.id.0,
50//!                 existing_task_id = %existing_id,
51//!                 dispatch_key,
52//!                 "dispatch key collision: unexpected task already holds this key"
53//!             );
54//!         }
55//!         continue;
56//!     }
57//!     Entry::Vacant(slot) => {
58//!         slot.insert(task.id.0.clone());
59//!     }
60//! }
61//!
62//! // 2. Create a guard that owns the removal obligation.
63//! let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
64//!
65//! // 3. Move the guard into the spawned task.
66//! tokio::spawn(async move {
67//!     let _guard = guard; // key removed on drop, even on panic
68//!     // ... do work ...
69//! });
70//! ```
71
72use dashmap::DashMap;
73use std::sync::Arc;
74
75/// Removes a key from a shared [`DashMap<String, String>`] when dropped.
76///
77/// Create this guard (step 2) only after the key has already been inserted into
78/// the map (step 1) and before calling `tokio::spawn` (step 3).  Moving the guard
79/// into the spawned async block ensures the key is removed regardless of whether
80/// the block completes normally or unwinds via a panic.
81pub struct DispatchGuard {
82    map: Arc<DashMap<String, String>>,
83    key: String,
84}
85
86impl DispatchGuard {
87    /// Creates a guard that will remove `key` from `map` on drop.
88    ///
89    /// The caller must have already inserted `key` into `map`.
90    pub fn new(map: Arc<DashMap<String, String>>, key: String) -> Self {
91        Self { map, key }
92    }
93}
94
95impl Drop for DispatchGuard {
96    fn drop(&mut self) {
97        self.map.remove(&self.key);
98        // Deliberately logged at drop time (not just claim time): this is the only
99        // signal that lets a future stuck-task-reclaim race be diagnosed from logs
100        // by comparing this timestamp against the reclaim check's own log line for
101        // the same key, instead of relying on static reasoning about ordering.
102        //
103        // info! (not debug!, issue #3526): the default log filter is "orch=info"
104        // (main.rs), so at debug! this line never reached orch.log in production —
105        // every prior occurrence of the reclaim race (#3518, #3519, #3523, #3525)
106        // had no way to show whether the guard was actually still held at reclaim
107        // time. Promoted alongside the matching "dispatch guard claimed" line and
108        // the stuck-task-reclaim skip logs in tick.rs.
109        tracing::info!(dispatch_key = %self.key, "dispatch guard released");
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::sync::Arc;
117
118    fn make_map(entries: &[(&str, &str)]) -> Arc<DashMap<String, String>> {
119        let map = DashMap::new();
120        for (k, v) in entries {
121            map.insert(k.to_string(), v.to_string());
122        }
123        Arc::new(map)
124    }
125
126    #[test]
127    fn drop_removes_key() {
128        let map = make_map(&[("owner/repo/1", "1"), ("owner/repo/2", "2")]);
129        let guard = DispatchGuard::new(Arc::clone(&map), "owner/repo/1".to_string());
130        drop(guard);
131        assert!(
132            !map.contains_key("owner/repo/1"),
133            "key must be removed on drop"
134        );
135        assert!(
136            map.contains_key("owner/repo/2"),
137            "unrelated key must be untouched"
138        );
139    }
140
141    #[test]
142    fn drop_is_idempotent_when_key_absent() {
143        let map = make_map(&[]);
144        // Key not present — should not panic
145        let guard = DispatchGuard::new(Arc::clone(&map), "missing".to_string());
146        drop(guard);
147        assert!(map.is_empty());
148    }
149
150    #[tokio::test]
151    async fn drop_runs_on_task_panic() {
152        let map = make_map(&[("owner/repo/42", "42")]);
153        let key = "owner/repo/42".to_string();
154        let guard = DispatchGuard::new(Arc::clone(&map), key.clone());
155
156        let handle = tokio::spawn(async move {
157            let _guard = guard;
158            panic!("simulated panic");
159        });
160        let _ = handle.await; // absorb JoinError
161
162        assert!(
163            !map.contains_key(&key),
164            "key must be removed even when spawned task panics"
165        );
166    }
167}