Skip to main content

spiral/resources/
task.rs

1//! Task polling and collection workflows over generated resource operations.
2
3use std::time::{Duration, Instant};
4
5use spiral_contract::call::CallError;
6use uuid::Uuid;
7
8use super::generated::tasks::AuthenticatedClient;
9pub use crate::api::tasks::*;
10use crate::ids::ProjectId;
11/// Authenticated Task operations generated from the service contract.
12pub type HttpTaskClient = AuthenticatedClient;
13
14/// Polling policy for waiting until a Task finishes.
15#[derive(Clone, Copy, Debug)]
16pub struct TaskWaitOptions {
17    /// Initial delay between status reads, doubling to at most ten seconds.
18    pub poll_interval: Duration,
19    /// Optional client deadline; reaching it does not cancel the Task.
20    pub timeout: Option<Duration>,
21}
22
23impl Default for TaskWaitOptions {
24    fn default() -> Self {
25        Self {
26            poll_interval: Duration::from_secs(1),
27            timeout: None,
28        }
29    }
30}
31
32/// A Task wait deadline expired or a status read failed.
33#[derive(Debug, thiserror::Error)]
34pub enum TaskWaitError {
35    /// The Task may still be running after the client stops polling.
36    #[error("timed out before Task {task_id} finished")]
37    Timeout { task_id: Uuid },
38    /// The generated status request failed.
39    #[error(transparent)]
40    Service(#[from] CallError<TasksError>),
41}
42
43impl TaskStatus {
44    /// Whether the Task has finished and will not run another Attempt.
45    pub fn is_terminal(&self) -> bool {
46        matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
47    }
48}
49
50impl AuthenticatedClient {
51    /// Poll until the Task finishes, tolerating up to four consecutive transient failures.
52    pub async fn wait(
53        &self,
54        task_id: Uuid,
55        options: TaskWaitOptions,
56    ) -> Result<Task, TaskWaitError> {
57        let started = Instant::now();
58        let mut interval = options.poll_interval;
59        let mut failures = 0;
60        loop {
61            match self.read_task(task_id).await {
62                Ok(details) => {
63                    failures = 0;
64                    if details.task.status.is_terminal() {
65                        return Ok(details.task);
66                    }
67                }
68                Err(error) => {
69                    failures += 1;
70                    if !transient(&error) || failures >= 5 {
71                        return Err(error.into());
72                    }
73                }
74            }
75            if options
76                .timeout
77                .is_some_and(|limit| started.elapsed() >= limit)
78            {
79                return Err(TaskWaitError::Timeout { task_id });
80            }
81            tokio::time::sleep(interval).await;
82            interval = interval.saturating_mul(2).min(Duration::from_secs(10));
83        }
84    }
85
86    /// Collect every Worker pool page using the server's opaque continuation tokens.
87    pub async fn list_all_worker_pools(
88        &self,
89        project_id: &ProjectId,
90    ) -> Result<ListWorkerPoolsResponse, CallError<TasksError>> {
91        let mut request = ListWorkerPoolsRequest {
92            page_size: Some(1000),
93            page_token: None,
94            worker_pool_id: None,
95        };
96        let mut response = self
97            .list_worker_pools(project_id.to_string(), request.clone())
98            .await?;
99        while let Some(token) = response.next_page_token.take() {
100            request.page_token = Some(token);
101            let page = self
102                .list_worker_pools(project_id.to_string(), request.clone())
103                .await?;
104            response.worker_pools.extend(page.worker_pools);
105            response.stats.extend(page.stats);
106            response.next_page_token = page.next_page_token;
107        }
108        Ok(response)
109    }
110
111    /// Apply one lifecycle action to an existing Worker pool.
112    pub async fn worker_pool_action(
113        &self,
114        project_id: &ProjectId,
115        worker_pool_id: Uuid,
116        action: WorkerPoolAction,
117    ) -> Result<WorkerPoolResponse, CallError<TasksError>> {
118        match action {
119            WorkerPoolAction::Activate => {
120                self.activate_worker_pool(project_id.to_string(), worker_pool_id)
121                    .await
122            }
123            WorkerPoolAction::Pause => {
124                self.pause_worker_pool(project_id.to_string(), worker_pool_id)
125                    .await
126            }
127            WorkerPoolAction::Retire => {
128                self.retire_worker_pool(project_id.to_string(), worker_pool_id)
129                    .await
130            }
131        }
132    }
133}
134
135/// A lifecycle action for an existing Worker pool.
136#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137pub enum WorkerPoolAction {
138    /// Activate the pool.
139    Activate,
140    /// Pause the pool.
141    Pause,
142    /// Retire the pool.
143    Retire,
144}
145
146fn transient(error: &CallError<TasksError>) -> bool {
147    match error {
148        CallError::Service(failure) => failure.status() >= 500,
149        CallError::UnknownRemote(failure) => failure.status() >= 500,
150        CallError::Transport(_) | CallError::Internal { .. } => true,
151        CallError::Protocol(_) => false,
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::sync::Arc;
158    use std::sync::atomic::{AtomicUsize, Ordering};
159
160    use futures::{FutureExt as _, TryStreamExt as _};
161    use serde_json::{from_value, json};
162    use wiremock::matchers::{header, method, path, query_param};
163    use wiremock::{Mock, MockServer, ResponseTemplate};
164
165    use super::*;
166    use crate::auth::{Token, TokenSource};
167
168    #[tokio::test]
169    async fn generated_task_pages_preserve_filters_and_refresh_credentials() {
170        let server = MockServer::start().await;
171        let resource = Uuid::new_v4();
172        for (token, cursor) in [("token-0", Some("next /&")), ("token-1", None)] {
173            Mock::given(method("GET"))
174                .and(path("/deployment/api/tasks/projects/project/tasks"))
175                .and(header("authorization", format!("Bearer {token}")))
176                .and(query_param("resourceId", resource.to_string()))
177                .and(query_param("resourceRole", "target"))
178                .and(query_param("status", "failed"))
179                .and(query_param("pageSize", "10"))
180                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
181                    "tasks": [], "targetResources": {}, "nextPageToken": cursor,
182                })))
183                .expect(1)
184                .mount(&server)
185                .await;
186        }
187        let calls = Arc::new(AtomicUsize::new(0));
188        let provider = TokenSource::new(move || {
189            let token = Token::from(format!("token-{}", calls.fetch_add(1, Ordering::SeqCst)));
190            async move { Ok(Some(token)) }.boxed()
191        });
192        let client = HttpTaskClient::new(&format!("{}/deployment", server.uri()))
193            .unwrap()
194            .with_token_source(provider);
195        let request: ListTasksRequest = from_value(json!({
196            "resourceId": resource, "resourceRole": "target", "status": "failed", "pageSize": 10,
197            "pageToken": "resume /&",
198        }))
199        .unwrap();
200        let listing = client.list_tasks_list("project".into(), request);
201        assert!(server.received_requests().await.unwrap().is_empty());
202        let tasks: Vec<_> = listing.stream().try_collect().await.unwrap();
203        assert!(tasks.is_empty());
204        let requests = server.received_requests().await.unwrap();
205        assert_eq!(
206            requests[0]
207                .url
208                .query_pairs()
209                .find(|(name, _)| name == "pageToken")
210                .unwrap()
211                .1,
212            "resume /&"
213        );
214        assert_eq!(
215            requests[1]
216                .url
217                .query_pairs()
218                .find(|(name, _)| name == "pageToken")
219                .unwrap()
220                .1,
221            "next /&"
222        );
223    }
224}