1use std::time::Duration;
4
5use chronon_core::models::{Job, ScheduleKind};
6use chronon_core::{ChrononError, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10const DEFAULT_TIMEOUT_MS: u64 = 3000;
11
12fn trim_slash(s: &str) -> String {
13 s.trim_end_matches('/').to_string()
14}
15
16fn remote_timeout() -> Duration {
17 let ms = std::env::var("CHRONON_REMOTE_HTTP_TIMEOUT_MS")
18 .ok()
19 .and_then(|s| s.parse().ok())
20 .unwrap_or(DEFAULT_TIMEOUT_MS);
21 Duration::from_millis(ms.max(100))
22}
23
24fn http_client() -> reqwest::Client {
25 reqwest::Client::builder()
26 .timeout(remote_timeout())
27 .build()
28 .unwrap_or_else(|_| reqwest::Client::new())
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct ApiResponse<T> {
33 success: bool,
34 data: Option<T>,
35 error: Option<String>,
36}
37
38#[derive(Debug, Deserialize, Serialize)]
39struct UpsertJobRequest {
40 job_name: String,
41 script_name: String,
42 cron_expr: Option<String>,
43 timezone: Option<String>,
44 #[serde(default)]
45 schedule_kind: ScheduleKindDto,
46 #[serde(default)]
47 params: Value,
48 #[serde(default = "default_true")]
49 enabled: bool,
50 #[serde(default = "default_concurrency")]
51 concurrency: i32,
52 pub timeout_ms: Option<i64>,
53 #[serde(default)]
54 actor_json: Option<Value>,
55 #[serde(default)]
56 retry_policy: Option<Value>,
57 #[serde(default)]
58 misfire_policy: Option<Value>,
59}
60
61fn default_true() -> bool {
62 true
63}
64
65fn default_concurrency() -> i32 {
66 1
67}
68
69#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy)]
70#[serde(rename_all = "snake_case")]
71enum ScheduleKindDto {
72 #[default]
73 Cron,
74 RunOnce,
75 Manual,
76}
77
78impl From<ScheduleKind> for ScheduleKindDto {
79 fn from(k: ScheduleKind) -> Self {
80 match k {
81 ScheduleKind::Cron => ScheduleKindDto::Cron,
82 ScheduleKind::RunOnce => ScheduleKindDto::RunOnce,
83 ScheduleKind::Manual => ScheduleKindDto::Manual,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
90pub struct JobSummary {
91 pub job_id: String,
93 pub job_name: String,
95 pub script_name: String,
97 pub enabled: bool,
99 pub schedule_kind: String,
101 pub cron_expr: Option<String>,
103 pub timezone: Option<String>,
105 pub next_run_at: Option<String>,
107 pub current_revision: i32,
109 pub created_at: String,
111 pub updated_at: String,
113}
114
115#[derive(Debug, Deserialize, Serialize)]
116struct JobResponse {
117 job_id: String,
118 job_name: String,
119 script_name: String,
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123struct JobActionRequest {
124 job_id: String,
125 #[serde(default)]
126 params: Option<Value>,
127}
128
129pub struct RemoteCoordinatorClient {
167 base_url: String,
168 client: reqwest::Client,
169}
170
171impl RemoteCoordinatorClient {
172 pub fn new(base_url: impl Into<String>) -> Self {
176 Self {
177 base_url: trim_slash(&base_url.into()),
178 client: http_client(),
179 }
180 }
181
182 fn api_url(&self, path: &str) -> String {
183 format!("{}/api/chronon{}", self.base_url, path)
184 }
185
186 async fn parse_response<T: for<'de> Deserialize<'de>>(
187 resp: reqwest::Response,
188 ) -> Result<T> {
189 let status = resp.status();
190 let body: ApiResponse<T> = resp
191 .json()
192 .await
193 .map_err(|e| ChrononError::Internal(format!("remote decode: {e}")))?;
194 if !status.is_success() || !body.success {
195 return Err(ChrononError::Internal(
196 body.error.unwrap_or_else(|| format!("HTTP {status}")),
197 ));
198 }
199 body.data
200 .ok_or_else(|| ChrononError::Internal("remote empty data".into()))
201 }
202
203 pub async fn upsert_job(&self, job: Job) -> Result<()> {
205 let req = UpsertJobRequest {
206 job_name: job.job_name,
207 script_name: job.script_name,
208 cron_expr: job.cron_expr,
209 timezone: job.timezone,
210 schedule_kind: job.schedule_kind.into(),
211 params: job.params_json,
212 enabled: job.enabled,
213 concurrency: job.concurrency,
214 timeout_ms: job.timeout_ms,
215 actor_json: Some(job.actor_json),
216 retry_policy: Some(job.retry_policy_json),
217 misfire_policy: Some(job.misfire_policy_json),
218 };
219 let _: JobResponse = Self::parse_response(
220 self.client
221 .post(self.api_url("/jobs/upsert"))
222 .json(&req)
223 .send()
224 .await
225 .map_err(|e| ChrononError::Internal(e.to_string()))?,
226 )
227 .await?;
228 Ok(())
229 }
230
231 pub async fn list_jobs(&self) -> Result<Vec<JobSummary>> {
233 Self::parse_response(
234 self.client
235 .get(self.api_url("/jobs"))
236 .send()
237 .await
238 .map_err(|e| ChrononError::Internal(e.to_string()))?,
239 )
240 .await
241 }
242
243 pub async fn run_now(&self, job_id: &str) -> Result<String> {
245 let req = JobActionRequest {
246 job_id: job_id.to_string(),
247 params: None,
248 };
249 let resp: String = Self::parse_response(
250 self.client
251 .post(self.api_url("/jobs/run_now"))
252 .json(&req)
253 .send()
254 .await
255 .map_err(|e| ChrononError::Internal(e.to_string()))?,
256 )
257 .await?;
258 Ok(resp)
259 }
260}
261
262pub fn resolve_remote_base_url() -> Option<String> {
279 if let Ok(u) = std::env::var("CHRONON_REMOTE_BASE_URL") {
280 let t = u.trim();
281 if !t.is_empty() {
282 return Some(trim_slash(t));
283 }
284 }
285 None
286}