Skip to main content

orch/store/
jobs.rs

1use super::TaskStore;
2use crate::cluster::Command;
3use serde::{Deserialize, Serialize};
4use sqlx::SqliteConnection;
5
6/// Explicit column list for `SELECT` queries on the `job_state` table.
7///
8/// Using `SELECT *` with `sqlx-sqlite`'s prepared statement cache can cause OOB
9/// panics when the schema changes (new migration columns) because the cached
10/// column metadata may not match the current table structure. Explicit columns
11/// prevent this mismatch.
12const JOB_STATE_COLS: &str = "repo, job_id, last_run, last_task_status, active_task_id";
13
14/// Runtime state for a scheduled job, stored in SQLite (not in .orch.yml).
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
16pub struct JobState {
17    pub repo: String,
18    pub job_id: String,
19    pub last_run: Option<String>,
20    pub last_task_status: Option<String>,
21    pub active_task_id: Option<String>,
22}
23
24impl JobState {
25    /// State for a job that has never run.
26    pub fn new(repo: &str, job_id: &str) -> Self {
27        Self {
28            repo: repo.to_string(),
29            job_id: job_id.to_string(),
30            last_run: None,
31            last_task_status: None,
32            active_task_id: None,
33        }
34    }
35}
36
37impl TaskStore {
38    pub async fn get_job_state(
39        &self,
40        repo: &str,
41        job_id: &str,
42    ) -> anyhow::Result<Option<JobState>> {
43        let sql = format!("SELECT {JOB_STATE_COLS} FROM job_state WHERE repo = ? AND job_id = ?");
44        let row: Option<JobState> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
45            .bind(repo)
46            .bind(job_id)
47            .fetch_optional(&self.pool)
48            .await?;
49        Ok(row)
50    }
51
52    /// Upsert runtime state for a job.
53    pub async fn upsert_job_state(&self, state: &JobState) -> anyhow::Result<()> {
54        self.write(Command::JobStateUpsert(state.clone()))
55            .await
56            .map(|_| ())
57    }
58
59    /// List all job states for a repo.
60    pub async fn list_job_states(&self, repo: &str) -> anyhow::Result<Vec<JobState>> {
61        let sql = format!("SELECT {JOB_STATE_COLS} FROM job_state WHERE repo = ?");
62        let rows: Vec<JobState> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
63            .bind(repo)
64            .fetch_all(&self.pool)
65            .await?;
66        Ok(rows)
67    }
68
69    // ---------------------------------------------------------------
70    // Channel Subscriptions
71    // ---------------------------------------------------------------
72
73    /// Subscribe a channel/thread to notifications for a project.
74    pub async fn subscribe_channel(
75        &self,
76        channel: &str,
77        thread_id: &str,
78        repo: &str,
79        topic_id: Option<&str>,
80    ) -> anyhow::Result<()> {
81        let cmd = Command::ChannelSubscribe {
82            channel: channel.to_string(),
83            thread_id: thread_id.to_string(),
84            repo: repo.to_string(),
85            topic_id: topic_id.unwrap_or("").to_string(),
86        };
87        self.write(cmd).await.map(|_| ())
88    }
89
90    /// Unsubscribe a channel/thread from a project's notifications.
91    pub async fn unsubscribe_channel(
92        &self,
93        channel: &str,
94        thread_id: &str,
95        repo: &str,
96    ) -> anyhow::Result<()> {
97        let cmd = Command::ChannelUnsubscribe {
98            channel: channel.to_string(),
99            thread_id: thread_id.to_string(),
100            repo: repo.to_string(),
101        };
102        self.write(cmd).await.map(|_| ())
103    }
104
105    /// List repos that a channel/thread is subscribed to.
106    #[cfg(test)]
107    pub async fn list_channel_subscriptions(
108        &self,
109        channel: &str,
110        thread_id: &str,
111    ) -> anyhow::Result<Vec<String>> {
112        let rows: Vec<(String,)> = sqlx::query_as(
113            "SELECT repo FROM channel_subscriptions WHERE channel = ? AND thread_id = ?",
114        )
115        .bind(channel)
116        .bind(thread_id)
117        .fetch_all(&self.pool)
118        .await?;
119        Ok(rows.into_iter().map(|r| r.0).collect())
120    }
121
122    /// List all subscriptions (channel, thread_id, topic_id) for a repo.
123    pub async fn list_subscribers_for_repo(
124        &self,
125        repo: &str,
126    ) -> anyhow::Result<Vec<(String, String, String)>> {
127        let rows: Vec<(String, String, String)> = sqlx::query_as(
128            "SELECT channel, thread_id, topic_id FROM channel_subscriptions WHERE repo = ?",
129        )
130        .bind(repo)
131        .fetch_all(&self.pool)
132        .await?;
133        Ok(rows)
134    }
135}
136
137/// The SQL behind each replicated job and channel write
138pub(crate) mod apply {
139    use super::{JobState, SqliteConnection};
140
141    pub(crate) async fn upsert_job_state(
142        conn: &mut SqliteConnection,
143        state: &JobState,
144    ) -> anyhow::Result<()> {
145        sqlx::query(
146            "INSERT INTO job_state (repo, job_id, last_run, last_task_status, active_task_id)
147             VALUES (?, ?, ?, ?, ?)
148             ON CONFLICT(repo, job_id) DO UPDATE SET
149               last_run = excluded.last_run,
150               last_task_status = excluded.last_task_status,
151               active_task_id = excluded.active_task_id",
152        )
153        .bind(&state.repo)
154        .bind(&state.job_id)
155        .bind(&state.last_run)
156        .bind(&state.last_task_status)
157        .bind(&state.active_task_id)
158        .execute(conn)
159        .await?;
160        Ok(())
161    }
162
163    pub(crate) async fn subscribe(
164        conn: &mut SqliteConnection,
165        channel: &str,
166        thread_id: &str,
167        repo: &str,
168        topic_id: &str,
169    ) -> anyhow::Result<()> {
170        sqlx::query(
171            "INSERT OR IGNORE INTO channel_subscriptions (channel, thread_id, repo, topic_id) VALUES (?, ?, ?, ?)",
172        )
173        .bind(channel)
174        .bind(thread_id)
175        .bind(repo)
176        .bind(topic_id)
177        .execute(conn)
178        .await?;
179        Ok(())
180    }
181
182    pub(crate) async fn unsubscribe(
183        conn: &mut SqliteConnection,
184        channel: &str,
185        thread_id: &str,
186        repo: &str,
187    ) -> anyhow::Result<()> {
188        sqlx::query(
189            "DELETE FROM channel_subscriptions WHERE channel = ? AND thread_id = ? AND repo = ?",
190        )
191        .bind(channel)
192        .bind(thread_id)
193        .bind(repo)
194        .execute(conn)
195        .await?;
196        Ok(())
197    }
198}