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}