This repository has no description
11 kB
413 lines
1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use knot_git::{PackRefsReport, ReflogReport, Repo};
6use knot_types::{Oid, UnixSeconds};
7
8mod bitmap;
9mod commitgraph;
10mod cruft;
11mod fsio;
12mod midx;
13mod prune;
14mod repack;
15mod scheduler;
16#[cfg(test)]
17mod test_support;
18
19pub use midx::MidxStatus;
20pub use scheduler::{MaintenanceHandle, PushBytes, RepoSource, Scheduler};
21
22pub const MIN_REFLOG_RETENTION_SECS: i64 = 30 * 24 * 60 * 60;
23
24#[derive(Debug, thiserror::Error)]
25pub enum MaintError {
26 #[error("git: {0}")]
27 Git(#[from] knot_git::GitError),
28 #[error("pack: {0}")]
29 Pack(String),
30 #[error("io {path}: {message}")]
31 Io { path: PathBuf, message: String },
32 #[error("commit-graph: {0}")]
33 CommitGraph(String),
34}
35
36impl From<knot_resource::FsError> for MaintError {
37 fn from(error: knot_resource::FsError) -> Self {
38 MaintError::Io {
39 path: error.path,
40 message: error.source.to_string(),
41 }
42 }
43}
44
45pub use knot_types::ObjectCount;
46
47knot_types::scalar_newtype! {
48 pub struct FileCount(usize);
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub struct GeometricFactor(u64);
53
54impl GeometricFactor {
55 pub const fn new(value: u64) -> Self {
56 Self(if value < 2 { 2 } else { value })
57 }
58
59 pub const fn full_repack() -> Self {
60 Self(u64::MAX)
61 }
62
63 pub const fn get(self) -> u64 {
64 self.0
65 }
66}
67
68#[derive(Debug, Clone, Copy)]
69pub struct Options {
70 pub repack_max_objects: ObjectCount,
71 pub geometric_factor: GeometricFactor,
72 pub prune_grace: PruneGrace,
73 pub reflog_floor: ReflogRetention,
74 pub commit_graph: bool,
75 pub multi_pack_index: bool,
76 pub bitmap: bool,
77}
78
79impl Options {
80 pub fn from_config(config: &knot_config::MaintenanceConfig) -> Self {
81 Self {
82 repack_max_objects: ObjectCount::new(config.repack_max_objects as usize),
83 geometric_factor: GeometricFactor::new(config.repack_geometric_factor),
84 prune_grace: PruneGrace::from_secs(config.prune_grace_secs),
85 reflog_floor: ReflogRetention::from_secs(config.reflog_expire_secs),
86 commit_graph: config.commit_graph,
87 multi_pack_index: config.multi_pack_index,
88 bitmap: config.bitmap,
89 }
90 }
91}
92
93const LFS_GRACE_MIN: Duration = Duration::from_secs(86_400);
94
95#[derive(Debug, Clone, Copy)]
96pub struct GcGrace(Duration);
97
98impl GcGrace {
99 pub const fn from_secs(secs: u64) -> Self {
100 Self(Duration::from_secs(secs))
101 }
102}
103
104#[derive(Debug, Clone, Copy)]
105pub struct ReflogRetention(Duration);
106
107impl ReflogRetention {
108 pub const fn from_secs(secs: u64) -> Self {
109 Self(Duration::from_secs(secs))
110 }
111
112 pub const fn get(self) -> Duration {
113 self.0
114 }
115}
116
117#[derive(Debug, Clone, Copy)]
118pub struct PruneGrace(Duration);
119
120impl PruneGrace {
121 pub const fn from_secs(secs: u64) -> Self {
122 Self(Duration::from_secs(secs))
123 }
124
125 pub const fn get(self) -> Duration {
126 self.0
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct LfsGrace(Duration);
132
133impl LfsGrace {
134 pub const fn get(self) -> Duration {
135 self.0
136 }
137}
138
139#[derive(Debug, Clone, Copy)]
140pub struct SweepInterval(Duration);
141
142impl SweepInterval {
143 pub const fn new(interval: Duration) -> Self {
144 Self(interval)
145 }
146
147 pub const fn get(self) -> Duration {
148 self.0
149 }
150}
151
152pub fn lfs_grace(gc_grace: GcGrace, reflog_retention: ReflogRetention) -> LfsGrace {
153 let ceiling = reflog_retention
154 .0
155 .max(Duration::from_secs(MIN_REFLOG_RETENTION_SECS as u64))
156 .max(LFS_GRACE_MIN);
157 LfsGrace(gc_grace.0.clamp(LFS_GRACE_MIN, ceiling))
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum RepackStatus {
162 Repacked,
163 Clean,
164 SkippedTooLarge,
165 ClosureFailed,
166 NothingReachable,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub struct RepackReport {
171 pub status: RepackStatus,
172 pub packed_objects: ObjectCount,
173 pub removed_loose: FileCount,
174 pub removed_packs: FileCount,
175}
176
177impl RepackReport {
178 fn skipped(status: RepackStatus) -> Self {
179 Self {
180 status,
181 packed_objects: ObjectCount::new(0),
182 removed_loose: FileCount::new(0),
183 removed_packs: FileCount::new(0),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct PruneReport {
190 pub removed: FileCount,
191 pub removed_packs: FileCount,
192 pub crufted: ObjectCount,
193 pub ran: bool,
194}
195
196impl PruneReport {
197 fn skipped() -> Self {
198 Self {
199 removed: FileCount::new(0),
200 removed_packs: FileCount::new(0),
201 crufted: ObjectCount::new(0),
202 ran: false,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub struct Report {
209 pub packed_refs: PackRefsReport,
210 pub reflog: ReflogReport,
211 pub commit_graph: bool,
212 pub repack: RepackReport,
213 pub prune: PruneReport,
214 pub multi_pack_index: MidxStatus,
215 pub bitmap: bool,
216}
217
218impl Report {
219 fn noop() -> Self {
220 Self {
221 packed_refs: PackRefsReport { packed: 0 },
222 reflog: ReflogReport {
223 files: 0,
224 dropped: 0,
225 },
226 commit_graph: false,
227 repack: RepackReport::skipped(RepackStatus::Clean),
228 prune: PruneReport::skipped(),
229 multi_pack_index: MidxStatus::Absent,
230 bitmap: false,
231 }
232 }
233}
234
235pub fn run_repo(
236 repo: &Repo,
237 now_seconds: UnixSeconds,
238 opts: &Options,
239) -> Result<Report, MaintError> {
240 let objects_dir = repo.objects_dir();
241 let kind = repo.object_format().kind();
242 let loose = fsio::loose_objects(&objects_dir);
243 let pack_count = fsio::pack_idx_paths(&objects_dir).len();
244 let loose_refs = fsio::has_loose_refs(repo.git().git_dir());
245
246 let graph_pending = opts.commit_graph && pack_count >= 1 && !commitgraph::exists(repo);
247 let bitmap_pending = opts.bitmap && pack_count == 1 && !bitmap::exists(&objects_dir);
248 if !opts.commit_graph {
249 commitgraph::remove(repo)?;
250 }
251 if loose.is_empty() && pack_count <= 1 && !loose_refs && !graph_pending && !bitmap_pending {
252 return Ok(Report::noop());
253 }
254
255 let packed_refs = if loose_refs {
256 repo.pack_refs()?
257 } else {
258 PackRefsReport { packed: 0 }
259 };
260 let floor_secs = (opts.reflog_floor.get().as_secs() as i64).max(MIN_REFLOG_RETENTION_SECS);
261 let reflog = repo.expire_reflogs(now_seconds.saturating_sub_secs(floor_secs))?;
262
263 let commit_graph = if opts.commit_graph {
264 commitgraph::write(repo)?
265 } else {
266 false
267 };
268
269 let retention_floor = now_seconds.saturating_sub_secs(floor_secs);
270 let (repack, reachable, roots, new_stem, kept_large) = if loose.is_empty() && pack_count <= 1 {
271 (
272 RepackReport::skipped(RepackStatus::Clean),
273 None,
274 HashSet::new(),
275 None,
276 Vec::new(),
277 )
278 } else {
279 let roots = collect_roots(repo, retention_floor)?;
280 let (report, reachable, new_stem, kept_large) = repack::run(
281 repo,
282 &objects_dir,
283 kind,
284 roots.iter().copied().collect(),
285 opts.repack_max_objects,
286 opts.geometric_factor,
287 &loose,
288 )?;
289 (report, reachable, roots, new_stem, kept_large)
290 };
291
292 let prune = match &reachable {
293 Some(set) => repo.with_ref_lock(|| {
294 let current = collect_roots(repo, retention_floor)?;
295 if current != roots {
296 return Ok(PruneReport::skipped());
297 }
298 if repack.status == RepackStatus::Repacked {
299 midx::clear(&objects_dir)?;
300 cruft::run(
301 &objects_dir,
302 kind,
303 set,
304 new_stem.as_ref(),
305 &kept_large,
306 &loose,
307 opts.prune_grace.get(),
308 )
309 } else {
310 prune::run(&objects_dir, set, &loose, opts.prune_grace.get())
311 }
312 })?,
313 None => PruneReport::skipped(),
314 };
315
316 let multi_pack_index = if opts.multi_pack_index {
317 midx::write(repo)?
318 } else {
319 MidxStatus::Absent
320 };
321
322 let bitmap = if opts.bitmap {
323 bitmap::refresh(repo, &objects_dir)?
324 } else {
325 false
326 };
327
328 Ok(Report {
329 packed_refs,
330 reflog,
331 commit_graph,
332 repack,
333 prune,
334 multi_pack_index,
335 bitmap,
336 })
337}
338
339fn collect_roots(repo: &Repo, retention_floor: UnixSeconds) -> Result<HashSet<Oid>, MaintError> {
340 let mut roots: HashSet<Oid> = repo
341 .references()?
342 .into_iter()
343 .map(|record| record.target)
344 .collect();
345 repo.reflog_updates_since(retention_floor)
346 .into_iter()
347 .for_each(|update| {
348 roots.insert(update.new);
349 if let Some(old) = update.old {
350 roots.insert(old);
351 }
352 });
353 Ok(roots
354 .into_iter()
355 .filter(|oid| repo.contains(*oid))
356 .collect())
357}
358
359#[cfg(test)]
360mod tests {
361 use super::{GcGrace, LFS_GRACE_MIN, MIN_REFLOG_RETENTION_SECS, ReflogRetention, lfs_grace};
362
363 #[test]
364 fn the_default_grace_is_not_clamped_by_the_coupling() {
365 let fourteen_days = 14 * 86_400;
366 let ninety_days = 90 * 86_400;
367 assert_eq!(
368 lfs_grace(
369 GcGrace::from_secs(fourteen_days),
370 ReflogRetention::from_secs(ninety_days)
371 )
372 .get()
373 .as_secs(),
374 fourteen_days,
375 "the 14-day default is within the window and never clamped"
376 );
377 }
378
379 #[test]
380 fn a_small_grace_is_clamped_to_the_hard_minimum() {
381 assert_eq!(
382 lfs_grace(
383 GcGrace::from_secs(0),
384 ReflogRetention::from_secs(90 * 86_400)
385 )
386 .get(),
387 LFS_GRACE_MIN
388 );
389 assert_eq!(
390 lfs_grace(
391 GcGrace::from_secs(60),
392 ReflogRetention::from_secs(90 * 86_400)
393 )
394 .get(),
395 LFS_GRACE_MIN
396 );
397 }
398
399 #[test]
400 fn grace_never_exceeds_the_reflog_retention() {
401 let short_reflog = MIN_REFLOG_RETENTION_SECS as u64;
402 assert_eq!(
403 lfs_grace(
404 GcGrace::from_secs(u64::MAX),
405 ReflogRetention::from_secs(short_reflog)
406 )
407 .get()
408 .as_secs(),
409 short_reflog,
410 "a grace above the reflog retention is clamped to it"
411 );
412 }
413}