Skip to main content

cherenkov/model/index/
packing.rs

1use super::{
2    Artifact, ArtifactKind, ArtifactLease, ModelDetails, ModelEntry, ModelIndex, Source, local,
3    store::Pending,
4};
5use crate::{
6    download,
7    model::{Checkpoint, Preparation},
8    qwen4_exp,
9    storage::Paths,
10};
11use anyhow::{Context, Result, ensure};
12use std::{
13    os::unix::fs::DirBuilderExt,
14    path::{Path, PathBuf},
15};
16
17/// Requested expert variants and source retention for an indexed model.
18pub struct PackOptions<'a> {
19    /// Export to a caller-owned directory instead of selecting the managed store.
20    /// A changed or new export must use a new directory outside managed artifacts.
21    pub output: Option<&'a Path>,
22    /// Nonempty set of requested expert bit widths: 2, 3, or 4.
23    pub experts: &'a [u32],
24    /// Retain a downloaded HF source after successful preparation.
25    pub keep_source: bool,
26    /// Optional HF credential for source downloads; never written to the catalog.
27    pub token: Option<&'a str>,
28    /// Force rebuilding the requested low-bit variants even if they are usable.
29    pub repack: bool,
30}
31
32impl ModelIndex {
33    /// Prepare requested variants and atomically update the model's artifact reference.
34    /// Existing usable stores can be reused. Rebuilds leave previously leased stores
35    /// unchanged. HF imports use a full local snapshot; newly downloaded sources are
36    /// discarded after success unless retention was requested.
37    pub fn pack(&self, reference: &str, request: PackOptions<'_>) -> Result<ModelDetails> {
38        let PackOptions {
39            output,
40            experts,
41            keep_source,
42            token,
43            repack,
44        } = request;
45
46        ensure!(
47            !experts.is_empty() && experts.iter().all(|b| (2..=4).contains(b)),
48            "expert precisions must be 4,3,2"
49        );
50
51        let model = self.resolve(reference)?;
52
53        ensure!(
54            !matches!(
55                model.description.preparation(),
56                Preparation::Unsupported { .. }
57            ),
58            "model cannot be prepared by this engine"
59        );
60
61        let previous = self.prepared_copy(&model)?;
62        let retain_download = keep_source
63            && matches!(&model.source, Source::HuggingFace { .. })
64            && model.retained_source.is_none();
65
66        let metadata_source =
67            self.metadata_repair(&model, previous.as_ref().map(|(_, lease)| lease))?;
68
69        if let Some((artifact, lease)) = &previous {
70            let bits = super::store::precisions(&lease.path);
71            let same_output =
72                output.is_none_or(|p| p.canonicalize().ok() == lease.path.canonicalize().ok());
73
74            if !repack
75                && experts.iter().all(|b| bits.contains(b))
76                && same_output
77                && !retain_download
78                && metadata_source.is_none()
79            {
80                return self.show(reference);
81            }
82
83            ensure!(
84                output.is_none_or(|p| !p.exists()),
85                "--output must name a new directory when adding variants"
86            );
87            ensure!(artifact.ready, "previous artifact is incomplete");
88        }
89
90        let pending = self.begin(ArtifactKind::Prepared)?;
91        let mut downloaded = None;
92        let mut source_lease = None;
93
94        if let Some((artifact, lease)) = &previous {
95            if artifact.external.is_some() {
96                crate::storage::require_space(
97                    &pending.lease.path,
98                    super::store::bytes(&lease.path)?,
99                )?;
100            }
101
102            local::copy_store(
103                &lease.path,
104                &pending.lease.path,
105                artifact.external.is_none(),
106            )?;
107
108            copy_runtime_metadata(lease, &pending.lease.path)?;
109
110            if let Some(source) = &metadata_source {
111                qwen4_exp::pack::copy_chat_metadata(&source.path, &pending.lease.path)?;
112            }
113
114            // Both explicit and automatic rebuilds must detach shared inodes.
115            let rebuild = super::store::rebuild_variants(&pending.lease.path, experts, repack);
116
117            remove_variants(&pending.lease.path, &rebuild)?;
118
119            qwen4_exp::pack::prepare(&pending.lease.path, None, experts)?;
120
121            if retain_download {
122                source_lease = Some(self.acquire_source(&model, token, &mut downloaded)?);
123            }
124        } else {
125            let input = self.acquire_source(&model, token, &mut downloaded)?;
126
127            qwen4_exp::pack::prepare(&input.path, Some(&pending.lease.path), experts)?;
128            check_local(&model)?;
129
130            source_lease = Some(input);
131        }
132
133        Checkpoint::open(&pending.lease.path).context("validating prepared output")?;
134        sync_files(&pending.lease.path)?;
135
136        let artifact = match output {
137            Some(path) => self.export(&pending, path)?,
138            None => pending.artifact.clone(),
139        };
140        let retained = keep_source
141            .then(|| downloaded.as_ref().map(|p: &Pending| p.artifact.clone()))
142            .flatten();
143
144        self.publish(&model, artifact, retained)?;
145        drop(source_lease);
146
147        if let Some(source) = downloaded {
148            let id = source.artifact.id.clone();
149
150            drop(source);
151
152            if !keep_source {
153                self.discard(&id)?;
154            }
155        }
156
157        let id = pending.artifact.id.clone();
158
159        drop(pending);
160
161        if output.is_some() {
162            self.discard(&id)?;
163        }
164
165        self.show(reference)
166    }
167
168    fn prepared_copy(&self, model: &ModelEntry) -> Result<Option<(Artifact, ArtifactLease)>> {
169        let Some(id) = &model.prepared else {
170            return Ok(None);
171        };
172        let catalog = self.lock()?;
173        let artifact = catalog
174            .value
175            .artifacts
176            .get(id)
177            .context("prepared artifact disappeared")?;
178
179        if !self.artifact_path(artifact).exists() {
180            return Ok(None);
181        }
182
183        Ok(Some((artifact.clone(), self.lease(artifact)?)))
184    }
185
186    /// Hold the source only when a prepared artifact needs missing metadata copied.
187    fn metadata_repair(
188        &self,
189        model: &ModelEntry,
190        previous: Option<&ArtifactLease>,
191    ) -> Result<Option<ArtifactLease>> {
192        let Some(previous) = previous else {
193            return Ok(None);
194        };
195        let Some(source) = self.available_metadata(model)? else {
196            return Ok(None);
197        };
198
199        if !qwen4_exp::pack::missing_chat_metadata(&source.path, &previous.path) {
200            return Ok(None);
201        }
202
203        check_local(model)?;
204
205        Ok(Some(source))
206    }
207
208    /// Retain available source metadata without downloading another weight snapshot.
209    fn available_metadata(&self, model: &ModelEntry) -> Result<Option<ArtifactLease>> {
210        if let Source::Local { path, .. } = &model.source {
211            return path
212                .is_dir()
213                .then(|| self.local_source_lease(model, path))
214                .transpose();
215        }
216
217        let Some(id) = &model.retained_source else {
218            return Ok(None);
219        };
220        let catalog = self.lock()?;
221        let artifact = catalog
222            .value
223            .artifacts
224            .get(id)
225            .context("source artifact disappeared")?;
226
227        if !self.artifact_path(artifact).is_dir() {
228            return Ok(None);
229        }
230
231        Ok(Some(self.lease(artifact)?))
232    }
233
234    fn acquire_source(
235        &self,
236        model: &ModelEntry,
237        token: Option<&str>,
238        downloaded: &mut Option<Pending>,
239    ) -> Result<ArtifactLease> {
240        if let Source::Local { path, .. } = &model.source {
241            let lease = self.local_source_lease(model, path)?;
242
243            check_local(model)?;
244
245            return Ok(lease);
246        }
247
248        if let Some(id) = &model.retained_source {
249            let catalog = self.lock()?;
250
251            if let Some(artifact) = catalog.value.artifacts.get(id)
252                && self.artifact_path(artifact).exists()
253            {
254                return self.lease(artifact);
255            }
256        }
257
258        let Source::HuggingFace {
259            repo,
260            revision,
261            endpoint,
262        } = &model.source
263        else {
264            unreachable!()
265        };
266        let mut pending = self.begin(ArtifactKind::Source)?;
267        let root = pending.lease.path.clone();
268        let paths = Paths {
269            data: root.clone(),
270            scratch: root.join("transfer"),
271            config: root.join("unused.toml"),
272        };
273        let path = download::run_at(
274            &paths,
275            download::Download {
276                repo,
277                revision,
278                token,
279                metadata_only: false,
280            },
281            Some(endpoint),
282        )?;
283        pending.artifact.entry = path.strip_prefix(&root)?.to_owned();
284        let lease = ArtifactLease::external(path);
285        *downloaded = Some(pending);
286
287        Ok(lease)
288    }
289
290    fn local_source_lease(&self, model: &ModelEntry, path: &Path) -> Result<ArtifactLease> {
291        let Some(id) = &model.retained_source else {
292            return Ok(ArtifactLease::external(path.to_owned()));
293        };
294        let catalog = self.lock()?;
295        let artifact = catalog
296            .value
297            .artifacts
298            .get(id)
299            .context("source artifact disappeared")?;
300
301        self.lease(artifact)
302    }
303
304    fn export(&self, pending: &Pending, path: &Path) -> Result<Artifact> {
305        ensure!(!path.exists(), "--output must name a new directory");
306
307        let parent = path
308            .parent()
309            .filter(|p| !p.as_os_str().is_empty())
310            .unwrap_or(Path::new("."));
311
312        crate::storage::create_private_dir(parent)?;
313        self.require_external_path(&parent.canonicalize()?)?;
314        // Atomically claim the final directory so concurrent exporters cannot mix files.
315        std::fs::DirBuilder::new().mode(0o700).create(path)?;
316        crate::storage::require_space(path, super::store::bytes(&pending.lease.path)?)?;
317        local::copy_store(&pending.lease.path, path, false)?;
318        sync_files(path)?;
319
320        Ok(Artifact {
321            id: super::id(),
322            kind: ArtifactKind::Prepared,
323            external: Some(path.canonicalize()?),
324            entry: PathBuf::new(),
325            ready: true,
326        })
327    }
328
329    fn discard(&self, id: &str) -> Result<()> {
330        let mut catalog = self.lock()?;
331
332        ensure!(
333            super::store::references(&catalog.value, id) == 0,
334            "cannot discard referenced artifact"
335        );
336
337        let lease = self.lease_file(id)?;
338
339        lease
340            .try_lock()
341            .context("temporary artifact is still in use")?;
342
343        let path = self.artifact_root(id);
344
345        if path.exists() {
346            std::fs::remove_dir_all(path)?;
347        }
348
349        catalog.value.artifacts.remove(id);
350
351        catalog.save()
352    }
353}
354
355pub(super) fn check_local(model: &ModelEntry) -> Result<()> {
356    if let Source::Local { path, fingerprint } = &model.source {
357        ensure!(
358            &local::fingerprint(path)? == fingerprint,
359            "local source changed; register the changed checkpoint before packing"
360        );
361    }
362
363    Ok(())
364}
365
366fn sync_files(path: &Path) -> Result<()> {
367    for entry in std::fs::read_dir(path)? {
368        let path = entry?.path();
369
370        if path.is_file() {
371            std::fs::File::open(path)?.sync_all()?;
372        }
373    }
374
375    std::fs::File::open(path)?.sync_all()?;
376
377    Ok(())
378}
379
380/// Unlink copied variants before rebuilding: their files may share the old store's inode.
381fn remove_variants(path: &Path, experts: &[u32]) -> Result<()> {
382    for bits in experts.iter().filter(|&&bits| bits != 4) {
383        for name in [format!("experts{bits}.bin"), format!("manifest{bits}.json")] {
384            let file = path.join(name);
385
386            if file.exists() {
387                std::fs::remove_file(file)?;
388            }
389        }
390    }
391
392    Ok(())
393}
394
395/// Make a copied legacy store self-contained before extending its expert variants.
396fn copy_runtime_metadata(lease: &ArtifactLease, target: &Path) -> Result<()> {
397    let metadata = lease.runtime_path();
398
399    if metadata == lease.path {
400        return Ok(());
401    }
402
403    for name in ["config.json", "tokenizer.json"] {
404        let source = metadata.join(name);
405        let destination = target.join(name);
406
407        if source.is_file() && !destination.exists() {
408            std::fs::copy(source, destination)?;
409        }
410    }
411
412    qwen4_exp::pack::copy_chat_metadata(metadata, target)
413}