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