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}