forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0

⚡ perf: replace polling loops with event-driven wakes

Tahinli/forge

c5248ecc7c3c2cd83c0488155b8c41460f1c261f

Author Ahmet Kaan Gümüş ·

Parent: c1ec07a

Browse files at this commit

⚡ perf: replace polling loops with event-driven wakes

- workers sleep until their next due time, with a 15-minute backstop
- ci claim wakes all runners

11 changed files +278 -114

crates/forge-core/src/store/webhooks.rsmodified+15 -0
@@ -450,6 +450,21 @@ impl Store {
450450 finish(tx, outcome).await
451451 }
452452
453+ /// The earliest `next_attempt_at` among pending and failed deliveries, or `None` when none is
454+ /// pending. A claimed delivery carries its lease expiry here, so the lease counts as due time.
455+ pub async fn next_delivery_due(&self) -> Result<Option<String>> {
456+ let conn = self.read_conn().await;
457+ fetch_opt(
458+ &conn,
459+ "SELECT MIN(next_attempt_at) FROM webhook_deliveries \
460+ WHERE state IN ('pending', 'failed')",
461+ (),
462+ |row| col_opt_text(row, 0),
463+ )
464+ .await
465+ .map(Option::flatten)
466+ }
467+
453468 /// Records the outcome of one attempt. See [`AttemptOutcome`] for the resulting state.
454469 /// On success `delivered_at` is stamped and `next_attempt_at` is cleared. The response body
455470 /// is truncated to 64 KiB. `NotFound` if the delivery does not exist.
crates/forge-web/src/advisories/worker.rsmodified+66 -29
@@ -1,8 +1,10 @@
11 //! The advisory worker. When `[advisories] enabled = true`, it keeps an OSV dump per configured
2-//! ecosystem in `<data_dir>/advisories/<ecosystem>/all.zip`. A dump older than `refresh_hours` is
3-//! downloaded again (capped at [`MAX_DUMP_BYTES`], with timeouts, no redirects). A dump that is
4-//! newer than the last import is imported, and then every repo is rescanned. It checks once an
5-//! hour, and once at boot, so an existing dump is imported without a download.
2+//! ecosystem in `<data_dir>/advisories/<ecosystem>/all.zip`. Each dump is downloaded again
3+//! `refresh_hours` after its last refresh (the file's modification time), sleeping until then
4+//! (capped at [`MAX_DUMP_BYTES`], with timeouts, no redirects). A failed download is retried with
5+//! a backoff that doubles from [`RETRY_FIRST`] up to [`RETRY_MAX`]. A dump that is newer than the
6+//! last import is imported, and then every repo is rescanned. At boot, a fresh dump is imported
7+//! without a download.
68
79 use std::collections::HashMap;
810 use std::fs::{self, File};
@@ -16,8 +18,10 @@ use forge_core::{Error, Result};
1618 use super::scan::scan_all;
1719 use crate::state::AppState;
1820
19-/// How often the worker checks the dumps.
20-const CHECK_EVERY: Duration = Duration::from_secs(60 * 60);
21+/// First wait after a failed download. It doubles with each further failure.
22+const RETRY_FIRST: Duration = Duration::from_secs(5 * 60);
23+/// Longest wait after a failed download.
24+const RETRY_MAX: Duration = Duration::from_secs(60 * 60);
2125 /// Largest dump accepted, in bytes.
2226 const MAX_DUMP_BYTES: u64 = 500 * 1024 * 1024;
2327 const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -57,40 +61,83 @@ async fn run(state: AppState) {
5761 return;
5862 }
5963 };
64+ let refresh_after = Duration::from_secs(state.config.advisories.refresh_hours.max(1) * 3600);
6065 let mut imported: HashMap<String, SystemTime> = HashMap::new();
66+ // When each ecosystem's dump is next downloaded, and how many downloads in a row failed.
67+ let mut due: HashMap<String, SystemTime> = HashMap::new();
68+ let mut failures: HashMap<String, u32> = HashMap::new();
6169 loop {
70+ let mut wake_at: Option<SystemTime> = None;
6271 for ecosystem in &state.config.advisories.ecosystems {
6372 if !KNOWN_ECOSYSTEMS.contains(&ecosystem.as_str()) {
6473 tracing::warn!(ecosystem = %ecosystem, "advisories: unknown ecosystem, skipped");
6574 continue;
6675 }
67- if let Err(e) = refresh_one(&state, &client, ecosystem, &mut imported).await {
68- tracing::error!(error = %e, ecosystem = %ecosystem, "advisories: refresh failed");
76+ let path = dump_path(&state.config.data_dir, ecosystem);
77+ let mut refresh_at = *due
78+ .entry(ecosystem.clone())
79+ .or_insert_with(|| next_refresh_at(&path, refresh_after));
80+ let now = SystemTime::now();
81+ if refresh_at <= now {
82+ refresh_at = match download(&client, ecosystem, &path).await {
83+ Ok(()) => {
84+ failures.remove(ecosystem);
85+ next_refresh_at(&path, refresh_after)
86+ }
87+ Err(e) => {
88+ tracing::error!(error = %e, ecosystem = %ecosystem, "advisories: refresh failed");
89+ let count = failures.entry(ecosystem.clone()).or_insert(0);
90+ *count += 1;
91+ now + retry_delay(*count)
92+ }
93+ };
94+ due.insert(ecosystem.clone(), refresh_at);
6995 }
96+ if let Err(e) = import_if_changed(&state, ecosystem, &path, &mut imported).await {
97+ tracing::error!(error = %e, ecosystem = %ecosystem, "advisories: import failed");
98+ }
99+ wake_at = Some(wake_at.map_or(refresh_at, |at| at.min(refresh_at)));
70100 }
71- tokio::time::sleep(CHECK_EVERY).await;
101+ let Some(wake_at) = wake_at else {
102+ tracing::info!("advisories: no known ecosystems configured, worker stopped");
103+ return;
104+ };
105+ let wait = wake_at
106+ .duration_since(SystemTime::now())
107+ .unwrap_or(Duration::ZERO);
108+ tokio::time::sleep(wait).await;
72109 }
73110 }
74111
75-/// Downloads `ecosystem`'s dump if it is stale, then imports it if it changed since the last import.
76-async fn refresh_one(
112+/// When the dump at `path` is due for its next download: `refresh_after` past its last refresh.
113+/// A missing dump is due now.
114+fn next_refresh_at(path: &Path, refresh_after: Duration) -> SystemTime {
115+ match modified_time(path) {
116+ None => SystemTime::now(),
117+ Some(last) => last + refresh_after,
118+ }
119+}
120+
121+/// The wait after the `failures`-th failed download in a row: 5 minutes, doubling, capped at an hour.
122+fn retry_delay(failures: u32) -> Duration {
123+ let shift = failures.saturating_sub(1).min(16);
124+ (RETRY_FIRST * 2u32.pow(shift)).min(RETRY_MAX)
125+}
126+
127+/// Imports the dump of `ecosystem` if it changed since the last import, then rescans every repo.
128+async fn import_if_changed(
77129 state: &AppState,
78- client: &reqwest::Client,
79130 ecosystem: &str,
131+ path: &Path,
80132 imported: &mut HashMap<String, SystemTime>,
81133 ) -> Result<()> {
82- let path = dump_path(&state.config.data_dir, ecosystem);
83- let max_age = Duration::from_secs(state.config.advisories.refresh_hours.max(1) * 3600);
84- if is_older_than(&path, max_age) {
85- download(client, ecosystem, &path).await?;
86- }
87- let Some(modified) = modified_time(&path) else {
134+ let Some(modified) = modified_time(path) else {
88135 return Ok(());
89136 };
90137 if imported.get(ecosystem) == Some(&modified) {
91138 return Ok(());
92139 }
93- import(state, ecosystem, &path).await?;
140+ import(state, ecosystem, path).await?;
94141 imported.insert(ecosystem.to_owned(), modified);
95142 scan_all(state).await;
96143 Ok(())
@@ -100,16 +147,6 @@ fn modified_time(path: &Path) -> Option<SystemTime> {
100147 fs::metadata(path).and_then(|m| m.modified()).ok()
101148 }
102149
103-/// True when the file is missing or was written more than `max_age` ago.
104-fn is_older_than(path: &Path, max_age: Duration) -> bool {
105- match modified_time(path) {
106- None => true,
107- Some(at) => SystemTime::now()
108- .duration_since(at)
109- .is_ok_and(|age| age > max_age),
110- }
111-}
112-
113150 /// Streams the dump to `<path>.part` and renames it into place once complete. A response that is
114151 /// too large, or that fails midway, leaves the previous dump in place.
115152 async fn download(client: &reqwest::Client, ecosystem: &str, path: &Path) -> Result<()> {
crates/forge-web/src/backup/scheduler.rsmodified+79 -45
@@ -17,10 +17,11 @@ use super::{
1717 };
1818 use crate::state::AppState;
1919
20-/// How often the scheduler looks at the clock.
21-const TICK: Duration = Duration::from_secs(30);
2220 /// After a failed run, the next try waits at least this long.
2321 const RETRY_AFTER_SECS: i64 = 60 * 60;
22+/// Pause after each pass. A due job that did not clear its due time (it could not take the run
23+/// slot while a manual run held it, say) waits this long instead of spinning the loop.
24+const RECHECK: Duration = Duration::from_secs(30);
2425
2526 /// The one run slot of this process.
2627 #[derive(Default)]
@@ -101,61 +102,87 @@ pub fn secs_until_due(
101102 }
102103
103104 /// Starts the scheduler loop. The caller checks that `[backup] dir` is set.
105+///
106+/// Each pass sleeps until the earliest of the backup and fsck is due, runs what is due, and then
107+/// works out the next instant from the run history again.
104108 pub fn spawn(state: AppState) -> tokio::task::JoinHandle<()> {
105109 tokio::spawn(async move {
106110 loop {
107- tick(&state).await;
108- tokio::time::sleep(TICK).await;
111+ match next_due_in(&state).await {
112+ Ok(wait) => {
113+ let until = tokio::time::Instant::now()
114+ + Duration::from_secs(u64::try_from(wait).unwrap_or(0));
115+ tokio::time::sleep_until(until).await;
116+ }
117+ Err(e) => {
118+ tracing::error!(error = %e, "backup scheduler could not read its history");
119+ tokio::time::sleep(RECHECK).await;
120+ continue;
121+ }
122+ }
123+ run_due(&state).await;
124+ tokio::time::sleep(RECHECK).await;
109125 }
110126 })
111127 }
112128
113-/// One pass of the loop: runs the backup and the fsck when either is due.
114-async fn tick(state: &AppState) {
129+/// How often a job of this kind is due, in seconds.
130+fn interval_secs(state: &AppState, kind: RunKind) -> i64 {
115131 let cfg = &state.config.backup;
116- let backup_every = i64::try_from(cfg.interval_hours.saturating_mul(3600)).unwrap_or(i64::MAX);
117- let fsck_every =
118- i64::try_from(cfg.fsck_interval_days.saturating_mul(86_400)).unwrap_or(i64::MAX);
119- let backup_wait = match due_inputs(state, RunKind::Backup).await {
120- Ok((ok, attempt)) => secs_until_due(
121- OffsetDateTime::now_utc(),
122- ok.as_ref(),
123- attempt.as_ref(),
124- backup_every,
125- RETRY_AFTER_SECS,
126- ),
127- Err(e) => {
128- tracing::error!(error = %e, "backup scheduler could not read its history");
129- return;
132+ match kind {
133+ RunKind::Backup => {
134+ i64::try_from(cfg.interval_hours.saturating_mul(3600)).unwrap_or(i64::MAX)
130135 }
131- };
132- if backup_wait == 0 {
133- // A manual run holding the slot just means this run waits for the next tick.
134- if let Ok(guard) = state.jobs.claim(RunKind::Backup) {
135- if let Err(e) = scheduled_backup(state).await {
136- tracing::error!(error = %e, "scheduled backup failed");
137- }
138- drop(guard);
136+ RunKind::Fsck => {
137+ i64::try_from(cfg.fsck_interval_days.saturating_mul(86_400)).unwrap_or(i64::MAX)
139138 }
140139 }
141- let fsck_wait = match due_inputs(state, RunKind::Fsck).await {
142- Ok((ok, attempt)) => secs_until_due(
143- OffsetDateTime::now_utc(),
144- ok.as_ref(),
145- attempt.as_ref(),
146- fsck_every,
147- RETRY_AFTER_SECS,
148- ),
149- Err(e) => {
150- tracing::error!(error = %e, "fsck scheduler could not read its history");
151- return;
140+}
141+
142+/// Seconds until a job of this kind is due, from its run history. Zero means run now.
143+async fn secs_until_run(state: &AppState, kind: RunKind) -> Result<i64> {
144+ let (ok, attempt) = due_inputs(state, kind).await?;
145+ Ok(secs_until_due(
146+ OffsetDateTime::now_utc(),
147+ ok.as_ref(),
148+ attempt.as_ref(),
149+ interval_secs(state, kind),
150+ RETRY_AFTER_SECS,
151+ ))
152+}
153+
154+/// Seconds until the earlier of the backup and the fsck is due. Zero means one is due now.
155+async fn next_due_in(state: &AppState) -> Result<i64> {
156+ let backup = secs_until_run(state, RunKind::Backup).await?;
157+ let fsck = secs_until_run(state, RunKind::Fsck).await?;
158+ Ok(backup.min(fsck))
159+}
160+
161+/// Runs each job that is due now. A job that cannot take the run slot, because a manual run holds
162+/// it, is left for the next pass.
163+async fn run_due(state: &AppState) {
164+ for kind in [RunKind::Backup, RunKind::Fsck] {
165+ let due = match secs_until_run(state, kind).await {
166+ Ok(wait) => wait == 0,
167+ Err(e) => {
168+ tracing::error!(error = %e, kind = kind.as_str(), "scheduler could not read its history");
169+ false
170+ }
171+ };
172+ if !due {
173+ continue;
152174 }
153- };
154- if fsck_wait == 0
155- && let Ok(guard) = state.jobs.claim(RunKind::Fsck)
156- {
157- if let Err(e) = fsck_job(state, RunTrigger::Schedule, false).await {
158- tracing::error!(error = %e, "scheduled fsck failed");
175+ let Ok(guard) = state.jobs.claim(kind) else {
176+ continue;
177+ };
178+ let outcome = match kind {
179+ RunKind::Backup => scheduled_backup(state).await.map(|_| ()),
180+ RunKind::Fsck => fsck_job(state, RunTrigger::Schedule, false)
181+ .await
182+ .map(|_| ()),
183+ };
184+ if let Err(e) = outcome {
185+ tracing::error!(error = %e, kind = kind.as_str(), "scheduled run failed");
159186 }
160187 drop(guard);
161188 }
@@ -463,6 +490,13 @@ mod tests {
463490 assert_eq!(state.jobs.running(), None);
464491 }
465492
493+ #[tokio::test]
494+ async fn a_fresh_install_wakes_at_once() {
495+ let dir = tempfile::tempdir().expect("tempdir");
496+ let state = crate::test_support::test_state(dir.path()).await;
497+ assert_eq!(next_due_in(&state).await.expect("next due"), 0);
498+ }
499+
466500 #[tokio::test]
467501 async fn scheduled_backup_is_recorded_verified_and_pinged() {
468502 let dir = tempfile::tempdir().expect("tempdir");
crates/forge-web/src/ci/reaper.rsmodified+3 -1
@@ -58,6 +58,8 @@ pub async fn reap_before(state: &AppState, older_than: &str) -> usize {
5858 let mut seen = HashSet::new();
5959 let mut wake = false;
6060 for job in &jobs {
61+ state.ci_live.close_job(&job.id);
62+ state.ci_live.bump_run(&job.run_id);
6163 if !seen.insert(job.run_id.clone()) {
6264 continue;
6365 }
@@ -73,7 +75,7 @@ pub async fn reap_before(state: &AppState, older_than: &str) -> usize {
7375 }
7476 }
7577 if wake {
76- state.ci_waker.notify_one();
78+ state.ci_waker.notify_waiters();
7779 }
7880 if !jobs.is_empty() {
7981 tracing::warn!(count = jobs.len(), "ci: reaped lost jobs");
crates/forge-web/src/ci/schedule.rsmodified+1 -1
@@ -243,7 +243,7 @@ async fn create(
243243 .await;
244244 }
245245 if sync_run_statuses(state, &run).await? {
246- state.ci_waker.notify_one();
246+ state.ci_waker.notify_waiters();
247247 }
248248 Ok(run)
249249 }
crates/forge-web/src/mail/worker.rsmodified+12 -11
@@ -13,8 +13,8 @@ use time::format_description::well_known::Rfc3339;
1313
1414 use crate::state::AppState;
1515
16-/// The longest the worker sleeps when nothing wakes it.
17-const POLL: Duration = Duration::from_secs(60);
16+/// The longest the worker sleeps. It guards against a missed wake, not polling.
17+const BACKSTOP: Duration = Duration::from_secs(15 * 60);
1818 /// The shortest sleep, so a due time that has already passed does not spin the loop.
1919 const MIN_SLEEP: Duration = Duration::from_secs(1);
2020 /// Messages leased per pass.
@@ -33,7 +33,7 @@ async fn run(state: AppState) {
3333 Ok(nap) => nap,
3434 Err(e) => {
3535 tracing::error!(error = %e, "mail: could not run a pass");
36- POLL
36+ BACKSTOP
3737 }
3838 };
3939 tokio::select! {
@@ -47,7 +47,8 @@ async fn run(state: AppState) {
4747 async fn tick(state: &AppState) -> forge_core::Result<Duration> {
4848 let settings = state.store.mail_settings().await?;
4949 if !settings.enabled {
50- return Ok(POLL);
50+ // Turning mail on notifies the waker; the backstop only guards a missed wake.
51+ return Ok(BACKSTOP);
5152 }
5253 let now = now_stamp();
5354 let lease_until = stamp_after(LEASE);
@@ -93,13 +94,13 @@ async fn deliver(state: &AppState, entry: &OutboxEntry) {
9394 }
9495
9596 /// How long the worker sleeps: until `next_due` (an RFC 3339 stamp), clamped to
96-/// `MIN_SLEEP..=POLL`. `None` or an unparsable stamp gives `POLL`.
97+/// `MIN_SLEEP..=BACKSTOP`. `None` (nothing queued) or an unparsable stamp gives `BACKSTOP`.
9798 pub fn sleep_for(next_due: Option<&str>, now: OffsetDateTime) -> Duration {
9899 let Some(due) = next_due.and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()) else {
99- return POLL;
100+ return BACKSTOP;
100101 };
101102 let until = Duration::try_from(due - now).unwrap_or(Duration::ZERO);
102- until.clamp(MIN_SLEEP, POLL)
103+ until.clamp(MIN_SLEEP, BACKSTOP)
103104 }
104105
105106 #[cfg(test)]
@@ -140,14 +141,14 @@ mod tests {
140141
141142 let now = datetime!(2026-10-15 12:00:00 UTC);
142143 let stamp = |t: OffsetDateTime| t.format(&Rfc3339).expect("stamp");
143- assert_eq!(sleep_for(None, now), POLL);
144+ assert_eq!(sleep_for(None, now), BACKSTOP);
144145 let in_10s = stamp(now + time::Duration::seconds(10));
145146 assert_eq!(sleep_for(Some(&in_10s), now), Duration::from_secs(10));
146147 let in_1h = stamp(now + time::Duration::hours(1));
147- assert_eq!(sleep_for(Some(&in_1h), now), POLL);
148+ assert_eq!(sleep_for(Some(&in_1h), now), BACKSTOP);
148149 let past = stamp(now - time::Duration::seconds(5));
149150 assert_eq!(sleep_for(Some(&past), now), MIN_SLEEP);
150- assert_eq!(sleep_for(Some("soon"), now), POLL);
151+ assert_eq!(sleep_for(Some("soon"), now), BACKSTOP);
151152 }
152153
153154 #[tokio::test]
@@ -197,7 +198,7 @@ mod tests {
197198 enqueue(&state, "k", "a@example.com", "Hi", "body")
198199 .await
199200 .expect("queued");
200- assert_eq!(tick(&state).await.expect("pass"), POLL);
201+ assert_eq!(tick(&state).await.expect("pass"), BACKSTOP);
201202 assert!(fake.sent().is_empty());
202203 let row = state.store.recent_mail(10).await.expect("rows");
203204 assert_eq!(row[0].status(), OutboxStatus::Queued);
crates/forge-web/src/mirrors/worker.rsmodified+19 -20
@@ -1,6 +1,6 @@
1-//! The mirror sync worker. It polls for due mirrors every minute, and wakes at once when
2-//! `AppState::mirror_waker` is notified (a "sync now", a new mirror, or a push). At most two runs
3-//! are in flight, and never two on the same repo.
1+//! The mirror sync worker. It sleeps until the next due mirror, and wakes at once when
2+//! `AppState::mirror_waker` is notified (a "sync now", a new mirror, or a push). With nothing due
3+//! it waits on the waker alone. At most two runs are in flight, and never two on the same repo.
44
55 use std::collections::HashSet;
66 use std::time::Duration;
@@ -14,8 +14,8 @@ use super::{policy, run_one};
1414 use crate::state::AppState;
1515 use crate::webhooks::client::WebhookPolicy;
1616
17-/// The longest the worker sleeps when nothing wakes it.
18-const POLL: Duration = Duration::from_secs(60);
17+/// The longest the worker sleeps. It guards against a missed wake, not polling.
18+const BACKSTOP: Duration = Duration::from_secs(15 * 60);
1919 /// The shortest sleep, so a due time that has already passed does not spin the loop.
2020 const MIN_SLEEP: Duration = Duration::from_secs(1);
2121 /// Runs in flight at once.
@@ -44,14 +44,13 @@ async fn run(state: AppState) {
4444 .await;
4545 // Wake when the earliest enabled mirror is due, so a push mirror queued with a short
4646 // debounce runs on time instead of waiting for the next poll.
47- let next = match state.store.next_mirror_due().await {
48- Ok(next) => next,
47+ let nap = match state.store.next_mirror_due().await {
48+ Ok(next) => sleep_for(next.as_deref(), OffsetDateTime::now_utc()),
4949 Err(e) => {
5050 tracing::error!(error = %e, "mirrors: could not read the next due time");
51- None
51+ BACKSTOP
5252 }
5353 };
54- let nap = sleep_for(next.as_deref(), OffsetDateTime::now_utc());
5554 tokio::select! {
5655 _ = tokio::time::sleep(nap) => {}
5756 _ = state.mirror_waker.notified() => {}
@@ -64,14 +63,14 @@ async fn run(state: AppState) {
6463 }
6564
6665 /// How long the worker sleeps before its next pass: until `next_due` (an RFC 3339 stamp, the
67-/// earliest enabled mirror), clamped to `MIN_SLEEP..=POLL`. `None` or an unparsable stamp gives
68-/// `POLL`.
66+/// earliest enabled mirror), clamped to `MIN_SLEEP..=BACKSTOP`. `None` (nothing enabled) or an
67+/// unparsable stamp gives `BACKSTOP`.
6968 pub fn sleep_for(next_due: Option<&str>, now: OffsetDateTime) -> Duration {
7069 let Some(due) = next_due.and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()) else {
71- return POLL;
70+ return BACKSTOP;
7271 };
7372 let until = Duration::try_from(due - now).unwrap_or(Duration::ZERO);
74- until.clamp(MIN_SLEEP, POLL)
73+ until.clamp(MIN_SLEEP, BACKSTOP)
7574 }
7675
7776 /// Starts runs for due mirrors until the concurrency limit, skipping mirrors and repos already
@@ -130,22 +129,22 @@ mod tests {
130129
131130 let now = datetime!(2026-10-15 12:00:00 UTC);
132131 let stamp = |t: OffsetDateTime| t.format(&Rfc3339).expect("stamp");
133- // Nothing enabled: the full poll interval.
134- assert_eq!(super::sleep_for(None, now), super::POLL);
135- // A push queued with a 10 s debounce: sleep until it is due, not the full poll.
132+ // Nothing enabled: the backstop.
133+ assert_eq!(super::sleep_for(None, now), super::BACKSTOP);
134+ // A push queued with a 10 s debounce: sleep until it is due.
136135 let in_10s = stamp(now + time::Duration::seconds(10));
137136 assert_eq!(
138137 super::sleep_for(Some(&in_10s), now),
139138 Duration::from_secs(10)
140139 );
141- // Due later than the poll interval: wake at the poll interval.
140+ // Due later than the backstop: wake at the backstop.
142141 let in_1h = stamp(now + time::Duration::hours(1));
143- assert_eq!(super::sleep_for(Some(&in_1h), now), super::POLL);
142+ assert_eq!(super::sleep_for(Some(&in_1h), now), super::BACKSTOP);
144143 // Already due (or in flight and still due): do not spin, wait the minimum.
145144 let past = stamp(now - time::Duration::seconds(5));
146145 assert_eq!(super::sleep_for(Some(&past), now), super::MIN_SLEEP);
147- // A stamp that does not parse falls back to the poll interval.
148- assert_eq!(super::sleep_for(Some("soon"), now), super::POLL);
146+ // A stamp that does not parse falls back to the backstop.
147+ assert_eq!(super::sleep_for(Some("soon"), now), super::BACKSTOP);
149148 }
150149
151150 /// Plain git against a bare repo, for setting up the fake upstream.
crates/forge-web/src/pages/repo_mirrors.rsmodified+4 -1
@@ -633,8 +633,9 @@ async fn store_mirror(
633633 .store
634634 .schedule_mirror(&mirror.id, &now_stamp())
635635 .await?;
636- state.mirror_waker.notify_one();
637636 }
637+ // A new mirror is due now, push or pull.
638+ state.mirror_waker.notify_one();
638639 Ok(Ok(mirror))
639640 }
640641
@@ -931,6 +932,8 @@ async fn mirror_update(cx: &Cx, Form(form): Form<EditForm>) -> Result<impl View>
931932 form.enabled.is_some(),
932933 )
933934 .await?;
935+ // The interval and the enabled flag decide when this mirror runs next.
936+ state.mirror_waker.notify_one();
934937 audit::record(
935938 &state.store,
936939 Some(cx),
crates/forge-web/src/replication/mod.rsmodified+11 -0
@@ -547,6 +547,16 @@ impl ReplicationQueue {
547547 due
548548 }
549549
550+ /// The earliest debounce due time among the queued repos, or `None` when nothing is queued.
551+ pub fn next_due(&self) -> Option<Instant> {
552+ self.pending
553+ .lock()
554+ .unwrap_or_else(PoisonError::into_inner)
555+ .values()
556+ .min()
557+ .copied()
558+ }
559+
550560 /// Asks the worker for a full reconcile pass as soon as it can.
551561 pub fn request_reconcile(&self) {
552562 self.reconcile.store(true, Ordering::SeqCst);
@@ -762,6 +772,7 @@ mod tests {
762772 let later = Instant::now() + PUSH_DEBOUNCE + Duration::from_millis(10);
763773 assert_eq!(queue.take_due(later), ["a", "b"]);
764774 assert!(queue.take_due(later).is_empty(), "drained");
775+ assert_eq!(queue.next_due(), None, "nothing left to wait for");
765776 assert!(!queue.take_reconcile_request());
766777 queue.request_reconcile();
767778 assert!(queue.take_reconcile_request());
crates/forge-web/src/replication/worker.rsmodified+15 -3
@@ -22,8 +22,8 @@ use super::{
2222 use crate::backup::healthcheck;
2323 use crate::state::AppState;
2424
25-/// How often the worker checks its queue and timers when nothing wakes it.
26-const TICK: Duration = Duration::from_secs(1);
25+/// Longest the worker sleeps with nothing due. Guards against a missed wake, not polling.
26+const BACKSTOP: Duration = Duration::from_secs(15 * 60);
2727
2828 /// Everything a replication pass needs. The web side builds one from `AppState`; tests build one
2929 /// around a `LocalTransport`.
@@ -83,9 +83,21 @@ async fn run(state: AppState) {
8383 let mut next_db = Instant::now();
8484 let mut next_reconcile = Instant::now();
8585 loop {
86+ // Sleep until the earliest debounce or calendar timer, or until something is queued.
87+ let backstop = Instant::now() + BACKSTOP;
88+ let next = [
89+ state.replication.next_due(),
90+ Some(next_db),
91+ Some(next_reconcile),
92+ Some(backstop),
93+ ]
94+ .into_iter()
95+ .flatten()
96+ .min()
97+ .unwrap_or(backstop);
8698 tokio::select! {
99+ () = tokio::time::sleep_until(tokio::time::Instant::from_std(next)) => {}
87100 () = state.replication.wait() => {}
88- () = tokio::time::sleep(TICK) => {}
89101 }
90102 for repo_id in state.replication.take_due(Instant::now()) {
91103 // A repo deleted since the update is skipped; the next reconcile removes its copy.
crates/forge-web/src/webhooks/worker.rsmodified+53 -3
@@ -15,6 +15,8 @@ use forge_core::time::{now_stamp, stamp_after, stamp_ago};
1515 use forge_core::webhooks::{AttemptOutcome, Delivery};
1616 use futures_util::StreamExt;
1717 use serde_json::{Map, Value};
18+use time::OffsetDateTime;
19+use time::format_description::well_known::Rfc3339;
1820 use tokio::sync::Notify;
1921
2022 use super::client::{HookClient, SendOutcome, WebhookPolicy, sign, validate_url};
@@ -27,8 +29,10 @@ pub type WebhookWaker = Arc<Notify>;
2729 const BATCH: u32 = 16;
2830 /// Deliveries sent at once.
2931 const CONCURRENCY: usize = 4;
30-/// How long the worker sleeps when nothing wakes it.
31-const IDLE_POLL: Duration = Duration::from_secs(30);
32+/// The shortest sleep, so a due time that has already passed does not spin the loop.
33+const MIN_SLEEP: Duration = Duration::from_secs(1);
34+/// The longest sleep. It only guards against a missed wake, it is not polling.
35+const BACKSTOP: Duration = Duration::from_secs(15 * 60);
3236 /// Deliveries older than this are purged.
3337 const RETENTION: Duration = Duration::from_secs(14 * 24 * 60 * 60);
3438 /// How often the purge runs.
@@ -86,14 +90,41 @@ async fn run(state: AppState, waker: WebhookWaker) {
8690 };
8791 // A full batch may mean more is due, so go straight back for it.
8892 if claimed < usize::try_from(BATCH).unwrap_or(usize::MAX) {
93+ // Wake when the earliest pending delivery is due (a claimed one wakes at its lease
94+ // expiry), or at the next purge. With nothing pending, sleep up to `BACKSTOP`.
95+ let next = match state.store.next_delivery_due().await {
96+ Ok(next) => next,
97+ Err(e) => {
98+ tracing::error!(error = %e, "webhooks: could not read the next due time");
99+ None
100+ }
101+ };
102+ let nap = next
103+ .as_deref()
104+ .map_or(BACKSTOP, |due| sleep_for(due, OffsetDateTime::now_utc()));
105+ let purge_nap = last_purge.map_or(Duration::ZERO, |at| {
106+ PURGE_EVERY.saturating_sub(at.elapsed())
107+ });
89108 tokio::select! {
90109 () = waker.notified() => {}
91- () = tokio::time::sleep(IDLE_POLL) => {}
110+ () = tokio::time::sleep(nap) => {}
111+ () = tokio::time::sleep(purge_nap) => {}
92112 }
93113 }
94114 }
95115 }
96116
117+/// How long the worker sleeps until `next_due` (an RFC 3339 stamp, the earliest pending
118+/// delivery), clamped to `MIN_SLEEP..=BACKSTOP`. An unparsable stamp gives `MIN_SLEEP`, so it is
119+/// retried promptly rather than stalling.
120+fn sleep_for(next_due: &str, now: OffsetDateTime) -> Duration {
121+ let Ok(due) = OffsetDateTime::parse(next_due, &Rfc3339) else {
122+ return MIN_SLEEP;
123+ };
124+ let until = Duration::try_from(due - now).unwrap_or(Duration::ZERO);
125+ until.clamp(MIN_SLEEP, BACKSTOP)
126+}
127+
97128 /// Claims up to [`BATCH`] due deliveries at `now`, sends them, and records each result. Returns
98129 /// how many were claimed.
99130 pub(crate) async fn process_due(
@@ -499,6 +530,25 @@ mod tests {
499530 assert!(row.next_attempt_at.is_some());
500531 }
501532
533+ #[test]
534+ fn sleep_stops_at_the_next_due_time_and_never_spins() {
535+ use time::macros::datetime;
536+
537+ let now = datetime!(2026-10-15 12:00:00 UTC);
538+ let stamp = |t: OffsetDateTime| t.format(&Rfc3339).expect("stamp");
539+ // Due in 10 minutes: sleep until then.
540+ let in_10m = stamp(now + time::Duration::minutes(10));
541+ assert_eq!(sleep_for(&in_10m, now), Duration::from_secs(600));
542+ // Due later than the backstop: wake at the backstop.
543+ let in_1h = stamp(now + time::Duration::hours(1));
544+ assert_eq!(sleep_for(&in_1h, now), BACKSTOP);
545+ // Already due, or a lease that has just passed: wait the minimum.
546+ let past = stamp(now - time::Duration::seconds(5));
547+ assert_eq!(sleep_for(&past, now), MIN_SLEEP);
548+ // A stamp that does not parse also waits the minimum.
549+ assert_eq!(sleep_for("soon", now), MIN_SLEEP);
550+ }
551+
502552 #[test]
503553 fn backoff_schedule_matches_the_contract() {
504554 assert_eq!(next_attempt_at(0), None);