Skip to main content

platform_runtime/
schedules.rs

1use 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}