| 36 | 36 | | const POLL_EVERY_MS: u64 = 2_500; |
| 37 | 37 | | /// A runner that has been offline this long is removed, as on GitHub. |
| 38 | 38 | | const FORGET_OFFLINE_MS: u64 = 14 * 24 * 60 * 60 * 1000; |
| 39 | + | /// How often a poll writes down that a runner, or the work it holds, is |
| 40 | + | /// still there. Polls come every twenty seconds or so; a write each time |
| 41 | + | /// would be most of the service's writes for nothing. Well inside |
| 42 | + | /// [`model::ONLINE_WITHIN_MS`] and [`crate::SILENT_MS`]. |
| 43 | + | const SEEN_RESOLUTION_MS: u64 = 60 * 1000; |
| 44 | + | |
| 45 | + | /// Whether `seen` is old enough, at `at`, to be written down again. |
| 46 | + | fn seen_is_stale(seen: Option<&str>, at: u64) -> bool { |
| 47 | + | seen.is_none_or(|seen| seen < rfc3339(at.saturating_sub(SEEN_RESOLUTION_MS)).as_str()) |
| 48 | + | } |
| 39 | 49 | | |
| 40 | 50 | | #[derive(Clone, Debug, Deserialize)] |
| 41 | 51 | | pub struct RunnerRow { |
| ⋯ |
| 124 | 134 | | pub runner_id: Option<String>, |
| 125 | 135 | | pub runner_name: Option<String>, |
| 126 | 136 | | pub started_at: Option<String>, |
| 137 | + | #[serde(default)] |
| 138 | + | pub seen_at: Option<String>, |
| 127 | 139 | | } |
| 128 | 140 | | |
| 129 | 141 | | /// Who may do what with a set of runners: the workspace, and the |
| ⋯ |
| 912 | 924 | | for id in a.running.iter().take(10) { |
| 913 | 925 | | if let Some(job) = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[id.as_str().into()])?.first::<JobRow>(None).await? { |
| 914 | 926 | | if job.status == "in_progress" && job.runner_id.as_deref() == Some(row.id.as_str()) { |
| 915 | | − | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?; |
| 927 | + | if seen_is_stale(job.seen_at.as_deref(), at) { |
| 928 | + | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?; |
| 929 | + | } |
| 916 | 930 | | active = Some((id.clone(), "workflow")); |
| 917 | 931 | | } else { |
| 918 | 932 | | cancel.push(id.clone()); |
| ⋯ |
| 921 | 935 | | } |
| 922 | 936 | | match self.db.prepare("SELECT * FROM runner_tasks WHERE id = ?").bind(&[id.as_str().into()])?.first::<TaskRow>(None).await? { |
| 923 | 937 | | Some(task) if task.status == "in_progress" && task.runner_id.as_deref() == Some(row.id.as_str()) => { |
| 924 | | − | self.db.prepare("UPDATE runner_tasks SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?; |
| 938 | + | if seen_is_stale(task.seen_at.as_deref(), at) { |
| 939 | + | self.db.prepare("UPDATE runner_tasks SET seen_at = ? WHERE id = ?").bind(&[seen.as_str().into(), id.as_str().into()])?.run().await?; |
| 940 | + | } |
| 925 | 941 | | active = Some((id.clone(), "agent")); |
| 926 | 942 | | } |
| 927 | 943 | | _ => cancel.push(id.clone()), |
| 928 | 944 | | } |
| 929 | 945 | | } |
| 930 | | − | self.db |
| 931 | | − | .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ? WHERE id = ?") |
| 932 | | − | .bind(&[ |
| 933 | | − | seen.as_str().into(), |
| 934 | | − | (if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() }).into(), |
| 935 | | − | optional(active.as_ref().map(|(id, _)| id.as_str())), |
| 936 | | − | optional(active.as_ref().map(|(_, kind)| *kind)), |
| 937 | | − | row.id.as_str().into(), |
| 938 | | − | ])? |
| 939 | | − | .run() |
| 940 | | − | .await?; |
| 946 | + | // Written when something changed, or once a minute to keep it online. |
| 947 | + | let version: String = if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() }; |
| 948 | + | let work_id = active.as_ref().map(|(id, _)| id.as_str()); |
| 949 | + | let work_kind = active.as_ref().map(|(_, kind)| *kind); |
| 950 | + | let changed = version != row.version || work_id != row.work_id.as_deref() || work_kind != row.work_kind.as_deref(); |
| 951 | + | if changed || seen_is_stale(row.last_seen_at.as_deref(), at) { |
| 952 | + | self.db |
| 953 | + | .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ? WHERE id = ?") |
| 954 | + | .bind(&[seen.as_str().into(), version.into(), optional(work_id), optional(work_kind), row.id.as_str().into()])? |
| 955 | + | .run() |
| 956 | + | .await?; |
| 957 | + | } |
| 941 | 958 | | let mut poll = Poll { assignment: None, cancel, credential, removed: false }; |
| 942 | 959 | | // Busy, or an ephemeral runner that has had its one job. |
| 943 | 960 | | if active.is_some() || !a.running.is_empty() { |
| ⋯ |
| 1543 | 1560 | | } |
| 1544 | 1561 | | |
| 1545 | 1562 | | #[test] |
| 1563 | + | fn being_seen_is_written_once_a_minute() { |
| 1564 | + | let at = 2_000_000_000_000; |
| 1565 | + | assert!(seen_is_stale(None, at)); |
| 1566 | + | assert!(!seen_is_stale(Some(&rfc3339(at - 59_000)), at)); |
| 1567 | + | assert!(seen_is_stale(Some(&rfc3339(at - 61_000)), at)); |
| 1568 | + | // A runner written down at the last moment is still online until the |
| 1569 | + | // next write, a poll later. |
| 1570 | + | assert!(SEEN_RESOLUTION_MS + model::MAX_POLL_WAIT_MS < model::ONLINE_WITHIN_MS); |
| 1571 | + | assert!(SEEN_RESOLUTION_MS < crate::SILENT_MS); |
| 1572 | + | } |
| 1573 | + | |
| 1574 | + | #[test] |
| 1546 | 1575 | | fn a_group_lets_its_repositories_use_it() { |
| 1547 | 1576 | | assert!(group(&[]).allows("acme/web")); |
| 1548 | 1577 | | assert!(group(&["web", "api"]).allows("acme/WEB")); |