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
40/// Process-local registry of detached administrative tasks.
41///
42/// Task futures run on the server runtime and retain abort handles for service
43/// shutdown. Terminal records are pruned by their scheduling timestamp and by
44/// a bounded retention policy, while nonterminal records are never evicted.
45pub struct Service {
46	services: Arc<crate::services::OnceServices>,
47	tasks: StdMutex<BTreeMap<TaskId, Task>>,
48}
49
50/// Execution state of a tracked administrative task.
51///
52/// Tasks normally progress from [`Self::Scheduled`] to [`Self::Active`] and
53/// finish as either [`Self::Complete`] or [`Self::Failed`]. A panic or abort can
54/// leave the last recorded state nonterminal. Terminal records remain available
55/// until the service's retention policy prunes them.
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum Status {
58	/// The task is recorded but its future has not begun running.
59	Scheduled,
60
61	/// The task future has begun running and has not completed.
62	Active,
63
64	/// The task future completed with a result value.
65	Complete,
66
67	/// The task future completed with an error.
68	Failed,
69}
70
71/// Snapshot of one tracked administrative task.
72///
73/// Snapshots clone their owned result and error data while the registry is
74/// locked, then remain independent of subsequent task transitions. Their
75/// ordering in query results is not a creation-time ordering.
76#[derive(Clone, Debug)]
77pub struct TaskInfo {
78	/// Random identifier assigned when the task was scheduled.
79	pub id: TaskId,
80
81	/// Static action name used to classify the task.
82	pub action: &'static str,
83
84	/// Caller-supplied identifier of the resource being acted upon.
85	pub resource_id: String,
86
87	/// Current execution state captured by this snapshot.
88	pub status: Status,
89
90	/// Unix timestamp in milliseconds recorded when the task was scheduled.
91	pub timestamp_ms: u64,
92
93	/// Successful task result, present only after completion.
94	pub result: Option<JsonValue>,
95
96	/// Rendered task error, present only after failure.
97	pub error: Option<String>,
98}
99
100struct Task {
101	action: &'static str,
102	resource_id: String,
103	status: Status,
104	timestamp_ms: u64,
105	result: Option<JsonValue>,
106	error: Option<String>,
107	handle: Option<JoinHandle<()>>,
108}
109
110#[async_trait]
111impl crate::Service for Service {
112	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
113		Ok(Arc::new(Self {
114			services: args.services.clone(),
115			tasks: StdMutex::new(BTreeMap::new()),
116		}))
117	}
118
119	async fn worker(self: Arc<Self>) -> Result {
120		loop {
121			self.prune();
122
123			tokio::select! {
124				() = sleep(GC_INTERVAL) => {},
125				() = self.services.server.until_shutdown() => return Ok(()),
126			}
127		}
128	}
129
130	async fn interrupt(&self) { self.abort_all(); }
131
132	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
133}
134
135/// Spawns `work` on the server runtime and returns its tracking identifier.
136///
137/// The record is inserted as scheduled before the task can mark itself active.
138/// A successful output becomes the stored result, while an error becomes its
139/// rendered failure text.
140#[implement(Service)]
141pub fn spawn<F>(self: &Arc<Self>, action: &'static str, resource_id: String, work: F) -> TaskId
142where
143	F: Future<Output = Result<JsonValue>> + Send + 'static,
144{
145	let id = string_array::<TASK_ID_LEN>();
146
147	// Hold the lock across the spawn+insert so the task cannot mark itself
148	// Active before its record exists.
149	let mut tasks = self.tasks.lock().expect("locked");
150	let this = Arc::clone(self);
151	let task_id = id;
152	let handle = self.services.server.runtime().spawn(async move {
153		this.set_active(&task_id);
154		let outcome = work.await;
155		this.finish(&task_id, outcome);
156	});
157
158	tasks.insert(id, Task {
159		action,
160		resource_id,
161		status: Status::Scheduled,
162		timestamp_ms: now_millis(),
163		result: None,
164		error: None,
165		handle: Some(handle),
166	});
167
168	id
169}
170
171/// Returns the tracked task with identifier `id`.
172///
173/// The returned snapshot is cloned out of the registry lock. Missing and
174/// already-pruned identifiers return `None`.
175#[implement(Service)]
176pub fn get(&self, id: &str) -> Option<TaskInfo> {
177	self.tasks
178		.lock()
179		.expect("locked")
180		.get_key_value(id)
181		.map(|(id, task)| task.info(id))
182}
183
184/// Returns every tracked task acting on `resource_id`.
185///
186/// Each result is an independent snapshot. Results follow the identifier-keyed
187/// registry order rather than creation or completion time.
188#[implement(Service)]
189pub fn by_resource(&self, resource_id: &str) -> Vec<TaskInfo> {
190	self.tasks
191		.lock()
192		.expect("locked")
193		.iter()
194		.filter(|(_, task)| task.resource_id.as_str() == resource_id)
195		.map(|(id, task)| task.info(id))
196		.collect()
197}
198
199/// Tests whether a matching task is still nonterminal.
200///
201/// Both the action and resource identifier must match. Completed and failed
202/// tasks never satisfy the predicate even while their records are retained.
203#[implement(Service)]
204pub fn has_nonterminal(&self, action: &str, resource_id: &str) -> bool {
205	self.tasks
206		.lock()
207		.expect("locked")
208		.values()
209		.any(|task| matches_nonterminal(task, action, resource_id))
210}
211
212fn matches_nonterminal(task: &Task, action: &str, resource_id: &str) -> bool {
213	task.action == action && task.resource_id == resource_id && !task.status.is_terminal()
214}
215
216/// Returns snapshots of every retained task.
217///
218/// Callers can filter the snapshots by action, resource, or status. Results
219/// follow the identifier-keyed registry order rather than chronological order.
220#[implement(Service)]
221pub fn list(&self) -> Vec<TaskInfo> {
222	self.tasks
223		.lock()
224		.expect("locked")
225		.iter()
226		.map(|(id, task)| task.info(id))
227		.collect()
228}
229
230#[implement(Service)]
231fn set_active(&self, id: &str) {
232	if let Some(task) = self.tasks.lock().expect("locked").get_mut(id) {
233		task.status = Status::Active;
234	}
235}
236
237#[implement(Service)]
238fn finish(&self, id: &str, outcome: Result<JsonValue>) {
239	let mut tasks = self.tasks.lock().expect("locked");
240	let Some(task) = tasks.get_mut(id) else {
241		return;
242	};
243
244	match outcome {
245		| Ok(value) => {
246			task.status = Status::Complete;
247			task.result = Some(value);
248		},
249		| Err(error) => {
250			task.status = Status::Failed;
251			task.error = Some(error.to_string());
252		},
253	}
254}
255
256#[implement(Service)]
257fn prune(&self) {
258	let now = now_millis();
259
260	prune_tasks(&mut self.tasks.lock().expect("locked"), now);
261}
262
263#[implement(Service)]
264fn abort_all(&self) {
265	self.tasks
266		.lock()
267		.expect("locked")
268		.values()
269		.filter_map(|task| task.handle.as_ref())
270		.for_each(JoinHandle::abort);
271}
272
273impl Status {
274	/// Tests whether no further execution transition is expected.
275	///
276	/// Complete and failed tasks are terminal. Scheduled and active tasks can
277	/// still transition as their futures run.
278	#[must_use]
279	pub fn is_terminal(self) -> bool { matches!(self, Self::Complete | Self::Failed) }
280
281	/// Returns the lowercase status spelling used by administrative responses.
282	///
283	/// The returned string is static and allocation-free. Each enum variant has
284	/// one stable spelling.
285	#[must_use]
286	pub fn as_str(self) -> &'static str {
287		match self {
288			| Self::Scheduled => "scheduled",
289			| Self::Active => "active",
290			| Self::Complete => "complete",
291			| Self::Failed => "failed",
292		}
293	}
294}
295
296impl Task {
297	fn info(&self, id: &TaskId) -> TaskInfo {
298		TaskInfo {
299			id: *id,
300			action: self.action,
301			resource_id: self.resource_id.clone(),
302			status: self.status,
303			timestamp_ms: self.timestamp_ms,
304			result: self.result.clone(),
305			error: self.error.clone(),
306		}
307	}
308}
309
310/// Drop terminal tasks past the retention window, then cap the survivors.
311fn prune_tasks(tasks: &mut BTreeMap<TaskId, Task>, now_ms: u64) {
312	tasks.retain(|_, task| {
313		!task.status.is_terminal() || now_ms.saturating_sub(task.timestamp_ms) < RETENTION_MS
314	});
315
316	let mut timestamps: Vec<u64> = tasks
317		.values()
318		.filter(|task| task.status.is_terminal())
319		.map(|task| task.timestamp_ms)
320		.collect();
321
322	if timestamps.len() <= CAPACITY {
323		return;
324	}
325
326	timestamps.sort_unstable();
327
328	let cutoff = timestamps[timestamps.len().saturating_sub(CAPACITY)];
329
330	tasks.retain(|_, task| !task.status.is_terminal() || task.timestamp_ms >= cutoff);
331}
332
333#[cfg(test)]
334mod tests;