use std::io::{Error, ErrorKind, Result};
use std::collections::{BTreeMap, BTreeSet};
use log::{debug, info, warn};
use hyperfile::{BlockIndex, Cno, SegmentId};
use hyperfile::config::{FlushConflictPolicy, HyperFileConfig, HyperFileConfigBuilder,
HyperFileRuntimeConfig};
use hyperfile::file::flags::{FileFlags, HyperFileFlags};
use hyperfile::file::hyper::Hyper;
use hyperfile::meta_format::BlockPtrFormat;
use hyperfile::staging::config::StagingConfig;
use crate::s3::{ContainerView, S3Cleaner};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjectUsage {
pub segid: SegmentId,
pub bytes: u64,
pub live_data_blocks: usize,
pub live_meta_blocks: usize,
pub live_bytes: u64,
}
impl ObjectUsage {
pub fn is_dead(&self) -> bool {
self.live_data_blocks == 0 && self.live_meta_blocks == 0
}
pub fn live_ratio(&self) -> f64 {
if self.bytes == 0 {
return 0.0;
}
self.live_bytes as f64 / self.bytes as f64
}
pub fn holds_metadata(&self) -> bool {
self.live_meta_blocks > 0
}
}
#[derive(Clone, Debug)]
pub struct Report {
pub high_cno: Cno,
pub latest_compacted: bool,
pub data_block_size: usize,
pub meta_block_size: usize,
pub objects: Vec<ObjectUsage>,
pub out_of_range: usize,
pub foreign_keys: usize,
pub foreign_sample: Option<String>,
}
impl Report {
pub fn bytes(&self) -> u64 {
self.objects.iter().map(|o| o.bytes).sum()
}
pub fn bytes_live(&self) -> u64 {
self.objects.iter().map(|o| o.live_bytes).sum()
}
pub fn dead(&self) -> (usize, u64) {
let dead = self.objects.iter().filter(|o| o.is_dead());
(dead.clone().count(), dead.map(|o| o.bytes).sum())
}
pub fn compactable(&self, max_live_ratio: f64) -> (usize, u64, u64) {
let picked = self.objects.iter()
.filter(|o| is_candidate(o, max_live_ratio));
(
picked.clone().count(),
picked.clone().map(|o| o.live_bytes).sum(),
picked.map(|o| o.bytes).sum(),
)
}
}
#[derive(Clone, Debug)]
pub struct CompactOptions {
pub max_live_ratio: f64,
pub max_pass_bytes: u64,
pub runtime: Option<HyperFileRuntimeConfig>,
pub relocate_chunk_bytes: usize,
pub dry_run: bool,
pub allow_consecutive: bool,
pub allow_foreign_keys: bool,
}
impl Default for CompactOptions {
fn default() -> Self {
Self {
max_live_ratio: 0.5,
max_pass_bytes: u64::MAX,
runtime: None,
relocate_chunk_bytes: 64 * 1024 * 1024,
dry_run: false,
allow_consecutive: false,
allow_foreign_keys: false,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct CompactOutcome {
pub selected: Vec<SegmentId>,
pub still_referenced: Vec<SegmentId>,
pub blocks_rewritten: usize,
pub bytes_rewritten: u64,
pub bytes_freed: u64,
pub checkpoints: usize,
pub content_cno_before: Cno,
pub content_cno_after: Cno,
pub more_remaining: bool,
pub dry_run: bool,
}
impl CompactOutcome {
pub fn content_moved(&self) -> bool {
self.content_cno_before != self.content_cno_after
}
}
impl S3Cleaner {
pub async fn report(&self, low: Cno, high: Cno) -> Result<Report> {
let view = self.view(low, high).await?;
Ok(build_report(&view))
}
pub async fn compact(
&self,
low: Cno,
high: Cno,
opts: &CompactOptions,
) -> Result<CompactOutcome> {
let view = self.view(low, high).await?;
if view.listing.foreign > 0 && !opts.allow_foreign_keys {
return Err(Error::new(
ErrorKind::InvalidInput,
format!(
"{} key(s) under the container's prefix are neither the inode nor \
segment objects (for example {:?}); a WAL is the usual reason, and \
compacting past a log this pass does not read would publish over \
entries recovery has not replayed. Set allow_foreign_keys once you \
have established otherwise",
view.listing.foreign,
view.listing.foreign_sample.as_deref().unwrap_or("?"),
),
));
}
if view.latest_compacted && !opts.allow_consecutive && !opts.dry_run {
return Err(Error::new(
ErrorKind::InvalidInput,
format!(
"checkpoint {} is already a compaction, and the flag that keeps \
content_cno still holds for one checkpoint only: a second pass in a \
row would be seen by consumers as a change of content. Write to the \
file first, or set allow_consecutive",
view.high_cno,
),
));
}
let report = build_report(&view);
let runtime = compaction_runtime(opts.runtime.as_ref());
let pass_cap = pass_cap(opts.max_pass_bytes, &view.fmt, &runtime);
let (selected, bytes_planned, bytes_freed, more_remaining) =
plan_pass(&report.objects, opts.max_live_ratio, pass_cap);
debug!("compact: {} object(s) selected of {} in range, {} live bytes (cap {})",
selected.len(), report.objects.len(), bytes_planned, pass_cap);
let mut outcome = CompactOutcome {
selected: selected.iter().copied().collect(),
bytes_freed,
more_remaining,
dry_run: opts.dry_run,
..Default::default()
};
if opts.dry_run {
outcome.blocks_rewritten =
(bytes_planned / view.data_block_size.max(1) as u64) as usize;
outcome.bytes_rewritten = bytes_planned;
return Ok(outcome);
}
let config = compaction_config(&self.root_uri(), &runtime);
let mut file = Hyper::open(
self.client().clone(), config, HyperFileFlags::from_flags(FileFlags::rdwr()),
).await?;
let result = self.relocate_and_publish(&mut file, &view, &selected, opts,
&mut outcome).await;
match result {
Ok(()) => { let _ = file.fs_release().await; },
Err(e) => {
drop(file);
return Err(e);
},
}
if outcome.checkpoints == 0 {
return Ok(outcome);
}
let after = self.view(low, 0).await?;
let referenced = after.referenced_objects();
outcome.still_referenced = outcome.selected.iter()
.filter(|segid| referenced.contains(segid))
.copied()
.collect();
if !outcome.still_referenced.is_empty() {
warn!("compact: {} of {} selected object(s) are still referenced: {:?}",
outcome.still_referenced.len(), outcome.selected.len(),
outcome.still_referenced);
}
if outcome.content_moved() {
warn!("compact: content_cno moved {} -> {}; consumers will see this pass \
as a change of content", outcome.content_cno_before,
outcome.content_cno_after);
}
info!("compact: rewrote {} block(s) ({} bytes) in {} checkpoint(s), freeing {} bytes \
across {} object(s)", outcome.blocks_rewritten, outcome.bytes_rewritten,
outcome.checkpoints, outcome.bytes_freed, outcome.selected.len());
Ok(outcome)
}
async fn relocate_and_publish(
&self,
file: &mut Hyper<'static>,
view: &ContainerView,
selected: &BTreeSet<SegmentId>,
opts: &CompactOptions,
outcome: &mut CompactOutcome,
) -> Result<()> {
outcome.content_cno_before = file.fs_content_cno()?;
outcome.content_cno_after = outcome.content_cno_before;
if selected.is_empty() {
return Ok(());
}
let bs = view.data_block_size;
let chunk_blocks = (opts.relocate_chunk_bytes / bs.max(1)).max(1);
let mut pending: Vec<BlockIndex> = Vec::new();
for (start, n) in view.live.windows.iter().copied() {
let places = file.fs_block_placement(start, n).await?;
for (i, place) in places.iter().enumerate() {
if let Some((segid, _)) = place
&& selected.contains(segid)
{
pending.push(start + i as BlockIndex);
if pending.len() >= chunk_blocks {
outcome.blocks_rewritten += relocate_runs(file, &pending).await?;
pending.clear();
}
}
}
}
if !pending.is_empty() {
outcome.blocks_rewritten += relocate_runs(file, &pending).await?;
}
outcome.bytes_rewritten = (outcome.blocks_rewritten * bs) as u64;
if outcome.blocks_rewritten == 0 {
return Ok(());
}
let cno = file.fs_flush().await?;
outcome.checkpoints = 1;
outcome.content_cno_after = file.fs_content_cno()?;
debug!("compact: published checkpoint {} with {} relocated block(s); content_cno \
{} -> {}", cno, outcome.blocks_rewritten, outcome.content_cno_before,
outcome.content_cno_after);
Ok(())
}
}
async fn relocate_runs(file: &mut Hyper<'static>, indices: &[BlockIndex]) -> Result<usize> {
let runs = into_runs(indices);
let marked = file.fs_relocate(&runs).await?;
debug!("compact: relocate {} block(s) in {} run(s) marked {}",
indices.len(), runs.len(), marked);
Ok(marked)
}
pub const UNBOUNDED_PASS_BYTES: u64 = 512 * 1024 * 1024;
fn max_object_bytes(fmt: &BlockPtrFormat, runtime: &HyperFileRuntimeConfig) -> u64 {
runtime.segment_max_object_bytes.clamp(16 * 1024, fmt.max_object_bytes()) as u64
}
fn pass_cap(max_pass_bytes: u64, fmt: &BlockPtrFormat, runtime: &HyperFileRuntimeConfig) -> u64 {
if fmt.is_parted() {
if runtime.parted_segment_enabled {
return max_pass_bytes;
}
return max_pass_bytes.min(UNBOUNDED_PASS_BYTES);
}
let one_object = max_object_bytes(fmt, runtime);
max_pass_bytes.min(UNBOUNDED_PASS_BYTES).min(one_object / 4 * 3)
}
fn plan_pass(
objects: &[ObjectUsage],
max_live_ratio: f64,
cap: u64,
) -> (BTreeSet<SegmentId>, u64, u64, bool) {
let mut selected = BTreeSet::new();
let mut live = 0u64;
let mut stored = 0u64;
let candidates = select_candidates(objects, max_live_ratio);
let total = candidates.len();
for usage in candidates {
if live.saturating_add(usage.live_bytes) > cap {
break;
}
live += usage.live_bytes;
stored += usage.bytes;
selected.insert(usage.segid);
}
let more = selected.len() < total;
(selected, live, stored, more)
}
pub(crate) fn build_report(view: &ContainerView) -> Report {
let mut live_data: BTreeMap<SegmentId, usize> = BTreeMap::new();
let mut live_meta: BTreeMap<SegmentId, usize> = BTreeMap::new();
for blkptr in &view.live.all {
let segid = view.object_of(blkptr);
if view.live.meta.contains(blkptr) {
*live_meta.entry(segid).or_default() += 1;
} else {
*live_data.entry(segid).or_default() += 1;
}
}
let mut objects = Vec::new();
let mut out_of_range = 0;
for (segid, bytes) in &view.listing.objects {
let cno = segid.as_cno();
if cno < view.low || cno > view.high_cno {
out_of_range += 1;
continue;
}
let live_data_blocks = live_data.get(segid).copied().unwrap_or(0);
let live_meta_blocks = live_meta.get(segid).copied().unwrap_or(0);
objects.push(ObjectUsage {
segid: *segid,
bytes: *bytes,
live_data_blocks,
live_meta_blocks,
live_bytes: (live_data_blocks * view.data_block_size
+ live_meta_blocks * view.meta_block_size) as u64,
});
}
Report {
high_cno: view.high_cno,
latest_compacted: view.latest_compacted,
data_block_size: view.data_block_size,
meta_block_size: view.meta_block_size,
objects,
out_of_range,
foreign_keys: view.listing.foreign,
foreign_sample: view.listing.foreign_sample.clone(),
}
}
fn is_candidate(usage: &ObjectUsage, max_live_ratio: f64) -> bool {
!usage.is_dead()
&& !usage.holds_metadata()
&& usage.live_ratio() <= max_live_ratio
}
fn select_candidates(
objects: &[ObjectUsage],
max_live_ratio: f64,
) -> Vec<&ObjectUsage> {
let mut picked: Vec<&ObjectUsage> = objects.iter()
.filter(|o| is_candidate(o, max_live_ratio))
.collect();
picked.sort_by(|a, b| {
a.live_ratio().total_cmp(&b.live_ratio())
.then(b.bytes.cmp(&a.bytes))
.then(a.segid.cmp(&b.segid))
});
picked
}
fn into_runs(indices: &[BlockIndex]) -> Vec<(BlockIndex, usize)> {
let mut runs: Vec<(BlockIndex, usize)> = Vec::new();
for idx in indices {
match runs.last_mut() {
Some((start, count)) if *start + *count as BlockIndex == *idx => *count += 1,
_ => runs.push((*idx, 1)),
}
}
runs
}
pub fn compaction_runtime_default() -> HyperFileRuntimeConfig {
const PART: usize = 64 * 1024 * 1024;
HyperFileRuntimeConfig {
data_cache_dirty_max_bytes_threshold: PART,
data_cache_dirty_min_bytes_to_part: PART,
segment_buffer_size: PART,
data_cache_dirty_max_flush_interval: 3_600_000,
..HyperFileRuntimeConfig::default()
}
}
fn compaction_runtime(given: Option<&HyperFileRuntimeConfig>) -> HyperFileRuntimeConfig {
let mut runtime = given.cloned().unwrap_or_else(compaction_runtime_default);
runtime.flush_conflict_policy = FlushConflictPolicy::FailFast;
runtime
}
fn compaction_config(root_uri: &str, runtime: &HyperFileRuntimeConfig) -> HyperFileConfig {
let staging = StagingConfig::new_s3_uri(root_uri, None);
HyperFileConfigBuilder::new()
.with_staging_config(&staging)
.with_runtime_config(runtime)
.build()
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(segid: SegmentId, bytes: u64, data: usize, meta: usize) -> ObjectUsage {
ObjectUsage {
segid,
bytes,
live_data_blocks: data,
live_meta_blocks: meta,
live_bytes: (data * 4096 + meta * 4096) as u64,
}
}
#[test]
fn a_dead_object_is_pruned_not_compacted() {
let o = usage(SegmentId::new(1), 1 << 20, 0, 0);
assert!(o.is_dead());
assert!(!is_candidate(&o, 1.0), "nothing to rewrite frees nothing");
}
#[test]
fn an_object_holding_metadata_is_not_a_candidate() {
let o = usage(SegmentId::with_part(3, 0), 1 << 20, 1, 1);
assert!(o.holds_metadata());
assert!(!is_candidate(&o, 1.0));
}
#[test]
fn the_ratio_is_a_ceiling() {
let low = usage(SegmentId::new(1), 4096 * 100, 5, 0); let high = usage(SegmentId::new(2), 4096 * 100, 90, 0); assert!(is_candidate(&low, 0.5));
assert!(!is_candidate(&high, 0.5));
assert!(is_candidate(&high, 0.9));
}
#[test]
fn candidates_come_cheapest_first() {
let objects = vec![
usage(SegmentId::new(1), 4096 * 100, 40, 0), usage(SegmentId::new(2), 4096 * 100, 5, 0), usage(SegmentId::new(3), 4096 * 200, 10, 0), ];
let picked = select_candidates(&objects, 0.5);
let order: Vec<SegmentId> = picked.iter().map(|o| o.segid).collect();
assert_eq!(order, vec![SegmentId::new(3), SegmentId::new(2), SegmentId::new(1)]);
}
#[test]
fn runs_are_maximal() {
assert_eq!(into_runs(&[]), vec![]);
assert_eq!(into_runs(&[7]), vec![(7, 1)]);
assert_eq!(into_runs(&[0, 1, 2, 5, 6, 9]), vec![(0, 3), (5, 2), (9, 1)]);
}
#[test]
fn a_pass_takes_whole_objects_until_the_budget() {
let objects = vec![
usage(SegmentId::new(1), 4096 * 100, 10, 0), usage(SegmentId::new(2), 4096 * 100, 20, 0), usage(SegmentId::new(3), 4096 * 100, 1, 0), ];
let (picked, live, stored, more) = plan_pass(&objects, 0.5, 4096 * 15);
assert_eq!(picked.into_iter().collect::<Vec<_>>(),
vec![SegmentId::new(1), SegmentId::new(3)]);
assert_eq!(live, 4096 * 11);
assert_eq!(stored, 4096 * 200);
assert!(more, "object 2 did not fit and is still a candidate");
let (picked, _, _, more) = plan_pass(&objects, 0.5, u64::MAX);
assert_eq!(picked.len(), 3);
assert!(!more);
}
#[test]
fn the_pass_cap_follows_what_can_write_it_out() {
let big = u64::MAX;
let parts_on = compaction_runtime(None);
assert!(parts_on.parted_segment_enabled);
assert_eq!(pass_cap(big, &BlockPtrFormat::PartedSegment, &parts_on), big);
let parts_off = HyperFileRuntimeConfig { parted_segment_enabled: false, ..parts_on.clone() };
assert_eq!(pass_cap(big, &BlockPtrFormat::PartedSegment, &parts_off), UNBOUNDED_PASS_BYTES);
let mg = BlockPtrFormat::MicroGroup.max_object_bytes() as u64;
let cap = pass_cap(big, &BlockPtrFormat::MicroGroup, &parts_on);
assert!(cap < mg && cap <= UNBOUNDED_PASS_BYTES);
let small_objects = HyperFileRuntimeConfig {
segment_max_object_bytes: 1024 * 1024, ..parts_on.clone()
};
assert_eq!(pass_cap(big, &BlockPtrFormat::MicroGroup, &small_objects), 1024 * 1024 / 4 * 3,
"the caller's object size is what hyperfile refuses past");
assert_eq!(pass_cap(4096, &BlockPtrFormat::MicroGroup, &parts_on), 4096,
"a smaller budget is kept as given");
}
#[test]
fn a_given_runtime_is_kept_but_for_the_conflict_policy() {
use hyperfile::config::FlushConflictPolicy;
let given = HyperFileRuntimeConfig {
data_cache_dirty_min_bytes_to_part: 16 * 1024 * 1024,
data_cache_dirty_max_bytes_threshold: 16 * 1024 * 1024,
segment_mpu_chunk_size: 16 * 1024 * 1024,
flush_conflict_policy: FlushConflictPolicy::RetryLastWriterWins,
..HyperFileRuntimeConfig::default()
};
let used = compaction_runtime(Some(&given));
assert_eq!(used.flush_conflict_policy, FlushConflictPolicy::FailFast);
assert_eq!(HyperFileRuntimeConfig {
flush_conflict_policy: FlushConflictPolicy::RetryLastWriterWins, ..used
}, given);
assert_eq!(compaction_runtime(None).flush_conflict_policy, FlushConflictPolicy::FailFast);
}
#[test]
fn an_object_larger_than_the_budget_is_reported_not_split() {
let objects = vec![usage(SegmentId::new(1), 4096 * 1000, 100, 0)];
let (picked, live, _, more) = plan_pass(&objects, 0.5, 4096 * 10);
assert!(picked.is_empty());
assert_eq!(live, 0);
assert!(more);
}
#[test]
fn the_estimate_agrees_with_the_selection() {
let report = Report {
high_cno: 9,
latest_compacted: false,
data_block_size: 4096,
meta_block_size: 4096,
objects: vec![
usage(SegmentId::new(1), 4096 * 100, 5, 0),
usage(SegmentId::new(2), 4096 * 100, 80, 0),
usage(SegmentId::new(3), 4096 * 100, 0, 0),
usage(SegmentId::new(4), 4096 * 100, 2, 3),
],
out_of_range: 0,
foreign_keys: 0,
foreign_sample: None,
};
let (n, rewrite, freed) = report.compactable(0.5);
assert_eq!(n, 1, "only the 5% object qualifies");
assert_eq!(rewrite, 5 * 4096);
assert_eq!(freed, 4096 * 100);
assert_eq!(report.dead(), (1, 4096 * 100));
let picked = select_candidates(&report.objects, CompactOptions::default().max_live_ratio);
assert_eq!(picked.len(), n);
}
}