ostree_ext/
chunking.rs

1//! Split an OSTree commit into separate chunks
2
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5use std::borrow::{Borrow, Cow};
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Write;
8use std::hash::{Hash, Hasher};
9use std::num::NonZeroU32;
10use std::rc::Rc;
11use std::time::Instant;
12
13use crate::container::{COMPONENT_SEPARATOR, CONTENT_ANNOTATION};
14use crate::objectsource::{ContentID, ObjectMeta, ObjectMetaMap, ObjectSourceMeta};
15use crate::objgv::*;
16use crate::statistics;
17use anyhow::{anyhow, Result};
18use camino::Utf8PathBuf;
19use containers_image_proxy::oci_spec;
20use gvariant::aligned_bytes::TryAsAligned;
21use gvariant::{Marker, Structure};
22use indexmap::IndexMap;
23use ostree::{gio, glib};
24use serde::{Deserialize, Serialize};
25
26/// Maximum number of layers (chunks) we will use.
27// We take half the limit of 128.
28// https://github.com/ostreedev/ostree-rs-ext/issues/69
29pub(crate) const MAX_CHUNKS: u32 = 64;
30/// Minimum number of layers we can create in a "chunked" flow; otherwise
31/// we will just drop down to one.
32const MIN_CHUNKED_LAYERS: u32 = 4;
33
34/// A convenient alias for a reference-counted, immutable string.
35pub(crate) type RcStr = Rc<str>;
36/// Maps from a checksum to its size and file names (multiple in the case of
37/// hard links).
38pub(crate) type ChunkMapping = BTreeMap<RcStr, (u64, Vec<Utf8PathBuf>)>;
39// TODO type PackageSet = HashSet<RcStr>;
40
41const LOW_PARTITION: &str = "2ls";
42const HIGH_PARTITION: &str = "1hs";
43
44#[derive(Debug, Default)]
45pub(crate) struct Chunk {
46    pub(crate) name: String,
47    pub(crate) content: ChunkMapping,
48    pub(crate) size: u64,
49    pub(crate) packages: Vec<String>,
50}
51
52#[derive(Debug, Deserialize, Serialize)]
53/// Object metadata, but with additional size data
54pub struct ObjectSourceMetaSized {
55    /// The original metadata
56    #[serde(flatten)]
57    pub meta: ObjectSourceMeta,
58    /// Total size of associated objects
59    pub size: u64,
60}
61
62impl Hash for ObjectSourceMetaSized {
63    fn hash<H: Hasher>(&self, state: &mut H) {
64        self.meta.identifier.hash(state);
65    }
66}
67
68impl Eq for ObjectSourceMetaSized {}
69
70impl PartialEq for ObjectSourceMetaSized {
71    fn eq(&self, other: &Self) -> bool {
72        self.meta.identifier == other.meta.identifier
73    }
74}
75
76/// Extend content source metadata with sizes.
77#[derive(Debug)]
78pub struct ObjectMetaSized {
79    /// Mapping from content object to source.
80    pub map: ObjectMetaMap,
81    /// Computed sizes of each content source
82    pub sizes: Vec<ObjectSourceMetaSized>,
83}
84
85impl ObjectMetaSized {
86    /// Given object metadata and a repo, compute the size of each content source.
87    pub fn compute_sizes(repo: &ostree::Repo, meta: ObjectMeta) -> Result<ObjectMetaSized> {
88        let cancellable = gio::Cancellable::NONE;
89        // Destructure into component parts; we'll create the version with sizes
90        let map = meta.map;
91        let mut set = meta.set;
92        // Maps content id -> total size of associated objects
93        let mut sizes = BTreeMap::<&str, u64>::new();
94        // Populate two mappings above, iterating over the object -> contentid mapping
95        for (checksum, contentid) in map.iter() {
96            let finfo = repo.query_file(checksum, cancellable)?.0;
97            let sz = sizes.entry(contentid).or_default();
98            *sz += finfo.size() as u64;
99        }
100        // Combine data from sizes and the content mapping.
101        let sized: Result<Vec<_>> = sizes
102            .into_iter()
103            .map(|(id, size)| -> Result<ObjectSourceMetaSized> {
104                set.take(id)
105                    .ok_or_else(|| anyhow!("Failed to find {} in content set", id))
106                    .map(|meta| ObjectSourceMetaSized { meta, size })
107            })
108            .collect();
109        let mut sizes = sized?;
110        sizes.sort_by(|a, b| b.size.cmp(&a.size));
111        Ok(ObjectMetaSized { map, sizes })
112    }
113}
114
115/// How to split up an ostree commit into "chunks" - designed to map to container image layers.
116#[derive(Debug, Default)]
117pub struct Chunking {
118    pub(crate) metadata_size: u64,
119    pub(crate) remainder: Chunk,
120    pub(crate) chunks: Vec<Chunk>,
121
122    pub(crate) max: u32,
123
124    processed_mapping: bool,
125    /// Number of components (e.g. packages) provided originally
126    pub(crate) n_provided_components: u32,
127    /// The above, but only ones with non-zero size
128    pub(crate) n_sized_components: u32,
129}
130
131#[derive(Default)]
132struct Generation {
133    path: Utf8PathBuf,
134    metadata_size: u64,
135    dirtree_found: BTreeSet<RcStr>,
136    dirmeta_found: BTreeSet<RcStr>,
137}
138
139fn push_dirmeta(repo: &ostree::Repo, gen: &mut Generation, checksum: &str) -> Result<()> {
140    if gen.dirtree_found.contains(checksum) {
141        return Ok(());
142    }
143    let checksum = RcStr::from(checksum);
144    gen.dirmeta_found.insert(RcStr::clone(&checksum));
145    let child_v = repo.load_variant(ostree::ObjectType::DirMeta, checksum.borrow())?;
146    gen.metadata_size += child_v.data_as_bytes().as_ref().len() as u64;
147    Ok(())
148}
149
150fn push_dirtree(
151    repo: &ostree::Repo,
152    gen: &mut Generation,
153    checksum: &str,
154) -> Result<glib::Variant> {
155    let child_v = repo.load_variant(ostree::ObjectType::DirTree, checksum)?;
156    if !gen.dirtree_found.contains(checksum) {
157        gen.metadata_size += child_v.data_as_bytes().as_ref().len() as u64;
158    } else {
159        let checksum = RcStr::from(checksum);
160        gen.dirtree_found.insert(checksum);
161    }
162    Ok(child_v)
163}
164
165fn generate_chunking_recurse(
166    repo: &ostree::Repo,
167    gen: &mut Generation,
168    chunk: &mut Chunk,
169    dt: &glib::Variant,
170) -> Result<()> {
171    let dt = dt.data_as_bytes();
172    let dt = dt.try_as_aligned()?;
173    let dt = gv_dirtree!().cast(dt);
174    let (files, dirs) = dt.to_tuple();
175    // A reusable buffer to avoid heap allocating these
176    let mut hexbuf = [0u8; 64];
177    for file in files {
178        let (name, csum) = file.to_tuple();
179        let fpath = gen.path.join(name.to_str());
180        hex::encode_to_slice(csum, &mut hexbuf)?;
181        let checksum = std::str::from_utf8(&hexbuf)?;
182        let meta = repo.query_file(checksum, gio::Cancellable::NONE)?.0;
183        let size = meta.size() as u64;
184        let entry = chunk.content.entry(RcStr::from(checksum)).or_default();
185        entry.0 = size;
186        let first = entry.1.is_empty();
187        if first {
188            chunk.size += size;
189        }
190        entry.1.push(fpath);
191    }
192    for item in dirs {
193        let (name, contents_csum, meta_csum) = item.to_tuple();
194        let name = name.to_str();
195        // Extend our current path
196        gen.path.push(name);
197        hex::encode_to_slice(contents_csum, &mut hexbuf)?;
198        let checksum_s = std::str::from_utf8(&hexbuf)?;
199        let dirtree_v = push_dirtree(repo, gen, checksum_s)?;
200        generate_chunking_recurse(repo, gen, chunk, &dirtree_v)?;
201        drop(dirtree_v);
202        hex::encode_to_slice(meta_csum, &mut hexbuf)?;
203        let checksum_s = std::str::from_utf8(&hexbuf)?;
204        push_dirmeta(repo, gen, checksum_s)?;
205        // We did a push above, so pop must succeed.
206        assert!(gen.path.pop());
207    }
208    Ok(())
209}
210
211impl Chunk {
212    fn new(name: &str) -> Self {
213        Chunk {
214            name: name.to_string(),
215            ..Default::default()
216        }
217    }
218
219    pub(crate) fn move_obj(&mut self, dest: &mut Self, checksum: &str) -> bool {
220        // In most cases, we expect the object to exist in the source.  However, it's
221        // conveneient here to simply ignore objects which were already moved into
222        // a chunk.
223        if let Some((name, (size, paths))) = self.content.remove_entry(checksum) {
224            let v = dest.content.insert(name, (size, paths));
225            debug_assert!(v.is_none());
226            self.size -= size;
227            dest.size += size;
228            true
229        } else {
230            false
231        }
232    }
233}
234
235impl Chunking {
236    /// Generate an initial single chunk.
237    pub fn new(repo: &ostree::Repo, rev: &str) -> Result<Self> {
238        // Find the target commit
239        let rev = repo.require_rev(rev)?;
240
241        // Load and parse the commit object
242        let (commit_v, _) = repo.load_commit(&rev)?;
243        let commit_v = commit_v.data_as_bytes();
244        let commit_v = commit_v.try_as_aligned()?;
245        let commit = gv_commit!().cast(commit_v);
246        let commit = commit.to_tuple();
247
248        // Load it all into a single chunk
249        let mut gen = Generation {
250            path: Utf8PathBuf::from("/"),
251            ..Default::default()
252        };
253        let mut chunk: Chunk = Default::default();
254
255        // Find the root directory tree
256        let contents_checksum = &hex::encode(commit.6);
257        let contents_v = repo.load_variant(ostree::ObjectType::DirTree, contents_checksum)?;
258        push_dirtree(repo, &mut gen, contents_checksum)?;
259        let meta_checksum = &hex::encode(commit.7);
260        push_dirmeta(repo, &mut gen, meta_checksum.as_str())?;
261
262        generate_chunking_recurse(repo, &mut gen, &mut chunk, &contents_v)?;
263
264        let chunking = Chunking {
265            metadata_size: gen.metadata_size,
266            remainder: chunk,
267            ..Default::default()
268        };
269        Ok(chunking)
270    }
271
272    /// Generate a chunking from an object mapping.
273    pub fn from_mapping(
274        repo: &ostree::Repo,
275        rev: &str,
276        meta: &ObjectMetaSized,
277        max_layers: &Option<NonZeroU32>,
278        prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
279    ) -> Result<Self> {
280        let mut r = Self::new(repo, rev)?;
281        r.process_mapping(meta, max_layers, prior_build_metadata)?;
282        Ok(r)
283    }
284
285    fn remaining(&self) -> u32 {
286        self.max.saturating_sub(self.chunks.len() as u32)
287    }
288
289    /// Given metadata about which objects are owned by a particular content source,
290    /// generate chunks that group together those objects.
291    #[allow(clippy::or_fun_call)]
292    pub fn process_mapping(
293        &mut self,
294        meta: &ObjectMetaSized,
295        max_layers: &Option<NonZeroU32>,
296        prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
297    ) -> Result<()> {
298        self.max = max_layers
299            .unwrap_or(NonZeroU32::new(MAX_CHUNKS).unwrap())
300            .get();
301
302        let sizes = &meta.sizes;
303        // It doesn't make sense to handle multiple mappings
304        assert!(!self.processed_mapping);
305        self.processed_mapping = true;
306        let remaining = self.remaining();
307        if remaining == 0 {
308            return Ok(());
309        }
310
311        // Reverses `contentmeta.map` i.e. contentid -> Vec<checksum>
312        let mut rmap = IndexMap::<ContentID, Vec<&String>>::new();
313        for (checksum, contentid) in meta.map.iter() {
314            rmap.entry(Rc::clone(contentid)).or_default().push(checksum);
315        }
316
317        // Safety: Let's assume no one has over 4 billion components.
318        self.n_provided_components = meta.sizes.len().try_into().unwrap();
319        self.n_sized_components = sizes
320            .iter()
321            .filter(|v| v.size > 0)
322            .count()
323            .try_into()
324            .unwrap();
325
326        // TODO: Compute bin packing in a better way
327        let start = Instant::now();
328        let packing = basic_packing(
329            sizes,
330            NonZeroU32::new(self.max).unwrap(),
331            prior_build_metadata,
332        )?;
333        let duration = start.elapsed();
334        tracing::debug!("Time elapsed in packing: {:#?}", duration);
335
336        for bin in packing.into_iter() {
337            let name = match bin.len() {
338                0 => Cow::Borrowed("Reserved for new packages"),
339                1 => {
340                    let first = bin[0];
341                    let first_name = &*first.meta.identifier;
342                    Cow::Borrowed(first_name)
343                }
344                2..=5 => {
345                    let first = bin[0];
346                    let first_name = &*first.meta.identifier;
347                    let r = bin.iter().map(|v| &*v.meta.identifier).skip(1).fold(
348                        String::from(first_name),
349                        |mut acc, v| {
350                            write!(acc, " and {}", v).unwrap();
351                            acc
352                        },
353                    );
354                    Cow::Owned(r)
355                }
356                n => Cow::Owned(format!("{n} components")),
357            };
358            let mut chunk = Chunk::new(&name);
359            chunk.packages = bin.iter().map(|v| String::from(&*v.meta.name)).collect();
360            for szmeta in bin {
361                for &obj in rmap.get(&szmeta.meta.identifier).unwrap() {
362                    self.remainder.move_obj(&mut chunk, obj.as_str());
363                }
364            }
365            self.chunks.push(chunk);
366        }
367
368        assert_eq!(self.remainder.content.len(), 0);
369
370        Ok(())
371    }
372
373    pub(crate) fn take_chunks(&mut self) -> Vec<Chunk> {
374        let mut r = Vec::new();
375        std::mem::swap(&mut self.chunks, &mut r);
376        r
377    }
378
379    /// Print information about chunking to standard output.
380    pub fn print(&self) {
381        println!("Metadata: {}", glib::format_size(self.metadata_size));
382        if self.n_provided_components > 0 {
383            println!(
384                "Components: provided={} sized={}",
385                self.n_provided_components, self.n_sized_components
386            );
387        }
388        for (n, chunk) in self.chunks.iter().enumerate() {
389            let sz = glib::format_size(chunk.size);
390            println!(
391                "Chunk {}: \"{}\": objects:{} size:{}",
392                n,
393                chunk.name,
394                chunk.content.len(),
395                sz
396            );
397        }
398        if !self.remainder.content.is_empty() {
399            let sz = glib::format_size(self.remainder.size);
400            println!(
401                "Remainder: \"{}\": objects:{} size:{}",
402                self.remainder.name,
403                self.remainder.content.len(),
404                sz
405            );
406        }
407    }
408}
409
410#[cfg(test)]
411fn components_size(components: &[&ObjectSourceMetaSized]) -> u64 {
412    components.iter().map(|k| k.size).sum()
413}
414
415/// Compute the total size of a packing
416#[cfg(test)]
417fn packing_size(packing: &[Vec<&ObjectSourceMetaSized>]) -> u64 {
418    packing.iter().map(|v| components_size(v)).sum()
419}
420
421/// Given a certain threshold, divide a list of packages into all combinations
422/// of (high, medium, low) size and (high,medium,low) using the following
423/// outlier detection methods:
424/// - Median and Median Absolute Deviation Method
425///      Aggressively detects outliers in size and classifies them by
426///      high, medium, low. The high size and low size are separate partitions
427///      and deserve bins of their own
428/// - Mean and Standard Deviation Method
429///      The medium partition from the previous step is less aggressively
430///      classified by using mean for both size and frequency
431///
432/// Note: Assumes components is sorted by descending size
433fn get_partitions_with_threshold<'a>(
434    components: &[&'a ObjectSourceMetaSized],
435    limit_hs_bins: usize,
436    threshold: f64,
437) -> Option<BTreeMap<String, Vec<&'a ObjectSourceMetaSized>>> {
438    let mut partitions: BTreeMap<String, Vec<&ObjectSourceMetaSized>> = BTreeMap::new();
439    let mut med_size: Vec<&ObjectSourceMetaSized> = Vec::new();
440    let mut high_size: Vec<&ObjectSourceMetaSized> = Vec::new();
441
442    let mut sizes: Vec<u64> = components.iter().map(|a| a.size).collect();
443    let (median_size, mad_size) = statistics::median_absolute_deviation(&mut sizes)?;
444
445    // We use abs here to ensure the lower limit stays positive
446    let size_low_limit = 0.5 * f64::abs(median_size - threshold * mad_size);
447    let size_high_limit = median_size + threshold * mad_size;
448
449    for pkg in components {
450        let size = pkg.size as f64;
451
452        // high size (hs)
453        if size >= size_high_limit {
454            high_size.push(pkg);
455        }
456        // low size (ls)
457        else if size <= size_low_limit {
458            partitions
459                .entry(LOW_PARTITION.to_string())
460                .and_modify(|bin| bin.push(pkg))
461                .or_insert_with(|| vec![pkg]);
462        }
463        // medium size (ms)
464        else {
465            med_size.push(pkg);
466        }
467    }
468
469    // Extra high-size packages
470    let mut remaining_pkgs: Vec<_> = if high_size.len() <= limit_hs_bins {
471        Vec::new()
472    } else {
473        high_size.drain(limit_hs_bins..).collect()
474    };
475    assert!(high_size.len() <= limit_hs_bins);
476
477    // Concatenate extra high-size packages + med_sizes to keep it descending sorted
478    remaining_pkgs.append(&mut med_size);
479    partitions.insert(HIGH_PARTITION.to_string(), high_size);
480
481    // Ascending sorted by frequency, so each partition within medium-size is freq sorted
482    remaining_pkgs.sort_by(|a, b| {
483        a.meta
484            .change_frequency
485            .partial_cmp(&b.meta.change_frequency)
486            .unwrap()
487    });
488    let med_sizes: Vec<u64> = remaining_pkgs.iter().map(|a| a.size).collect();
489    let med_frequencies: Vec<u64> = remaining_pkgs
490        .iter()
491        .map(|a| a.meta.change_frequency.into())
492        .collect();
493
494    let med_mean_freq = statistics::mean(&med_frequencies)?;
495    let med_stddev_freq = statistics::std_deviation(&med_frequencies)?;
496    let med_mean_size = statistics::mean(&med_sizes)?;
497    let med_stddev_size = statistics::std_deviation(&med_sizes)?;
498
499    // We use abs to avoid the lower limit being negative
500    let med_freq_low_limit = 0.5f64 * f64::abs(med_mean_freq - threshold * med_stddev_freq);
501    let med_freq_high_limit = med_mean_freq + threshold * med_stddev_freq;
502    let med_size_low_limit = 0.5f64 * f64::abs(med_mean_size - threshold * med_stddev_size);
503    let med_size_high_limit = med_mean_size + threshold * med_stddev_size;
504
505    for pkg in remaining_pkgs {
506        let size = pkg.size as f64;
507        let freq = pkg.meta.change_frequency as f64;
508
509        let size_name;
510        if size >= med_size_high_limit {
511            size_name = "hs";
512        } else if size <= med_size_low_limit {
513            size_name = "ls";
514        } else {
515            size_name = "ms";
516        }
517
518        // Numbered to maintain order of partitions in a BTreeMap of hf, mf, lf
519        let freq_name;
520        if freq >= med_freq_high_limit {
521            freq_name = "3hf";
522        } else if freq <= med_freq_low_limit {
523            freq_name = "5lf";
524        } else {
525            freq_name = "4mf";
526        }
527
528        let bucket = format!("{freq_name}_{size_name}");
529        partitions
530            .entry(bucket.to_string())
531            .and_modify(|bin| bin.push(pkg))
532            .or_insert_with(|| vec![pkg]);
533    }
534
535    for (name, pkgs) in &partitions {
536        tracing::debug!("{:#?}: {:#?}", name, pkgs.len());
537    }
538
539    Some(partitions)
540}
541
542/// If the current rpm-ostree commit to be encapsulated is not the one in which packing structure changes, then
543///  Flatten out prior_build_metadata to view all the packages in prior build as a single vec
544///  Compare the flattened vector to components to see if pkgs added, updated,
545///  removed or kept same
546///  if pkgs added, then add them to the last bin of prior
547///  if pkgs removed, then remove them from the prior[i]
548///  iterate through prior[i] and make bins according to the name in nevra of pkgs to update
549///  required packages
550/// else if pkg structure to be changed || prior build not specified
551///  Recompute optimal packaging strcuture (Compute partitions, place packages and optimize build)
552fn basic_packing_with_prior_build<'a>(
553    components: &'a [ObjectSourceMetaSized],
554    bin_size: NonZeroU32,
555    prior_build: &oci_spec::image::ImageManifest,
556) -> Result<Vec<Vec<&'a ObjectSourceMetaSized>>> {
557    let before_processing_pkgs_len = components.len();
558
559    tracing::debug!("Keeping old package structure");
560
561    // The first layer is the ostree commit, which will always be different for different builds,
562    // so we ignore it.  For the remaining layers, extract the components/packages in each one.
563    let curr_build: Result<Vec<Vec<String>>> = prior_build
564        .layers()
565        .iter()
566        .skip(1)
567        .map(|layer| -> Result<_> {
568            let annotation_layer = layer
569                .annotations()
570                .as_ref()
571                .and_then(|annos| annos.get(CONTENT_ANNOTATION))
572                .ok_or_else(|| anyhow!("Missing {CONTENT_ANNOTATION} on prior build"))?;
573            Ok(annotation_layer
574                .split(COMPONENT_SEPARATOR)
575                .map(ToOwned::to_owned)
576                .collect())
577        })
578        .collect();
579    let mut curr_build = curr_build?;
580
581    // View the packages as unordered sets for lookups and differencing
582    let prev_pkgs_set: BTreeSet<String> = curr_build
583        .iter()
584        .flat_map(|v| v.iter().cloned())
585        .filter(|name| !name.is_empty())
586        .collect();
587    let curr_pkgs_set: BTreeSet<String> = components
588        .iter()
589        .map(|pkg| pkg.meta.name.to_string())
590        .collect();
591
592    // Added packages are included in the last bin which was reserved space.
593    if let Some(last_bin) = curr_build.last_mut() {
594        let added = curr_pkgs_set.difference(&prev_pkgs_set);
595        last_bin.retain(|name| !name.is_empty());
596        last_bin.extend(added.into_iter().cloned());
597    } else {
598        panic!("No empty last bin for added packages");
599    }
600
601    // Handle removed packages
602    let removed: BTreeSet<&String> = prev_pkgs_set.difference(&curr_pkgs_set).collect();
603    for bin in curr_build.iter_mut() {
604        bin.retain(|pkg| !removed.contains(pkg));
605    }
606
607    // Handle updated packages
608    let mut name_to_component: BTreeMap<String, &ObjectSourceMetaSized> = BTreeMap::new();
609    for component in components.iter() {
610        name_to_component
611            .entry(component.meta.name.to_string())
612            .or_insert(component);
613    }
614    let mut modified_build: Vec<Vec<&ObjectSourceMetaSized>> = Vec::new();
615    for bin in curr_build {
616        let mut mod_bin = Vec::new();
617        for pkg in bin {
618            // An empty component set can happen for the ostree commit layer; ignore that.
619            if pkg.is_empty() {
620                continue;
621            }
622            mod_bin.push(name_to_component[&pkg]);
623        }
624        modified_build.push(mod_bin);
625    }
626
627    // Verify all packages are included
628    let after_processing_pkgs_len: usize = modified_build.iter().map(|b| b.len()).sum();
629    assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len);
630    assert!(modified_build.len() <= bin_size.get() as usize);
631    Ok(modified_build)
632}
633
634/// Given a set of components with size metadata (e.g. boxes of a certain size)
635/// and a number of bins (possible container layers) to use, determine which components
636/// go in which bin.  This algorithm is pretty simple:
637/// Total available bins = n
638///
639/// 1 bin for all the u32_max frequency pkgs
640/// 1 bin for all newly added pkgs
641/// 1 bin for all low size pkgs
642///
643/// 60% of n-3 bins for high size pkgs
644/// 40% of n-3 bins for medium size pkgs
645///
646/// If HS bins > limit, spillover to MS to package
647/// If MS bins > limit, fold by merging 2 bins from the end
648///
649fn basic_packing<'a>(
650    components: &'a [ObjectSourceMetaSized],
651    bin_size: NonZeroU32,
652    prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
653) -> Result<Vec<Vec<&'a ObjectSourceMetaSized>>> {
654    const HIGH_SIZE_CUTOFF: f32 = 0.6;
655    let before_processing_pkgs_len = components.len();
656
657    anyhow::ensure!(bin_size.get() >= MIN_CHUNKED_LAYERS);
658
659    // If we have a prior build, then use that
660    if let Some(prior_build) = prior_build_metadata {
661        return basic_packing_with_prior_build(components, bin_size, prior_build);
662    }
663
664    tracing::debug!("Creating new packing structure");
665
666    // If there are fewer packages/components than there are bins, then we don't need to do
667    // any "bin packing" at all; just assign a single component to each and we're done.
668    if before_processing_pkgs_len < bin_size.get() as usize {
669        let mut r = components.iter().map(|pkg| vec![pkg]).collect::<Vec<_>>();
670        if before_processing_pkgs_len > 0 {
671            let new_pkgs_bin: Vec<&ObjectSourceMetaSized> = Vec::new();
672            r.push(new_pkgs_bin);
673        }
674        return Ok(r);
675    }
676
677    let mut r = Vec::new();
678    // Split off the components which are "max frequency".
679    let (components, max_freq_components) = components
680        .iter()
681        .partition::<Vec<_>, _>(|pkg| pkg.meta.change_frequency != u32::MAX);
682    if !components.is_empty() {
683        // Given a total number of bins (layers), compute how many should be assigned to our
684        // partitioning based on size and frequency.
685        let limit_ls_bins = 1usize;
686        let limit_new_bins = 1usize;
687        let _limit_new_pkgs = 0usize;
688        let limit_max_frequency_pkgs = max_freq_components.len();
689        let limit_max_frequency_bins = limit_max_frequency_pkgs.min(1);
690        let low_and_other_bin_limit = limit_ls_bins + limit_new_bins + limit_max_frequency_bins;
691        let limit_hs_bins = (HIGH_SIZE_CUTOFF
692            * (bin_size.get() - low_and_other_bin_limit as u32) as f32)
693            .floor() as usize;
694        let limit_ms_bins =
695            (bin_size.get() - (limit_hs_bins + low_and_other_bin_limit) as u32) as usize;
696        let partitions = get_partitions_with_threshold(&components, limit_hs_bins, 2f64)
697            .expect("Partitioning components into sets");
698
699        // Compute how many low-sized package/components we have.
700        let low_sized_component_count = partitions
701            .get(LOW_PARTITION)
702            .map(|p| p.len())
703            .unwrap_or_default();
704
705        // Approximate number of components we should have per medium-size bin.
706        let pkg_per_bin_ms: usize = (components.len() - limit_hs_bins - low_sized_component_count)
707            .checked_div(limit_ms_bins)
708            .ok_or_else(|| anyhow::anyhow!("number of bins should be >= {}", MIN_CHUNKED_LAYERS))?;
709
710        // Bins assignment
711        for (partition, pkgs) in partitions.iter() {
712            if partition == HIGH_PARTITION {
713                for pkg in pkgs {
714                    r.push(vec![*pkg]);
715                }
716            } else if partition == LOW_PARTITION {
717                let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new();
718                for pkg in pkgs {
719                    bin.push(*pkg);
720                }
721                r.push(bin);
722            } else {
723                let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new();
724                for (i, pkg) in pkgs.iter().enumerate() {
725                    if bin.len() < pkg_per_bin_ms {
726                        bin.push(*pkg);
727                    } else {
728                        r.push(bin.clone());
729                        bin.clear();
730                        bin.push(*pkg);
731                    }
732                    if i == pkgs.len() - 1 && !bin.is_empty() {
733                        r.push(bin.clone());
734                        bin.clear();
735                    }
736                }
737            }
738        }
739        tracing::debug!("Bins before unoptimized build: {}", r.len());
740
741        // Despite allocation certain number of pkgs per bin in medium-size partitions, the
742        // hard limit of number of medium-size bins can be exceeded. This is because the pkg_per_bin_ms
743        // is only upper limit and there is no lower limit. Thus, if a partition in medium-size has only 1 pkg
744        // but pkg_per_bin_ms > 1, then the entire bin will have 1 pkg. This prevents partition
745        // mixing.
746        //
747        // Addressing medium-size bins limit breach by mergin internal MS partitions
748        // The partitions in medium-size are merged beginning from the end so to not mix high-frequency bins with low-frequency bins. The
749        // bins are kept in this order: high-frequency, medium-frequency, low-frequency.
750        while r.len() > (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) {
751            for i in (limit_ls_bins + limit_hs_bins..r.len() - 1)
752                .step_by(2)
753                .rev()
754            {
755                if r.len() <= (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins)
756                {
757                    break;
758                }
759                let prev = &r[i - 1];
760                let curr = &r[i];
761                let mut merge: Vec<&ObjectSourceMetaSized> = Vec::new();
762                merge.extend(prev.iter());
763                merge.extend(curr.iter());
764                r.remove(i);
765                r.remove(i - 1);
766                r.insert(i, merge);
767            }
768        }
769        tracing::debug!("Bins after optimization: {}", r.len());
770    }
771
772    if !max_freq_components.is_empty() {
773        r.push(max_freq_components);
774    }
775
776    // Allocate an empty bin for new packages
777    r.push(Vec::new());
778    let after_processing_pkgs_len = r.iter().map(|b| b.len()).sum::<usize>();
779    assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len);
780    assert!(r.len() <= bin_size.get() as usize);
781    Ok(r)
782}
783
784#[cfg(test)]
785mod test {
786    use super::*;
787
788    use oci_spec::image as oci_image;
789    use std::str::FromStr;
790
791    const FCOS_CONTENTMETA: &[u8] = include_bytes!("fixtures/fedora-coreos-contentmeta.json.gz");
792    const SHA256_EXAMPLE: &str =
793        "sha256:0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff";
794
795    #[test]
796    fn test_packing_basics() -> Result<()> {
797        // null cases
798        for v in [4, 7].map(|v| NonZeroU32::new(v).unwrap()) {
799            assert_eq!(basic_packing(&[], v, None).unwrap().len(), 0);
800        }
801        Ok(())
802    }
803
804    #[test]
805    fn test_packing_fcos() -> Result<()> {
806        let contentmeta: Vec<ObjectSourceMetaSized> =
807            serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?;
808        let total_size = contentmeta.iter().map(|v| v.size).sum::<u64>();
809
810        let packing =
811            basic_packing(&contentmeta, NonZeroU32::new(MAX_CHUNKS).unwrap(), None).unwrap();
812        assert!(!contentmeta.is_empty());
813        // We should fit into the assigned chunk size
814        assert_eq!(packing.len() as u32, MAX_CHUNKS);
815        // And verify that the sizes match
816        let packed_total_size = packing_size(&packing);
817        assert_eq!(total_size, packed_total_size);
818        Ok(())
819    }
820
821    #[test]
822    fn test_packing_one_layer() -> Result<()> {
823        let contentmeta: Vec<ObjectSourceMetaSized> =
824            serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?;
825        let r = basic_packing(&contentmeta, NonZeroU32::new(1).unwrap(), None);
826        assert!(r.is_err());
827        Ok(())
828    }
829
830    fn create_manifest(prev_expected_structure: Vec<Vec<&str>>) -> oci_spec::image::ImageManifest {
831        use std::collections::HashMap;
832
833        let mut p = prev_expected_structure
834            .iter()
835            .map(|b| {
836                b.iter()
837                    .map(|p| p.split('.').collect::<Vec<&str>>()[0].to_string())
838                    .collect()
839            })
840            .collect();
841        let mut metadata_with_ostree_commit = vec![vec![String::from("ostree_commit")]];
842        metadata_with_ostree_commit.append(&mut p);
843
844        let config = oci_spec::image::DescriptorBuilder::default()
845            .media_type(oci_spec::image::MediaType::ImageConfig)
846            .size(7023_u64)
847            .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap())
848            .build()
849            .expect("build config descriptor");
850
851        let layers: Vec<oci_spec::image::Descriptor> = metadata_with_ostree_commit
852            .iter()
853            .map(|l| {
854                let mut buf = [0; 8];
855                let sep = COMPONENT_SEPARATOR.encode_utf8(&mut buf);
856                oci_spec::image::DescriptorBuilder::default()
857                    .media_type(oci_spec::image::MediaType::ImageLayerGzip)
858                    .size(100_u64)
859                    .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap())
860                    .annotations(HashMap::from([(
861                        CONTENT_ANNOTATION.to_string(),
862                        l.join(sep),
863                    )]))
864                    .build()
865                    .expect("build layer")
866            })
867            .collect();
868
869        let image_manifest = oci_spec::image::ImageManifestBuilder::default()
870            .schema_version(oci_spec::image::SCHEMA_VERSION)
871            .config(config)
872            .layers(layers)
873            .build()
874            .expect("build image manifest");
875        image_manifest
876    }
877
878    #[test]
879    fn test_advanced_packing() -> Result<()> {
880        // Step1 : Initial build (Packing sructure computed)
881        let contentmeta_v0: Vec<ObjectSourceMetaSized> = vec![
882            vec![1, u32::MAX, 100000],
883            vec![2, u32::MAX, 99999],
884            vec![3, 30, 99998],
885            vec![4, 100, 99997],
886            vec![10, 51, 1000],
887            vec![8, 50, 500],
888            vec![9, 1, 200],
889            vec![11, 100000, 199],
890            vec![6, 30, 2],
891            vec![7, 30, 1],
892        ]
893        .iter()
894        .map(|data| ObjectSourceMetaSized {
895            meta: ObjectSourceMeta {
896                identifier: RcStr::from(format!("pkg{}.0", data[0])),
897                name: RcStr::from(format!("pkg{}", data[0])),
898                srcid: RcStr::from(format!("srcpkg{}", data[0])),
899                change_time_offset: 0,
900                change_frequency: data[1],
901            },
902            size: data[2] as u64,
903        })
904        .collect();
905
906        let packing = basic_packing(
907            &contentmeta_v0.as_slice(),
908            NonZeroU32::new(6).unwrap(),
909            None,
910        )
911        .unwrap();
912        let structure: Vec<Vec<&str>> = packing
913            .iter()
914            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
915            .collect();
916        let v0_expected_structure = vec![
917            vec!["pkg3.0"],
918            vec!["pkg4.0"],
919            vec!["pkg6.0", "pkg7.0", "pkg11.0"],
920            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
921            vec!["pkg1.0", "pkg2.0"],
922            vec![],
923        ];
924        assert_eq!(structure, v0_expected_structure);
925
926        // Step 2: Derive packing structure from last build
927
928        let mut contentmeta_v1: Vec<ObjectSourceMetaSized> = contentmeta_v0;
929        // Upgrade pkg1.0 to 1.1
930        contentmeta_v1[0].meta.identifier = RcStr::from("pkg1.1");
931        // Remove pkg7
932        contentmeta_v1.remove(contentmeta_v1.len() - 1);
933        // Add pkg5
934        contentmeta_v1.push(ObjectSourceMetaSized {
935            meta: ObjectSourceMeta {
936                identifier: RcStr::from("pkg5.0"),
937                name: RcStr::from("pkg5"),
938                srcid: RcStr::from("srcpkg5"),
939                change_time_offset: 0,
940                change_frequency: 42,
941            },
942            size: 100000,
943        });
944
945        let image_manifest_v0 = create_manifest(v0_expected_structure);
946        let packing_derived = basic_packing(
947            &contentmeta_v1.as_slice(),
948            NonZeroU32::new(6).unwrap(),
949            Some(&image_manifest_v0),
950        )
951        .unwrap();
952        let structure_derived: Vec<Vec<&str>> = packing_derived
953            .iter()
954            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
955            .collect();
956        let v1_expected_structure = vec![
957            vec!["pkg3.0"],
958            vec!["pkg4.0"],
959            vec!["pkg6.0", "pkg11.0"],
960            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
961            vec!["pkg1.1", "pkg2.0"],
962            vec!["pkg5.0"],
963        ];
964
965        assert_eq!(structure_derived, v1_expected_structure);
966
967        // Step 3: Another update on derived where the pkg in the last bin updates
968
969        let mut contentmeta_v2: Vec<ObjectSourceMetaSized> = contentmeta_v1;
970        // Upgrade pkg5.0 to 5.1
971        contentmeta_v2[9].meta.identifier = RcStr::from("pkg5.1");
972        // Add pkg12
973        contentmeta_v2.push(ObjectSourceMetaSized {
974            meta: ObjectSourceMeta {
975                identifier: RcStr::from("pkg12.0"),
976                name: RcStr::from("pkg12"),
977                srcid: RcStr::from("srcpkg12"),
978                change_time_offset: 0,
979                change_frequency: 42,
980            },
981            size: 100000,
982        });
983
984        let image_manifest_v1 = create_manifest(v1_expected_structure);
985        let packing_derived = basic_packing(
986            &contentmeta_v2.as_slice(),
987            NonZeroU32::new(6).unwrap(),
988            Some(&image_manifest_v1),
989        )
990        .unwrap();
991        let structure_derived: Vec<Vec<&str>> = packing_derived
992            .iter()
993            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
994            .collect();
995        let v2_expected_structure = vec![
996            vec!["pkg3.0"],
997            vec!["pkg4.0"],
998            vec!["pkg6.0", "pkg11.0"],
999            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1000            vec!["pkg1.1", "pkg2.0"],
1001            vec!["pkg5.1", "pkg12.0"],
1002        ];
1003
1004        assert_eq!(structure_derived, v2_expected_structure);
1005        Ok(())
1006    }
1007}