1use std::{
10 collections::BTreeMap,
11 sync::{Arc, Mutex as StdMutex},
12 time::Duration,
13};
14
15use async_trait::async_trait;
16use serde_json::Value as JsonValue;
17use tokio::{task::JoinHandle, time::sleep};
18use tuwunel_core::{
19 Result,
20 arrayvec::ArrayString,
21 implement,
22 utils::{rand::string_array, time::now_millis},
23};
24
25const TASK_ID_LEN: usize = 16;
27
28const RETENTION_MS: u64 = 7 * 24 * 60 * 60 * 1000;
30
31const CAPACITY: usize = 1024;
33
34const GC_INTERVAL: Duration = Duration::from_hours(1);
36
37type TaskId = ArrayString<TASK_ID_LEN>;
39
40pub struct Service {
41 services: Arc<crate::services::OnceServices>,
42 tasks: StdMutex<BTreeMap<TaskId, Task>>,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum Status {
47 Scheduled,
48 Active,
49 Complete,
50 Failed,
51}
52
53#[derive(Clone, Debug)]
55pub struct TaskInfo {
56 pub id: TaskId,
57 pub action: &'static str,
58 pub resource_id: String,
59 pub status: Status,
60 pub timestamp_ms: u64,
61 pub result: Option<JsonValue>,
62 pub error: Option<String>,
63}
64
65struct Task {
66 action: &'static str,
67 resource_id: String,
68 status: Status,
69 timestamp_ms: u64,
70 result: Option<JsonValue>,
71 error: Option<String>,
72 handle: Option<JoinHandle<()>>,
73}
74
75#[async_trait]
76impl crate::Service for Service {
77 fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
78 Ok(Arc::new(Self {
79 services: args.services.clone(),
80 tasks: StdMutex::new(BTreeMap::new()),
81 }))
82 }
83
84 async fn worker(self: Arc<Self>) -> Result {
85 loop {
86 self.prune();
87
88 tokio::select! {
89 () = sleep(GC_INTERVAL) => {},
90 () = self.services.server.until_shutdown() => return Ok(()),
91 }
92 }
93 }
94
95 async fn interrupt(&self) { self.abort_all(); }
96
97 fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
98}
99
100#[implement(Service)]
104pub fn spawn<F>(self: &Arc<Self>, action: &'static str, resource_id: String, work: F) -> TaskId
105where
106 F: Future<Output = Result<JsonValue>> + Send + 'static,
107{
108 let id = string_array::<TASK_ID_LEN>();
109
110 let mut tasks = self.tasks.lock().expect("locked");
113 let this = Arc::clone(self);
114 let task_id = id;
115 let handle = self.services.server.runtime().spawn(async move {
116 this.set_active(&task_id);
117 let outcome = work.await;
118 this.finish(&task_id, outcome);
119 });
120
121 tasks.insert(id, Task {
122 action,
123 resource_id,
124 status: Status::Scheduled,
125 timestamp_ms: now_millis(),
126 result: None,
127 error: None,
128 handle: Some(handle),
129 });
130
131 id
132}
133
134#[implement(Service)]
136pub fn get(&self, id: &str) -> Option<TaskInfo> {
137 self.tasks
138 .lock()
139 .expect("locked")
140 .get_key_value(id)
141 .map(|(id, task)| task.info(id))
142}
143
144#[implement(Service)]
146pub fn by_resource(&self, resource_id: &str) -> Vec<TaskInfo> {
147 self.tasks
148 .lock()
149 .expect("locked")
150 .iter()
151 .filter(|(_, task)| task.resource_id.as_str() == resource_id)
152 .map(|(id, task)| task.info(id))
153 .collect()
154}
155
156#[implement(Service)]
158pub fn has_nonterminal(&self, action: &str, resource_id: &str) -> bool {
159 self.tasks
160 .lock()
161 .expect("locked")
162 .values()
163 .any(|task| matches_nonterminal(task, action, resource_id))
164}
165
166fn matches_nonterminal(task: &Task, action: &str, resource_id: &str) -> bool {
167 task.action == action && task.resource_id == resource_id && !task.status.is_terminal()
168}
169
170#[implement(Service)]
172pub fn list(&self) -> Vec<TaskInfo> {
173 self.tasks
174 .lock()
175 .expect("locked")
176 .iter()
177 .map(|(id, task)| task.info(id))
178 .collect()
179}
180
181#[implement(Service)]
182fn set_active(&self, id: &str) {
183 if let Some(task) = self.tasks.lock().expect("locked").get_mut(id) {
184 task.status = Status::Active;
185 }
186}
187
188#[implement(Service)]
189fn finish(&self, id: &str, outcome: Result<JsonValue>) {
190 let mut tasks = self.tasks.lock().expect("locked");
191 let Some(task) = tasks.get_mut(id) else {
192 return;
193 };
194
195 match outcome {
196 | Ok(value) => {
197 task.status = Status::Complete;
198 task.result = Some(value);
199 },
200 | Err(error) => {
201 task.status = Status::Failed;
202 task.error = Some(error.to_string());
203 },
204 }
205}
206
207#[implement(Service)]
208fn prune(&self) {
209 let now = now_millis();
210
211 prune_tasks(&mut self.tasks.lock().expect("locked"), now);
212}
213
214#[implement(Service)]
215fn abort_all(&self) {
216 self.tasks
217 .lock()
218 .expect("locked")
219 .values()
220 .filter_map(|task| task.handle.as_ref())
221 .for_each(JoinHandle::abort);
222}
223
224impl Status {
225 #[must_use]
226 pub fn is_terminal(self) -> bool { matches!(self, Self::Complete | Self::Failed) }
227
228 #[must_use]
229 pub fn as_str(self) -> &'static str {
230 match self {
231 | Self::Scheduled => "scheduled",
232 | Self::Active => "active",
233 | Self::Complete => "complete",
234 | Self::Failed => "failed",
235 }
236 }
237}
238
239impl Task {
240 fn info(&self, id: &TaskId) -> TaskInfo {
241 TaskInfo {
242 id: *id,
243 action: self.action,
244 resource_id: self.resource_id.clone(),
245 status: self.status,
246 timestamp_ms: self.timestamp_ms,
247 result: self.result.clone(),
248 error: self.error.clone(),
249 }
250 }
251}
252
253fn prune_tasks(tasks: &mut BTreeMap<TaskId, Task>, now_ms: u64) {
255 tasks.retain(|_, task| {
256 !task.status.is_terminal() || now_ms.saturating_sub(task.timestamp_ms) < RETENTION_MS
257 });
258
259 let mut timestamps: Vec<u64> = tasks
260 .values()
261 .filter(|task| task.status.is_terminal())
262 .map(|task| task.timestamp_ms)
263 .collect();
264
265 if timestamps.len() <= CAPACITY {
266 return;
267 }
268
269 timestamps.sort_unstable();
270
271 let cutoff = timestamps[timestamps.len().saturating_sub(CAPACITY)];
272
273 tasks.retain(|_, task| !task.status.is_terminal() || task.timestamp_ms >= cutoff);
274}
275
276#[cfg(test)]
277mod tests;