hyperfile_cleaner/compact.rs
1//! Offline compaction: rewriting the live blocks of mostly-dead objects so the
2//! objects can be reclaimed, and so a read of one file range costs fewer
3//! requests.
4//!
5//! ## What `prune` cannot do
6//!
7//! [`Cleaner::prune`](crate::Cleaner::prune) reclaims an object nothing points
8//! into. An object that is 5% live is not that object: those few blocks hold the
9//! file's only copy of their contents, so the other 95% cannot be given back
10//! while they stay there. Rewriting them into a new checkpoint is what turns the
11//! old object into one `prune` can take.
12//!
13//! The same rewrite is what makes a read cheaper. A request breaks where the
14//! object changes, so blocks of one file range scattered across many objects
15//! cost one request each; written out together they cost one. Both goals use the
16//! same mechanism and differ only in what they select.
17//!
18//! ## How the rewrite happens
19//!
20//! Not by a format this crate writes. Nothing here builds a segment, assigns a
21//! pointer or publishes an inode — that is `hyperfile`'s write path, and a second
22//! implementation of it would drift from the first. What this does instead is ask
23//! `hyperfile` to rewrite blocks it already knows how to rewrite:
24//!
25//! - [`Hyper::fs_relocate`] marks a run of blocks to be rewritten at the next flush
26//! with their current bytes. It reads them with the read path's own coalescing,
27//! so a contiguous run costs a few large requests rather than one per block; it
28//! skips holes, explicit zero blocks and anything already dirty, so a sparse
29//! file stays sparse; and it touches neither `mtime`, `ctime` nor the read cache.
30//! - One `fs_flush` at the end of the pass writes the dirty set as one checkpoint.
31//! The dirty set is keyed by block index, so the blocks land in file order and
32//! packed — which is both halves of what compaction is for.
33//!
34//! ## A compaction is not a change of content
35//!
36//! The pass has to publish a new checkpoint — blocks moved, so pointers, the map
37//! and the inode changed — and so `last_cno` advances. A consumer that reads a new
38//! `last_cno` as "the content changed" would take every compaction for a change.
39//! `hyperfile` therefore marks a publish whose every dirty block came from
40//! relocation with `InodeRaw::FLAG_COMPACTED`, and
41//! [`Hyper::fs_content_cno`] stays where it was across it. That is the number a
42//! consumer should compare.
43//!
44//! The mark is only set when the publish's number directly follows the previous
45//! one, so it holds for exactly one checkpoint. Two passes back to back would
46//! therefore move `content_cno` on the second, and [`S3Cleaner::compact`] refuses
47//! to start on a checkpoint that is already marked unless
48//! [`CompactOptions::allow_consecutive`] says otherwise.
49//!
50//! ## Why offline
51//!
52//! A compactor is a writer, and it writes *old* bytes — the content it read
53//! earlier. `hyperfile`'s default conflict policy, `RetryLastWriterWins`,
54//! refreshes from storage on a conflict and re-publishes the dirty set, which for
55//! a compactor means republishing a stale copy of a block another writer has
56//! since changed. So compaction runs with
57//! [`FlushConflictPolicy::FailFast`](hyperfile::config::FlushConflictPolicy::FailFast)
58//! and the caller must be the container's only writer. A concurrent publish is
59//! then detected — the inode carries an `If-Match` on its ETag — and the pass
60//! fails rather than losing the other writer's data.
61//!
62//! A log is the other half of that contract. This refuses to run when the
63//! container's prefix holds keys that are neither the inode nor segment objects,
64//! because a WAL is the usual reason for them and a compactor that does not read
65//! the log would publish over entries recovery has not replayed yet.
66
67use std::io::{Error, ErrorKind, Result};
68use std::collections::{BTreeMap, BTreeSet};
69use log::{debug, info, warn};
70use hyperfile::{BlockIndex, Cno, SegmentId};
71use hyperfile::config::{FlushConflictPolicy, HyperFileConfig, HyperFileConfigBuilder,
72 HyperFileRuntimeConfig};
73use hyperfile::file::flags::{FileFlags, HyperFileFlags};
74use hyperfile::file::hyper::Hyper;
75use hyperfile::meta_format::BlockPtrFormat;
76use hyperfile::staging::config::StagingConfig;
77use crate::s3::{ContainerView, S3Cleaner};
78
79
80/// How much of one stored object is still pointed into.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct ObjectUsage {
83 /// The object, `<cno>` or `<cno>.<part>`.
84 pub segid: SegmentId,
85 /// Its size on storage.
86 pub bytes: u64,
87 /// Live data blocks in it.
88 pub live_data_blocks: usize,
89 /// Live metadata nodes in it.
90 pub live_meta_blocks: usize,
91 /// What those occupy, by the container's own block sizes.
92 pub live_bytes: u64,
93 /// Whether the live metadata in it is rewritten by rewriting its live data.
94 ///
95 /// True for a data part — any part above 0 — of a `StreamSegment` container.
96 /// That layout puts every leaf of the block map in the object holding the data
97 /// under it, and the nodes above the leaves in part 0 only. A leaf in a data
98 /// part is therefore live only while none of the blocks it was written with
99 /// has changed, so those blocks are live and in the same object; relocating
100 /// them dirties the leaf, and the flush writes it elsewhere with them.
101 /// Under every other format the nodes sit in part 0 or the one object, apart
102 /// from the data they describe, and this is false.
103 pub meta_moves_with_data: bool,
104}
105
106impl ObjectUsage {
107 /// Nothing points into it: `prune` can take it, compaction has no work here.
108 pub fn is_dead(&self) -> bool {
109 self.live_data_blocks == 0 && self.live_meta_blocks == 0
110 }
111
112 /// Live bytes over stored bytes. Zero for a dead object, and the cost side
113 /// of the trade: rewriting this much frees [`Self::bytes`].
114 pub fn live_ratio(&self) -> f64 {
115 if self.bytes == 0 {
116 return 0.0;
117 }
118 self.live_bytes as f64 / self.bytes as f64
119 }
120
121 /// Whether a live metadata node is in it.
122 ///
123 /// Such an object cannot in general be freed by rewriting data blocks: a node
124 /// is relocated when the flush rewrites it, which happens because something
125 /// under it was dirtied, and which node covers which keys is not visible from
126 /// outside the map. Compaction skips these rather than rewriting their data
127 /// blocks for nothing — unless [`Self::meta_moves_with_data`] says the node
128 /// goes with the data.
129 pub fn holds_metadata(&self) -> bool {
130 self.live_meta_blocks > 0
131 }
132}
133
134/// What a container's storage is made of, and what could be given back.
135#[derive(Clone, Debug)]
136pub struct Report {
137 /// The checkpoint the walk was taken from.
138 pub high_cno: Cno,
139 /// Whether the newest published checkpoint is itself a compaction
140 /// (`InodeRaw::FLAG_COMPACTED`). `compact` will not run on top of one unless
141 /// asked to, since a second pass in a row moves `content_cno`.
142 pub latest_compacted: bool,
143 pub data_block_size: usize,
144 pub meta_block_size: usize,
145 /// Objects whose checkpoint falls in the queried `[low, high]`, ascending.
146 pub objects: Vec<ObjectUsage>,
147 /// Objects outside that range, which no operation here will touch.
148 pub out_of_range: usize,
149 /// Keys under the container's prefix that are neither the inode nor a segment
150 /// object. A WAL root nested under the container is the usual reason, and
151 /// compaction refuses to run while any are present.
152 pub foreign_keys: usize,
153 pub foreign_sample: Option<String>,
154}
155
156impl Report {
157 /// Bytes stored by the objects in range.
158 pub fn bytes(&self) -> u64 {
159 self.objects.iter().map(|o| o.bytes).sum()
160 }
161
162 /// Bytes still pointed into.
163 pub fn bytes_live(&self) -> u64 {
164 self.objects.iter().map(|o| o.live_bytes).sum()
165 }
166
167 /// Objects nothing points into, and what they hold. This is `prune`'s work,
168 /// and it needs no rewrite.
169 pub fn dead(&self) -> (usize, u64) {
170 let dead = self.objects.iter().filter(|o| o.is_dead());
171 (dead.clone().count(), dead.map(|o| o.bytes).sum())
172 }
173
174 /// What compaction at `max_live_ratio` would cost and free: `(objects,
175 /// bytes_to_rewrite, bytes_freed)`.
176 ///
177 /// An estimate from the same numbers [`S3Cleaner::compact`] selects by, so a
178 /// dry run and this agree. The bytes freed are the objects' own, which
179 /// `prune` collects afterwards; the rewrite itself publishes a new checkpoint
180 /// holding the live blocks.
181 pub fn compactable(&self, max_live_ratio: f64) -> (usize, u64, u64) {
182 let picked = self.objects.iter()
183 .filter(|o| is_candidate(o, max_live_ratio));
184 (
185 picked.clone().count(),
186 picked.clone().map(|o| o.live_bytes).sum(),
187 picked.map(|o| o.bytes).sum(),
188 )
189 }
190}
191
192/// Which objects compaction may rewrite, and how much it may do at once.
193#[derive(Clone, Debug)]
194pub struct CompactOptions {
195 /// Rewrite an object only if at most this much of it is still live. At `1.0`
196 /// every live object is a candidate, which rewrites the whole container.
197 pub max_live_ratio: f64,
198 /// The most live bytes one pass rewrites. Objects are taken whole and
199 /// cheapest first until the next would pass this; what is left waits for the
200 /// next pass. Unlimited by default.
201 ///
202 /// On a parted container with partial segments enabled — the default for
203 /// both — this is only a size: `fs_relocate` writes the pass out as partials
204 /// once it crosses the dirty-data limits, so memory is bounded by those
205 /// limits whatever the pass. Where no partial can be written, everything the
206 /// pass relocates stays dirty until its one flush, so the pass is also capped
207 /// at [`UNBOUNDED_PASS_BYTES`]; and a container that is not parted writes a
208 /// checkpoint as one object, so its pass is capped at what one object holds.
209 pub max_pass_bytes: u64,
210 /// The runtime configuration of the handle that does the rewrite: the size of
211 /// the partials a pass is written out as (`data_cache_dirty_min_bytes_to_part`
212 /// and the dirty-data limits), `segment_buffer_size`, the read cache,
213 /// multipart uploads. Give the one the container's writer uses, and what a
214 /// compaction writes is shaped like what the writer writes.
215 ///
216 /// `None` uses [`compaction_runtime_default`]. Either way the cleaner
217 /// overrides one field, `flush_conflict_policy`, to `FailFast`: a compactor
218 /// writes bytes it read earlier, and retrying on a conflict would republish
219 /// them over another writer's newer ones.
220 pub runtime: Option<HyperFileRuntimeConfig>,
221 /// How many bytes of blocks to hand `fs_relocate` at once. It holds what it
222 /// reads in a buffer of its own before the dirty cache takes it, so this bounds
223 /// that second copy.
224 pub relocate_chunk_bytes: usize,
225 /// Select and report, write nothing. No file is opened.
226 pub dry_run: bool,
227 /// Run even though the newest checkpoint is already a compaction. The flag that
228 /// keeps `content_cno` still holds for one checkpoint only, so a second pass in
229 /// a row is seen by consumers as a change of content. Set this when that is
230 /// acceptable, for example to finish a container larger than one pass.
231 pub allow_consecutive: bool,
232 /// Run even though the prefix holds keys this crate does not recognize. Only
233 /// set this having established that they are not a WAL.
234 pub allow_foreign_keys: bool,
235}
236
237impl Default for CompactOptions {
238 fn default() -> Self {
239 Self {
240 max_live_ratio: 0.5,
241 max_pass_bytes: u64::MAX,
242 runtime: None,
243 relocate_chunk_bytes: 64 * 1024 * 1024,
244 dry_run: false,
245 allow_consecutive: false,
246 allow_foreign_keys: false,
247 }
248 }
249}
250
251/// What a compaction pass did.
252#[derive(Clone, Debug, Default)]
253pub struct CompactOutcome {
254 /// The objects whose live blocks were rewritten, ascending.
255 pub selected: Vec<SegmentId>,
256 /// Of those, the ones a walk taken afterwards still finds referenced. Empty
257 /// on a pass that did what it set out to; anything here is an object that was
258 /// not freed, and is reported rather than assumed away.
259 pub still_referenced: Vec<SegmentId>,
260 /// Blocks `fs_relocate` marked: what was done, not what was planned. A hole,
261 /// an explicit zero block or a block already dirty is not counted.
262 pub blocks_rewritten: usize,
263 pub bytes_rewritten: u64,
264 /// The selected objects' stored bytes — what `prune` can now reclaim.
265 pub bytes_freed: u64,
266 /// Checkpoints published: one for a pass that rewrote anything, none
267 /// otherwise. That is how far `last_cno` advanced.
268 pub checkpoints: usize,
269 /// `fs_content_cno` before and after the pass. Equal when consumers will not
270 /// see the pass as a change of content, which is the ordinary case. They
271 /// differ when the publish could not be marked: its number did not follow the
272 /// previous one — an earlier flush failed and gave its number up — or the
273 /// newest checkpoint was already a compaction and the pass ran anyway.
274 pub content_cno_before: Cno,
275 pub content_cno_after: Cno,
276 /// Whether the candidates outlasted the pass budget. Another pass would find
277 /// more to do, at the cost of `content_cno` moving once (see
278 /// [`CompactOptions::allow_consecutive`]).
279 pub more_remaining: bool,
280 pub dry_run: bool,
281}
282
283impl CompactOutcome {
284 /// Whether a consumer comparing `content_cno` will see this pass as a change.
285 pub fn content_moved(&self) -> bool {
286 self.content_cno_before != self.content_cno_after
287 }
288}
289
290impl S3Cleaner {
291 /// What the container's storage is made of: per-object liveness for every
292 /// object whose checkpoint falls in `[low, high]`.
293 ///
294 /// Read-only, and the same walk `collect` does plus one `ListObjectsV2` for
295 /// sizes. `high == 0` means the latest published checkpoint.
296 pub async fn report(&self, low: Cno, high: Cno) -> Result<Report> {
297 let view = self.view(low, high).await?;
298 Ok(build_report(&view))
299 }
300
301 /// Rewrite the live blocks of mostly-dead objects so `prune` can reclaim
302 /// them.
303 ///
304 /// The caller must be the container's only writer for the duration; see the
305 /// [module docs](self) for why that is not a nicety. Nothing is deleted here
306 /// — a compaction makes objects reclaimable and `prune` reclaims them, which
307 /// keeps the destructive step in one place.
308 pub async fn compact(
309 &self,
310 low: Cno,
311 high: Cno,
312 opts: &CompactOptions,
313 ) -> Result<CompactOutcome> {
314 let view = self.view(low, high).await?;
315 if view.listing.foreign > 0 && !opts.allow_foreign_keys {
316 return Err(Error::new(
317 ErrorKind::InvalidInput,
318 format!(
319 "{} key(s) under the container's prefix are neither the inode nor \
320 segment objects (for example {:?}); a WAL is the usual reason, and \
321 compacting past a log this pass does not read would publish over \
322 entries recovery has not replayed. Set allow_foreign_keys once you \
323 have established otherwise",
324 view.listing.foreign,
325 view.listing.foreign_sample.as_deref().unwrap_or("?"),
326 ),
327 ));
328 }
329
330 if view.latest_compacted && !opts.allow_consecutive && !opts.dry_run {
331 return Err(Error::new(
332 ErrorKind::InvalidInput,
333 format!(
334 "checkpoint {} is already a compaction, and the flag that keeps \
335 content_cno still holds for one checkpoint only: a second pass in a \
336 row would be seen by consumers as a change of content. Write to the \
337 file first, or set allow_consecutive",
338 view.high_cno,
339 ),
340 ));
341 }
342
343 let report = build_report(&view);
344 let runtime = compaction_runtime(opts.runtime.as_ref());
345 let pass_cap = pass_cap(opts.max_pass_bytes, &view.fmt, &runtime);
346 let (selected, bytes_planned, bytes_freed, more_remaining) =
347 plan_pass(&report.objects, opts.max_live_ratio, pass_cap);
348 debug!("compact: {} object(s) selected of {} in range, {} live bytes (cap {})",
349 selected.len(), report.objects.len(), bytes_planned, pass_cap);
350
351 let mut outcome = CompactOutcome {
352 selected: selected.iter().copied().collect(),
353 bytes_freed,
354 more_remaining,
355 dry_run: opts.dry_run,
356 ..Default::default()
357 };
358 if opts.dry_run {
359 // The plan's own numbers, which is all a dry run can report: nothing
360 // is opened, so nothing can be counted as done.
361 outcome.blocks_rewritten =
362 (bytes_planned / view.data_block_size.max(1) as u64) as usize;
363 outcome.bytes_rewritten = bytes_planned;
364 return Ok(outcome);
365 }
366
367 let config = compaction_config(&self.root_uri(), &runtime);
368 let mut file = Hyper::open(
369 self.client().clone(), config, HyperFileFlags::from_flags(FileFlags::rdwr()),
370 ).await?;
371 let result = self.relocate_and_publish(&mut file, &view, &selected, opts,
372 &mut outcome).await;
373 match result {
374 // Published, or nothing to publish: close as usual.
375 Ok(()) => { let _ = file.fs_release().await; },
376 // A failed pass publishes nothing. `fs_release` would flush what was
377 // marked, so a pass reported failed could still have published part
378 // of itself. The handle is dropped instead; any partials it wrote sit
379 // above the published checkpoint, where the next open steps past
380 // them and a later `prune` collects them once a publish passes them.
381 Err(e) => {
382 drop(file);
383 return Err(e);
384 },
385 }
386 if outcome.checkpoints == 0 {
387 return Ok(outcome);
388 }
389
390 // Say what was actually freed rather than what should have been. A
391 // selected object that is still referenced means a block was not
392 // relocated, which is worth reporting where it can be seen.
393 let after = self.view(low, 0).await?;
394 let referenced = after.referenced_objects();
395 outcome.still_referenced = outcome.selected.iter()
396 .filter(|segid| referenced.contains(segid))
397 .copied()
398 .collect();
399 if !outcome.still_referenced.is_empty() {
400 warn!("compact: {} of {} selected object(s) are still referenced: {:?}",
401 outcome.still_referenced.len(), outcome.selected.len(),
402 outcome.still_referenced);
403 }
404 if outcome.content_moved() {
405 warn!("compact: content_cno moved {} -> {}; consumers will see this pass \
406 as a change of content", outcome.content_cno_before,
407 outcome.content_cno_after);
408 }
409 info!("compact: rewrote {} block(s) ({} bytes) in {} checkpoint(s), freeing {} bytes \
410 across {} object(s)", outcome.blocks_rewritten, outcome.bytes_rewritten,
411 outcome.checkpoints, outcome.bytes_freed, outcome.selected.len());
412 Ok(outcome)
413 }
414
415 /// Mark every block of the selected objects for relocation, then publish once.
416 async fn relocate_and_publish(
417 &self,
418 file: &mut Hyper<'static>,
419 view: &ContainerView,
420 selected: &BTreeSet<SegmentId>,
421 opts: &CompactOptions,
422 outcome: &mut CompactOutcome,
423 ) -> Result<()> {
424 outcome.content_cno_before = file.fs_content_cno()?;
425 outcome.content_cno_after = outcome.content_cno_before;
426 if selected.is_empty() {
427 return Ok(());
428 }
429 let bs = view.data_block_size;
430 let chunk_blocks = (opts.relocate_chunk_bytes / bs.max(1)).max(1);
431
432 // Which blocks are in the selected objects. `block_placement` answers
433 // exactly that, per index, from the live map — so it needs no agreement
434 // with a second reading of the container's layout. It is asked only about
435 // the windows the walk found mapped blocks in, never about the holes
436 // between them. Runs go to `fs_relocate` in index order, which is also
437 // the order the flush writes them in.
438 let mut pending: Vec<BlockIndex> = Vec::new();
439 for (start, n) in view.live.windows.iter().copied() {
440 let places = file.fs_block_placement(start, n).await?;
441 for (i, place) in places.iter().enumerate() {
442 if let Some((segid, _)) = place
443 && selected.contains(segid)
444 {
445 pending.push(start + i as BlockIndex);
446 if pending.len() >= chunk_blocks {
447 outcome.blocks_rewritten += relocate_runs(file, &pending).await?;
448 pending.clear();
449 }
450 }
451 }
452 }
453 if !pending.is_empty() {
454 outcome.blocks_rewritten += relocate_runs(file, &pending).await?;
455 }
456 outcome.bytes_rewritten = (outcome.blocks_rewritten * bs) as u64;
457 if outcome.blocks_rewritten == 0 {
458 return Ok(());
459 }
460
461 let cno = file.fs_flush().await?;
462 outcome.checkpoints = 1;
463 outcome.content_cno_after = file.fs_content_cno()?;
464 debug!("compact: published checkpoint {} with {} relocated block(s); content_cno \
465 {} -> {}", cno, outcome.blocks_rewritten, outcome.content_cno_before,
466 outcome.content_cno_after);
467 Ok(())
468 }
469}
470
471/// Hand `indices` to `fs_relocate` as runs of consecutive blocks.
472async fn relocate_runs(file: &mut Hyper<'static>, indices: &[BlockIndex]) -> Result<usize> {
473 let runs = into_runs(indices);
474 let marked = file.fs_relocate(&runs).await?;
475 debug!("compact: relocate {} block(s) in {} run(s) marked {}",
476 indices.len(), runs.len(), marked);
477 Ok(marked)
478}
479
480/// The most a pass holds in memory when nothing can write it out early: a parted
481/// container with partial segments turned off, or a container that is not parted.
482/// There `fs_relocate` keeps every block dirty until the pass's one flush.
483pub const UNBOUNDED_PASS_BYTES: u64 = 512 * 1024 * 1024;
484
485/// What `hyperfile` takes the most bytes of one object to be, for this format and
486/// configuration. A copy of `HyperTrait::max_object_bytes`, which is private, with
487/// its 16 KiB floor.
488fn max_object_bytes(fmt: &BlockPtrFormat, runtime: &HyperFileRuntimeConfig) -> u64 {
489 runtime.segment_max_object_bytes.clamp(16 * 1024, fmt.max_object_bytes()) as u64
490}
491
492/// What one pass may rewrite.
493///
494/// Where `fs_relocate` can write partials, the caller's budget alone: memory is
495/// bounded by the dirty-data limits. Where it cannot, also
496/// [`UNBOUNDED_PASS_BYTES`]. And on a container that is not parted, a checkpoint
497/// is one object and `hyperfile` refuses a flush larger, so the pass also fits
498/// one object, with room for the summary and index nodes it carries.
499fn pass_cap(max_pass_bytes: u64, fmt: &BlockPtrFormat, runtime: &HyperFileRuntimeConfig) -> u64 {
500 if fmt.is_parted() {
501 if runtime.parted_segment_enabled {
502 return max_pass_bytes;
503 }
504 return max_pass_bytes.min(UNBOUNDED_PASS_BYTES);
505 }
506 let one_object = max_object_bytes(fmt, runtime);
507 max_pass_bytes.min(UNBOUNDED_PASS_BYTES).min(one_object / 4 * 3)
508}
509
510/// Candidates, cheapest first, taken whole while they fit `cap`.
511///
512/// Returns `(selected, live_bytes, stored_bytes, more_remaining)`. An object is
513/// never split across passes: one that does not fit ends the selection, so the
514/// cheapest work is done first and nothing is half-moved.
515fn plan_pass(
516 objects: &[ObjectUsage],
517 max_live_ratio: f64,
518 cap: u64,
519) -> (BTreeSet<SegmentId>, u64, u64, bool) {
520 let mut selected = BTreeSet::new();
521 let mut live = 0u64;
522 let mut stored = 0u64;
523 let candidates = select_candidates(objects, max_live_ratio);
524 let total = candidates.len();
525 for usage in candidates {
526 if live.saturating_add(usage.live_bytes) > cap {
527 break;
528 }
529 live += usage.live_bytes;
530 stored += usage.bytes;
531 selected.insert(usage.segid);
532 }
533 let more = selected.len() < total;
534 (selected, live, stored, more)
535}
536
537/// Per-object liveness, from one view.
538///
539/// A location's size depends on what it holds, and a pointer does not say: a
540/// metadata node is `meta_block_size` and a data block is `data_block_size`. The
541/// metadata subset of the walk is what separates them.
542pub(crate) fn build_report(view: &ContainerView) -> Report {
543 let mut live_data: BTreeMap<SegmentId, usize> = BTreeMap::new();
544 let mut live_meta: BTreeMap<SegmentId, usize> = BTreeMap::new();
545 for blkptr in &view.live.all {
546 let segid = view.object_of(blkptr);
547 if view.live.meta.contains(blkptr) {
548 *live_meta.entry(segid).or_default() += 1;
549 } else {
550 *live_data.entry(segid).or_default() += 1;
551 }
552 }
553
554 let mut objects = Vec::new();
555 let mut out_of_range = 0;
556 for (segid, bytes) in &view.listing.objects {
557 let cno = segid.as_cno();
558 if cno < view.low || cno > view.high_cno {
559 out_of_range += 1;
560 continue;
561 }
562 let live_data_blocks = live_data.get(segid).copied().unwrap_or(0);
563 let live_meta_blocks = live_meta.get(segid).copied().unwrap_or(0);
564 objects.push(ObjectUsage {
565 segid: *segid,
566 bytes: *bytes,
567 live_data_blocks,
568 live_meta_blocks,
569 live_bytes: (live_data_blocks * view.data_block_size
570 + live_meta_blocks * view.meta_block_size) as u64,
571 meta_moves_with_data: view.fmt.is_stream() && segid.is_partial(),
572 });
573 }
574
575 Report {
576 high_cno: view.high_cno,
577 latest_compacted: view.latest_compacted,
578 data_block_size: view.data_block_size,
579 meta_block_size: view.meta_block_size,
580 objects,
581 out_of_range,
582 foreign_keys: view.listing.foreign,
583 foreign_sample: view.listing.foreign_sample.clone(),
584 }
585}
586
587/// Whether rewriting this object's live data would free it.
588///
589/// Three exclusions, each for its own reason: a dead object is `prune`'s work and
590/// rewriting nothing frees nothing; an object holding a live metadata node cannot
591/// in general be freed by rewriting data blocks, so its data would be rewritten
592/// for no return — except where the layout puts that node with the data
593/// ([`ObjectUsage::meta_moves_with_data`]); and one above the ratio costs more to
594/// rewrite than the policy accepts.
595///
596/// Getting the middle one wrong costs a rewrite, never data: compaction deletes
597/// nothing, `prune` keeps whatever the walk afterwards finds live, and
598/// [`CompactOutcome::still_referenced`] reports an object selected and not freed.
599fn is_candidate(usage: &ObjectUsage, max_live_ratio: f64) -> bool {
600 !usage.is_dead()
601 && (!usage.holds_metadata() || usage.meta_moves_with_data)
602 && usage.live_ratio() <= max_live_ratio
603}
604
605/// Candidates, cheapest first.
606///
607/// Ordered by live ratio ascending, which is the same as bytes freed per byte
608/// rewritten descending, with the larger object first among equals so a budget
609/// spent early buys more.
610fn select_candidates(
611 objects: &[ObjectUsage],
612 max_live_ratio: f64,
613) -> Vec<&ObjectUsage> {
614 let mut picked: Vec<&ObjectUsage> = objects.iter()
615 .filter(|o| is_candidate(o, max_live_ratio))
616 .collect();
617 picked.sort_by(|a, b| {
618 a.live_ratio().total_cmp(&b.live_ratio())
619 .then(b.bytes.cmp(&a.bytes))
620 .then(a.segid.cmp(&b.segid))
621 });
622 picked
623}
624
625/// Consecutive indices, as `(first, count)`.
626///
627/// What `read_ahead` wants: it takes a byte range and plans it the way a read of
628/// the same range would, so one call per run of adjacent blocks turns into a few
629/// large requests. Asking per block instead would be one request per block, which
630/// for scattered blocks it will be anyway.
631fn into_runs(indices: &[BlockIndex]) -> Vec<(BlockIndex, usize)> {
632 let mut runs: Vec<(BlockIndex, usize)> = Vec::new();
633 for idx in indices {
634 match runs.last_mut() {
635 Some((start, count)) if *start + *count as BlockIndex == *idx => *count += 1,
636 _ => runs.push((*idx, 1)),
637 }
638 }
639 runs
640}
641
642/// The runtime configuration a compaction uses when the caller gives none.
643///
644/// Partials of 64 MiB: large enough that a compacted range reads in few requests,
645/// small enough to bound what a pass holds. The flush interval is an hour so that
646/// partials are cut by size rather than by how long the reads took.
647pub fn compaction_runtime_default() -> HyperFileRuntimeConfig {
648 const PART: usize = 64 * 1024 * 1024;
649 HyperFileRuntimeConfig {
650 data_cache_dirty_max_bytes_threshold: PART,
651 data_cache_dirty_min_bytes_to_part: PART,
652 segment_buffer_size: PART,
653 data_cache_dirty_max_flush_interval: 3_600_000,
654 ..HyperFileRuntimeConfig::default()
655 }
656}
657
658/// The caller's runtime configuration, or the default, with the one field a
659/// compaction cannot leave to the caller.
660///
661/// Nothing else needs forcing. A pass never publishes before its flush — since
662/// `hyperfile 0.10.4`, `fs_relocate` answers the dirty-data limits with a partial
663/// or not at all, never with a publish — so the limits can be whatever the
664/// caller's writer uses.
665fn compaction_runtime(given: Option<&HyperFileRuntimeConfig>) -> HyperFileRuntimeConfig {
666 let mut runtime = given.cloned().unwrap_or_else(compaction_runtime_default);
667 // A compactor writes bytes it read earlier. Refreshing and re-publishing on a
668 // conflict, which `RetryLastWriterWins` does, would republish a stale copy
669 // over another writer's newer one.
670 runtime.flush_conflict_policy = FlushConflictPolicy::FailFast;
671 runtime
672}
673
674fn compaction_config(root_uri: &str, runtime: &HyperFileRuntimeConfig) -> HyperFileConfig {
675 let staging = StagingConfig::new_s3_uri(root_uri, None);
676 HyperFileConfigBuilder::new()
677 .with_staging_config(&staging)
678 .with_runtime_config(runtime)
679 .build()
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 fn usage(segid: SegmentId, bytes: u64, data: usize, meta: usize) -> ObjectUsage {
687 ObjectUsage {
688 segid,
689 bytes,
690 live_data_blocks: data,
691 live_meta_blocks: meta,
692 live_bytes: (data * 4096 + meta * 4096) as u64,
693 meta_moves_with_data: false,
694 }
695 }
696
697 #[test]
698 fn a_dead_object_is_pruned_not_compacted() {
699 let o = usage(SegmentId::new(1), 1 << 20, 0, 0);
700 assert!(o.is_dead());
701 assert!(!is_candidate(&o, 1.0), "nothing to rewrite frees nothing");
702 }
703
704 /// An object a live metadata node sits in cannot be freed by rewriting data
705 /// blocks: which keys that node covers is not visible from out here, so
706 /// nothing this crate can do will relocate it.
707 #[test]
708 fn an_object_holding_metadata_is_not_a_candidate() {
709 let o = usage(SegmentId::with_part(3, 0), 1 << 20, 1, 1);
710 assert!(o.holds_metadata());
711 assert!(!is_candidate(&o, 1.0));
712 }
713
714 /// A stream data part keeps its leaves with its data, so it is a candidate
715 /// despite them; its part 0, which holds the nodes above the leaves, is not.
716 #[test]
717 fn a_stream_data_part_is_a_candidate_despite_its_leaves() {
718 let part = ObjectUsage { meta_moves_with_data: true,
719 ..usage(SegmentId::with_part(3, 2), 1 << 20, 20, 1) };
720 assert!(part.holds_metadata());
721 assert!(is_candidate(&part, 0.5));
722 let part0 = usage(SegmentId::with_part(3, 0), 1 << 20, 20, 1);
723 assert!(!is_candidate(&part0, 1.0));
724 }
725
726 #[test]
727 fn the_ratio_is_a_ceiling() {
728 let low = usage(SegmentId::new(1), 4096 * 100, 5, 0); // 5% live
729 let high = usage(SegmentId::new(2), 4096 * 100, 90, 0); // 90% live
730 assert!(is_candidate(&low, 0.5));
731 assert!(!is_candidate(&high, 0.5));
732 assert!(is_candidate(&high, 0.9));
733 }
734
735 /// Cheapest first, and the bigger object first among equals: the budget is
736 /// finite and the point is how much it buys.
737 #[test]
738 fn candidates_come_cheapest_first() {
739 let objects = vec![
740 usage(SegmentId::new(1), 4096 * 100, 40, 0), // 40%
741 usage(SegmentId::new(2), 4096 * 100, 5, 0), // 5%
742 usage(SegmentId::new(3), 4096 * 200, 10, 0), // 5%, larger
743 ];
744 let picked = select_candidates(&objects, 0.5);
745 let order: Vec<SegmentId> = picked.iter().map(|o| o.segid).collect();
746 assert_eq!(order, vec![SegmentId::new(3), SegmentId::new(2), SegmentId::new(1)]);
747 }
748
749 #[test]
750 fn runs_are_maximal() {
751 assert_eq!(into_runs(&[]), vec![]);
752 assert_eq!(into_runs(&[7]), vec![(7, 1)]);
753 assert_eq!(into_runs(&[0, 1, 2, 5, 6, 9]), vec![(0, 3), (5, 2), (9, 1)]);
754 }
755
756 /// A pass takes objects whole and cheapest first, and stops at the first one
757 /// that does not fit rather than skipping to a smaller one: the cheapest work
758 /// is done first, and the pass says there is more.
759 #[test]
760 fn a_pass_takes_whole_objects_until_the_budget() {
761 let objects = vec![
762 usage(SegmentId::new(1), 4096 * 100, 10, 0), // 10%, 40 KiB live
763 usage(SegmentId::new(2), 4096 * 100, 20, 0), // 20%, 80 KiB live
764 usage(SegmentId::new(3), 4096 * 100, 1, 0), // 1%, 4 KiB live
765 ];
766 let (picked, live, stored, more) = plan_pass(&objects, 0.5, 4096 * 15);
767 assert_eq!(picked.into_iter().collect::<Vec<_>>(),
768 vec![SegmentId::new(1), SegmentId::new(3)]);
769 assert_eq!(live, 4096 * 11);
770 assert_eq!(stored, 4096 * 200);
771 assert!(more, "object 2 did not fit and is still a candidate");
772
773 let (picked, _, _, more) = plan_pass(&objects, 0.5, u64::MAX);
774 assert_eq!(picked.len(), 3);
775 assert!(!more);
776 }
777
778 /// Where partials can be written, memory is bounded by the dirty-data limits
779 /// and only the caller's budget applies. Where they cannot, the pass is held
780 /// in memory whole and is capped; and a container that is not parted also has
781 /// to fit its pass in one object, as `hyperfile` reckons one — the caller's
782 /// `segment_max_object_bytes` included.
783 #[test]
784 fn the_pass_cap_follows_what_can_write_it_out() {
785 let big = u64::MAX;
786 let parts_on = compaction_runtime(None);
787 assert!(parts_on.parted_segment_enabled);
788 assert_eq!(pass_cap(big, &BlockPtrFormat::PartedSegment, &parts_on), big);
789
790 let parts_off = HyperFileRuntimeConfig { parted_segment_enabled: false, ..parts_on.clone() };
791 assert_eq!(pass_cap(big, &BlockPtrFormat::PartedSegment, &parts_off), UNBOUNDED_PASS_BYTES);
792
793 let mg = BlockPtrFormat::MicroGroup.max_object_bytes() as u64;
794 let cap = pass_cap(big, &BlockPtrFormat::MicroGroup, &parts_on);
795 assert!(cap < mg && cap <= UNBOUNDED_PASS_BYTES);
796
797 let small_objects = HyperFileRuntimeConfig {
798 segment_max_object_bytes: 1024 * 1024, ..parts_on.clone()
799 };
800 assert_eq!(pass_cap(big, &BlockPtrFormat::MicroGroup, &small_objects), 1024 * 1024 / 4 * 3,
801 "the caller's object size is what hyperfile refuses past");
802 assert_eq!(pass_cap(4096, &BlockPtrFormat::MicroGroup, &parts_on), 4096,
803 "a smaller budget is kept as given");
804 }
805
806 /// The caller's configuration is used as given, except for the conflict
807 /// policy, which a compactor cannot leave at retry-and-overwrite.
808 #[test]
809 fn a_given_runtime_is_kept_but_for_the_conflict_policy() {
810 use hyperfile::config::FlushConflictPolicy;
811 let given = HyperFileRuntimeConfig {
812 data_cache_dirty_min_bytes_to_part: 16 * 1024 * 1024,
813 data_cache_dirty_max_bytes_threshold: 16 * 1024 * 1024,
814 segment_mpu_chunk_size: 16 * 1024 * 1024,
815 flush_conflict_policy: FlushConflictPolicy::RetryLastWriterWins,
816 ..HyperFileRuntimeConfig::default()
817 };
818 let used = compaction_runtime(Some(&given));
819 assert_eq!(used.flush_conflict_policy, FlushConflictPolicy::FailFast);
820 assert_eq!(HyperFileRuntimeConfig {
821 flush_conflict_policy: FlushConflictPolicy::RetryLastWriterWins, ..used
822 }, given);
823 assert_eq!(compaction_runtime(None).flush_conflict_policy, FlushConflictPolicy::FailFast);
824 }
825
826 /// An object whose live data alone is over the budget is never taken, and
827 /// the pass reports that it left work behind instead of doing nothing quietly.
828 #[test]
829 fn an_object_larger_than_the_budget_is_reported_not_split() {
830 let objects = vec![usage(SegmentId::new(1), 4096 * 1000, 100, 0)];
831 let (picked, live, _, more) = plan_pass(&objects, 0.5, 4096 * 10);
832 assert!(picked.is_empty());
833 assert_eq!(live, 0);
834 assert!(more);
835 }
836
837 /// `compactable` must answer from the same rule the pass selects by,
838 /// otherwise a dry run and the pass disagree about what will happen.
839 #[test]
840 fn the_estimate_agrees_with_the_selection() {
841 let report = Report {
842 high_cno: 9,
843 latest_compacted: false,
844 data_block_size: 4096,
845 meta_block_size: 4096,
846 objects: vec![
847 usage(SegmentId::new(1), 4096 * 100, 5, 0),
848 usage(SegmentId::new(2), 4096 * 100, 80, 0),
849 usage(SegmentId::new(3), 4096 * 100, 0, 0),
850 usage(SegmentId::new(4), 4096 * 100, 2, 3),
851 ],
852 out_of_range: 0,
853 foreign_keys: 0,
854 foreign_sample: None,
855 };
856 let (n, rewrite, freed) = report.compactable(0.5);
857 assert_eq!(n, 1, "only the 5% object qualifies");
858 assert_eq!(rewrite, 5 * 4096);
859 assert_eq!(freed, 4096 * 100);
860 assert_eq!(report.dead(), (1, 4096 * 100));
861 let picked = select_candidates(&report.objects, CompactOptions::default().max_live_ratio);
862 assert_eq!(picked.len(), n);
863 }
864}