| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | use std::collections::HashSet; |
| 6 | use std::time::Duration; |
| 7 | |
| 8 | use forge_core::time::now_stamp; |
| 9 | use time::OffsetDateTime; |
| 10 | use time::format_description::well_known::Rfc3339; |
| 11 | use tokio::sync::mpsc; |
| 12 | |
| 13 | use super::{policy, run_one}; |
| 14 | use crate::state::AppState; |
| 15 | use crate::webhooks::client::WebhookPolicy; |
| 16 | |
| 17 | |
| 18 | const BACKSTOP: Duration = Duration::from_secs(15 * 60); |
| 19 | |
| 20 | const MIN_SLEEP: Duration = Duration::from_secs(1); |
| 21 | |
| 22 | const MAX_RUNNING: usize = 2; |
| 23 | |
| 24 | const CLAIM_BATCH: u32 = 20; |
| 25 | |
| 26 | |
| 27 | pub fn spawn(state: AppState) { |
| 28 | tokio::spawn(run(state)); |
| 29 | } |
| 30 | |
| 31 | async fn run(state: AppState) { |
| 32 | let policy = policy(&state); |
| 33 | let (done_tx, mut done_rx) = mpsc::unbounded_channel::<(String, String)>(); |
| 34 | let mut running_mirrors: HashSet<String> = HashSet::new(); |
| 35 | let mut running_repos: HashSet<String> = HashSet::new(); |
| 36 | loop { |
| 37 | claim( |
| 38 | &state, |
| 39 | &policy, |
| 40 | &done_tx, |
| 41 | &mut running_mirrors, |
| 42 | &mut running_repos, |
| 43 | ) |
| 44 | .await; |
| 45 | |
| 46 | |
| 47 | let nap = match state.store.next_mirror_due().await { |
| 48 | Ok(next) => sleep_for(next.as_deref(), OffsetDateTime::now_utc()), |
| 49 | Err(e) => { |
| 50 | tracing::error!(error = %e, "mirrors: could not read the next due time"); |
| 51 | BACKSTOP |
| 52 | } |
| 53 | }; |
| 54 | tokio::select! { |
| 55 | _ = tokio::time::sleep(nap) => {} |
| 56 | _ = state.mirror_waker.notified() => {} |
| 57 | Some((mirror_id, repo_id)) = done_rx.recv() => { |
| 58 | running_mirrors.remove(&mirror_id); |
| 59 | running_repos.remove(&repo_id); |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | |
| 66 | |
| 67 | |
| 68 | pub fn sleep_for(next_due: Option<&str>, now: OffsetDateTime) -> Duration { |
| 69 | let Some(due) = next_due.and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()) else { |
| 70 | return BACKSTOP; |
| 71 | }; |
| 72 | let until = Duration::try_from(due - now).unwrap_or(Duration::ZERO); |
| 73 | until.clamp(MIN_SLEEP, BACKSTOP) |
| 74 | } |
| 75 | |
| 76 | |
| 77 | |
| 78 | async fn claim( |
| 79 | state: &AppState, |
| 80 | policy: &WebhookPolicy, |
| 81 | done_tx: &mpsc::UnboundedSender<(String, String)>, |
| 82 | running_mirrors: &mut HashSet<String>, |
| 83 | running_repos: &mut HashSet<String>, |
| 84 | ) { |
| 85 | let due = match state.store.due_mirrors(&now_stamp(), CLAIM_BATCH).await { |
| 86 | Ok(due) => due, |
| 87 | Err(e) => { |
| 88 | tracing::error!(error = %e, "mirrors: could not list due mirrors"); |
| 89 | return; |
| 90 | } |
| 91 | }; |
| 92 | for mirror in due { |
| 93 | if running_mirrors.len() >= MAX_RUNNING { |
| 94 | break; |
| 95 | } |
| 96 | if running_mirrors.contains(&mirror.id) || running_repos.contains(&mirror.repo_id) { |
| 97 | continue; |
| 98 | } |
| 99 | running_mirrors.insert(mirror.id.clone()); |
| 100 | running_repos.insert(mirror.repo_id.clone()); |
| 101 | let (mirror_id, repo_id) = (mirror.id.clone(), mirror.repo_id.clone()); |
| 102 | let state = state.clone(); |
| 103 | let policy = policy.clone(); |
| 104 | let done = done_tx.clone(); |
| 105 | tokio::spawn(async move { |
| 106 | run_one(&state, &mirror, &policy).await; |
| 107 | let _ = done.send((mirror_id, repo_id)); |
| 108 | }); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | #[cfg(test)] |
| 113 | mod tests { |
| 114 | use std::path::Path; |
| 115 | |
| 116 | use forge_core::secret; |
| 117 | use forge_core::webhooks::WebhookEvent; |
| 118 | |
| 119 | use super::super::pull_refs; |
| 120 | use crate::test_support::test_state; |
| 121 | |
| 122 | #[test] |
| 123 | fn sleep_stops_at_the_next_due_time_and_is_clamped() { |
| 124 | use std::time::Duration; |
| 125 | |
| 126 | use time::OffsetDateTime; |
| 127 | use time::format_description::well_known::Rfc3339; |
| 128 | use time::macros::datetime; |
| 129 | |
| 130 | let now = datetime!(2026-10-15 12:00:00 UTC); |
| 131 | let stamp = |t: OffsetDateTime| t.format(&Rfc3339).expect("stamp"); |
| 132 | |
| 133 | assert_eq!(super::sleep_for(None, now), super::BACKSTOP); |
| 134 | |
| 135 | let in_10s = stamp(now + time::Duration::seconds(10)); |
| 136 | assert_eq!( |
| 137 | super::sleep_for(Some(&in_10s), now), |
| 138 | Duration::from_secs(10) |
| 139 | ); |
| 140 | |
| 141 | let in_1h = stamp(now + time::Duration::hours(1)); |
| 142 | assert_eq!(super::sleep_for(Some(&in_1h), now), super::BACKSTOP); |
| 143 | |
| 144 | let past = stamp(now - time::Duration::seconds(5)); |
| 145 | assert_eq!(super::sleep_for(Some(&past), now), super::MIN_SLEEP); |
| 146 | |
| 147 | assert_eq!(super::sleep_for(Some("soon"), now), super::BACKSTOP); |
| 148 | } |
| 149 | |
| 150 | |
| 151 | fn plumbing(git_dir: &Path, args: &[&str]) -> String { |
| 152 | let out = std::process::Command::new("git") |
| 153 | .arg(format!("--git-dir={}", git_dir.display())) |
| 154 | .args(args) |
| 155 | .env("GIT_AUTHOR_NAME", "t") |
| 156 | .env("GIT_AUTHOR_EMAIL", "t@example.com") |
| 157 | .env("GIT_COMMITTER_NAME", "t") |
| 158 | .env("GIT_COMMITTER_EMAIL", "t@example.com") |
| 159 | .output() |
| 160 | .expect("git runs"); |
| 161 | assert!(out.status.success(), "git {args:?}: {out:?}"); |
| 162 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 163 | } |
| 164 | |
| 165 | |
| 166 | fn commit_to(git_dir: &Path, refname: &str, message: &str) { |
| 167 | let tree = plumbing(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]); |
| 168 | let commit = plumbing(git_dir, &["commit-tree", &tree, "-m", message]); |
| 169 | plumbing(git_dir, &["update-ref", refname, &commit]); |
| 170 | } |
| 171 | |
| 172 | #[tokio::test] |
| 173 | async fn pull_sync_passes_only_changed_refs_to_post_update() { |
| 174 | let dir = tempfile::tempdir().expect("tempdir"); |
| 175 | let state = test_state(dir.path()).await; |
| 176 | let owner = state |
| 177 | .store |
| 178 | .create_user("alice", None, "unused", false) |
| 179 | .await |
| 180 | .expect("user"); |
| 181 | let repo = state |
| 182 | .store |
| 183 | .create_repo( |
| 184 | &owner.id, |
| 185 | "mirrored", |
| 186 | "", |
| 187 | forge_core::Visibility::Public, |
| 188 | "main", |
| 189 | ) |
| 190 | .await |
| 191 | .expect("repo"); |
| 192 | let bare = forge_git::repo::repo_path(&state.git.data_dir, &repo.id).expect("repo path"); |
| 193 | forge_git::repo::init_bare(&state.git, &bare, "main") |
| 194 | .await |
| 195 | .expect("init bare"); |
| 196 | |
| 197 | let sealed = secret::seal(&[9u8; 32], b"hook-secret") |
| 198 | .expect("seal") |
| 199 | .into_bytes(); |
| 200 | let hook = state |
| 201 | .store |
| 202 | .create_webhook( |
| 203 | &repo.id, |
| 204 | &owner.id, |
| 205 | "https://example.com/hook", |
| 206 | &sealed, |
| 207 | &[WebhookEvent::Push], |
| 208 | true, |
| 209 | ) |
| 210 | .await |
| 211 | .expect("hook"); |
| 212 | |
| 213 | |
| 214 | let upstream = bare.clone(); |
| 215 | let first = pull_refs(&state, &repo, async { |
| 216 | commit_to(&upstream, "refs/heads/main", "one"); |
| 217 | commit_to(&upstream, "refs/tags/v1", "tagged"); |
| 218 | Ok(()) |
| 219 | }) |
| 220 | .await |
| 221 | .expect("first sync"); |
| 222 | assert_eq!(first, 2); |
| 223 | let deliveries = state |
| 224 | .store |
| 225 | .list_deliveries(&hook.id, 10) |
| 226 | .await |
| 227 | .expect("deliveries"); |
| 228 | assert!( |
| 229 | deliveries.iter().any(|d| d.event == WebhookEvent::Push), |
| 230 | "a changed branch queues a push event" |
| 231 | ); |
| 232 | |
| 233 | |
| 234 | let idle = pull_refs(&state, &repo, async { Ok(()) }) |
| 235 | .await |
| 236 | .expect("idle sync"); |
| 237 | assert_eq!(idle, 0); |
| 238 | |
| 239 | |
| 240 | let moved = pull_refs(&state, &repo, async { |
| 241 | commit_to(&upstream, "refs/heads/main", "two"); |
| 242 | Ok(()) |
| 243 | }) |
| 244 | .await |
| 245 | .expect("moved sync"); |
| 246 | assert_eq!(moved, 1); |
| 247 | |
| 248 | |
| 249 | let failed = pull_refs(&state, &repo, async { Err("boom".to_string()) }).await; |
| 250 | assert_eq!(failed.unwrap_err(), "boom"); |
| 251 | } |
| 252 | } |