platform_runtime/
schedules.rs1use crate::EnqueueFunctionRequest;
2use crate::functions::{RuntimeClient, map_runtime_error};
3use chrono::Utc;
4use lenso_contracts::CronSchedule;
5use platform_core::{
6 ActorContext, AppError, AppResult, CorrelationId, DbPool, ErrorCode, TraceContext,
7};
8use serde_json::Value;
9use uuid::Uuid;
10
11#[derive(Debug, Clone)]
12pub struct ScheduledFunctionDefinition {
13 pub schedule_key: String,
14 pub module_name: String,
15 pub schedule_name: String,
16 pub function_name: String,
17 pub cron: String,
18 pub schedule: CronSchedule,
19 pub input_json: Value,
20 pub max_attempts: i32,
21}
22
23#[derive(Debug, Clone)]
24pub struct RuntimeScheduler {
25 pool: DbPool,
26 worker_id: String,
27 service_name: String,
28}
29
30impl RuntimeScheduler {
31 #[must_use]
32 pub fn new(pool: DbPool, worker_id: impl Into<String>) -> Self {
33 Self {
34 pool,
35 worker_id: worker_id.into(),
36 service_name: "lenso".to_owned(),
37 }
38 }
39
40 #[must_use]
41 pub fn with_service_name(mut self, service_name: impl Into<String>) -> Self {
42 self.service_name = service_name.into();
43 self
44 }
45
46 pub async fn enqueue_due(
47 &self,
48 schedules: &[ScheduledFunctionDefinition],
49 ) -> AppResult<Vec<String>> {
50 let mut run_ids = Vec::new();
51 let client =
52 RuntimeClient::new(self.pool.clone()).with_service_name(self.service_name.clone());
53
54 for schedule in schedules {
55 if let Some(run_id) = self.enqueue_due_schedule(&client, schedule).await? {
56 run_ids.push(run_id);
57 }
58 }
59
60 Ok(run_ids)
61 }
62
63 async fn enqueue_due_schedule(
64 &self,
65 client: &RuntimeClient,
66 schedule: &ScheduledFunctionDefinition,
67 ) -> AppResult<Option<String>> {
68 let next_run_at = schedule.schedule.next_after(Utc::now()).ok_or_else(|| {
69 AppError::new(
70 ErrorCode::Validation,
71 "Scheduled runtime function has no run within the lookahead window",
72 )
73 })?;
74
75 let mut tx = self.pool.begin().await.map_err(map_runtime_error)?;
76 let existing_cron: Option<String> = sqlx::query_scalar(
77 r#"
78 select cron_expression
79 from runtime.scheduled_functions
80 where schedule_key = $1
81 for update
82 "#,
83 )
84 .bind(&schedule.schedule_key)
85 .fetch_optional(&mut *tx)
86 .await
87 .map_err(map_runtime_error)?;
88
89 let reset_next_run = existing_cron.as_deref() != Some(schedule.cron.as_str());
90 if existing_cron.is_none() {
91 sqlx::query(
92 r#"
93 insert into runtime.scheduled_functions (
94 schedule_key,
95 module_name,
96 schedule_name,
97 function_name,
98 cron_expression,
99 input_json,
100 max_attempts,
101 next_run_at
102 )
103 values ($1, $2, $3, $4, $5, $6, $7, $8)
104 "#,
105 )
106 .bind(&schedule.schedule_key)
107 .bind(&schedule.module_name)
108 .bind(&schedule.schedule_name)
109 .bind(&schedule.function_name)
110 .bind(&schedule.cron)
111 .bind(&schedule.input_json)
112 .bind(schedule.max_attempts)
113 .bind(next_run_at)
114 .execute(&mut *tx)
115 .await
116 .map_err(map_runtime_error)?;
117 } else {
118 sqlx::query(
119 r#"
120 update runtime.scheduled_functions
121 set module_name = $2,
122 schedule_name = $3,
123 function_name = $4,
124 cron_expression = $5,
125 input_json = $6,
126 max_attempts = $7,
127 next_run_at = case when $8 then $9 else next_run_at end,
128 updated_at = now()
129 where schedule_key = $1
130 "#,
131 )
132 .bind(&schedule.schedule_key)
133 .bind(&schedule.module_name)
134 .bind(&schedule.schedule_name)
135 .bind(&schedule.function_name)
136 .bind(&schedule.cron)
137 .bind(&schedule.input_json)
138 .bind(schedule.max_attempts)
139 .bind(reset_next_run)
140 .bind(next_run_at)
141 .execute(&mut *tx)
142 .await
143 .map_err(map_runtime_error)?;
144 }
145
146 let due: Option<String> = sqlx::query_scalar(
147 r#"
148 select schedule_key
149 from runtime.scheduled_functions
150 where schedule_key = $1
151 and next_run_at <= now()
152 for update skip locked
153 "#,
154 )
155 .bind(&schedule.schedule_key)
156 .fetch_optional(&mut *tx)
157 .await
158 .map_err(map_runtime_error)?;
159
160 if due.is_none() {
161 tx.commit().await.map_err(map_runtime_error)?;
162 return Ok(None);
163 }
164
165 let run = client
166 .enqueue_function_in_tx(
167 &mut tx,
168 EnqueueFunctionRequest {
169 function_name: schedule.function_name.clone(),
170 input_json: schedule.input_json.clone(),
171 correlation_id: CorrelationId::new(format!("corr_schedule_{}", Uuid::now_v7())),
172 actor: ActorContext::Service {
173 service_id: self.worker_id.clone(),
174 scopes: vec!["runtime.functions.enqueue".to_owned()],
175 },
176 tenant_id: None,
177 tenancy_mode: crate::FunctionTenancyMode::None,
178 trace: TraceContext::default(),
179 causation_id: Some(format!("runtime_schedule:{}", schedule.schedule_key)),
180 max_attempts: Some(schedule.max_attempts),
181 },
182 )
183 .await?;
184
185 sqlx::query(
186 r#"
187 update runtime.scheduled_functions
188 set next_run_at = $2,
189 last_enqueued_at = now(),
190 updated_at = now()
191 where schedule_key = $1
192 "#,
193 )
194 .bind(&schedule.schedule_key)
195 .bind(next_run_at)
196 .execute(&mut *tx)
197 .await
198 .map_err(map_runtime_error)?;
199
200 tx.commit().await.map_err(map_runtime_error)?;
201 client.record_function_enqueued(&run).await;
202 Ok(Some(run.id))
203 }
204}