Skip to main content

pamin_kit/
tasks.rs

1//! Long operations as tasks: the call returns at once with a task, and the
2//! caller polls it, from the same connection or a new one.
3//!
4//! Where task state lives is a seam like the others. The kit ships an
5//! in-process store for a single local server; anything that must survive a
6//! restart or be read by other instances (PostgreSQL, files, object storage)
7//! implements [`TaskStore`] in the binary that needs it.
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::sync::Mutex;
12use std::time::{Duration, SystemTime};
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::kit::Reply;
18use crate::ops::Ask;
19use crate::seams::Space;
20
21/// How long a store keeps a task after its last update, at least. Callers are
22/// told this, so they know how long they may come back for a result.
23pub const TASK_TTL: Duration = Duration::from_secs(60 * 60);
24
25/// One task, as stored. Serializable so a store can keep it as JSON.
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub struct StoredTask {
28    pub id: String,
29    /// Whose task it is: only the same space may read or cancel it.
30    pub space: Space,
31    pub operation: String,
32    /// The operation's arguments, to run it again with the user's answers
33    /// when it asked for input.
34    #[serde(default)]
35    pub arguments: Value,
36    pub state: TaskState,
37    /// What the operation last reported while working.
38    #[serde(default)]
39    pub status: Option<String>,
40    pub created_at: SystemTime,
41    pub updated_at: SystemTime,
42    /// Incremented by every write, so that concurrent writers (a task
43    /// finishing while it is cancelled, on one instance or several) cannot
44    /// overwrite each other unseen.
45    #[serde(default)]
46    pub version: u64,
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum TaskState {
52    Working,
53    /// Waiting for the user's answers to these questions (`tasks/update`).
54    InputRequired(Vec<Ask>),
55    /// The operation's result.
56    Succeeded(Reply),
57    /// The operation's error message, which the model reads.
58    Errored(String),
59    Cancelled,
60}
61
62impl TaskState {
63    pub fn is_terminal(&self) -> bool {
64        matches!(
65            self,
66            Self::Succeeded(_) | Self::Errored(_) | Self::Cancelled
67        )
68    }
69}
70
71/// Keeps task state between the call that started a task and the polls that
72/// read it.
73pub trait TaskStore: Send + Sync + 'static {
74    /// Writes `task` only if the stored task with its id is at version
75    /// `expected` (`None`: no task with that id exists), atomically, and
76    /// returns whether it wrote: `false` means another writer got there
77    /// first and the kit reads again. In SQL, an insert or an `UPDATE ...
78    /// WHERE version = $expected`. Keeps the task for at least [`TASK_TTL`]
79    /// after `task.updated_at`.
80    fn put(
81        &self,
82        task: StoredTask,
83        expected: Option<u64>,
84    ) -> impl Future<Output = std::io::Result<bool>> + Send;
85
86    /// The task with `id`, if it is still kept. The kit checks the space.
87    fn get(&self, id: &str) -> impl Future<Output = std::io::Result<Option<StoredTask>>> + Send;
88}
89
90/// The open-source store: tasks held in this process, gone when it exits.
91/// Enough for one local server, where a reconnecting client reaches the same
92/// process.
93#[derive(Debug, Default)]
94pub struct InProcessTasks {
95    tasks: Mutex<Kept>,
96}
97
98#[derive(Debug, Default)]
99struct Kept {
100    tasks: HashMap<String, StoredTask>,
101    /// When finished tasks past their TTL were last dropped.
102    swept: Option<SystemTime>,
103}
104
105/// How often expired tasks are dropped, at most.
106const SWEEP: Duration = Duration::from_secs(60);
107
108impl TaskStore for InProcessTasks {
109    async fn put(&self, task: StoredTask, expected: Option<u64>) -> std::io::Result<bool> {
110        let mut kept = self
111            .tasks
112            .lock()
113            .unwrap_or_else(|poison| poison.into_inner());
114        let now = SystemTime::now();
115        // ponytail: a full sweep, at most once a minute under the lock; a
116        // local server keeps a handful of tasks. Index by expiry if one
117        // ever keeps many thousands.
118        if kept
119            .swept
120            .is_none_or(|swept| now.duration_since(swept).is_ok_and(|age| age >= SWEEP))
121        {
122            kept.tasks.retain(|_, task| {
123                !task.state.is_terminal()
124                    || now
125                        .duration_since(task.updated_at)
126                        .is_ok_and(|age| age < TASK_TTL)
127            });
128            kept.swept = Some(now);
129        }
130        if kept.tasks.get(&task.id).map(|stored| stored.version) != expected {
131            return Ok(false);
132        }
133        kept.tasks.insert(task.id.clone(), task);
134        Ok(true)
135    }
136
137    async fn get(&self, id: &str) -> std::io::Result<Option<StoredTask>> {
138        let kept = self
139            .tasks
140            .lock()
141            .unwrap_or_else(|poison| poison.into_inner());
142        Ok(kept.tasks.get(id).cloned())
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn task(version: u64) -> StoredTask {
151        StoredTask {
152            id: "t".into(),
153            space: Space {
154                tenant: "t".into(),
155                user: "u".into(),
156                project: "p".into(),
157            },
158            operation: "op".into(),
159            arguments: Value::Null,
160            state: TaskState::Working,
161            status: None,
162            created_at: SystemTime::now(),
163            updated_at: SystemTime::now(),
164            version,
165        }
166    }
167
168    #[tokio::test]
169    async fn only_the_writer_that_saw_the_current_version_writes() {
170        let store = InProcessTasks::default();
171        assert!(store.put(task(0), None).await.unwrap());
172        assert!(!store.put(task(0), None).await.unwrap(), "the id exists");
173        assert!(store.put(task(1), Some(0)).await.unwrap());
174        assert!(
175            !store.put(task(1), Some(0)).await.unwrap(),
176            "version 0 is gone"
177        );
178        assert_eq!(store.get("t").await.unwrap().unwrap().version, 1);
179    }
180}