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}