Skip to main content

orch/store/
jobs.rs

1use super::*;
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 TaskStore {
25    pub async fn get_job_state(
26        &self,
27        repo: &str,
28        job_id: &str,
29    ) -> anyhow::Result<Option<JobState>> {
30        let sql = format!(
31            "SELECT {} FROM job_state WHERE repo = ? AND job_id = ?",
32            JOB_STATE_COLS
33        );
34        let row: Option<JobState> = sqlx::query_as(&sql)
35            .bind(repo)
36            .bind(job_id)
37            .fetch_optional(&self.pool)
38            .await?;
39        Ok(row)
40    }
41
42    /// Upsert runtime state for a job.
43    pub async fn upsert_job_state(&self, state: &JobState) -> anyhow::Result<()> {
44        self.write(Command::JobStateUpsert(state.clone()))
45            .await
46            .map(|_| ())
47    }
48
49    /// List all job states for a repo.
50    pub async fn list_job_states(&self, repo: &str) -> anyhow::Result<Vec<JobState>> {
51        let sql = format!("SELECT {} FROM job_state WHERE repo = ?", JOB_STATE_COLS);
52        let rows: Vec<JobState> = sqlx::query_as(&sql)
53            .bind(repo)
54            .fetch_all(&self.pool)
55            .await?;
56        Ok(rows)
57    }
58
59    // ---------------------------------------------------------------
60    // Channel Subscriptions
61    // ---------------------------------------------------------------
62
63    /// Subscribe a channel/thread to notifications for a project.
64    pub async fn subscribe_channel(
65        &self,
66        channel: &str,
67        thread_id: &str,
68        repo: &str,
69        topic_id: Option<&str>,
70    ) -> anyhow::Result<()> {
71        let cmd = Command::ChannelSubscribe {
72            channel: channel.to_string(),
73            thread_id: thread_id.to_string(),
74            repo: repo.to_string(),
75            topic_id: topic_id.unwrap_or("").to_string(),
76        };
77        self.write(cmd).await.map(|_| ())
78    }
79
80    /// Unsubscribe a channel/thread from a project's notifications.
81    pub async fn unsubscribe_channel(
82        &self,
83        channel: &str,
84        thread_id: &str,
85        repo: &str,
86    ) -> anyhow::Result<()> {
87        let cmd = Command::ChannelUnsubscribe {
88            channel: channel.to_string(),
89            thread_id: thread_id.to_string(),
90            repo: repo.to_string(),
91        };
92        self.write(cmd).await.map(|_| ())
93    }
94
95    /// List repos that a channel/thread is subscribed to.
96    #[cfg(test)]
97    pub async fn list_channel_subscriptions(
98        &self,
99        channel: &str,
100        thread_id: &str,
101    ) -> anyhow::Result<Vec<String>> {
102        let rows: Vec<(String,)> = sqlx::query_as(
103            "SELECT repo FROM channel_subscriptions WHERE channel = ? AND thread_id = ?",
104        )
105        .bind(channel)
106        .bind(thread_id)
107        .fetch_all(&self.pool)
108        .await?;
109        Ok(rows.into_iter().map(|r| r.0).collect())
110    }
111
112    /// List all subscriptions (channel, thread_id, topic_id) for a repo.
113    pub async fn list_subscribers_for_repo(
114        &self,
115        repo: &str,
116    ) -> anyhow::Result<Vec<(String, String, String)>> {
117        let rows: Vec<(String, String, String)> = sqlx::query_as(
118            "SELECT channel, thread_id, topic_id FROM channel_subscriptions WHERE repo = ?",
119        )
120        .bind(repo)
121        .fetch_all(&self.pool)
122        .await?;
123        Ok(rows)
124    }
125}
126
127/// The SQL behind each replicated job and channel write
128pub(crate) mod apply {
129    use super::*;
130
131    pub(crate) async fn upsert_job_state(
132        conn: &mut SqliteConnection,
133        state: &JobState,
134    ) -> anyhow::Result<()> {
135        sqlx::query(
136            "INSERT INTO job_state (repo, job_id, last_run, last_task_status, active_task_id)
137             VALUES (?, ?, ?, ?, ?)
138             ON CONFLICT(repo, job_id) DO UPDATE SET
139               last_run = excluded.last_run,
140               last_task_status = excluded.last_task_status,
141               active_task_id = excluded.active_task_id",
142        )
143        .bind(&state.repo)
144        .bind(&state.job_id)
145        .bind(&state.last_run)
146        .bind(&state.last_task_status)
147        .bind(&state.active_task_id)
148        .execute(conn)
149        .await?;
150        Ok(())
151    }
152
153    pub(crate) async fn subscribe(
154        conn: &mut SqliteConnection,
155        channel: &str,
156        thread_id: &str,
157        repo: &str,
158        topic_id: &str,
159    ) -> anyhow::Result<()> {
160        sqlx::query(
161            "INSERT OR IGNORE INTO channel_subscriptions (channel, thread_id, repo, topic_id) VALUES (?, ?, ?, ?)",
162        )
163        .bind(channel)
164        .bind(thread_id)
165        .bind(repo)
166        .bind(topic_id)
167        .execute(conn)
168        .await?;
169        Ok(())
170    }
171
172    pub(crate) async fn unsubscribe(
173        conn: &mut SqliteConnection,
174        channel: &str,
175        thread_id: &str,
176        repo: &str,
177    ) -> anyhow::Result<()> {
178        sqlx::query(
179            "DELETE FROM channel_subscriptions WHERE channel = ? AND thread_id = ? AND repo = ?",
180        )
181        .bind(channel)
182        .bind(thread_id)
183        .bind(repo)
184        .execute(conn)
185        .await?;
186        Ok(())
187    }
188}