Skip to main content

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::{Result, anyhow};
18use camino::{Utf8Path, 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, Clone, 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, generation: &mut Generation, checksum: &str) -> Result<()> {
140    if generation.dirtree_found.contains(checksum) {
141        return Ok(());
142    }
143    let checksum = RcStr::from(checksum);
144    generation.dirmeta_found.insert(RcStr::clone(&checksum));
145    let child_v = repo.load_variant(ostree::ObjectType::DirMeta, checksum.borrow())?;
146    generation.metadata_size += child_v.data_as_bytes().as_ref().len() as u64;
147    Ok(())
148}
149
150fn push_dirtree(
151    repo: &ostree::Repo,
152    generation: &mut Generation,
153    checksum: &str,
154) -> Result<glib::Variant> {
155    let child_v = repo.load_variant(ostree::ObjectType::DirTree, checksum)?;
156    if !generation.dirtree_found.contains(checksum) {
157        generation.metadata_size += child_v.data_as_bytes().as_ref().len() as u64;
158    } else {
159        let checksum = RcStr::from(checksum);
160        generation.dirtree_found.insert(checksum);
161    }
162    Ok(child_v)
163}
164
165fn generate_chunking_recurse(
166    repo: &ostree::Repo,
167    generation: &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 = generation.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        generation.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, generation, checksum_s)?;
200        generate_chunking_recurse(repo, generation, 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, generation, checksum_s)?;
205        // We did a push above, so pop must succeed.
206        assert!(generation.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    pub(crate) fn move_path(&mut self, dest: &mut Self, checksum: &str, path: &Utf8Path) {
235        if let Some((_size, paths)) = self.content.get_mut(checksum) {
236            let path_index = paths.iter().position(|p| *p == path);
237            if let Some(index) = path_index {
238                let removed_path = paths.remove(index);
239
240                let dest_entry = dest
241                    .content
242                    .entry(RcStr::from(checksum))
243                    .or_insert((0, Vec::new()));
244                dest_entry.1.push(removed_path);
245
246                if paths.is_empty() {
247                    self.content.remove(checksum);
248                }
249            }
250        }
251    }
252}
253
254impl Chunking {
255    /// Creates a reverse map from content IDs to checksums
256    fn create_content_id_map(
257        map: &IndexMap<String, ContentID>,
258    ) -> IndexMap<ContentID, Vec<&String>> {
259        let mut rmap = IndexMap::<ContentID, Vec<&String>>::new();
260        for (checksum, contentid) in map.iter() {
261            rmap.entry(Rc::clone(contentid)).or_default().push(checksum);
262        }
263        rmap
264    }
265
266    /// Generate an initial single chunk.
267    pub fn new(repo: &ostree::Repo, rev: &str) -> Result<Self> {
268        // Find the target commit
269        let rev = repo.require_rev(rev)?;
270
271        // Load and parse the commit object
272        let (commit_v, _) = repo.load_commit(&rev)?;
273        let commit_v = commit_v.data_as_bytes();
274        let commit_v = commit_v.try_as_aligned()?;
275        let commit = gv_commit!().cast(commit_v);
276        let commit = commit.to_tuple();
277
278        // Load it all into a single chunk
279        let mut generation = Generation {
280            path: Utf8PathBuf::from("/"),
281            ..Default::default()
282        };
283        let mut chunk: Chunk = Default::default();
284
285        // Find the root directory tree
286        let contents_checksum = &hex::encode(commit.6);
287        let contents_v = repo.load_variant(ostree::ObjectType::DirTree, contents_checksum)?;
288        push_dirtree(repo, &mut generation, contents_checksum)?;
289        let meta_checksum = &hex::encode(commit.7);
290        push_dirmeta(repo, &mut generation, meta_checksum.as_str())?;
291
292        generate_chunking_recurse(repo, &mut generation, &mut chunk, &contents_v)?;
293
294        let chunking = Chunking {
295            metadata_size: generation.metadata_size,
296            remainder: chunk,
297            ..Default::default()
298        };
299        Ok(chunking)
300    }
301
302    /// Generate a chunking from an object mapping.
303    pub fn from_mapping(
304        repo: &ostree::Repo,
305        rev: &str,
306        meta: &ObjectMetaSized,
307        max_layers: &Option<NonZeroU32>,
308        prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
309        specific_contentmeta: Option<&BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>>,
310    ) -> Result<Self> {
311        let mut r = Self::new(repo, rev)?;
312        r.process_mapping(meta, max_layers, prior_build_metadata, specific_contentmeta)?;
313        Ok(r)
314    }
315
316    fn remaining(&self) -> u32 {
317        self.max.saturating_sub(self.chunks.len() as u32)
318    }
319
320    /// Given metadata about which objects are owned by a particular content source,
321    /// generate chunks that group together those objects.
322    #[allow(clippy::or_fun_call)]
323    pub fn process_mapping(
324        &mut self,
325        meta: &ObjectMetaSized,
326        max_layers: &Option<NonZeroU32>,
327        prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
328        specific_contentmeta: Option<&BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>>,
329    ) -> Result<()> {
330        self.max = max_layers
331            .unwrap_or(NonZeroU32::new(MAX_CHUNKS).unwrap())
332            .get();
333
334        let sizes = &meta.sizes;
335        // It doesn't make sense to handle multiple mappings
336        assert!(!self.processed_mapping);
337        self.processed_mapping = true;
338        let remaining = self.remaining();
339        if remaining == 0 {
340            return Ok(());
341        }
342
343        // Create exclusive chunks first if specified
344        let mut processed_specific_components = BTreeSet::new();
345        if let Some(specific_meta) = specific_contentmeta {
346            for (component, files) in specific_meta {
347                let mut chunk = Chunk::new(&component);
348                chunk.packages = vec![component.to_string()];
349
350                // Move all objects belonging to this exclusive component
351                for (path, checksum) in files {
352                    self.remainder
353                        .move_path(&mut chunk, checksum.as_str(), path);
354                }
355
356                self.chunks.push(chunk);
357                processed_specific_components.insert(component.clone());
358            }
359        }
360
361        // Safety: Let's assume no one has over 4 billion components.
362        self.n_provided_components = meta.sizes.len().try_into().unwrap();
363        self.n_sized_components = sizes
364            .iter()
365            .filter(|v| v.size > 0)
366            .count()
367            .try_into()
368            .unwrap();
369
370        // Filter out exclusive components for regular packing
371        let regular_sizes: Vec<ObjectSourceMetaSized> = sizes
372            .iter()
373            .filter(|component| {
374                !processed_specific_components.contains(&*component.meta.identifier)
375            })
376            .cloned()
377            .collect();
378
379        let rmap = Self::create_content_id_map(&meta.map);
380
381        // Process regular components with bin packing if we have remaining layers
382        if let Some(remaining) = NonZeroU32::new(self.remaining()) {
383            let start = Instant::now();
384            let packing = basic_packing(&regular_sizes, remaining, prior_build_metadata)?;
385            let duration = start.elapsed();
386            tracing::debug!("Time elapsed in packing: {:#?}", duration);
387
388            for bin in packing.into_iter() {
389                let name = match bin.len() {
390                    0 => Cow::Borrowed("Reserved for new packages"),
391                    1 => {
392                        let first = bin[0];
393                        let first_name = &*first.meta.identifier;
394                        Cow::Borrowed(first_name)
395                    }
396                    2..=5 => {
397                        let first = bin[0];
398                        let first_name = &*first.meta.identifier;
399                        let r = bin.iter().map(|v| &*v.meta.identifier).skip(1).fold(
400                            String::from(first_name),
401                            |mut acc, v| {
402                                write!(acc, " and {v}").unwrap();
403                                acc
404                            },
405                        );
406                        Cow::Owned(r)
407                    }
408                    n => Cow::Owned(format!("{n} components")),
409                };
410                let mut chunk = Chunk::new(&name);
411                chunk.packages = bin.iter().map(|v| String::from(&*v.meta.name)).collect();
412                for szmeta in bin {
413                    for &obj in rmap.get(&szmeta.meta.identifier).unwrap() {
414                        self.remainder.move_obj(&mut chunk, obj.as_str());
415                    }
416                }
417                self.chunks.push(chunk);
418            }
419        }
420
421        // Check that all objects have been processed
422        if !processed_specific_components.is_empty() || !regular_sizes.is_empty() {
423            assert_eq!(self.remainder.content.len(), 0);
424        }
425
426        Ok(())
427    }
428
429    pub(crate) fn take_chunks(&mut self) -> Vec<Chunk> {
430        let mut r = Vec::new();
431        std::mem::swap(&mut self.chunks, &mut r);
432        r
433    }
434
435    /// Print information about chunking to standard output.
436    pub fn print(&self) {
437        println!("Metadata: {}", glib::format_size(self.metadata_size));
438        if self.n_provided_components > 0 {
439            println!(
440                "Components: provided={} sized={}",
441                self.n_provided_components, self.n_sized_components
442            );
443        }
444        for (n, chunk) in self.chunks.iter().enumerate() {
445            let sz = glib::format_size(chunk.size);
446            println!(
447                "Chunk {}: \"{}\": objects:{} size:{}",
448                n,
449                chunk.name,
450                chunk.content.len(),
451                sz
452            );
453        }
454        if !self.remainder.content.is_empty() {
455            let sz = glib::format_size(self.remainder.size);
456            println!(
457                "Remainder: \"{}\": objects:{} size:{}",
458                self.remainder.name,
459                self.remainder.content.len(),
460                sz
461            );
462        }
463    }
464}
465
466#[cfg(test)]
467fn components_size(components: &[&ObjectSourceMetaSized]) -> u64 {
468    components.iter().map(|k| k.size).sum()
469}
470
471/// Compute the total size of a packing
472#[cfg(test)]
473fn packing_size(packing: &[Vec<&ObjectSourceMetaSized>]) -> u64 {
474    packing.iter().map(|v| components_size(v)).sum()
475}
476
477/// Given a certain threshold, divide a list of packages into all combinations
478/// of (high, medium, low) size and (high,medium,low) using the following
479/// outlier detection methods:
480/// - Median and Median Absolute Deviation Method
481///   Aggressively detects outliers in size and classifies them by
482///   high, medium, low. The high size and low size are separate partitions
483///   and deserve bins of their own
484/// - Mean and Standard Deviation Method
485///   The medium partition from the previous step is less aggressively
486///   classified by using mean for both size and frequency
487///
488/// Note: Assumes components is sorted by descending size
489fn get_partitions_with_threshold<'a>(
490    components: &[&'a ObjectSourceMetaSized],
491    limit_hs_bins: usize,
492    threshold: f64,
493) -> Option<BTreeMap<String, Vec<&'a ObjectSourceMetaSized>>> {
494    let mut partitions: BTreeMap<String, Vec<&ObjectSourceMetaSized>> = BTreeMap::new();
495    let mut med_size: Vec<&ObjectSourceMetaSized> = Vec::new();
496    let mut high_size: Vec<&ObjectSourceMetaSized> = Vec::new();
497
498    let mut sizes: Vec<u64> = components.iter().map(|a| a.size).collect();
499    let (median_size, mad_size) = statistics::median_absolute_deviation(&mut sizes)?;
500
501    // We use abs here to ensure the lower limit stays positive
502    let size_low_limit = 0.5 * f64::abs(median_size - threshold * mad_size);
503    let size_high_limit = median_size + threshold * mad_size;
504
505    for pkg in components {
506        let size = pkg.size as f64;
507
508        // high size (hs)
509        if size >= size_high_limit {
510            high_size.push(pkg);
511        }
512        // low size (ls)
513        else if size <= size_low_limit {
514            partitions
515                .entry(LOW_PARTITION.to_string())
516                .and_modify(|bin| bin.push(pkg))
517                .or_insert_with(|| vec![pkg]);
518        }
519        // medium size (ms)
520        else {
521            med_size.push(pkg);
522        }
523    }
524
525    // Extra high-size packages
526    let mut remaining_pkgs: Vec<_> = if high_size.len() <= limit_hs_bins {
527        Vec::new()
528    } else {
529        high_size.drain(limit_hs_bins..).collect()
530    };
531    assert!(high_size.len() <= limit_hs_bins);
532
533    // Concatenate extra high-size packages + med_sizes to keep it descending sorted
534    remaining_pkgs.append(&mut med_size);
535    partitions.insert(HIGH_PARTITION.to_string(), high_size);
536
537    // Ascending sorted by frequency, so each partition within medium-size is freq sorted
538    remaining_pkgs.sort_by(|a, b| {
539        a.meta
540            .change_frequency
541            .partial_cmp(&b.meta.change_frequency)
542            .unwrap()
543    });
544    let med_sizes: Vec<u64> = remaining_pkgs.iter().map(|a| a.size).collect();
545    let med_frequencies: Vec<u64> = remaining_pkgs
546        .iter()
547        .map(|a| a.meta.change_frequency.into())
548        .collect();
549
550    let med_mean_freq = statistics::mean(&med_frequencies)?;
551    let med_stddev_freq = statistics::std_deviation(&med_frequencies)?;
552    let med_mean_size = statistics::mean(&med_sizes)?;
553    let med_stddev_size = statistics::std_deviation(&med_sizes)?;
554
555    // We use abs to avoid the lower limit being negative
556    let med_freq_low_limit = 0.5f64 * f64::abs(med_mean_freq - threshold * med_stddev_freq);
557    let med_freq_high_limit = med_mean_freq + threshold * med_stddev_freq;
558    let med_size_low_limit = 0.5f64 * f64::abs(med_mean_size - threshold * med_stddev_size);
559    let med_size_high_limit = med_mean_size + threshold * med_stddev_size;
560
561    for pkg in remaining_pkgs {
562        let size = pkg.size as f64;
563        let freq = pkg.meta.change_frequency as f64;
564
565        let size_name;
566        if size >= med_size_high_limit {
567            size_name = "hs";
568        } else if size <= med_size_low_limit {
569            size_name = "ls";
570        } else {
571            size_name = "ms";
572        }
573
574        // Numbered to maintain order of partitions in a BTreeMap of hf, mf, lf
575        let freq_name;
576        if freq >= med_freq_high_limit {
577            freq_name = "3hf";
578        } else if freq <= med_freq_low_limit {
579            freq_name = "5lf";
580        } else {
581            freq_name = "4mf";
582        }
583
584        let bucket = format!("{freq_name}_{size_name}");
585        partitions
586            .entry(bucket.to_string())
587            .and_modify(|bin| bin.push(pkg))
588            .or_insert_with(|| vec![pkg]);
589    }
590
591    for (name, pkgs) in &partitions {
592        tracing::debug!("{:#?}: {:#?}", name, pkgs.len());
593    }
594
595    Some(partitions)
596}
597
598/// If the current rpm-ostree commit to be encapsulated is not the one in which packing structure changes, then
599///  Flatten out prior_build_metadata to view all the packages in prior build as a single vec
600///  Compare the flattened vector to components to see if pkgs added, updated,
601///  removed or kept same
602///  if pkgs added, then add them to the last bin of prior
603///  if pkgs removed, then remove them from the prior\[i\]
604///  iterate through prior\[i\] and make bins according to the name in nevra of pkgs to update
605///  required packages
606/// else if pkg structure to be changed || prior build not specified
607///  Recompute optimal packaging structure (Compute partitions, place packages and optimize build)
608///
609/// A return value of `Ok(None)` indicates there was not an error, but the prior packing structure
610/// is incompatible in some way with the current build.
611fn basic_packing_with_prior_build<'a>(
612    components: &'a [ObjectSourceMetaSized],
613    bin_size: NonZeroU32,
614    prior_build: &oci_spec::image::ImageManifest,
615) -> Result<Option<Vec<Vec<&'a ObjectSourceMetaSized>>>> {
616    let before_processing_pkgs_len = components.len();
617
618    tracing::debug!("Attempting to use old package structure");
619
620    // The first layer is the ostree commit, which will always be different for different builds,
621    // so we ignore it.  For the remaining layers, extract the components/packages in each one.
622    let curr_build: Result<Vec<Vec<String>>> = prior_build
623        .layers()
624        .iter()
625        .skip(1)
626        .map(|layer| -> Result<_> {
627            let annotation_layer = layer
628                .annotations()
629                .as_ref()
630                .and_then(|annos| annos.get(CONTENT_ANNOTATION))
631                .ok_or_else(|| anyhow!("Missing {CONTENT_ANNOTATION} on prior build"))?;
632            Ok(annotation_layer
633                .split(COMPONENT_SEPARATOR)
634                .map(ToOwned::to_owned)
635                .collect())
636        })
637        .collect();
638    let mut curr_build = curr_build?;
639
640    // View the packages as unordered sets for lookups and differencing
641    let prev_pkgs_set: BTreeSet<String> = curr_build
642        .iter()
643        .flat_map(|v| v.iter().cloned())
644        .filter(|name| !name.is_empty())
645        .collect();
646    let curr_pkgs_set: BTreeSet<String> = components
647        .iter()
648        .map(|pkg| pkg.meta.name.to_string())
649        .collect();
650
651    // Added packages are included in the last bin which was reserved space.
652    if let Some(last_bin) = curr_build.last_mut() {
653        let added = curr_pkgs_set.difference(&prev_pkgs_set);
654        last_bin.retain(|name| !name.is_empty());
655        last_bin.extend(added.into_iter().cloned());
656    } else {
657        tracing::debug!("No empty last bin for added packages.");
658        return Ok(None);
659    }
660
661    // Handle removed packages
662    let removed: BTreeSet<&String> = prev_pkgs_set.difference(&curr_pkgs_set).collect();
663    for bin in curr_build.iter_mut() {
664        bin.retain(|pkg| !removed.contains(pkg));
665    }
666
667    // Exclusive-component bins are already carved out by process_mapping(), so
668    // keep only the bins that still carry regular components plus the reserved
669    // "new packages" bin at the end of the prior layout.
670    let last_idx = curr_build.len().saturating_sub(1);
671    curr_build = curr_build
672        .into_iter()
673        .enumerate()
674        .filter_map(|(idx, bin)| (!bin.is_empty() || idx == last_idx).then_some(bin))
675        .collect();
676
677    if (bin_size.get() as usize) < curr_build.len() {
678        tracing::debug!("bin_size = {bin_size} is too small to be compatible with the prior build");
679        return Ok(None);
680    }
681
682    // Handle updated packages
683    let mut name_to_component: BTreeMap<String, &ObjectSourceMetaSized> = BTreeMap::new();
684    for component in components.iter() {
685        name_to_component
686            .entry(component.meta.name.to_string())
687            .or_insert(component);
688    }
689    let mut modified_build: Vec<Vec<&ObjectSourceMetaSized>> = Vec::new();
690    for bin in curr_build {
691        let mut mod_bin = Vec::new();
692        for pkg in bin {
693            // An empty component set can happen for the ostree commit layer; ignore that.
694            if pkg.is_empty() {
695                continue;
696            }
697            mod_bin.push(name_to_component[&pkg]);
698        }
699        modified_build.push(mod_bin);
700    }
701
702    // Verify all packages are included
703    let after_processing_pkgs_len: usize = modified_build.iter().map(|b| b.len()).sum();
704    assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len);
705    assert!(modified_build.len() <= bin_size.get() as usize);
706    Ok(Some(modified_build))
707}
708
709/// Given a set of components with size metadata (e.g. boxes of a certain size)
710/// and a number of bins (possible container layers) to use, determine which components
711/// go in which bin.  This algorithm is pretty simple:
712/// Total available bins = n
713///
714/// 1 bin for all the u32_max frequency pkgs
715/// 1 bin for all newly added pkgs
716/// 1 bin for all low size pkgs
717///
718/// 60% of n-3 bins for high size pkgs
719/// 40% of n-3 bins for medium size pkgs
720///
721/// If HS bins > limit, spillover to MS to package
722/// If MS bins > limit, fold by merging 2 bins from the end
723///
724fn basic_packing<'a>(
725    components: &'a [ObjectSourceMetaSized],
726    bin_size: NonZeroU32,
727    prior_build_metadata: Option<&oci_spec::image::ImageManifest>,
728) -> Result<Vec<Vec<&'a ObjectSourceMetaSized>>> {
729    const HIGH_SIZE_CUTOFF: f32 = 0.6;
730    let before_processing_pkgs_len = components.len();
731
732    anyhow::ensure!(bin_size.get() >= MIN_CHUNKED_LAYERS);
733
734    // If we have a prior build, then try to use that.
735    if let Some(prior_build) = prior_build_metadata {
736        if let Some(packing) = basic_packing_with_prior_build(components, bin_size, prior_build)? {
737            tracing::debug!("Keeping old package structure");
738            return Ok(packing);
739        } else {
740            tracing::debug!(
741                "Failed to use old package structure; discarding info from prior build."
742            );
743        }
744    }
745
746    tracing::debug!("Creating new packing structure");
747
748    // If there are fewer packages/components than there are bins, then we don't need to do
749    // any "bin packing" at all; just assign a single component to each and we're done.
750    if before_processing_pkgs_len < bin_size.get() as usize {
751        let mut r = components.iter().map(|pkg| vec![pkg]).collect::<Vec<_>>();
752        if before_processing_pkgs_len > 0 {
753            let new_pkgs_bin: Vec<&ObjectSourceMetaSized> = Vec::new();
754            r.push(new_pkgs_bin);
755        }
756        return Ok(r);
757    }
758
759    let mut r = Vec::new();
760    // Split off the components which are "max frequency".
761    let (components, max_freq_components) = components
762        .iter()
763        .partition::<Vec<_>, _>(|pkg| pkg.meta.change_frequency != u32::MAX);
764    if !components.is_empty() {
765        // Given a total number of bins (layers), compute how many should be assigned to our
766        // partitioning based on size and frequency.
767        let limit_ls_bins = 1usize;
768        let limit_new_bins = 1usize;
769        let _limit_new_pkgs = 0usize;
770        let limit_max_frequency_pkgs = max_freq_components.len();
771        let limit_max_frequency_bins = limit_max_frequency_pkgs.min(1);
772        let low_and_other_bin_limit = limit_ls_bins + limit_new_bins + limit_max_frequency_bins;
773        let limit_hs_bins = (HIGH_SIZE_CUTOFF
774            * (bin_size.get() - low_and_other_bin_limit as u32) as f32)
775            .floor() as usize;
776        let limit_ms_bins =
777            (bin_size.get() - (limit_hs_bins + low_and_other_bin_limit) as u32) as usize;
778        let partitions = get_partitions_with_threshold(&components, limit_hs_bins, 2f64)
779            .expect("Partitioning components into sets");
780
781        // Compute how many low-sized package/components we have.
782        let low_sized_component_count = partitions
783            .get(LOW_PARTITION)
784            .map(|p| p.len())
785            .unwrap_or_default();
786
787        // Approximate number of components we should have per medium-size bin.
788        let pkg_per_bin_ms: usize = (components.len() - limit_hs_bins - low_sized_component_count)
789            .checked_div(limit_ms_bins)
790            .ok_or_else(|| anyhow::anyhow!("number of bins should be >= {}", MIN_CHUNKED_LAYERS))?;
791
792        // Bins assignment
793        for (partition, pkgs) in partitions.iter() {
794            if partition == HIGH_PARTITION {
795                for pkg in pkgs {
796                    r.push(vec![*pkg]);
797                }
798            } else if partition == LOW_PARTITION {
799                let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new();
800                for pkg in pkgs {
801                    bin.push(*pkg);
802                }
803                r.push(bin);
804            } else {
805                let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new();
806                for (i, pkg) in pkgs.iter().enumerate() {
807                    if bin.len() < pkg_per_bin_ms {
808                        bin.push(*pkg);
809                    } else {
810                        r.push(bin.clone());
811                        bin.clear();
812                        bin.push(*pkg);
813                    }
814                    if i == pkgs.len() - 1 && !bin.is_empty() {
815                        r.push(bin.clone());
816                        bin.clear();
817                    }
818                }
819            }
820        }
821        tracing::debug!("Bins before unoptimized build: {}", r.len());
822
823        // Despite allocation certain number of pkgs per bin in medium-size partitions, the
824        // hard limit of number of medium-size bins can be exceeded. This is because the pkg_per_bin_ms
825        // is only upper limit and there is no lower limit. Thus, if a partition in medium-size has only 1 pkg
826        // but pkg_per_bin_ms > 1, then the entire bin will have 1 pkg. This prevents partition
827        // mixing.
828        //
829        // Addressing medium-size bins limit breach by mergin internal MS partitions
830        // The partitions in medium-size are merged beginning from the end so to not mix high-frequency bins with low-frequency bins. The
831        // bins are kept in this order: high-frequency, medium-frequency, low-frequency.
832        while r.len() > (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) {
833            for i in (limit_ls_bins + limit_hs_bins..r.len() - 1)
834                .step_by(2)
835                .rev()
836            {
837                if r.len() <= (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins)
838                {
839                    break;
840                }
841                let prev = &r[i - 1];
842                let curr = &r[i];
843                let mut merge: Vec<&ObjectSourceMetaSized> = Vec::new();
844                merge.extend(prev.iter());
845                merge.extend(curr.iter());
846                r.remove(i);
847                r.remove(i - 1);
848                r.insert(i, merge);
849            }
850        }
851        tracing::debug!("Bins after optimization: {}", r.len());
852    }
853
854    if !max_freq_components.is_empty() {
855        r.push(max_freq_components);
856    }
857
858    // Allocate an empty bin for new packages
859    r.push(Vec::new());
860    let after_processing_pkgs_len = r.iter().map(|b| b.len()).sum::<usize>();
861    assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len);
862    assert!(r.len() <= bin_size.get() as usize);
863    Ok(r)
864}
865
866#[cfg(test)]
867mod test {
868    use super::*;
869
870    use oci_spec::image as oci_image;
871    use std::str::FromStr;
872
873    const FCOS_CONTENTMETA: &[u8] = include_bytes!("fixtures/fedora-coreos-contentmeta.json.gz");
874    const SHA256_EXAMPLE: &str =
875        "sha256:0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff";
876
877    #[test]
878    fn test_packing_basics() -> Result<()> {
879        // null cases
880        for v in [4, 7].map(|v| NonZeroU32::new(v).unwrap()) {
881            assert_eq!(basic_packing(&[], v, None).unwrap().len(), 0);
882        }
883        Ok(())
884    }
885
886    #[test]
887    fn test_packing_fcos() -> Result<()> {
888        let contentmeta: Vec<ObjectSourceMetaSized> =
889            serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?;
890        let total_size = contentmeta.iter().map(|v| v.size).sum::<u64>();
891
892        let packing =
893            basic_packing(&contentmeta, NonZeroU32::new(MAX_CHUNKS).unwrap(), None).unwrap();
894        assert!(!contentmeta.is_empty());
895        // We should fit into the assigned chunk size
896        assert_eq!(packing.len() as u32, MAX_CHUNKS);
897        // And verify that the sizes match
898        let packed_total_size = packing_size(&packing);
899        assert_eq!(total_size, packed_total_size);
900        Ok(())
901    }
902
903    #[test]
904    fn test_packing_one_layer() -> Result<()> {
905        let contentmeta: Vec<ObjectSourceMetaSized> =
906            serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?;
907        let r = basic_packing(&contentmeta, NonZeroU32::new(1).unwrap(), None);
908        assert!(r.is_err());
909        Ok(())
910    }
911
912    fn create_manifest(prev_expected_structure: Vec<Vec<&str>>) -> oci_spec::image::ImageManifest {
913        use std::collections::HashMap;
914
915        let mut p = prev_expected_structure
916            .iter()
917            .map(|b| {
918                b.iter()
919                    .map(|p| p.split('.').collect::<Vec<&str>>()[0].to_string())
920                    .collect()
921            })
922            .collect();
923        let mut metadata_with_ostree_commit = vec![vec![String::from("ostree_commit")]];
924        metadata_with_ostree_commit.append(&mut p);
925
926        let config = oci_spec::image::DescriptorBuilder::default()
927            .media_type(oci_spec::image::MediaType::ImageConfig)
928            .size(7023_u64)
929            .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap())
930            .build()
931            .expect("build config descriptor");
932
933        let layers: Vec<oci_spec::image::Descriptor> = metadata_with_ostree_commit
934            .iter()
935            .map(|l| {
936                let mut buf = [0; 8];
937                let sep = COMPONENT_SEPARATOR.encode_utf8(&mut buf);
938                oci_spec::image::DescriptorBuilder::default()
939                    .media_type(oci_spec::image::MediaType::ImageLayerGzip)
940                    .size(100_u64)
941                    .digest(oci_image::Digest::from_str(SHA256_EXAMPLE).unwrap())
942                    .annotations(HashMap::from([(
943                        CONTENT_ANNOTATION.to_string(),
944                        l.join(sep),
945                    )]))
946                    .build()
947                    .expect("build layer")
948            })
949            .collect();
950
951        oci_spec::image::ImageManifestBuilder::default()
952            .schema_version(oci_spec::image::SCHEMA_VERSION)
953            .config(config)
954            .layers(layers)
955            .build()
956            .expect("build image manifest")
957    }
958
959    #[test]
960    fn test_advanced_packing() -> Result<()> {
961        // Step1 : Initial build (Packing structure computed)
962        let contentmeta_v0: Vec<ObjectSourceMetaSized> = vec![
963            vec![1, u32::MAX, 100000],
964            vec![2, u32::MAX, 99999],
965            vec![3, 30, 99998],
966            vec![4, 100, 99997],
967            vec![10, 51, 1000],
968            vec![8, 50, 500],
969            vec![9, 1, 200],
970            vec![11, 100000, 199],
971            vec![6, 30, 2],
972            vec![7, 30, 1],
973        ]
974        .iter()
975        .map(|data| ObjectSourceMetaSized {
976            meta: ObjectSourceMeta {
977                identifier: RcStr::from(format!("pkg{}.0", data[0])),
978                name: RcStr::from(format!("pkg{}", data[0])),
979                srcid: RcStr::from(format!("srcpkg{}", data[0])),
980                change_time_offset: 0,
981                change_frequency: data[1],
982            },
983            size: data[2] as u64,
984        })
985        .collect();
986
987        let packing = basic_packing(
988            &contentmeta_v0.as_slice(),
989            NonZeroU32::new(6).unwrap(),
990            None,
991        )
992        .unwrap();
993        let structure: Vec<Vec<&str>> = packing
994            .iter()
995            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
996            .collect();
997        let v0_expected_structure = vec![
998            vec!["pkg3.0"],
999            vec!["pkg4.0"],
1000            vec!["pkg6.0", "pkg7.0", "pkg11.0"],
1001            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1002            vec!["pkg1.0", "pkg2.0"],
1003            vec![],
1004        ];
1005        assert_eq!(structure, v0_expected_structure);
1006
1007        // Step 2: Derive packing structure from last build
1008
1009        let mut contentmeta_v1: Vec<ObjectSourceMetaSized> = contentmeta_v0;
1010        // Upgrade pkg1.0 to 1.1
1011        contentmeta_v1[0].meta.identifier = RcStr::from("pkg1.1");
1012        // Remove pkg7
1013        contentmeta_v1.remove(contentmeta_v1.len() - 1);
1014        // Add pkg5
1015        contentmeta_v1.push(ObjectSourceMetaSized {
1016            meta: ObjectSourceMeta {
1017                identifier: RcStr::from("pkg5.0"),
1018                name: RcStr::from("pkg5"),
1019                srcid: RcStr::from("srcpkg5"),
1020                change_time_offset: 0,
1021                change_frequency: 42,
1022            },
1023            size: 100000,
1024        });
1025
1026        let image_manifest_v0 = create_manifest(v0_expected_structure);
1027        let packing_derived = basic_packing(
1028            &contentmeta_v1.as_slice(),
1029            NonZeroU32::new(6).unwrap(),
1030            Some(&image_manifest_v0),
1031        )
1032        .unwrap();
1033        let structure_derived: Vec<Vec<&str>> = packing_derived
1034            .iter()
1035            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1036            .collect();
1037        let v1_expected_structure = vec![
1038            vec!["pkg3.0"],
1039            vec!["pkg4.0"],
1040            vec!["pkg6.0", "pkg11.0"],
1041            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1042            vec!["pkg1.1", "pkg2.0"],
1043            vec!["pkg5.0"],
1044        ];
1045
1046        assert_eq!(structure_derived, v1_expected_structure);
1047
1048        // Step 3: Another update on derived where the pkg in the last bin updates
1049
1050        let mut contentmeta_v2: Vec<ObjectSourceMetaSized> = contentmeta_v1;
1051        // Upgrade pkg5.0 to 5.1
1052        contentmeta_v2[9].meta.identifier = RcStr::from("pkg5.1");
1053        // Add pkg12
1054        contentmeta_v2.push(ObjectSourceMetaSized {
1055            meta: ObjectSourceMeta {
1056                identifier: RcStr::from("pkg12.0"),
1057                name: RcStr::from("pkg12"),
1058                srcid: RcStr::from("srcpkg12"),
1059                change_time_offset: 0,
1060                change_frequency: 42,
1061            },
1062            size: 100000,
1063        });
1064
1065        let image_manifest_v1 = create_manifest(v1_expected_structure);
1066        let packing_derived = basic_packing(
1067            &contentmeta_v2.as_slice(),
1068            NonZeroU32::new(6).unwrap(),
1069            Some(&image_manifest_v1),
1070        )
1071        .unwrap();
1072        let structure_derived: Vec<Vec<&str>> = packing_derived
1073            .iter()
1074            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1075            .collect();
1076        let v2_expected_structure = vec![
1077            vec!["pkg3.0"],
1078            vec!["pkg4.0"],
1079            vec!["pkg6.0", "pkg11.0"],
1080            vec!["pkg9.0", "pkg8.0", "pkg10.0"],
1081            vec!["pkg1.1", "pkg2.0"],
1082            vec!["pkg5.1", "pkg12.0"],
1083        ];
1084
1085        assert_eq!(structure_derived, v2_expected_structure);
1086        Ok(())
1087    }
1088
1089    #[test]
1090    fn test_advanced_packing_with_prior_exclusive_components() -> Result<()> {
1091        let contentmeta: Vec<ObjectSourceMetaSized> = [
1092            (1, 100, 50000),
1093            (2, 200, 40000),
1094            (3, 300, 30000),
1095            (4, 400, 20000),
1096            (5, 500, 10000),
1097            (6, 600, 5000),
1098        ]
1099        .iter()
1100        .map(|&(id, freq, size)| ObjectSourceMetaSized {
1101            meta: ObjectSourceMeta {
1102                identifier: RcStr::from(format!("pkg{id}.0")),
1103                name: RcStr::from(format!("pkg{id}")),
1104                srcid: RcStr::from(format!("srcpkg{id}")),
1105                change_time_offset: 0,
1106                change_frequency: freq,
1107            },
1108            size,
1109        })
1110        .collect();
1111
1112        let regular_components = contentmeta[2..].to_vec();
1113        let prior_structure = vec![
1114            vec!["pkg1.0"],
1115            vec!["pkg2.0"],
1116            vec!["pkg3.0", "pkg4.0"],
1117            vec!["pkg5.0", "pkg6.0"],
1118            vec![],
1119        ];
1120        let prior_build = create_manifest(prior_structure);
1121
1122        let packing = basic_packing_with_prior_build(
1123            &regular_components,
1124            NonZeroU32::new(3).unwrap(),
1125            &prior_build,
1126        )?
1127        .expect("prior layout should remain reusable after exclusive bins are removed");
1128        let structure: Vec<Vec<&str>> = packing
1129            .iter()
1130            .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect())
1131            .collect();
1132
1133        assert_eq!(
1134            structure,
1135            vec![vec!["pkg3.0", "pkg4.0"], vec!["pkg5.0", "pkg6.0"], vec![],]
1136        );
1137        Ok(())
1138    }
1139
1140    fn setup_exclusive_test(
1141        component_data: &[(u32, u32, u64)],
1142        max_layers: u32,
1143        num_fake_objects: Option<usize>,
1144    ) -> Result<(
1145        Vec<ObjectSourceMetaSized>,
1146        ObjectMetaSized,
1147        BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>,
1148        Chunking,
1149    )> {
1150        // Create content metadata from provided data
1151        let contentmeta: Vec<ObjectSourceMetaSized> = component_data
1152            .iter()
1153            .map(|&(id, freq, size)| ObjectSourceMetaSized {
1154                meta: ObjectSourceMeta {
1155                    identifier: RcStr::from(format!("pkg{id}.0")),
1156                    name: RcStr::from(format!("pkg{id}")),
1157                    srcid: RcStr::from(format!("srcpkg{id}")),
1158                    change_time_offset: 0,
1159                    change_frequency: freq,
1160                },
1161                size,
1162            })
1163            .collect();
1164
1165        // Create object maps with fake checksums
1166        let mut object_map = IndexMap::new();
1167
1168        for (i, component) in contentmeta.iter().enumerate() {
1169            let checksum = format!("checksum_{i}");
1170            object_map.insert(checksum, component.meta.identifier.clone());
1171        }
1172
1173        let regular_meta = ObjectMetaSized {
1174            map: object_map,
1175            sizes: contentmeta.clone(),
1176        };
1177
1178        // Create exclusive metadata (initially empty, to be populated by individual tests)
1179        let specific_contentmeta = BTreeMap::new();
1180
1181        // Set up chunking with remainder chunk
1182        let mut chunking = Chunking::default();
1183        chunking.max = max_layers;
1184        chunking.remainder = Chunk::new("remainder");
1185
1186        // Add fake objects to the remainder chunk if specified
1187        if let Some(num_objects) = num_fake_objects {
1188            for i in 0..num_objects {
1189                let checksum = format!("checksum_{i}");
1190                chunking.remainder.content.insert(
1191                    RcStr::from(checksum),
1192                    (
1193                        1000,
1194                        vec![Utf8PathBuf::from(format!("/path/to/checksum_{i}"))],
1195                    ),
1196                );
1197                chunking.remainder.size += 1000;
1198            }
1199        }
1200
1201        Ok((contentmeta, regular_meta, specific_contentmeta, chunking))
1202    }
1203
1204    #[test]
1205    fn test_exclusive_chunks() -> Result<()> {
1206        // Test that exclusive chunks are created first and get their own layers
1207        let component_data = [
1208            (1, 100, 50000),
1209            (2, 200, 40000),
1210            (3, 300, 30000),
1211            (4, 400, 20000),
1212            (5, 500, 10000),
1213        ];
1214
1215        let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) =
1216            setup_exclusive_test(&component_data, 8, Some(5))?;
1217
1218        // Create specific content metadata for pkg1 and pkg2
1219        specific_contentmeta.insert(
1220            contentmeta[0].meta.identifier.clone(),
1221            vec![(
1222                Utf8PathBuf::from("/path/to/checksum_0"),
1223                "checksum_0".to_string(),
1224            )],
1225        );
1226        specific_contentmeta.insert(
1227            contentmeta[1].meta.identifier.clone(),
1228            vec![(
1229                Utf8PathBuf::from("/path/to/checksum_1"),
1230                "checksum_1".to_string(),
1231            )],
1232        );
1233
1234        chunking.process_mapping(
1235            &regular_meta,
1236            &Some(NonZeroU32::new(8).unwrap()),
1237            None,
1238            Some(&specific_contentmeta),
1239        )?;
1240
1241        // Verify exclusive chunks are created first
1242        assert!(chunking.chunks.len() >= 2);
1243        assert_eq!(chunking.chunks[0].name, "pkg1.0");
1244        assert_eq!(chunking.chunks[1].name, "pkg2.0");
1245        assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]);
1246        assert_eq!(chunking.chunks[1].packages, vec!["pkg2.0".to_string()]);
1247
1248        Ok(())
1249    }
1250
1251    #[test]
1252    fn test_exclusive_chunks_with_regular_packing() -> Result<()> {
1253        // Test that exclusive chunks are created first, then regular packing continues
1254        let component_data = [
1255            (1, 100, 50000), // exclusive
1256            (2, 200, 40000), // exclusive
1257            (3, 300, 30000), // regular
1258            (4, 400, 20000), // regular
1259            (5, 500, 10000), // regular
1260            (6, 600, 5000),  // regular
1261        ];
1262
1263        let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) =
1264            setup_exclusive_test(&component_data, 8, Some(6))?;
1265
1266        // Create specific content metadata for pkg1 and pkg2
1267        specific_contentmeta.insert(
1268            contentmeta[0].meta.identifier.clone(),
1269            vec![(
1270                Utf8PathBuf::from("/path/to/checksum_0"),
1271                "checksum_0".to_string(),
1272            )],
1273        );
1274        specific_contentmeta.insert(
1275            contentmeta[1].meta.identifier.clone(),
1276            vec![(
1277                Utf8PathBuf::from("/path/to/checksum_1"),
1278                "checksum_1".to_string(),
1279            )],
1280        );
1281
1282        chunking.process_mapping(
1283            &regular_meta,
1284            &Some(NonZeroU32::new(8).unwrap()),
1285            None,
1286            Some(&specific_contentmeta),
1287        )?;
1288
1289        // Verify exclusive chunks are created first
1290        assert!(chunking.chunks.len() >= 2);
1291        assert_eq!(chunking.chunks[0].name, "pkg1.0");
1292        assert_eq!(chunking.chunks[1].name, "pkg2.0");
1293        assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]);
1294        assert_eq!(chunking.chunks[1].packages, vec!["pkg2.0".to_string()]);
1295
1296        // Verify regular components are not in exclusive chunks
1297        for chunk in &chunking.chunks[2..] {
1298            assert!(!chunk.packages.contains(&"pkg1.0".to_string()));
1299            assert!(!chunk.packages.contains(&"pkg2.0".to_string()));
1300        }
1301
1302        Ok(())
1303    }
1304
1305    #[test]
1306    fn test_exclusive_chunks_isolation() -> Result<()> {
1307        // Test that exclusive chunks properly isolate components
1308        let component_data = [(1, 100, 50000), (2, 200, 40000), (3, 300, 30000)];
1309
1310        let (contentmeta, regular_meta, mut specific_contentmeta, mut chunking) =
1311            setup_exclusive_test(&component_data, 8, Some(3))?;
1312
1313        // Create specific content metadata for pkg1 only
1314        specific_contentmeta.insert(
1315            contentmeta[0].meta.identifier.clone(),
1316            vec![(
1317                Utf8PathBuf::from("/path/to/checksum_0"),
1318                "checksum_0".to_string(),
1319            )],
1320        );
1321
1322        chunking.process_mapping(
1323            &regular_meta,
1324            &Some(NonZeroU32::new(8).unwrap()),
1325            None,
1326            Some(&specific_contentmeta),
1327        )?;
1328
1329        // Verify pkg1 is in its own exclusive chunk
1330        assert!(!chunking.chunks.is_empty());
1331        assert_eq!(chunking.chunks[0].name, "pkg1.0");
1332        assert_eq!(chunking.chunks[0].packages, vec!["pkg1.0".to_string()]);
1333
1334        // Verify pkg2 and pkg3 are in regular chunks, not mixed with pkg1
1335        let mut found_pkg2 = false;
1336        let mut found_pkg3 = false;
1337        for chunk in &chunking.chunks[1..] {
1338            if chunk.packages.contains(&"pkg2".to_string()) {
1339                found_pkg2 = true;
1340                assert!(!chunk.packages.contains(&"pkg1.0".to_string()));
1341            }
1342            if chunk.packages.contains(&"pkg3".to_string()) {
1343                found_pkg3 = true;
1344                assert!(!chunk.packages.contains(&"pkg1.0".to_string()));
1345            }
1346        }
1347        assert!(found_pkg2 && found_pkg3);
1348
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn test_process_mapping_specific_components_contain_correct_objects() -> Result<()> {
1354        // This test validates that specific components get their own dedicated layers
1355        // and that their objects are properly isolated from regular package layers
1356
1357        // Setup: Create 5 packages
1358        // - pkg1, pkg2: Will be marked as "specific components" (get their own layers)
1359        // - pkg3, pkg4, pkg5: Regular packages (will be bin-packed together)
1360        let packages = [
1361            (1, 100, 50000), // pkg1 - SPECIFIC COMPONENT
1362            (2, 200, 40000), // pkg2 - SPECIFIC COMPONENT
1363            (3, 300, 30000), // pkg3 - regular package
1364            (4, 400, 20000), // pkg4 - regular package
1365            (5, 500, 10000), // pkg5 - regular package
1366        ];
1367
1368        let (contentmeta, mut system_metadata, mut specific_contentmeta, mut chunking) =
1369            setup_exclusive_test(&packages, 8, None)?;
1370
1371        // Create object mappings
1372        // - system_objects_map: Contains ALL objects in the system (both specific and regular)
1373        let mut system_objects_map = IndexMap::new();
1374
1375        // SPECIFIC COMPONENT 1 (pkg1): owns 3 objects
1376        let pkg1_objects = ["checksum_1_a", "checksum_1_b", "checksum_1_c"];
1377
1378        // SPECIFIC COMPONENT 2 (pkg2): owns 2 objects
1379        let pkg2_objects = ["checksum_2_a", "checksum_2_b"];
1380
1381        // REGULAR PACKAGE 1 (pkg3): owns 2 objects
1382        let pkg3_objects = ["checksum_3_a", "checksum_3_b"];
1383        for obj in &pkg3_objects {
1384            system_objects_map.insert(obj.to_string(), contentmeta[2].meta.identifier.clone());
1385        }
1386
1387        // REGULAR PACKAGE 2 (pkg4): owns 1 object
1388        let pkg4_objects = ["checksum_4_a"];
1389        for obj in &pkg4_objects {
1390            system_objects_map.insert(obj.to_string(), contentmeta[3].meta.identifier.clone());
1391        }
1392
1393        // REGULAR PACKAGE 3 (pkg5): owns 3 objects
1394        let pkg5_objects = ["checksum_5_a", "checksum_5_b", "checksum_5_c"];
1395        for obj in &pkg5_objects {
1396            system_objects_map.insert(obj.to_string(), contentmeta[4].meta.identifier.clone());
1397        }
1398
1399        // Set up metadata
1400        system_metadata.map = system_objects_map;
1401
1402        // Create specific content metadata for pkg1 and pkg2
1403        specific_contentmeta.insert(
1404            contentmeta[0].meta.identifier.clone(),
1405            pkg1_objects
1406                .iter()
1407                .map(|obj| {
1408                    (
1409                        Utf8PathBuf::from(format!("/path/to/{obj}")),
1410                        obj.to_string(),
1411                    )
1412                })
1413                .collect(),
1414        );
1415        specific_contentmeta.insert(
1416            contentmeta[1].meta.identifier.clone(),
1417            pkg2_objects
1418                .iter()
1419                .map(|obj| {
1420                    (
1421                        Utf8PathBuf::from(format!("/path/to/{obj}")),
1422                        obj.to_string(),
1423                    )
1424                })
1425                .collect(),
1426        );
1427
1428        // Initialize: Add ALL objects to the remainder chunk before processing
1429        // This includes both specific component objects and regular package objects
1430        // because process_mapping needs to move them from remainder to their final layers
1431        for (checksum, _) in &system_metadata.map {
1432            chunking.remainder.content.insert(
1433                RcStr::from(checksum.as_str()),
1434                (
1435                    1000,
1436                    vec![Utf8PathBuf::from(format!("/path/to/{checksum}"))],
1437                ),
1438            );
1439            chunking.remainder.size += 1000;
1440        }
1441        for paths in specific_contentmeta.values() {
1442            for (p, checksum) in paths {
1443                chunking
1444                    .remainder
1445                    .content
1446                    .insert(RcStr::from(checksum.as_str()), (1000, vec![p.clone()]));
1447            }
1448        }
1449
1450        // Process the mapping
1451        // - system_metadata contains ALL objects in the system
1452        // - specific_contentmeta tells process_mapping which objects belong to specific components
1453        chunking.process_mapping(
1454            &system_metadata,
1455            &Some(NonZeroU32::new(8).unwrap()),
1456            None,
1457            Some(&specific_contentmeta),
1458        )?;
1459
1460        // VALIDATION PART 1: Specific components get their own dedicated chunks
1461        assert!(
1462            chunking.chunks.len() >= 2,
1463            "Should have at least 2 chunks for specific components"
1464        );
1465
1466        // Specific Component Layer 1: pkg1 only
1467        let specific_component_1_layer = &chunking.chunks[0];
1468        assert_eq!(specific_component_1_layer.name, "pkg1.0");
1469        assert_eq!(specific_component_1_layer.packages, vec!["pkg1.0"]);
1470        assert_eq!(specific_component_1_layer.content.len(), 3);
1471        for obj in &pkg1_objects {
1472            assert!(
1473                specific_component_1_layer.content.contains_key(*obj),
1474                "Specific component 1 layer should contain {obj}"
1475            );
1476        }
1477
1478        // Specific Component Layer 2: pkg2 only
1479        let specific_component_2_layer = &chunking.chunks[1];
1480        assert_eq!(specific_component_2_layer.name, "pkg2.0");
1481        assert_eq!(specific_component_2_layer.packages, vec!["pkg2.0"]);
1482        assert_eq!(specific_component_2_layer.content.len(), 2);
1483        for obj in &pkg2_objects {
1484            assert!(
1485                specific_component_2_layer.content.contains_key(*obj),
1486                "Specific component 2 layer should contain {obj}"
1487            );
1488        }
1489
1490        // VALIDATION PART 2: Specific component layers contain NO regular package objects
1491        for specific_layer in &chunking.chunks[0..2] {
1492            for obj in pkg3_objects
1493                .iter()
1494                .chain(&pkg4_objects)
1495                .chain(&pkg5_objects)
1496            {
1497                assert!(
1498                    !specific_layer.content.contains_key(*obj),
1499                    "Specific component layer '{}' should NOT contain regular package object {}",
1500                    specific_layer.name,
1501                    obj
1502                );
1503            }
1504        }
1505
1506        // VALIDATION PART 3: Regular package layers contain NO specific component objects
1507        let regular_package_layers = &chunking.chunks[2..];
1508        for regular_layer in regular_package_layers {
1509            for obj in pkg1_objects.iter().chain(&pkg2_objects) {
1510                assert!(
1511                    !regular_layer.content.contains_key(*obj),
1512                    "Regular package layer should NOT contain specific component object {obj}"
1513                );
1514            }
1515        }
1516
1517        // VALIDATION PART 4: All regular package objects are in some regular layer
1518        let mut found_regular_objects = BTreeSet::new();
1519        for regular_layer in regular_package_layers {
1520            for obj in regular_layer.content.keys() {
1521                found_regular_objects.insert(obj.as_ref());
1522            }
1523        }
1524
1525        for obj in pkg3_objects
1526            .iter()
1527            .chain(&pkg4_objects)
1528            .chain(&pkg5_objects)
1529        {
1530            assert!(
1531                found_regular_objects.contains(*obj),
1532                "Regular package object {obj} should be in some regular layer"
1533            );
1534        }
1535
1536        // VALIDATION PART 5: All objects moved from remainder
1537        assert_eq!(
1538            chunking.remainder.content.len(),
1539            0,
1540            "All objects should be moved from remainder"
1541        );
1542        assert_eq!(chunking.remainder.size, 0, "Remainder size should be 0");
1543
1544        Ok(())
1545    }
1546}