Skip to main content

kernel/install/
pulls.rs

1//! The record a pull keeps on disk, so a download outlives the process that
2//! started it.
3//!
4//! One directory per pull under `<data>/pulls/<id>/`: the descriptor
5//! (`job.json`, written once), the live record (`status.json`, rewritten as the
6//! transfer moves), an append-only history (`events.jsonl`), the lock a worker
7//! holds for as long as it runs, and the `control` file a client writes to ask
8//! for a pause or a cancel.
9//!
10//! Nothing here starts, signals, or waits on a process: this module owns the
11//! format and the rules, the runtime owns the worker.
12//!
13//! The liveness rule rests on Unix advisory locks (`flock`), where a lock
14//! belongs to an open file description rather than to a process. Readers take a
15//! shared lock to test, so they never exclude one another; a worker takes the
16//! exclusive one.
17
18use std::fs::{self, File, OpenOptions, TryLockError};
19use std::io::{self, Write};
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23
24use crate::install::event::InstallProgress;
25use crate::install::plan::InstallPlan;
26use crate::install::provider::InstallProviderId;
27use crate::persistence::{self, StoreError};
28
29const JOB_FILE: &str = "job.json";
30const STATUS_FILE: &str = "status.json";
31const EVENTS_FILE: &str = "events.jsonl";
32const LOCK_FILE: &str = "lock";
33const CONTROL_FILE: &str = "control";
34
35/// The longest reference slug a job id carries; the timestamp in front is what
36/// makes the id unique, the slug is only there to make it readable.
37const SLUG_LIMIT: usize = 40;
38/// How long a job queued with no worker is given before it counts as abandoned.
39/// A worker writes its pid as soon as it holds the job, which it does within
40/// milliseconds of starting, so this is generous rather than tuned.
41pub const START_GRACE_MS: i64 = 3_000;
42/// How many suffixed ids `create` tries before giving up. Two pulls of the same
43/// reference in the same millisecond is already unlikely; sixty-four is a
44/// runaway guard, not a working limit.
45const CREATE_ATTEMPTS: u32 = 64;
46/// How many times a lock file swept away between its open and its lock is
47/// opened again. One sweep in that window is rare; several in a row is not
48/// something to wait on.
49const LOCK_REOPENS: u32 = 3;
50
51/// A failure reading or writing a pull's record.
52#[derive(Debug, thiserror::Error)]
53pub enum PullError {
54    /// A filesystem operation failed.
55    #[error("pull io error: {0}")]
56    Io(#[from] io::Error),
57
58    /// A store helper failed.
59    #[error("pull store error: {0}")]
60    Store(#[from] StoreError),
61
62    /// A descriptor exists but this build cannot decode it. It is left exactly
63    /// where it is: another process may own the pull it describes.
64    #[error("unreadable pull descriptor at {path}: {source}")]
65    Unreadable {
66        /// The descriptor that would not decode.
67        path: PathBuf,
68        /// Why it would not decode.
69        #[source]
70        source: serde_json::Error,
71    },
72
73    /// No job matched the id, prefix, or reference given.
74    #[error("no pull matches \"{0}\"")]
75    NotFound(String),
76
77    /// More than one job matched, so the caller has to be more specific.
78    #[error("\"{query}\" matches {count} pulls")]
79    Ambiguous {
80        /// What the caller asked for.
81        query: String,
82        /// How many jobs it matched.
83        count: usize,
84    },
85
86    /// The job directory could not be named uniquely.
87    #[error("could not claim a directory for a pull of {0}")]
88    Unclaimable(String),
89
90    /// The job has not ended, so its record is still the way to reach it.
91    #[error("{id} is {state}, not ended")]
92    NotEnded {
93        /// The job.
94        id: String,
95        /// Where it is.
96        state: PullState,
97    },
98
99    /// A worker still holds the job, whatever its record reads.
100    #[error("a worker still holds {0}")]
101    Held(String),
102}
103
104/// Where a pull is in its life.
105///
106/// `Paused` and `Interrupted` both mean "stopped with bytes worth keeping"; they
107/// differ in who stopped it, which is what the user needs to know. `Interrupted`
108/// is never written by the process that died: it is what a reader concludes from
109/// a record whose worker no longer holds the lock.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
111#[serde(rename_all = "lowercase")]
112pub enum PullState {
113    /// Waiting for a free slot.
114    Queued,
115    /// Transferring.
116    Running,
117    /// Stopped by the user, resumable.
118    Paused,
119    /// Installed.
120    Done,
121    /// Ended on something retrying will not fix.
122    Failed,
123    /// Stopped by the user for good.
124    Cancelled,
125    /// Stopped by something other than the user, resumable.
126    Interrupted,
127}
128
129impl PullState {
130    /// Whether the job has ended for good (`Done`/`Failed`/`Cancelled`).
131    pub fn is_terminal(self) -> bool {
132        matches!(self, Self::Done | Self::Failed | Self::Cancelled)
133    }
134
135    /// Whether a worker could pick the job up again.
136    pub fn is_resumable(self) -> bool {
137        matches!(self, Self::Paused | Self::Interrupted)
138    }
139
140    /// Whether a worker should be running for this job right now.
141    pub fn is_live(self) -> bool {
142        matches!(self, Self::Queued | Self::Running)
143    }
144
145    /// The lowercase word this state is written and shown as.
146    pub fn as_str(self) -> &'static str {
147        match self {
148            Self::Queued => "queued",
149            Self::Running => "running",
150            Self::Paused => "paused",
151            Self::Done => "done",
152            Self::Failed => "failed",
153            Self::Cancelled => "cancelled",
154            Self::Interrupted => "interrupted",
155        }
156    }
157}
158
159impl std::fmt::Display for PullState {
160    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        formatter.write_str(self.as_str())
162    }
163}
164
165/// What a client asked a running worker to do.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
167pub enum PullControl {
168    /// Stop, keep the partial, stay resumable.
169    Pause,
170    /// Stop for good.
171    Cancel,
172}
173
174impl PullControl {
175    /// The bare word the control file carries.
176    pub fn as_str(self) -> &'static str {
177        match self {
178            Self::Pause => "pause",
179            Self::Cancel => "cancel",
180        }
181    }
182
183    /// The control a control file's contents name, if any.
184    pub fn parse(text: &str) -> Option<Self> {
185        match text.trim() {
186            "pause" => Some(Self::Pause),
187            "cancel" => Some(Self::Cancel),
188            _ => None,
189        }
190    }
191
192    /// The state a worker lands in after honouring this control.
193    pub fn resulting_state(self) -> PullState {
194        match self {
195            Self::Pause => PullState::Paused,
196            Self::Cancel => PullState::Cancelled,
197        }
198    }
199}
200
201impl std::fmt::Display for PullControl {
202    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        formatter.write_str(self.as_str())
204    }
205}
206
207/// A pull's descriptor: what was asked for, written once when the job is created.
208///
209/// It carries only what a listing needs. The authoritative [`InstallPlan`] is
210/// resolved again by the worker, because `remaining_bytes` is stale the moment a
211/// partial download exists.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct PullJob {
214    /// The job id, which is also its directory name.
215    pub id: String,
216    /// The install provider that will fetch it.
217    pub provider: InstallProviderId,
218    /// The reference being installed (repo or tag).
219    pub reference: String,
220    /// The name to show.
221    pub display_name: String,
222    /// Where the model will land.
223    pub destination: String,
224    /// The resolved revision, when the plan pinned one.
225    #[serde(skip_serializing_if = "Option::is_none", default)]
226    pub revision: Option<String>,
227    /// The plan's total size at creation, for a listing that has not started yet.
228    #[serde(skip_serializing_if = "Option::is_none", default)]
229    pub total_bytes: Option<i64>,
230    /// When the job was created, epoch milliseconds.
231    pub created_at_ms: i64,
232}
233
234impl PullJob {
235    /// The descriptor for `plan`, as job `id` created at `created_at_ms`.
236    pub(crate) fn from_plan(plan: &InstallPlan, id: impl Into<String>, created_at_ms: i64) -> Self {
237        Self {
238            id: id.into(),
239            provider: plan.provider.clone(),
240            reference: plan.reference.clone(),
241            display_name: plan.display_name.clone(),
242            destination: plan.destination.clone(),
243            revision: plan.revision.clone(),
244            total_bytes: plan.total_bytes,
245            created_at_ms,
246        }
247    }
248}
249
250/// A pull's live record, rewritten as the transfer moves.
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252pub struct PullStatus {
253    /// Where the pull is.
254    pub state: PullState,
255    /// How much has transferred.
256    #[serde(default)]
257    pub progress: InstallProgress,
258    /// The provider's last human-readable line.
259    #[serde(skip_serializing_if = "Option::is_none", default)]
260    pub status_line: Option<String>,
261    /// Which attempt is running; `0` until the first transfer starts.
262    #[serde(default)]
263    pub attempt: u32,
264    /// When the next retry is due, epoch milliseconds, while one is waiting.
265    #[serde(skip_serializing_if = "Option::is_none", default)]
266    pub next_attempt_at_ms: Option<i64>,
267    /// Why the job ended, or why it is waiting to retry.
268    #[serde(skip_serializing_if = "Option::is_none", default)]
269    pub message: Option<String>,
270    /// The worker's process id, for display only. Liveness comes from the lock.
271    #[serde(skip_serializing_if = "Option::is_none", default)]
272    pub pid: Option<u32>,
273    /// When this record was last written, epoch milliseconds.
274    pub updated_at_ms: i64,
275}
276
277impl PullStatus {
278    /// Whether this pull has gone past the point an ask can stop it: a running
279    /// worker that has said it is registering what it fetched, and so will read
280    /// no control file until it is done.
281    ///
282    /// The byte count is deliberately not the signal. Bytes reaching the total
283    /// says the transfer is over, not that nothing is listening: the worker
284    /// polls the control file until the install reports itself done, and an ask
285    /// in that window is read and can still take effect. It is also a figure the
286    /// record can be wrong about, being carried over from a previous attempt or
287    /// summed from a listing that left a file's size out, and a wrong full bar
288    /// would make a pull unstoppable for the rest of its life.
289    pub fn past_stopping(&self) -> bool {
290        self.state == PullState::Running && self.status_line.as_deref() == Some(REGISTERING_LINE)
291    }
292
293    /// A fresh record for a job that has not started, stamped `now`.
294    pub fn queued(now: i64) -> Self {
295        Self {
296            state: PullState::Queued,
297            progress: InstallProgress::default(),
298            status_line: None,
299            attempt: 0,
300            next_attempt_at_ms: None,
301            message: None,
302            pid: None,
303            updated_at_ms: now,
304        }
305    }
306}
307
308/// The status line a worker writes while it registers what it fetched. The
309/// scan it names is the one stretch of a job that reads no control file, so the
310/// line doubles as the mark of a job past stopping.
311pub const REGISTERING_LINE: &str = "registering";
312
313/// One line of a job's history.
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315pub struct PullEvent {
316    /// When it happened, epoch milliseconds.
317    pub at_ms: i64,
318    /// What happened.
319    #[serde(flatten)]
320    pub kind: PullEventKind,
321}
322
323/// What a history line records. Progress is deliberately absent: it belongs in
324/// the rewritten status, not in a file that only grows.
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326#[serde(tag = "event", rename_all = "lowercase")]
327pub enum PullEventKind {
328    /// The job moved to a new state.
329    State {
330        /// The state it moved to.
331        state: PullState,
332    },
333    /// The provider said something worth keeping.
334    Status {
335        /// The line.
336        text: String,
337    },
338    /// A transfer failed and another attempt is scheduled.
339    Retry {
340        /// Which attempt just failed.
341        attempt: u32,
342        /// Why it failed.
343        reason: String,
344        /// How long until the next one, milliseconds.
345        delay_ms: i64,
346    },
347}
348
349/// A worker's claim on a job, held for as long as the worker runs. Dropping it
350/// releases the lock, and so does the process ending for any reason, which is
351/// what makes a lost worker detectable.
352#[derive(Debug)]
353pub struct PullLock {
354    file: File,
355}
356
357impl Drop for PullLock {
358    fn drop(&mut self) {
359        let _ = self.file.unlock();
360    }
361}
362
363/// The directory of pull jobs.
364#[derive(Debug, Clone)]
365pub struct PullStore {
366    root: PathBuf,
367}
368
369impl PullStore {
370    /// A store over `root`, which is created when the first job is.
371    pub fn new(root: impl Into<PathBuf>) -> Self {
372        Self { root: root.into() }
373    }
374
375    /// The directory the jobs live in.
376    pub fn root(&self) -> &Path {
377        &self.root
378    }
379
380    /// Create a job for `plan` at `now`, claiming its directory and writing the
381    /// descriptor and a `queued` record.
382    pub fn create(&self, plan: &InstallPlan, now: i64) -> Result<PullJobDir, PullError> {
383        let base = format!("{now}-{}", reference_slug(&plan.reference));
384        fs::create_dir_all(&self.root)?;
385        for attempt in 0..CREATE_ATTEMPTS {
386            let id = match attempt {
387                0 => base.clone(),
388                _ => format!("{base}-{}", attempt + 1),
389            };
390            let path = self.root.join(&id);
391            // `create_dir` failing on an existing directory is what claims the id
392            // against another process racing for the same millisecond.
393            match fs::create_dir(&path) {
394                Ok(()) => {
395                    let job = PullJob::from_plan(plan, id, now);
396                    persistence::write_json_atomic(&path.join(JOB_FILE), &job)?;
397                    let handle = PullJobDir { path, job };
398                    handle.write_status(&PullStatus::queued(now))?;
399                    return Ok(handle);
400                }
401                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
402                Err(error) => return Err(error.into()),
403            }
404        }
405        Err(PullError::Unclaimable(plan.reference.clone()))
406    }
407
408    /// The job with exactly this id.
409    pub fn open(&self, id: &str) -> Result<PullJobDir, PullError> {
410        PullJobDir::open(self.root.join(id))
411    }
412
413    /// Every readable job, oldest first. A directory without a readable
414    /// descriptor is not a job and is skipped; so is a store that cannot be
415    /// read at all, which [`PullStore::jobs`] reports instead of hiding.
416    pub fn list(&self) -> Vec<PullJobDir> {
417        self.jobs().unwrap_or_default()
418    }
419
420    /// Every readable job, oldest first, reporting a store that could not be
421    /// read (a missing store is simply empty).
422    pub fn jobs(&self) -> Result<Vec<PullJobDir>, PullError> {
423        let entries = match fs::read_dir(&self.root) {
424            Ok(entries) => entries,
425            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
426            Err(error) => return Err(error.into()),
427        };
428        let mut jobs: Vec<PullJobDir> = entries
429            .flatten()
430            .filter(|entry| entry.path().is_dir())
431            .filter_map(|entry| PullJobDir::open(entry.path()).ok())
432            .collect();
433        jobs.sort_by(|left, right| {
434            left.job
435                .created_at_ms
436                .cmp(&right.job.created_at_ms)
437                .then_with(|| left.job.id.cmp(&right.job.id))
438        });
439        Ok(jobs)
440    }
441
442    /// The one job `query` names: an exact id, then an unambiguous id prefix,
443    /// then an exact reference (ignoring case).
444    pub fn resolve(&self, query: &str) -> Result<PullJobDir, PullError> {
445        let query = query.trim();
446        if query.is_empty() {
447            return Err(PullError::NotFound(String::new()));
448        }
449        let jobs = self.jobs()?;
450        if let Some(job) = jobs.iter().find(|job| job.job.id == query) {
451            return Ok(job.clone());
452        }
453        let by_prefix = jobs.iter().filter(|job| job.job.id.starts_with(query));
454        if let Some(job) = single(by_prefix, query)? {
455            return Ok(job.clone());
456        }
457        let by_reference = jobs
458            .iter()
459            .filter(|job| job.job.reference.eq_ignore_ascii_case(query));
460        match single(by_reference, query)? {
461            Some(job) => Ok(job.clone()),
462            None => Err(PullError::NotFound(query.to_owned())),
463        }
464    }
465
466    /// The newest job pulling `reference` from `provider` that a client could
467    /// join: one still going, or one that stopped with bytes worth resuming.
468    ///
469    /// The reference is matched the way [`PullStore::resolve`] matches one, so a
470    /// tag the provider rewrote (`ls` into `ls:latest`) only joins the job it
471    /// created once the caller passes the rewritten form. A job whose worker
472    /// never arrived is past joining: nothing is coming for it, and preferring
473    /// it because it is newest would hide the pull that is actually running.
474    pub fn under_way(
475        &self,
476        provider: &InstallProviderId,
477        reference: &str,
478        now_ms: i64,
479    ) -> Option<PullJobDir> {
480        self.list()
481            .into_iter()
482            .rev()
483            .filter(|job| {
484                job.job().provider == *provider
485                    && job.job().reference.eq_ignore_ascii_case(reference)
486            })
487            .find(|job| !job.status().state.is_terminal() && !job.abandoned(now_ms, START_GRACE_MS))
488    }
489
490    /// Remove ended jobs last touched before `before_ms`, keeping the newest
491    /// `keep` of them however old they are. Returns how many were removed.
492    ///
493    /// Each goes through [`forget`](PullJobDir::forget), so a job whose
494    /// worker still holds the lock is left, however its record reads.
495    pub fn sweep(&self, keep: usize, before_ms: i64) -> usize {
496        let mut ended: Vec<(i64, PullJobDir)> = self
497            .list()
498            .into_iter()
499            .filter(|job| !job.worker_alive())
500            .filter_map(|job| {
501                let status = job.stored_status();
502                status
503                    .state
504                    .is_terminal()
505                    .then_some((status.updated_at_ms, job))
506            })
507            .collect();
508        ended.sort_by_key(|(touched_at, _)| std::cmp::Reverse(*touched_at));
509        let mut removed = 0;
510        for (touched_at, job) in ended.into_iter().skip(keep) {
511            if touched_at < before_ms && job.forget().is_ok() {
512                removed += 1;
513            }
514        }
515        removed
516    }
517}
518
519/// The single job `found` yields: `None` when it yields nothing, an
520/// [`PullError::Ambiguous`] when it yields several that are equally plausible.
521///
522/// A name several jobs answer to means the one still going. Pulling a model a
523/// second time would otherwise make its own name ambiguous for good, since the
524/// first job keeps that name for as long as its record is kept.
525fn single<'a>(
526    found: impl Iterator<Item = &'a PullJobDir>,
527    query: &str,
528) -> Result<Option<&'a PullJobDir>, PullError> {
529    let matches: Vec<&PullJobDir> = found.collect();
530    match matches.as_slice() {
531        [] => Ok(None),
532        [only] => Ok(Some(only)),
533        many => {
534            let going: Vec<&PullJobDir> = many
535                .iter()
536                .copied()
537                .filter(|job| !job.status().state.is_terminal())
538                .collect();
539            match going.as_slice() {
540                [only] => Ok(Some(only)),
541                _ => Err(PullError::Ambiguous {
542                    query: query.to_owned(),
543                    count: many.len(),
544                }),
545            }
546        }
547    }
548}
549
550/// One pull's directory: its descriptor, and the files around it.
551#[derive(Debug, Clone)]
552pub struct PullJobDir {
553    path: PathBuf,
554    job: PullJob,
555}
556
557impl PullJobDir {
558    /// Open the job directory at `path`, reading its descriptor.
559    ///
560    /// An undecodable descriptor is reported, never moved aside: another
561    /// process may still be pulling what it describes, and a reader that
562    /// quarantines it would take the job away from every client at once.
563    pub fn open(path: impl Into<PathBuf>) -> Result<Self, PullError> {
564        let path = path.into();
565        let descriptor = path.join(JOB_FILE);
566        let bytes = match fs::read(&descriptor) {
567            Ok(bytes) => bytes,
568            Err(error) if error.kind() == io::ErrorKind::NotFound => {
569                return Err(PullError::NotFound(name_of(&path)));
570            }
571            Err(error) => return Err(error.into()),
572        };
573        match serde_json::from_slice(&bytes) {
574            Ok(job) => Ok(Self { path, job }),
575            Err(source) => Err(PullError::Unreadable {
576                path: descriptor,
577                source,
578            }),
579        }
580    }
581
582    /// The directory itself.
583    pub fn path(&self) -> &Path {
584        &self.path
585    }
586
587    /// The job id.
588    pub fn id(&self) -> &str {
589        &self.job.id
590    }
591
592    /// The descriptor.
593    pub fn job(&self) -> &PullJob {
594        &self.job
595    }
596
597    /// The lock a worker holds for as long as it owns this job.
598    pub fn lock_path(&self) -> PathBuf {
599        self.path.join(LOCK_FILE)
600    }
601
602    /// Take the job for this process, or `None` when someone else holds it.
603    ///
604    /// A single refusal is not proof of another worker: a reader probing
605    /// liveness holds a shared lock for a moment, and that is enough to deny an
606    /// exclusive claim. A caller concluding "already owned" should try a few
607    /// times over a short window first.
608    pub fn claim(&self) -> Result<Option<PullLock>, PullError> {
609        take_lock(&self.lock_path())
610    }
611
612    /// Whether a worker still owns this job.
613    ///
614    /// The test is the lock, not the pid: a pid can be reused, and the operating
615    /// system releases an advisory lock even when the process is killed or
616    /// panics. The probe takes a *shared* lock, which a worker's exclusive one
617    /// still blocks, but which two readers can hold at once: probing with an
618    /// exclusive lock would make concurrent readers report each other as the
619    /// worker.
620    ///
621    /// A lock file that exists but cannot be opened counts as alive. Calling a
622    /// live pull dead is the costlier mistake, since it invites a second worker
623    /// onto the same download.
624    pub fn worker_alive(&self) -> bool {
625        let file = match File::open(self.lock_path()) {
626            Ok(file) => file,
627            Err(error) if error.kind() == io::ErrorKind::NotFound => return false,
628            Err(_) => return true,
629        };
630        match file.try_lock_shared() {
631            Ok(()) => {
632                let _ = file.unlock();
633                false
634            }
635            Err(_) => true,
636        }
637    }
638
639    /// The record as written, without the liveness rule applied.
640    ///
641    /// A missing record reads as `queued`, which is what a job whose worker
642    /// never got started is. An undecodable one reads the same way rather than
643    /// being quarantined: this file is a rewritten view of live state, not a
644    /// store of truth, and a running worker replaces it within the second.
645    pub fn stored_status(&self) -> PullStatus {
646        fs::read(self.path.join(STATUS_FILE))
647            .ok()
648            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
649            .unwrap_or_else(|| PullStatus::queued(self.job.created_at_ms))
650    }
651
652    /// The record as it is true right now: a job a worker was meant to be
653    /// holding, with nobody holding it, is `interrupted`.
654    ///
655    /// A `queued` job counts only once a worker has written its pid, which it
656    /// does after it has everything it needs to run. Before that the job has
657    /// simply not been picked up yet, and a client that read it as interrupted
658    /// would start a second worker on top of one still starting, or on top of
659    /// one that stood down because another worker owns the same reference.
660    pub fn status(&self) -> PullStatus {
661        let mut status = self.stored_status();
662        let expects_worker = match status.state {
663            PullState::Running => true,
664            PullState::Queued => status.pid.is_some(),
665            _ => false,
666        };
667        if expects_worker && !self.worker_alive() {
668            status.state = PullState::Interrupted;
669        }
670        status
671    }
672
673    /// Whether the job is waiting for a worker that is not coming: queued with
674    /// no pid written, nobody holding the lock, and `grace_ms` past its last
675    /// write, by which time a worker on its way would have claimed it.
676    ///
677    /// This is the one state the liveness rule cannot speak for. A worker writes
678    /// its pid as soon as it holds the job, so `queued` without one means
679    /// nothing has taken the job yet; only time tells a job still being picked
680    /// up from one whose worker died on the way.
681    pub fn abandoned(&self, now_ms: i64, grace_ms: i64) -> bool {
682        self.abandoned_by(&self.stored_status(), now_ms, grace_ms)
683    }
684
685    /// [`abandoned`](Self::abandoned) for a record already read, so a reader
686    /// that holds one does not read it again; the lock is still probed.
687    pub fn abandoned_by(&self, status: &PullStatus, now_ms: i64, grace_ms: i64) -> bool {
688        status.state == PullState::Queued
689            && status.pid.is_none()
690            && now_ms.saturating_sub(status.updated_at_ms) >= grace_ms
691            && !self.worker_alive()
692    }
693
694    /// Write `status` atomically, so a reader sees the old record or the new one
695    /// and never half of either.
696    ///
697    /// A job whose directory has been swept is reported gone rather than
698    /// recreated: an atomic write makes its parents, and a worker writing into a
699    /// removed job would leave a directory no listing can see.
700    pub fn write_status(&self, status: &PullStatus) -> Result<(), PullError> {
701        self.guard_write(|| {
702            persistence::write_json_atomic(&self.path.join(STATUS_FILE), status)?;
703            Ok(())
704        })
705    }
706
707    /// Read the stored record, hand it to `change`, and write it back stamped
708    /// `now`. Saves the caller from carrying the record between writes; the
709    /// worker is the only writer, so no two of these can interleave.
710    pub fn update_status(
711        &self,
712        now: i64,
713        change: impl FnOnce(&mut PullStatus),
714    ) -> Result<PullStatus, PullError> {
715        let mut status = self.stored_status();
716        change(&mut status);
717        status.updated_at_ms = now;
718        self.write_status(&status)?;
719        Ok(status)
720    }
721
722    /// Append `kind` to the history, stamped `now`. A file whose last line was
723    /// torn off mid-write gets its newline back first, so the damage stays on
724    /// the line it happened to.
725    pub fn append(&self, kind: PullEventKind, now: i64) -> Result<(), PullError> {
726        let event = PullEvent { at_ms: now, kind };
727        let mut line = serde_json::to_vec(&event).map_err(StoreError::Encode)?;
728        line.push(b'\n');
729        let path = self.path.join(EVENTS_FILE);
730        let unterminated = ends_mid_line(&path);
731        let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
732        if unterminated {
733            file.write_all(b"\n")?;
734        }
735        file.write_all(&line)?;
736        Ok(())
737    }
738
739    /// The history, oldest first. A line that will not decode is skipped rather
740    /// than sinking the rest of the file, and that includes a line that is not
741    /// even valid text.
742    pub fn events(&self) -> Vec<PullEvent> {
743        let Ok(bytes) = fs::read(self.path.join(EVENTS_FILE)) else {
744            return Vec::new();
745        };
746        bytes
747            .split(|byte| *byte == b'\n')
748            .filter_map(|line| serde_json::from_slice(line).ok())
749            .collect()
750    }
751
752    /// What a client has asked the worker to do, if anything.
753    pub fn control(&self) -> Option<PullControl> {
754        fs::read_to_string(self.path.join(CONTROL_FILE))
755            .ok()
756            .as_deref()
757            .and_then(PullControl::parse)
758    }
759
760    /// Ask the worker for `control`.
761    pub fn request(&self, control: PullControl) -> Result<(), PullError> {
762        self.guard_write(|| {
763            persistence::write_atomic(&self.path.join(CONTROL_FILE), control.as_str().as_bytes())?;
764            Ok(())
765        })
766    }
767
768    /// Drop the control the worker has honoured, leaving a later one alone: a
769    /// cancel that arrived while a pause was being honoured is still waiting to
770    /// be read, and deleting it would lose it silently.
771    pub fn clear_control(&self, honoured: PullControl) -> Result<(), PullError> {
772        if self.control() != Some(honoured) {
773            return Ok(());
774        }
775        match fs::remove_file(self.path.join(CONTROL_FILE)) {
776            Ok(()) => Ok(()),
777            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
778            Err(error) => Err(error.into()),
779        }
780    }
781
782    /// Delete the job's directory. The weights it fetched are not touched: they
783    /// belong to the model store, not to the job.
784    pub fn remove(&self) -> Result<(), PullError> {
785        fs::remove_dir_all(&self.path)?;
786        Ok(())
787    }
788
789    /// Forget an ended job: [`remove`](Self::remove) once the record has
790    /// ended and no worker holds the lock. A worker holds the lock until it
791    /// exits, a moment after it settles the record, so a record that reads
792    /// ended can still be under a worker, and deleting the directory then
793    /// would leave a partial one behind. The rule every collector follows,
794    /// the sweep included.
795    pub fn forget(&self) -> Result<(), PullError> {
796        let state = self.stored_status().state;
797        if !state.is_terminal() {
798            return Err(PullError::NotEnded {
799                id: self.id().to_owned(),
800                state,
801            });
802        }
803        if self.worker_alive() {
804            return Err(PullError::Held(self.id().to_owned()));
805        }
806        self.remove()
807    }
808
809    /// Refuse to write into a job that has been swept, and undo the write when
810    /// the sweep lands between the check and it.
811    ///
812    /// An atomic write makes the directories it needs, so a write racing a
813    /// sweep would otherwise leave a directory holding a record and no
814    /// descriptor: invisible to every listing, and therefore never collected.
815    fn guard_write(&self, write: impl FnOnce() -> Result<(), PullError>) -> Result<(), PullError> {
816        self.require_job()?;
817        write()?;
818        match self.require_job() {
819            Ok(()) => Ok(()),
820            Err(error) => {
821                let _ = fs::remove_dir_all(&self.path);
822                Err(error)
823            }
824        }
825    }
826
827    fn require_job(&self) -> Result<(), PullError> {
828        match self.path.join(JOB_FILE).is_file() {
829            true => Ok(()),
830            false => Err(PullError::NotFound(self.job.id.clone())),
831        }
832    }
833}
834
835/// Take an exclusive lock on `path`, creating it if it is not there, or `None`
836/// when someone else holds it. The lock lives with the returned handle.
837///
838/// A lock file may be unlinked by a sweep between this opening it and locking
839/// it; a lock on a file that is no longer at its path is a lock on nothing
840/// anyone else can see, so the path is opened again. After [`LOCK_REOPENS`]
841/// sweeps in a row it is given up on, which reads as held.
842pub fn take_lock(path: &Path) -> Result<Option<PullLock>, PullError> {
843    for _ in 0..LOCK_REOPENS {
844        let file = OpenOptions::new()
845            .create(true)
846            .truncate(false)
847            .read(true)
848            .write(true)
849            .open(path)?;
850        match file.try_lock() {
851            Ok(()) if still_at(&file, path) => return Ok(Some(PullLock { file })),
852            Ok(()) => continue,
853            Err(TryLockError::WouldBlock) => return Ok(None),
854            Err(TryLockError::Error(error)) => return Err(error.into()),
855        }
856    }
857    Ok(None)
858}
859
860/// Whether `file` is the file at `path`, rather than one unlinked from it.
861#[cfg(unix)]
862fn still_at(file: &File, path: &Path) -> bool {
863    use std::os::unix::fs::MetadataExt;
864    match (file.metadata(), fs::metadata(path)) {
865        (Ok(held), Ok(at_path)) => held.ino() == at_path.ino() && held.dev() == at_path.dev(),
866        _ => false,
867    }
868}
869
870/// Without inode identity there is nothing to compare; the liveness rule this
871/// module rests on is `flock`'s in any case.
872#[cfg(not(unix))]
873fn still_at(_file: &File, _path: &Path) -> bool {
874    true
875}
876
877/// Whether `path` holds bytes that do not end in a newline, so the next append
878/// would glue itself onto a line torn off mid-write.
879fn ends_mid_line(path: &Path) -> bool {
880    fs::read(path)
881        .ok()
882        .filter(|bytes| !bytes.is_empty())
883        .is_some_and(|bytes| bytes.last() != Some(&b'\n'))
884}
885
886/// The last segment of `path`, for naming a directory that has no descriptor.
887fn name_of(path: &Path) -> String {
888    path.file_name()
889        .and_then(|name| name.to_str())
890        .unwrap_or("pull")
891        .to_owned()
892}
893
894/// `reference` as the readable half of a job id: lowercase, one dash between
895/// runs of anything else, and short enough to keep the id typeable. Deliberately
896/// not the artifact store's slug, which has its own rules for its own names.
897fn reference_slug(reference: &str) -> String {
898    let mut slug = String::with_capacity(reference.len().min(SLUG_LIMIT));
899    for character in reference.chars() {
900        if character.is_ascii_alphanumeric() {
901            slug.push(character.to_ascii_lowercase());
902        } else if !slug.ends_with('-') {
903            slug.push('-');
904        }
905        if slug.len() >= SLUG_LIMIT {
906            break;
907        }
908    }
909    let trimmed = slug.trim_matches('-');
910    match trimmed.is_empty() {
911        true => "model".to_owned(),
912        false => trimmed.to_owned(),
913    }
914}