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.
81#[derive(Debug)]
82pub struct DispatchGuard {
83    map: Arc<DashMap<String, String>>,
84    key: String,
85}
86
87impl DispatchGuard {
88    /// Creates a guard that will remove `key` from `map` on drop.
89    ///
90    /// The caller must have already inserted `key` into `map`.
91    pub fn new(map: Arc<DashMap<String, String>>, key: String) -> Self {
92        Self { map, key }
93    }
94}
95
96impl Drop for DispatchGuard {
97    fn drop(&mut self) {
98        self.map.remove(&self.key);
99        // Deliberately logged at drop time (not just claim time): this is the only
100        // signal that lets a future stuck-task-reclaim race be diagnosed from logs
101        // by comparing this timestamp against the reclaim check's own log line for
102        // the same key, instead of relying on static reasoning about ordering.
103        //
104        // info! (not debug!, issue #3526): the default log filter is "orch=info"
105        // (main.rs), so at debug! this line never reached orch.log in production —
106        // every prior occurrence of the reclaim race (#3518, #3519, #3523, #3525)
107        // had no way to show whether the guard was actually still held at reclaim
108        // time. Promoted alongside the matching "dispatch guard claimed" line and
109        // the stuck-task-reclaim skip logs in tick.rs.
110        tracing::info!(dispatch_key = %self.key, "dispatch guard released");
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::sync::Arc;
118
119    fn make_map(entries: &[(&str, &str)]) -> Arc<DashMap<String, String>> {
120        let map = DashMap::new();
121        for (k, v) in entries {
122            map.insert(k.to_string(), v.to_string());
123        }
124        Arc::new(map)
125    }
126
127    #[test]
128    fn drop_removes_key() {
129        let map = make_map(&[("owner/repo/1", "1"), ("owner/repo/2", "2")]);
130        let guard = DispatchGuard::new(Arc::clone(&map), "owner/repo/1".to_string());
131        drop(guard);
132        assert!(
133            !map.contains_key("owner/repo/1"),
134            "key must be removed on drop"
135        );
136        assert!(
137            map.contains_key("owner/repo/2"),
138            "unrelated key must be untouched"
139        );
140    }
141
142    #[test]
143    fn drop_is_idempotent_when_key_absent() {
144        let map = make_map(&[]);
145        // Key not present — should not panic
146        let guard = DispatchGuard::new(Arc::clone(&map), "missing".to_string());
147        drop(guard);
148        assert!(map.is_empty());
149    }
150
151    #[tokio::test]
152    async fn drop_runs_on_task_panic() {
153        let map = make_map(&[("owner/repo/42", "42")]);
154        let key = "owner/repo/42".to_string();
155        let guard = DispatchGuard::new(Arc::clone(&map), key.clone());
156
157        let handle = tokio::spawn(async move {
158            let _guard = guard;
159            panic!("simulated panic");
160        });
161        let _ = handle.await; // absorb JoinError
162
163        assert!(
164            !map.contains_key(&key),
165            "key must be removed even when spawned task panics"
166        );
167    }
168}