Skip to main content

tuwunel_service/tasks/
mod.rs

1//! In-memory background-task tracker for the Synapse admin API.
2//!
3//! Long-running admin actions (room deletion, history purge, bulk redaction)
4//! run detached on the runtime and are polled by their id or by the resource
5//! they act on. State is process-local: a restart drops history where Synapse
6//! persists it for seven days, which consumers tolerate (they poll right after
7//! issuing, and a post-restart miss reads as Synapse's post-retention 404).
8
9use 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
25/// Random task-id length, matching Synapse's `random_string(16)`.
26const TASK_ID_LEN: usize = 16;
27
28/// Terminal tasks older than this (seven days) are pruned by the worker.
29const RETENTION_MS: u64 = 7 * 24 * 60 * 60 * 1000;
30
31/// Cap on retained terminal tasks; the oldest are pruned once it is exceeded.
32const CAPACITY: usize = 1024;
33
34/// Interval between worker garbage-collection sweeps.
35const GC_INTERVAL: Duration = Duration::from_hours(1);
36
37/// A task's random id: a fixed 16-byte string kept inline.
38type 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/// A tracked task's public snapshot, cloned out from under the lock.
54#[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/// Spawn `work` on the runtime as a tracked task, returning its id. The record
101/// transitions Scheduled -> Active -> Complete/Failed; `work`'s `Ok` value is
102/// stored as the result, its `Err` as the error string.
103#[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	// Hold the lock across the spawn+insert so the task cannot mark itself
111	// Active before its record exists.
112	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/// The task with this id, if it is still tracked.
135#[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/// Every tracked task acting on `resource_id`, newest ordering not guaranteed.
145#[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/// Whether a nonterminal task matches both `action` and `resource_id`.
157#[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/// Every tracked task; callers filter by action or status.
171#[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
253/// Drop terminal tasks past the retention window, then cap the survivors.
254fn 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;