Skip to main content

heddle_pack/store/pack/repack/
scheduler.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{
4    collections::{HashMap, HashSet},
5    sync::{Arc, Mutex, atomic::AtomicU64, mpsc},
6    thread,
7    time::{Duration, Instant},
8};
9
10use super::{
11    CancellationToken, LoadMonitor, RepackContext, RepackError, RepackHandle, RepackOperation,
12    RepackPolicy, RepackReason, RepackReport, RepackResourceLimits, RepackSchedule, types::NoLoad,
13};
14
15#[derive(Default)]
16struct SchedulerState {
17    running: HashSet<String>,
18    next_attempt: HashMap<String, Instant>,
19    failure_count: HashMap<String, u32>,
20}
21
22/// Bounded background scheduler shared by native and hosted repack payloads.
23#[derive(Clone)]
24pub struct RepackScheduler {
25    policy: RepackPolicy,
26    limits: RepackResourceLimits,
27    load: Arc<dyn LoadMonitor>,
28    state: Arc<Mutex<SchedulerState>>,
29    success_backoff: Duration,
30    failure_backoff: Duration,
31    maximum_failure_backoff: Duration,
32}
33
34impl RepackScheduler {
35    /// Construct a scheduler with configurable triggers and resource limits.
36    pub fn new(policy: RepackPolicy, limits: RepackResourceLimits) -> Self {
37        Self {
38            policy,
39            limits,
40            load: Arc::new(NoLoad),
41            state: Arc::new(Mutex::new(SchedulerState::default())),
42            success_backoff: Duration::from_secs(30 * 60),
43            failure_backoff: Duration::from_secs(30),
44            maximum_failure_backoff: Duration::from_secs(30 * 60),
45        }
46    }
47
48    /// Install a foreground-load signal used by worker checkpoints.
49    pub fn with_load_monitor(mut self, load: Arc<dyn LoadMonitor>) -> Self {
50        self.load = load;
51        self
52    }
53
54    /// Override success and exponential failure backoff windows.
55    pub fn with_backoff(
56        mut self,
57        success: Duration,
58        initial_failure: Duration,
59        maximum_failure: Duration,
60    ) -> Self {
61        self.success_backoff = success;
62        self.failure_backoff = initial_failure;
63        self.maximum_failure_backoff = maximum_failure.max(initial_failure);
64        self
65    }
66
67    /// Inspect thresholds and start a background operation when needed.
68    pub fn schedule_if_needed(
69        &self,
70        operation: Arc<dyn RepackOperation>,
71    ) -> Result<RepackSchedule, RepackError> {
72        let inventory = operation.inspect()?;
73        let Some(reason) = self.policy.evaluate(inventory) else {
74            return Ok(RepackSchedule::NotNeeded(inventory));
75        };
76        self.start(operation, reason, false, CancellationToken::default())
77    }
78
79    /// Start an operator-requested repack, bypassing heuristics and backoff.
80    pub fn repack_now(
81        &self,
82        operation: Arc<dyn RepackOperation>,
83    ) -> Result<RepackSchedule, RepackError> {
84        self.start(
85            operation,
86            RepackReason::Manual,
87            true,
88            CancellationToken::default(),
89        )
90    }
91
92    /// Start a manual repack controlled by an existing cancellation token.
93    pub fn repack_now_with_token(
94        &self,
95        operation: Arc<dyn RepackOperation>,
96        cancellation: CancellationToken,
97    ) -> Result<RepackSchedule, RepackError> {
98        self.start(operation, RepackReason::Manual, true, cancellation)
99    }
100
101    fn start(
102        &self,
103        operation: Arc<dyn RepackOperation>,
104        reason: RepackReason,
105        bypass_backoff: bool,
106        cancellation: CancellationToken,
107    ) -> Result<RepackSchedule, RepackError> {
108        let key = operation.key();
109        {
110            let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
111            if state.running.contains(&key)
112                || state.running.len() >= self.limits.max_concurrent_operations.get()
113            {
114                return Ok(RepackSchedule::Busy);
115            }
116            if !bypass_backoff
117                && let Some(next) = state.next_attempt.get(&key)
118                && *next > Instant::now()
119            {
120                return Ok(RepackSchedule::BackingOff {
121                    remaining: next.saturating_duration_since(Instant::now()),
122                });
123            }
124            state.running.insert(key.clone());
125        }
126
127        let state = Arc::clone(&self.state);
128        let load = Arc::clone(&self.load);
129        let limits = self.limits;
130        let success_backoff = self.success_backoff;
131        let failure_backoff = self.failure_backoff;
132        let maximum_failure_backoff = self.maximum_failure_backoff;
133        let worker_cancellation = cancellation.clone();
134        let (sender, receiver) = mpsc::channel();
135        let worker_state = Arc::clone(&state);
136        let worker_key = key.clone();
137        let spawn = thread::Builder::new()
138            .name(format!("heddle-repack-{key}"))
139            .spawn(move || {
140                let started = Instant::now();
141                let context = RepackContext {
142                    cancellation: worker_cancellation,
143                    load,
144                    limits,
145                    started,
146                    accounted_io: AtomicU64::new(0),
147                };
148                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
149                    operation.run(&context)
150                }))
151                .map_err(|_| RepackError::WorkerPanicked)
152                .and_then(|result| result)
153                .map(|outcome| RepackReport {
154                    reason,
155                    objects_repacked: outcome.objects_repacked,
156                    bytes_repacked: outcome.bytes_repacked,
157                    duration: started.elapsed(),
158                    bytes_reclaimed: outcome.bytes_reclaimed,
159                });
160                finish_attempt(
161                    &worker_state,
162                    &worker_key,
163                    &result,
164                    success_backoff,
165                    failure_backoff,
166                    maximum_failure_backoff,
167                );
168                if let Ok(report) = result {
169                    tracing::info!(
170                        operation = %worker_key,
171                        objects_repacked = report.objects_repacked,
172                        bytes_repacked = report.bytes_repacked,
173                        duration_ms = report.duration.as_millis(),
174                        bytes_reclaimed = report.bytes_reclaimed,
175                        "background repack completed"
176                    );
177                }
178                let _ = sender.send(result);
179            });
180        let worker = match spawn {
181            Ok(worker) => worker,
182            Err(error) => {
183                state
184                    .lock()
185                    .unwrap_or_else(|poisoned| poisoned.into_inner())
186                    .running
187                    .remove(&key);
188                return Err(RepackError::operation(error));
189            }
190        };
191
192        Ok(RepackSchedule::Started(RepackHandle {
193            cancellation,
194            result: receiver,
195            worker,
196        }))
197    }
198}
199
200fn finish_attempt(
201    state: &Mutex<SchedulerState>,
202    key: &str,
203    result: &Result<RepackReport, RepackError>,
204    success_backoff: Duration,
205    failure_backoff: Duration,
206    maximum_failure_backoff: Duration,
207) {
208    let mut state = state.lock().unwrap_or_else(|error| error.into_inner());
209    state.running.remove(key);
210    let delay = if result.is_ok() {
211        state.failure_count.remove(key);
212        success_backoff
213    } else {
214        let failures = state.failure_count.entry(key.to_string()).or_default();
215        *failures = failures.saturating_add(1);
216        failure_backoff
217            .checked_mul(2u32.saturating_pow(failures.saturating_sub(1)))
218            .unwrap_or(maximum_failure_backoff)
219            .min(maximum_failure_backoff)
220    };
221    state
222        .next_attempt
223        .insert(key.to_string(), Instant::now() + delay);
224}