use std::ops::Range;
use thiserror::Error;
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum LayerSelectionError {
#[error("a disk chain must contain at least one layer")]
EmptyChain,
#[error("compaction requires at least two layers, including the base")]
TooFewLayersToCompact,
#[error("cannot compact {requested} layers: only {sealed} sealed layers are available")]
CompactionIncludesWritableHead {
requested: usize,
sealed: usize,
},
#[error("cannot export the last {requested} layers of a {available}-layer checkpoint")]
InvalidExportCount {
requested: usize,
available: usize,
},
#[error(
"the export baseline is not an exact physical prefix; export the new base first or save a complete archive"
)]
IncompatibleExportBase,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiskCompactionPlan {
input_layers: usize,
prefix_layers: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiskLayerExportPlan {
checkpoint_layers: usize,
required_layers: usize,
}
impl DiskCompactionPlan {
pub fn new(runtime_layers: usize, layers: Option<usize>) -> Result<Self, LayerSelectionError> {
let sealed = runtime_layers
.checked_sub(1)
.ok_or(LayerSelectionError::EmptyChain)?;
let prefix_layers = match layers {
Some(0 | 1) => return Err(LayerSelectionError::TooFewLayersToCompact),
_ if sealed < 2 => 0,
Some(requested) => requested.min(sealed),
None => sealed,
};
Ok(Self {
input_layers: runtime_layers,
prefix_layers,
})
}
pub fn prefix(&self) -> Range<usize> {
0..self.prefix_layers
}
pub fn retained(&self) -> Range<usize> {
self.prefix_layers..self.input_layers
}
pub fn output_layers(&self) -> usize {
if self.prefix_layers == 0 {
self.input_layers
} else {
self.input_layers - self.prefix_layers + 1
}
}
pub fn is_noop(&self) -> bool {
self.prefix_layers == 0
}
}
impl DiskLayerExportPlan {
pub fn complete(checkpoint_layers: usize) -> Result<Self, LayerSelectionError> {
if checkpoint_layers == 0 {
return Err(LayerSelectionError::EmptyChain);
}
Ok(Self {
checkpoint_layers,
required_layers: 0,
})
}
pub fn last(checkpoint_layers: usize, layers: usize) -> Result<Self, LayerSelectionError> {
let mut plan = Self::complete(checkpoint_layers)?;
if layers == 0 || layers > checkpoint_layers {
return Err(LayerSelectionError::InvalidExportCount {
requested: layers,
available: checkpoint_layers,
});
}
plan.required_layers = checkpoint_layers - layers;
Ok(plan)
}
pub fn since<T: PartialEq>(target: &[T], baseline: &[T]) -> Result<Self, LayerSelectionError> {
let mut plan = Self::complete(target.len())?;
if baseline.is_empty() || !target.starts_with(baseline) {
return Err(LayerSelectionError::IncompatibleExportBase);
}
plan.required_layers = baseline.len();
Ok(plan)
}
pub fn included(&self) -> Range<usize> {
self.required_layers..self.checkpoint_layers
}
pub fn required(&self) -> Range<usize> {
0..self.required_layers
}
pub fn is_disk_complete(&self) -> bool {
self.required_layers == 0
}
}
#[cfg(test)]
mod tests {
use super::{DiskCompactionPlan, DiskLayerExportPlan, LayerSelectionError};
#[test]
fn compaction_count_includes_base_and_excludes_writable_head() {
let plan = DiskCompactionPlan::new(5, Some(3)).unwrap();
assert_eq!(plan.prefix(), 0..3);
assert_eq!(plan.retained(), 3..5);
assert_eq!(plan.output_layers(), 3);
assert!(!plan.is_noop());
}
#[test]
fn default_compacts_all_sealed_layers_only() {
let plan = DiskCompactionPlan::new(5, None).unwrap();
assert_eq!(plan.prefix(), 0..4);
assert_eq!(plan.retained(), 4..5);
assert_eq!(plan.output_layers(), 2);
}
#[test]
fn insufficient_sealed_layers_are_a_noop_not_a_conversion() {
for count in [1, 2] {
for requested in [None, Some(2), Some(usize::MAX)] {
let plan = DiskCompactionPlan::new(count, requested).unwrap();
assert!(plan.is_noop());
assert_eq!(plan.prefix(), 0..0);
assert_eq!(plan.retained(), 0..count);
assert_eq!(plan.output_layers(), count);
}
}
}
#[test]
fn explicit_compaction_uses_up_to_available_sealed_layers() {
assert_eq!(
DiskCompactionPlan::new(0, None),
Err(LayerSelectionError::EmptyChain)
);
for count in [0, 1] {
assert_eq!(
DiskCompactionPlan::new(5, Some(count)),
Err(LayerSelectionError::TooFewLayersToCompact)
);
}
for count in [5, 6, usize::MAX] {
let plan = DiskCompactionPlan::new(5, Some(count)).unwrap();
assert_eq!(plan.prefix(), 0..4);
assert_eq!(plan.retained(), 4..5);
assert_eq!(plan.output_layers(), 2);
}
}
#[test]
fn export_includes_the_checkpoint_top_layer() {
let plan = DiskLayerExportPlan::last(5, 2).unwrap();
assert_eq!(plan.included(), 3..5);
assert_eq!(plan.required(), 0..3);
assert!(!plan.is_disk_complete());
}
#[test]
fn exporting_all_layers_is_disk_complete() {
let plan = DiskLayerExportPlan::last(5, 5).unwrap();
assert_eq!(plan, DiskLayerExportPlan::complete(5).unwrap());
assert_eq!(plan.required(), 0..0);
assert!(plan.is_disk_complete());
}
#[test]
fn export_rejects_empty_and_out_of_range_counts() {
assert_eq!(
DiskLayerExportPlan::complete(0),
Err(LayerSelectionError::EmptyChain)
);
for count in [0, 6, usize::MAX] {
assert!(DiskLayerExportPlan::last(5, count).is_err());
}
}
#[test]
fn since_requires_the_exact_physical_prefix() {
let target = [("base", "raw"), ("a", "qcow2"), ("b", "qcow2")];
let plan = DiskLayerExportPlan::since(&target, &target[..2]).unwrap();
assert_eq!(plan.required(), 0..2);
assert_eq!(plan.included(), 2..3);
for invalid in [
vec![],
vec![("compacted", "raw")],
vec![("base", "qcow2")],
vec![("a", "qcow2")],
] {
assert_eq!(
DiskLayerExportPlan::since(&target, &invalid),
Err(LayerSelectionError::IncompatibleExportBase)
);
}
assert!(DiskLayerExportPlan::since(&target[..1], &target).is_err());
}
#[test]
fn equal_disk_prefix_can_omit_all_disk_bytes_without_omitting_vm_state() {
let target = ["base", "a"];
let plan = DiskLayerExportPlan::since(&target, &target).unwrap();
assert_eq!(plan.included(), 2..2);
assert_eq!(plan.required(), 0..2);
assert!(!plan.is_disk_complete());
}
}