1use 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;
11pub type HttpTaskClient = AuthenticatedClient;
13
14#[derive(Clone, Copy, Debug)]
16pub struct TaskWaitOptions {
17 pub poll_interval: Duration,
19 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#[derive(Debug, thiserror::Error)]
34pub enum TaskWaitError {
35 #[error("timed out before Task {task_id} finished")]
37 Timeout { task_id: Uuid },
38 #[error(transparent)]
40 Service(#[from] CallError<TasksError>),
41}
42
43impl TaskStatus {
44 pub fn is_terminal(&self) -> bool {
46 matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
47 }
48}
49
50impl AuthenticatedClient {
51 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137pub enum WorkerPoolAction {
138 Activate,
140 Pause,
142 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}