Skip to main content

mj_controller/pollers/
quota.rs

1use super::*;
2
3pub fn quota_refresh_profiles(controller: &Controller) -> Vec<QuotaRefreshRequest> {
4    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5    controller
6        .config
7        .enabled_profiles()
8        .map(|(id, profile)| QuotaRefreshRequest::for_profile(id, profile, cwd.clone()))
9        .collect()
10}
11
12pub fn spawn_quota_refresher() -> (
13    tokio::sync::watch::Sender<QuotaRefreshBatch>,
14    tokio::sync::mpsc::Receiver<QuotaUpdate>,
15) {
16    let (profiles_tx, mut profiles_rx) = tokio::sync::watch::channel(QuotaRefreshBatch::default());
17    let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(32);
18    tokio::spawn(async move {
19        let mut quotas = QuotaManager::default();
20        let mut batch = QuotaRefreshBatch::default();
21        let mut interval = tokio::time::interval(QUOTA_REFRESH_INTERVAL);
22        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
23        interval.tick().await;
24        loop {
25            tokio::select! {
26                _ = interval.tick(), if !batch.profiles.is_empty() => {
27                    if !refresh_profile_quotas(
28                        &mut quotas,
29                        batch.generation,
30                        &batch.profiles,
31                        &updates_tx,
32                    ).await {
33                        break;
34                    }
35                }
36                changed = profiles_rx.changed() => {
37                    if changed.is_err() {
38                        tracing::debug!("quota profile target feed closed; stopping quota refresher");
39                        break;
40                    }
41                    batch = profiles_rx.borrow_and_update().clone();
42                    if !refresh_profile_quotas(
43                        &mut quotas,
44                        batch.generation,
45                        &batch.profiles,
46                        &updates_tx,
47                    ).await {
48                        break;
49                    }
50                }
51            }
52        }
53        quotas.shutdown().await;
54    });
55    (profiles_tx, updates_rx)
56}
57
58pub(super) async fn refresh_profile_quotas(
59    quotas: &mut QuotaManager,
60    generation: u64,
61    profiles: &[QuotaRefreshRequest],
62    updates: &tokio::sync::mpsc::Sender<QuotaUpdate>,
63) -> bool {
64    let ids = profiles
65        .iter()
66        .map(|profile| profile.profile_id.clone())
67        .collect::<Vec<_>>();
68    if updates
69        .send(QuotaUpdate::Refreshing { profile_ids: ids })
70        .await
71        .is_err()
72    {
73        tracing::debug!("quota update consumer closed before refresh started");
74        return false;
75    }
76    // Keep draining even if the UI is gone so codex clients return to the
77    // manager for a clean shutdown; just stop sending.
78    let delivered = AtomicBool::new(true);
79    quotas
80        .refresh_profiles(profiles.to_vec(), |quota| {
81            let delivered = &delivered;
82            async move {
83                if delivered.load(Ordering::Acquire)
84                    && updates.send(QuotaUpdate::Report(quota)).await.is_err()
85                {
86                    tracing::debug!("quota update consumer closed while reporting a profile");
87                    delivered.store(false, Ordering::Release);
88                }
89            }
90        })
91        .await;
92    if !delivered.into_inner() {
93        return false;
94    }
95    if updates
96        .send(QuotaUpdate::Finished { generation })
97        .await
98        .is_err()
99    {
100        tracing::debug!(
101            generation,
102            "quota update consumer closed before refresh completed"
103        );
104        false
105    } else {
106        true
107    }
108}
109
110/// What the daemon wants to tell the user about a background image download.
111///
112/// The refresher logs every detail; these are the few moments worth a notice
113/// in the dashboard, because the person's first session waits on them.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum ImageRefreshReport {
116    /// A download has just begun.
117    Started { host: String, image: String },
118    /// A download finished and left the host with the image.
119    Pulled { host: String, image: String },
120    /// A download failed. Reported once per distinct error, not once an hour.
121    Failed {
122        host: String,
123        image: String,
124        error: String,
125    },
126}
127
128/// Download every configured container image the host lacks, and keep the
129/// ones that track a moving tag current, away from any session launch.
130///
131/// The first pass runs shortly after the daemon starts, which is what spares
132/// the person's first session a multi-gigabyte download. `plan` is called on
133/// every tick rather than once, so a config reload changes what gets
134/// downloaded without a daemon restart. Hosts refresh concurrently; each host
135/// runs its own commands in order.
136///
137/// `report` is how the daemon speaks: it is called from the blocking download
138/// threads as well as from this task, so it must be cheap and must not block.
139pub fn spawn_image_refresher(
140    plan: impl Fn() -> Vec<ImageRefresh> + Send + 'static,
141    report: impl Fn(ImageRefreshReport) + Send + Sync + 'static,
142    cancellation: tokio_util::sync::CancellationToken,
143) -> tokio::task::JoinHandle<()> {
144    let report: Arc<dyn Fn(ImageRefreshReport) + Send + Sync> = Arc::new(report);
145    tokio::spawn(async move {
146        let mut interval = tokio::time::interval_at(
147            tokio::time::Instant::now() + IMAGE_REFRESH_DELAY,
148            IMAGE_REFRESH_INTERVAL,
149        );
150        // A refresh slower than the interval collapses the ticks it missed
151        // instead of stacking a second pull behind the first.
152        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
153        // The last error reported for each host and image. An unreachable
154        // host must not produce the same notice every hour.
155        let mut last_failures: BTreeMap<String, String> = BTreeMap::new();
156        loop {
157            tokio::select! {
158                // Quitting wins over a tick that came due during a long
159                // refresh, so shutdown never starts one more pull.
160                biased;
161                _ = cancellation.cancelled() => return,
162                _ = interval.tick() => {
163                    refresh_images(plan(), &report, &mut last_failures, &cancellation).await;
164                }
165            }
166        }
167    })
168}
169
170/// The key that identifies one host's copy of one image, for failure
171/// suppression.
172fn refresh_key(host: &str, image: &str) -> String {
173    format!("{host}|{image}")
174}
175
176/// Report a failed download once, and stay quiet while it keeps failing the
177/// same way.
178///
179/// A host that is simply offline fails identically every hour, and a notice
180/// an hour would be noise. A different error is new information, and so is a
181/// failure after a success, which is why success clears the record.
182pub(super) fn record_refresh_result(
183    last_failures: &mut BTreeMap<String, String>,
184    host: &str,
185    image: &str,
186    error: Option<String>,
187    report: &dyn Fn(ImageRefreshReport),
188) {
189    let key = refresh_key(host, image);
190    let Some(error) = error else {
191        last_failures.remove(&key);
192        return;
193    };
194    if last_failures.get(&key) == Some(&error) {
195        return;
196    }
197    last_failures.insert(key, error.clone());
198    report(ImageRefreshReport::Failed {
199        host: host.to_owned(),
200        image: image.to_owned(),
201        error,
202    });
203}
204
205/// Whether a local image host's engine can be run at all.
206///
207/// Only local hosts are checked: a remote host's engine lives on the other
208/// side of ssh, and a failure there is real news about that host.
209pub(super) fn local_engine_installed(host: &ImageHost, path: Option<&std::ffi::OsStr>) -> bool {
210    match host {
211        ImageHost::LocalPodman | ImageHost::LocalDocker | ImageHost::AppleContainer => {
212            let Some(path) = path else { return false };
213            let engine = host.engine();
214            std::env::split_paths(path).any(|directory| directory.join(engine).is_file())
215        }
216        ImageHost::SshPodman(_) | ImageHost::SshDocker(_) => true,
217    }
218}
219
220pub(super) async fn refresh_images(
221    plan: Vec<ImageRefresh>,
222    report: &Arc<dyn Fn(ImageRefreshReport) + Send + Sync>,
223    last_failures: &mut BTreeMap<String, String>,
224    cancellation: &tokio_util::sync::CancellationToken,
225) {
226    if plan.is_empty() {
227        return;
228    }
229    let Ok(_upgrade_work) = crate::upgrade::activity("image download") else {
230        return;
231    };
232    // One flag for every host, so quitting kills the pulls in flight instead of
233    // waiting out a multi-gigabyte download.
234    let cancelled = Arc::new(AtomicBool::new(false));
235    let mut hosts = tokio::task::JoinSet::new();
236    for refresh in plan {
237        // The default configuration names a podman, a docker, and on macOS an
238        // Apple container target whether or not the engine is installed. An
239        // engine that is not on this machine is not a failed download, and it
240        // must not become a notice on every start.
241        if !local_engine_installed(&refresh.host, std::env::var_os("PATH").as_deref()) {
242            tracing::debug!(
243                host = refresh.host.label(),
244                image = refresh.image,
245                "container engine is not installed; skipping the image refresh"
246            );
247            continue;
248        }
249        // ProcessExecutor is synchronous, and a pull is long: it belongs on a
250        // blocking thread, never on the runtime.
251        let executor = CancellableProcessExecutor::new(cancelled.clone());
252        let report = report.clone();
253        hosts.spawn_blocking(move || {
254            let host = refresh.host.label();
255            // A launch that needs this image waits on the same lock, so it
256            // never starts a second download of what this tick is fetching.
257            let lock = crate::image_pull_gate::image_pull_mutex(&refresh.host, &refresh.image);
258            let held =
259                crate::image_pull_gate::hold_image_pull(&lock, || executor.is_cancelled(), || {});
260            let outcome = held.and_then(|guard| {
261                let outcome = refresh_host_image(&refresh, &executor, &*report);
262                drop(guard);
263                outcome
264            });
265            let error = match outcome {
266                Ok(_) => None,
267                Err(error) if executor.is_cancelled() => {
268                    // The daemon is leaving. That is not a fault of the host,
269                    // and it is not news for the user either.
270                    tracing::debug!(
271                        host,
272                        image = refresh.image,
273                        error = format!("{error:#}"),
274                        "container image refresh cancelled"
275                    );
276                    return None;
277                }
278                Err(error) => {
279                    tracing::warn!(
280                        host,
281                        image = refresh.image,
282                        error = format!("{error:#}"),
283                        "could not refresh a container image"
284                    );
285                    Some(format!("{error:#}"))
286                }
287            };
288            Some((host, refresh.image, error))
289        });
290    }
291    let mut cancelling = false;
292    loop {
293        tokio::select! {
294            biased;
295            _ = cancellation.cancelled(), if !cancelling => {
296                cancelling = true;
297                cancelled.store(true, Ordering::Release);
298            }
299            joined = hosts.join_next() => match joined {
300                None => return,
301                Some(Ok(None)) => {}
302                Some(Ok(Some((host, image, error)))) => {
303                    record_refresh_result(last_failures, &host, &image, error, &**report);
304                }
305                Some(Err(error)) => {
306                    tracing::warn!(%error, "container image refresh task failed");
307                }
308            },
309        }
310    }
311}
312
313/// What one host's refresh of one image did.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub(super) enum ImageRefreshOutcome {
316    /// The host already had the image and this target only wants it present,
317    /// so nothing was downloaded.
318    Present,
319    /// A pull ran and the host's copy did not change.
320    Unchanged,
321    /// A pull ran and left the host with a different image.
322    Pulled { id: String },
323}
324
325/// Pull one image on one host, then drop whatever that unlinked.
326///
327/// The image id before and after says whether the pull actually changed
328/// anything, which is the only part worth an `info` line. An image that is
329/// only downloaded when absent skips the pull, and the prune with it, as soon
330/// as the host reports a copy.
331///
332/// `report` is told when a download actually starts and when one leaves the
333/// host with a new image, so the user hears about the wait they are in rather
334/// than about every hourly check.
335pub(super) fn refresh_host_image(
336    refresh: &ImageRefresh,
337    executor: &impl CommandExecutor,
338    report: &dyn Fn(ImageRefreshReport),
339) -> Result<ImageRefreshOutcome> {
340    let host = refresh.host.label();
341    let cached = image_id(&refresh.image_id, executor);
342    if refresh.when == RefreshWhen::WhenAbsent && cached.is_some() {
343        tracing::debug!(
344            host,
345            image = refresh.image,
346            "the host already has this container image"
347        );
348        return Ok(ImageRefreshOutcome::Present);
349    }
350    // Only a host with no copy is about to download for real. An hourly
351    // refresh of a moving tag usually finds nothing newer, and announcing it
352    // every hour would be noise.
353    if cached.is_none() {
354        report(ImageRefreshReport::Started {
355            host: host.clone(),
356            image: refresh.image.clone(),
357        });
358    }
359    run_refresh_command(&refresh.pull, executor)?;
360    let pulled = image_id(&refresh.image_id, executor);
361    let outcome = if pulled.is_some() && (cached.is_none() || pulled != cached) {
362        let id = pulled.unwrap_or_default();
363        tracing::info!(
364            host,
365            image = refresh.image,
366            id,
367            "pulled a newer container image"
368        );
369        report(ImageRefreshReport::Pulled {
370            host,
371            image: refresh.image.clone(),
372        });
373        ImageRefreshOutcome::Pulled { id }
374    } else {
375        tracing::debug!(
376            host,
377            image = refresh.image,
378            "container image is already current"
379        );
380        ImageRefreshOutcome::Unchanged
381    };
382    if let Some(prune) = &refresh.prune {
383        run_refresh_command(prune, executor)?;
384    }
385    Ok(outcome)
386}
387
388/// The host's id for an image, or `None` when it has no copy of it yet. A
389/// missing image is the ordinary first-pull case, not a fault.
390pub(super) fn image_id(command: &CommandSpec, executor: &impl CommandExecutor) -> Option<String> {
391    let output = executor.execute(command).ok()?;
392    if output.status != 0 {
393        return None;
394    }
395    let id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
396    (!id.is_empty()).then_some(id)
397}
398
399pub(super) fn run_refresh_command(
400    command: &CommandSpec,
401    executor: &impl CommandExecutor,
402) -> Result<()> {
403    let output = executor.execute(command)?;
404    if output.status != 0 {
405        bail!(
406            "{} failed with status {}: {}",
407            command.purpose,
408            output.status,
409            String::from_utf8_lossy(&output.stderr).trim()
410        );
411    }
412    Ok(())
413}
414
415pub fn complete_manual_quota_refresh(
416    pending_generation: &mut Option<u64>,
417    completed_generation: u64,
418) -> bool {
419    if *pending_generation != Some(completed_generation) {
420        return false;
421    }
422    *pending_generation = None;
423    true
424}