Skip to main content

krabka_log/log/compaction/
job.rs

1//! A compaction pass in three steps, so the slow one runs without the log.
2//!
3//! [`Log::prepare_compaction`] takes what the pass needs from the log: the
4//! sealed segments it consumes, their aborted transactions, the active
5//! producers and the configuration. [`CompactionJob::build`] reads those
6//! segments and writes the `.cleaned` files, and touches nothing the log
7//! holds, so the caller runs it without the log's lock while appends and
8//! reads go on. [`Log::finish_compaction`] installs the result, unless the
9//! log has meanwhile dropped or replaced a segment the pass read, or been
10//! fenced. Kafka's cleaner works the same way: `Cleaner.cleanSegments` reads
11//! and writes without the log's lock and takes it in `replaceSegments`.
12//!
13//! [`Log::prepare_compaction`]: super::Log::prepare_compaction
14//! [`Log::finish_compaction`]: super::Log::finish_compaction
15
16use {
17    super::GroupRewrite,
18    crate::{
19        compact::{
20            CleanedTransactionMetadata, CleaningRound, ProducerLastRecord, RewriteRetention,
21            build_offset_map, rewrite_segments,
22        },
23        error::LogError,
24        io::LogIo,
25        log::rollover_flush::FlushTicket,
26        recovery::swap::{cleaned_path, remove_if_present},
27        segment::SealedInput,
28        txn_index::AbortedTxn,
29    },
30    krabka_ids::{Offset, ProducerId},
31    krabka_units::prelude::ByteSize,
32    std::{
33        collections::HashMap,
34        mem,
35        path::PathBuf,
36        sync::{
37            Arc, Condvar, Mutex, MutexGuard, PoisonError,
38            atomic::{AtomicBool, Ordering},
39        },
40    },
41};
42
43/// The `.cleaned` sidecars a group's rewrite can leave, by extension.
44const CLEANED_EXTENSIONS: [&str; 4] = ["log", "index", "timeindex", "txnindex"];
45
46/// What a log knows about its compaction job, shared with the job.
47///
48/// It allows one job at a time, and it lets [`Log::fence`] stop a job's build
49/// and wait until the build writes nothing more, so the broker can delete the
50/// partition's directory once the fence returns.
51///
52/// [`Log::fence`]: super::Log::fence
53#[derive(Debug, Default)]
54pub(in crate::log) struct CompactionGate {
55    /// Set by the fence. Each input of a job reads it before every batch.
56    fenced: Arc<AtomicBool>,
57    state: Mutex<GateState>,
58    /// Signalled when a build ends.
59    build_ended: Condvar,
60}
61
62#[derive(Debug, Default)]
63struct GateState {
64    /// A job exists: prepared and not yet finished or dropped.
65    job: bool,
66    /// A job's build is running, and may be writing `.cleaned` files.
67    building: bool,
68}
69
70impl CompactionGate {
71    fn state(&self) -> MutexGuard<'_, GateState> {
72        self.state.lock().unwrap_or_else(PoisonError::into_inner)
73    }
74
75    /// The flag the fence sets, for a job's inputs to watch.
76    pub(in crate::log) fn fenced_flag(&self) -> Arc<AtomicBool> { Arc::clone(&self.fenced) }
77
78    /// Claim the log's one job, or `None` when a job already holds it.
79    pub(in crate::log) fn claim(self: &Arc<Self>) -> Option<JobSlot> {
80        let mut state = self.state();
81        if state.job {
82            return None;
83        }
84        state.job = true;
85        drop(state);
86        Some(JobSlot(Arc::clone(self)))
87    }
88
89    /// Fence the gate: a build that has not started refuses to, and a running
90    /// one stops at its next batch. Returns once no build is running.
91    pub(in crate::log) fn fence(&self) {
92        let state = self.state();
93        self.fenced.store(true, Ordering::Release);
94        drop(
95            self.build_ended
96                .wait_while(state, |state| state.building)
97                .unwrap_or_else(PoisonError::into_inner),
98        );
99    }
100}
101
102/// A claim on the log's one compaction job. Dropping it releases the claim.
103#[derive(Debug)]
104pub(in crate::log) struct JobSlot(Arc<CompactionGate>);
105
106impl JobSlot {
107    /// Mark the build as running, or refuse with [`LogError::Fenced`] when the
108    /// log is fenced.
109    fn start_building(&self) -> Result<Building<'_>, LogError> {
110        let mut state = self.0.state();
111        if self.0.fenced.load(Ordering::Acquire) {
112            return Err(LogError::Fenced);
113        }
114        state.building = true;
115        drop(state);
116        Ok(Building(&self.0))
117    }
118}
119
120impl Drop for JobSlot {
121    fn drop(&mut self) { self.0.state().job = false; }
122}
123
124/// A running build. Dropping it, on return or on a panic, ends the build and
125/// wakes a fence that waits for it.
126struct Building<'a>(&'a CompactionGate);
127
128impl Drop for Building<'_> {
129    fn drop(&mut self) {
130        self.0.state().building = false;
131        self.0.build_ended.notify_all();
132    }
133}
134
135/// One output group of the job: how many inputs it consumes, and the aborted
136/// transactions of its range, which its rewrite adds to the walk's tracker.
137#[derive(Debug)]
138pub(in crate::log) struct GroupPlan {
139    pub(in crate::log) len: usize,
140    pub(in crate::log) aborted: Vec<AbortedTxn>,
141}
142
143/// A compaction pass taken from a log by [`Log::prepare_compaction`], ready
144/// to [`build`](Self::build) without the log.
145///
146/// [`Log::prepare_compaction`]: super::Log::prepare_compaction
147#[derive(Debug)]
148pub struct CompactionJob {
149    pub(in crate::log) dir: PathBuf,
150    pub(in crate::log) io: Arc<dyn LogIo>,
151    /// The sealed segments the pass consumes, oldest first.
152    pub(in crate::log) inputs: Vec<SealedInput>,
153    /// The output groups, in order. Their lengths sum to `inputs.len()`.
154    pub(in crate::log) groups: Vec<GroupPlan>,
155    /// The aborted transactions of the whole consumed range, for the map.
156    pub(in crate::log) map_aborted: Vec<AbortedTxn>,
157    pub(in crate::log) active_producers: HashMap<ProducerId, ProducerLastRecord>,
158    /// The offset after the last consumed segment.
159    pub(in crate::log) upper_bound: Offset,
160    pub(in crate::log) record_limit: Option<ByteSize>,
161    pub(in crate::log) retention: RewriteRetention,
162    pub(in crate::log) index_interval: ByteSize,
163    /// The rollover flushes submitted when the job was prepared, which cover
164    /// every consumed segment. The install waits for them.
165    pub(in crate::log) flushes: FlushTicket,
166    pub(in crate::log) slot: JobSlot,
167    /// Whether the build may have written `.cleaned` files that no install
168    /// has taken. Dropping the job deletes them then, so a pass that is
169    /// abandoned, finished as stale, or failed leaves nothing behind.
170    pub(in crate::log) output_unclaimed: bool,
171}
172
173impl CompactionJob {
174    /// Read the consumed segments and write the pass's `.cleaned` files.
175    ///
176    /// The build reads the segments through file handles it took from them,
177    /// and touches nothing the log holds, so it needs no lock on the log. A
178    /// failure, a [`LogError::Fenced`] from a fence among them, travels in
179    /// the result to [`Log::finish_compaction`], which decides what it means.
180    ///
181    /// [`Log::finish_compaction`]: super::Log::finish_compaction
182    #[must_use]
183    pub fn build(mut self) -> BuiltCompaction {
184        self.output_unclaimed = true;
185        let rewrites = self.rewrite();
186        BuiltCompaction {
187            job: self,
188            rewrites,
189        }
190    }
191
192    fn rewrite(&mut self) -> Result<Vec<GroupRewrite>, LogError> {
193        let _building = self.slot.start_building()?;
194        let offset_map = build_offset_map(
195            &self.inputs,
196            mem::take(&mut self.map_aborted),
197            self.record_limit,
198        )?;
199        // One tracker walks every group in order, as a transaction can span
200        // two of them.
201        let mut txn_meta = CleanedTransactionMetadata::default();
202        let mut rewrites = Vec::with_capacity(self.groups.len());
203        let mut start = 0;
204        for group in &mut self.groups {
205            let inputs = self.inputs.get(start..start + group.len).ok_or_else(|| {
206                LogError::Corrupt(format!(
207                    "a compaction group of {} segments from input {start} reaches past the {} \
208                     inputs",
209                    group.len,
210                    self.inputs.len()
211                ))
212            })?;
213            // The rewrite creates its files before it reads a batch, so a
214            // fenced job must stop here.
215            if inputs.iter().any(SealedInput::is_fenced) {
216                return Err(LogError::Fenced);
217            }
218            txn_meta.add_aborted_transactions(mem::take(&mut group.aborted));
219            let rewrite = rewrite_segments(
220                Arc::clone(&self.io),
221                &self.dir,
222                inputs,
223                &offset_map,
224                &mut txn_meta,
225                self.retention,
226                CleaningRound {
227                    active_producers: &self.active_producers,
228                    // The round rewrites the offset map's whole range, so it
229                    // ends where the last consumed segment does.
230                    upper_bound: self.upper_bound,
231                    max_decompressed_record: self.record_limit,
232                    index_interval: self.index_interval,
233                },
234            )?;
235            rewrites.push(GroupRewrite {
236                group_bases: inputs.iter().map(SealedInput::base_offset).collect(),
237                rewrite,
238            });
239            start += group.len;
240        }
241        Ok(rewrites)
242    }
243
244    /// Delete the `.cleaned` files the build may have written, as far as the
245    /// directory allows. Recovery deletes any it leaves.
246    fn discard_output(&self) {
247        let mut start = 0;
248        for group in &self.groups {
249            let Some(base) = self.inputs.get(start).map(SealedInput::base_offset) else {
250                break;
251            };
252            for extension in CLEANED_EXTENSIONS {
253                let path = cleaned_path(&self.dir, base.0, extension);
254                if let Err(error) = remove_if_present(&*self.io, &path) {
255                    tracing::warn!(path = %path.display(), %error,
256                        "deleting an abandoned compaction output failed");
257                }
258            }
259            start += group.len;
260        }
261    }
262}
263
264impl Drop for CompactionJob {
265    fn drop(&mut self) {
266        if self.output_unclaimed {
267            self.discard_output();
268        }
269    }
270}
271
272/// A built [`CompactionJob`], for [`Log::finish_compaction`] to install.
273/// Dropping it instead deletes the files the build wrote.
274///
275/// [`Log::finish_compaction`]: super::Log::finish_compaction
276#[derive(Debug)]
277pub struct BuiltCompaction {
278    pub(in crate::log) job: CompactionJob,
279    pub(in crate::log) rewrites: Result<Vec<GroupRewrite>, LogError>,
280}
281
282/// What [`Log::finish_compaction`] did with a built pass.
283///
284/// [`Log::finish_compaction`]: super::Log::finish_compaction
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286#[must_use]
287pub enum CompactionOutcome {
288    /// The pass replaced the segments it read.
289    Installed,
290    /// The log dropped or replaced a segment the pass read, or was fenced or
291    /// closed, while the pass was built. The pass changed nothing, and its
292    /// files are gone. The next pass starts from the log as it is.
293    Stale,
294}