summaryrefslogtreecommitdiff
path: root/src/polling.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-01-22 22:07:32 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-10 18:44:26 +0100
commit064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch)
treea2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/polling.rs
witryna 0.1.0 — initial releasev0.1.0
Minimalist Git-based static site deployment orchestrator. Webhook-triggered builds in Podman/Docker containers with atomic symlink publishing, SIGHUP hot-reload, and zero-downtime deploys. See README.md for usage, CHANGELOG.md for details.
Diffstat (limited to 'src/polling.rs')
-rw-r--r--src/polling.rs242
1 files changed, 242 insertions, 0 deletions
diff --git a/src/polling.rs b/src/polling.rs
new file mode 100644
index 0000000..6c25326
--- /dev/null
+++ b/src/polling.rs
@@ -0,0 +1,242 @@
+//! Polling manager for periodic repository change detection.
+//!
+//! Spawns background tasks for sites with `poll_interval` configured.
+//! Integrates with SIGHUP reload to restart polling tasks on config change.
+
+use crate::build_guard::BuildGuard;
+use crate::config::SiteConfig;
+use crate::git;
+use crate::server::AppState;
+use std::collections::HashMap;
+use std::hash::{Hash as _, Hasher as _};
+use std::sync::Arc;
+use std::time::Duration;
+use tokio::sync::RwLock;
+use tokio_util::sync::CancellationToken;
+use tracing::{debug, error, info};
+
+/// Manages polling tasks for all sites.
+pub struct PollingManager {
+ /// Map of `site_name` -> cancellation token for active polling tasks
+ tasks: Arc<RwLock<HashMap<String, CancellationToken>>>,
+}
+
+impl PollingManager {
+ #[must_use]
+ pub fn new() -> Self {
+ Self {
+ tasks: Arc::new(RwLock::new(HashMap::new())),
+ }
+ }
+
+ /// Start polling tasks for sites with `poll_interval` configured.
+ /// Call this on startup and after SIGHUP reload.
+ pub async fn start_polling(&self, state: AppState) {
+ let config = state.config.read().await;
+
+ for site in &config.sites {
+ if let Some(interval) = site.poll_interval {
+ self.spawn_poll_task(state.clone(), site.clone(), interval)
+ .await;
+ }
+ }
+ }
+
+ /// Stop all currently running polling tasks.
+ /// Call this before starting new tasks on SIGHUP.
+ pub async fn stop_all(&self) {
+ let mut tasks = self.tasks.write().await;
+
+ for (site_name, token) in tasks.drain() {
+ info!(site = %site_name, "stopping polling task");
+ token.cancel();
+ }
+ }
+
+ /// Spawn a single polling task for a site.
+ async fn spawn_poll_task(&self, state: AppState, site: SiteConfig, interval: Duration) {
+ let site_name = site.name.clone();
+ let token = CancellationToken::new();
+
+ // Store the cancellation token
+ {
+ let mut tasks = self.tasks.write().await;
+ tasks.insert(site_name.clone(), token.clone());
+ }
+
+ info!(
+ site = %site_name,
+ interval_secs = interval.as_secs(),
+ "starting polling task"
+ );
+
+ // Spawn the polling loop
+ let tasks = Arc::clone(&self.tasks);
+ tokio::spawn(async move {
+ #[allow(clippy::large_futures)]
+ poll_loop(state, site, interval, token.clone()).await;
+
+ // Remove from active tasks when done
+ tasks.write().await.remove(&site_name);
+ debug!(site = %site_name, "polling task ended");
+ });
+ }
+}
+
+impl Default for PollingManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// The main polling loop for a single site.
+async fn poll_loop(
+ state: AppState,
+ site: SiteConfig,
+ interval: Duration,
+ cancel_token: CancellationToken,
+) {
+ let site_name = &site.name;
+
+ // Initial delay before first poll (avoid thundering herd on startup)
+ let initial_delay = calculate_initial_delay(site_name, interval);
+ debug!(site = %site_name, delay_secs = initial_delay.as_secs(), "initial poll delay");
+
+ tokio::select! {
+ () = tokio::time::sleep(initial_delay) => {}
+ () = cancel_token.cancelled() => return,
+ }
+
+ loop {
+ debug!(site = %site_name, "polling for changes");
+
+ // 1. Acquire build lock before any git operation
+ let Some(guard) = BuildGuard::try_acquire(site_name.clone(), &state.build_scheduler) else {
+ debug!(site = %site_name, "build in progress, skipping poll cycle");
+ tokio::select! {
+ () = tokio::time::sleep(interval) => {}
+ () = cancel_token.cancelled() => {
+ info!(site = %site_name, "polling cancelled");
+ return;
+ }
+ }
+ continue;
+ };
+
+ // Get current config (might have changed via SIGHUP)
+ let (base_dir, git_timeout) = {
+ let config = state.config.read().await;
+ (
+ config.base_dir.clone(),
+ config.git_timeout.unwrap_or(git::GIT_TIMEOUT_DEFAULT),
+ )
+ };
+ let clone_dir = base_dir.join("clones").join(site_name);
+
+ // 2. Check for changes (guard held — no concurrent git ops possible)
+ let has_changes = match git::has_remote_changes(
+ &clone_dir,
+ &site.branch,
+ git_timeout,
+ site.git_depth.unwrap_or(git::GIT_DEPTH_DEFAULT),
+ )
+ .await
+ {
+ Ok(changed) => changed,
+ Err(e) => {
+ error!(site = %site_name, error = %e, "failed to check for changes");
+ false
+ }
+ };
+
+ if has_changes {
+ // 3a. Keep guard alive — move into build pipeline
+ info!(site = %site_name, "new commits detected, triggering build");
+ #[allow(clippy::large_futures)]
+ crate::server::run_build_pipeline(
+ state.clone(),
+ site_name.clone(),
+ site.clone(),
+ guard,
+ )
+ .await;
+ } else {
+ // 3b. Explicit drop BEFORE sleep — release lock immediately
+ drop(guard);
+ }
+
+ // 4. Sleep (lock is NOT held here in either branch)
+ tokio::select! {
+ () = tokio::time::sleep(interval) => {}
+ () = cancel_token.cancelled() => {
+ info!(site = %site_name, "polling cancelled");
+ return;
+ }
+ }
+ }
+}
+
+/// Calculate staggered initial delay to avoid all sites polling at once.
+/// Uses a simple hash of the site name to distribute start times.
+fn calculate_initial_delay(site_name: &str, interval: Duration) -> Duration {
+ use std::collections::hash_map::DefaultHasher;
+
+ let mut hasher = DefaultHasher::new();
+ site_name.hash(&mut hasher);
+ let hash = hasher.finish();
+
+ // Spread across 0 to interval/2
+ let max_delay_secs = interval.as_secs() / 2;
+ let delay_secs = if max_delay_secs > 0 {
+ hash % max_delay_secs
+ } else {
+ 0
+ };
+
+ Duration::from_secs(delay_secs)
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn initial_delay_zero_interval() {
+ // interval=0 → max_delay_secs=0 → delay=0
+ let delay = calculate_initial_delay("site", Duration::from_secs(0));
+ assert_eq!(delay, Duration::from_secs(0));
+ }
+
+ #[test]
+ fn initial_delay_one_second_interval() {
+ // interval=1s → max_delay_secs=0 → delay=0
+ let delay = calculate_initial_delay("site", Duration::from_secs(1));
+ assert_eq!(delay, Duration::from_secs(0));
+ }
+
+ #[test]
+ fn initial_delay_within_half_interval() {
+ let interval = Duration::from_secs(600); // 10 min
+ let delay = calculate_initial_delay("my-site", interval);
+ // Must be < interval/2 (300s)
+ assert!(delay < Duration::from_secs(300));
+ }
+
+ #[test]
+ fn initial_delay_deterministic() {
+ let interval = Duration::from_secs(600);
+ let d1 = calculate_initial_delay("my-site", interval);
+ let d2 = calculate_initial_delay("my-site", interval);
+ assert_eq!(d1, d2);
+ }
+
+ #[test]
+ fn initial_delay_different_sites_differ() {
+ let interval = Duration::from_secs(3600);
+ let d1 = calculate_initial_delay("site-alpha", interval);
+ let d2 = calculate_initial_delay("site-beta", interval);
+ // Different names should (almost certainly) produce different delays
+ assert_ne!(d1, d2);
+ }
+}