OXYGENHUB
# e1ec9fbf538e oxygenhub / crates / hub / src / db / flow.rs
10.4 KiB · Rust · last changed OxygenFlow runs: pushes start pipeline runs, a runner API with job tokens, jobs as checks, and a Flow tab with the job graph and live logs 1 month ago source blame history raw
1
//! OxygenFlow runs, jobs and logs (hub spec §13.2).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
#[derive(Clone, Debug, FromRow)]
7
pub struct Run {
8
    pub id: i64,
9
    pub repo_id: i64,
10
    pub bookmark: String,
11
    pub state: String,
12
    pub heads: String,
13
    pub pipeline_hash: String,
14
    pub status: String,
15
    pub error: String,
16
    pub actor: String,
17
    pub created_ms: i64,
18
    pub finished_ms: Option<i64>,
19
}
20
21
impl Run {
22
    pub fn head_list(&self) -> Vec<&str> {
23
        self.heads.split('+').filter(|h| !h.is_empty()).collect()
24
    }
25
26
    pub fn live(&self) -> bool {
27
        matches!(self.status.as_str(), "queued" | "running")
28
    }
29
}
30
31
#[derive(Clone, Debug, FromRow)]
32
pub struct Job {
33
    pub id: i64,
34
    pub run_id: i64,
35
    pub name: String,
36
    pub depth: i64,
37
    pub depends: String,
38
    pub spec: String,
39
    pub sandbox: String,
40
    pub runtime: String,
41
    pub status: String,
42
    pub runner: String,
43
    pub token_hash: String,
44
    pub started_ms: Option<i64>,
45
    pub finished_ms: Option<i64>,
46
    pub exit_code: Option<i64>,
47
    pub error: String,
48
    pub receipt: String,
49
    pub receipt_hash: String,
50
    pub log_count: i64,
51
}
52
53
impl Job {
54
    pub fn depends_on(&self) -> Vec<String> {
55
        serde_json::from_str(&self.depends).unwrap_or_default()
56
    }
57
58
    pub fn done(&self) -> bool {
59
        matches!(self.status.as_str(), "passed" | "failed" | "skipped" | "error")
60
    }
61
}
62
63
#[derive(Clone, Debug, FromRow)]
64
pub struct LogLine {
65
    pub seq: i64,
66
    pub t: i64,
67
    pub stream: String,
68
    pub text: String,
69
}
70
71
/// A job as the trigger creates it.
72
pub struct NewJob {
73
    pub name: String,
74
    pub depth: i64,
75
    pub depends: Vec<String>,
76
    pub spec: String,
77
    pub sandbox: String,
78
    pub runtime: String,
79
}
80
81
pub struct NewRun<'a> {
82
    pub repo_id: i64,
83
    pub bookmark: &'a str,
84
    pub state: &'a str,
85
    pub heads: &'a str,
86
    pub pipeline_hash: &'a str,
87
    pub actor: &'a str,
88
    /// Problems when the pipeline is invalid: the run is recorded in error.
89
    pub error: Option<&'a str>,
90
}
91
92
pub async fn create_run(pool: &Pool, r: NewRun<'_>, jobs: &[NewJob]) -> sqlx::Result<i64> {
93
    let mut tx = pool.begin().await?;
94
    let now = now_ms();
95
    let status = if r.error.is_some() { "error" } else { "queued" };
96
    let id: i64 = sqlx::query_scalar(
97
        "INSERT INTO flow_runs (repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
98
    )
99
    .bind(r.repo_id)
100
    .bind(r.bookmark)
101
    .bind(r.state)
102
    .bind(r.heads)
103
    .bind(r.pipeline_hash)
104
    .bind(status)
105
    .bind(r.error.unwrap_or(""))
106
    .bind(r.actor)
107
    .bind(now)
108
    .bind(r.error.map(|_| now))
109
    .fetch_one(&mut *tx)
110
    .await?;
111
    for j in jobs {
112
        sqlx::query(
113
            "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')",
114
        )
115
        .bind(id)
116
        .bind(&j.name)
117
        .bind(j.depth)
118
        .bind(serde_json::to_string(&j.depends).unwrap_or_else(|_| "[]".into()))
119
        .bind(&j.spec)
120
        .bind(&j.sandbox)
121
        .bind(&j.runtime)
122
        .execute(&mut *tx)
123
        .await?;
124
    }
125
    tx.commit().await?;
126
    Ok(id)
127
}
128
129
pub async fn runs(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> {
130
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id < ? ORDER BY id DESC LIMIT ?")
131
        .bind(repo_id)
132
        .bind(before.unwrap_or(i64::MAX))
133
        .bind(limit)
134
        .fetch_all(pool)
135
        .await
136
}
137
138
pub async fn run(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Run>> {
139
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id = ?").bind(repo_id).bind(id).fetch_optional(pool).await
140
}
141
142
pub async fn run_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Run>> {
143
    sqlx::query_as("SELECT * FROM flow_runs WHERE id = ?").bind(id).fetch_optional(pool).await
144
}
145
146
pub async fn jobs(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Job>> {
147
    sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ? ORDER BY depth, name").bind(run_id).fetch_all(pool).await
148
}
149
150
pub async fn job(pool: &Pool, id: i64) -> sqlx::Result<Option<Job>> {
151
    sqlx::query_as("SELECT * FROM flow_jobs WHERE id = ?").bind(id).fetch_optional(pool).await
152
}
153
154
/// The running job a job token belongs to, while it may still be used.
155
pub async fn job_by_token(pool: &Pool, token_hash: &str, max_age_ms: i64) -> sqlx::Result<Option<Job>> {
156
    sqlx::query_as("SELECT * FROM flow_jobs WHERE token_hash = ? AND status = 'running' AND started_ms >= ?")
157
        .bind(token_hash)
158
        .bind(now_ms() - max_age_ms)
159
        .fetch_optional(pool)
160
        .await
161
}
162
163
/// Take the oldest queued job whose dependencies all passed and whose tier
164
/// the runner offers, and mark it running for `runner` with a token.
165
pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> {
166
    let mut tx = pool.begin().await?;
167
    let queued: Vec<Job> = sqlx::query_as(
168
        "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id WHERE j.status = 'queued' AND r.status IN ('queued', 'running') ORDER BY j.run_id, j.depth, j.id LIMIT 200",
169
    )
170
    .fetch_all(&mut *tx)
171
    .await?;
172
    for j in queued {
173
        if !runtimes.iter().any(|r| r == &j.runtime) {
174
            continue;
175
        }
176
        let deps = j.depends_on();
177
        if !deps.is_empty() {
178
            let mut passed = 0i64;
179
            for d in &deps {
180
                let st: Option<String> = sqlx::query_scalar("SELECT status FROM flow_jobs WHERE run_id = ? AND name = ?")
181
                    .bind(j.run_id)
182
                    .bind(d)
183
                    .fetch_optional(&mut *tx)
184
                    .await?;
185
                if st.as_deref() == Some("passed") {
186
                    passed += 1;
187
                }
188
            }
189
            if passed != deps.len() as i64 {
190
                continue;
191
            }
192
        }
193
        let now = now_ms();
194
        let done = sqlx::query(
195
            "UPDATE flow_jobs SET status = 'running', runner = ?, token_hash = ?, started_ms = ? WHERE id = ? AND status = 'queued'",
196
        )
197
        .bind(runner)
198
        .bind(token_hash)
199
        .bind(now)
200
        .bind(j.id)
201
        .execute(&mut *tx)
202
        .await?;
203
        if done.rows_affected() == 1 {
204
            sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = ? AND status = 'queued'")
205
                .bind(j.run_id)
206
                .execute(&mut *tx)
207
                .await?;
208
            tx.commit().await?;
209
            return job(pool, j.id).await;
210
        }
211
    }
212
    tx.commit().await?;
213
    Ok(None)
214
}
215
216
/// Append records; returns how many were kept (the rest exceed the cap).
217
pub async fn append_log(pool: &Pool, job_id: i64, records: &[flow_core::api::Record], cap: i64) -> sqlx::Result<usize> {
218
    let mut tx = pool.begin().await?;
219
    let count: i64 = sqlx::query_scalar("SELECT log_count FROM flow_jobs WHERE id = ?").bind(job_id).fetch_one(&mut *tx).await?;
220
    let room = (cap - count).max(0) as usize;
221
    let keep = &records[..records.len().min(room)];
222
    for (i, r) in keep.iter().enumerate() {
223
        sqlx::query("INSERT INTO flow_logs (job_id, seq, t, stream, text) VALUES (?, ?, ?, ?, ?)")
224
            .bind(job_id)
225
            .bind(count + i as i64 + 1)
226
            .bind(r.t as i64)
227
            .bind(&r.stream)
228
            .bind(&r.text)
229
            .execute(&mut *tx)
230
            .await?;
231
    }
232
    sqlx::query("UPDATE flow_jobs SET log_count = log_count + ? WHERE id = ?")
233
        .bind(keep.len() as i64)
234
        .bind(job_id)
235
        .execute(&mut *tx)
236
        .await?;
237
    tx.commit().await?;
238
    Ok(keep.len())
239
}
240
241
pub async fn log(pool: &Pool, job_id: i64, after: i64, limit: i64) -> sqlx::Result<Vec<LogLine>> {
242
    sqlx::query_as("SELECT seq, t, stream, text FROM flow_logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT ?")
243
        .bind(job_id)
244
        .bind(after)
245
        .bind(limit)
246
        .fetch_all(pool)
247
        .await
248
}
249
250
pub struct Outcome<'a> {
251
    pub status: &'a str,
252
    pub exit_code: Option<i64>,
253
    pub error: &'a str,
254
    pub receipt: &'a str,
255
    pub receipt_hash: &'a str,
256
}
257
258
/// Record a job's end, skip the jobs that can no longer run, and settle the
259
/// run. Returns the jobs whose status changed (for their checks).
260
pub async fn finish(pool: &Pool, job_id: i64, o: Outcome<'_>) -> sqlx::Result<Vec<Job>> {
261
    let mut tx = pool.begin().await?;
262
    let now = now_ms();
263
    let j: Job = sqlx::query_as("SELECT * FROM flow_jobs WHERE id = ?").bind(job_id).fetch_one(&mut *tx).await?;
264
    sqlx::query("UPDATE flow_jobs SET status = ?, exit_code = ?, error = ?, receipt = ?, receipt_hash = ?, finished_ms = ?, token_hash = '' WHERE id = ?")
265
        .bind(o.status)
266
        .bind(o.exit_code)
267
        .bind(o.error)
268
        .bind(o.receipt)
269
        .bind(o.receipt_hash)
270
        .bind(now)
271
        .bind(job_id)
272
        .execute(&mut *tx)
273
        .await?;
274
    let mut changed = vec![job_id];
275
    // Dependents of anything that did not pass are skipped, transitively.
276
    loop {
277
        let all: Vec<Job> = sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ?").bind(j.run_id).fetch_all(&mut *tx).await?;
278
        let blocked: Vec<i64> = all
279
            .iter()
280
            .filter(|x| x.status == "queued")
281
            .filter(|x| {
282
                x.depends_on()
283
                    .iter()
284
                    .any(|d| all.iter().any(|y| &y.name == d && matches!(y.status.as_str(), "failed" | "skipped" | "error")))
285
            })
286
            .map(|x| x.id)
287
            .collect();
288
        if blocked.is_empty() {
289
            break;
290
        }
291
        for id in blocked {
292
            sqlx::query("UPDATE flow_jobs SET status = 'skipped', finished_ms = ? WHERE id = ?")
293
                .bind(now)
294
                .bind(id)
295
                .execute(&mut *tx)
296
                .await?;
297
            changed.push(id);
298
        }
299
    }
300
    let all: Vec<Job> = sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ?").bind(j.run_id).fetch_all(&mut *tx).await?;
301
    if all.iter().all(Job::done) {
302
        let status = if all.iter().all(|x| x.status == "passed") { "passed" } else { "failed" };
303
        sqlx::query("UPDATE flow_runs SET status = ?, finished_ms = ? WHERE id = ?")
304
            .bind(status)
305
            .bind(now)
306
            .bind(j.run_id)
307
            .execute(&mut *tx)
308
            .await?;
309
    }
310
    tx.commit().await?;
311
    let mut out = Vec::new();
312
    for id in changed {
313
        if let Some(x) = job(pool, id).await? {
314
            out.push(x);
315
        }
316
    }
317
    Ok(out)
318
}