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}