1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//! What:
//! The history of `cli-git-audit.jsonl`: where a rotated file goes, which generations may be removed, and the pass that
//! removes them.
//! Why:
//! The audit lines name the saved copies of conflicted files, and with a single `.1` the second rotation dropped the lines
//! that named copies still on disk. Each rotation now keeps its file under a new number; a pass after the rotation removes the
//! oldest generations that name no copy still on disk until those fit the bound `AUDIT_HISTORY_BYTES`; a generation that
//! names a copy on disk is never removed (the delegated pick 11, option C, in
//! `doc/planning/cli-git-shipped-limits-audit.md`). No lock is taken: a number is claimed with a hard link, which fails when
//! the name exists, so two writers cannot take one number, and a removal that another pass did first is not an error.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const taken = await rename(target, privateName);
//! await installGeneration(taken, target); // link to `${target}.${n}`, n + 1 on EEXIST, then unlink the private name
//! await trimGenerations(target, AUDIT_HISTORY_BYTES);
//! ```
use super::AUDIT_FILE;
use super::TAG;
use crate::diagnostic_log::debug;
use std::ffi::OsStr;
use std::io::BufRead;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
/// What:
/// How many rotated files a log keeps.
/// An `enum` is a closed set of named alternatives.
/// Why:
/// The audit file explains saved copies and keeps a history; the timing logs and the push trigger log are diagnostics that
/// the next rotation may replace.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Retention = 'oneGeneration' | 'generations';
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum Retention {
/// The rotated file is `<name>.1`, and the next rotation replaces it.
OneGeneration,
/// The rotated file gets the next free number, and the pass in `trim_generations` decides what may go.
Generations,
}
/// The retention of the log at `target`: the audit file keeps generations, every other file one.
pub(super) fn retention_of(target: &Path) -> Retention {
if target.file_name() == Some(OsStr::new(AUDIT_FILE)) {
return Retention::Generations;
}
return Retention::OneGeneration;
}
/// The path of generation `number` of the log at `target`: the name with `.` and the number after it.
fn numbered(target: &Path, number: u64) -> PathBuf {
let mut name: std::ffi::OsString = target.as_os_str().to_os_string();
name.push(format!(".{number}"));
return PathBuf::from(name);
}
/// What:
/// A regular file of the log's directory that belongs to the log at `target`.
/// A `struct` is a record with named fields.
/// Why:
/// The pass needs each file's number, size and identity together, and a struct keeps the listing and the choice
/// of what to remove readable.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type Piece = { path: string; number: number | undefined; length: number; identity: [bigint, bigint] };
/// ```
struct Piece {
/// The path.
path: PathBuf,
/// The generation number, or nothing for a file at a private name.
number: Option<u64>,
/// Bytes.
length: u64,
/// The device and inode, which say that two names are one file.
identity: (u64, u64),
}
/// What:
/// The regular files of the directory of `target` named `<target>.<digits>` (generations) or `<target>.moving-...` (files a
/// rotation took off the name and has not placed).
/// Why:
/// One read of the directory serves both the choice of the next number and the pass that trims. A directory, a link or
/// anything else with such a name is not a log file and is left alone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const pieces = (await readdir(dirname(target))).filter((name) => /^audit\.(\d+|moving-.*)$/.test(name));
/// ```
fn list_pieces(target: &Path) -> std::io::Result<Vec<Piece>> {
let directory: &Path = target.parent().unwrap_or(Path::new("."));
let stem: String = target.file_name().map(|name| { return name.to_string_lossy().into_owned(); }).unwrap_or_default();
let mut pieces: Vec<Piece> = Vec::new();
for listed in std::fs::read_dir(directory)? {
let entry: std::fs::DirEntry = listed?;
let name: String = entry.file_name().to_string_lossy().into_owned();
let Some(rest) = name.strip_prefix(stem.as_str()).and_then(|after| { return after.strip_prefix('.'); }) else { continue };
let number: Option<u64> = if !rest.is_empty() && rest.bytes().all(|byte| { return byte.is_ascii_digit(); }) { rest.parse::<u64>().ok() } else { None };
if number.is_none() && !rest.starts_with("moving-") {
continue;
}
// A file that vanished since the listing was another pass's removal.
let Ok(found) = std::fs::symlink_metadata(entry.path()) else { continue };
if !found.is_file() {
continue;
}
pieces.push(Piece { path: entry.path(), number, length: found.len(), identity: (found.dev(), found.ino()) });
}
return Ok(pieces);
}
/// What:
/// Gives the file a rotation took off `target` (now at `taken`) the next free generation number, and removes the private name.
/// Why:
/// A hard link fails when the name exists, so each writer claims a number nobody holds and never replaces another's
/// generation; on a failure each tries the number after. A failure means another writer's success, so the loop ends. A kill
/// between the link and the removal leaves two names for one file, which the next `trim_generations` pass removes.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// for (let n = highest + 1; ; n++) { try { await link(taken, `${target}.${n}`); break; } catch (e) { if (e.code !== 'EEXIST') throw e; } }
/// await unlink(taken);
/// ```
///
/// # Errors
/// The system's error when the directory cannot be read or a link cannot be made for a reason other than a taken name.
pub(super) fn install_generation(taken: &Path, target: &Path) -> std::io::Result<()> {
let highest: u64 = list_pieces(target)?.iter().filter_map(|piece| { return piece.number; }).max().unwrap_or(0);
let mut number: u64 = highest.saturating_add(1);
loop {
match std::fs::hard_link(taken, numbered(target, number)) {
Ok(()) => break,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => number = number.saturating_add(1),
Err(error) => return Err(error),
}
}
if let Err(error) = std::fs::remove_file(taken) {
debug(TAG, &format!("generation {number} of {} is in place but its private name {} stays: {error}", target.display(), taken.display()));
}
return Ok(());
}
/// The words a saved-conflict line carries, which no other line has.
const SAVED_CONFLICT_MARK: &[u8] = b"\"outcome\":\"saved-conflict\"";
/// The characters that stand for a byte the line's writer could not show as text: a place that has one cannot be looked up.
const REPLACEMENT: char = '\u{FFFD}';
/// What:
/// Whether a `saved-conflict` line names a saved copy that exists, or might.
/// Why:
/// The line lists `<path> => <place>`; the path is the person's and may hold ` => `, so every text after such a separator is
/// tried as a place. A line the reader cannot parse, and a place the writer could not show as text, count as naming a copy:
/// the generation is kept, because a wrong answer in that direction costs disk and the other direction costs the only
/// explanation of a copy.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// line.copies.some((c) => c.split(' => ').slice(1).some((_, i) => existsSync(c.split(' => ').slice(i + 1).join(' => '))))
/// ```
fn line_names_a_copy(line: &[u8]) -> bool {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(line) else {
return true;
};
let Some(copies) = value.get("copies").and_then(|found| { return found.as_array(); }) else {
return false;
};
for copy in copies.iter().filter_map(|entry| { return entry.as_str(); }) {
for (index, _) in copy.match_indices(" => ") {
let place: &str = ©[index + " => ".len()..];
if place == "none" {
continue;
}
if place.contains(REPLACEMENT) || std::fs::symlink_metadata(place).is_ok() {
return true;
}
}
}
return false;
}
/// What:
/// Whether a generation holds a `saved-conflict` line that names a copy still on disk, read in one pass over the file.
/// Why:
/// Only that line says where a copy went; a pruned directory's line records a removal and explains nothing that is still
/// there. One line at a time is held, so the memory is the longest line.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// for await (const line of readLines(path)) if (line.includes('"outcome":"saved-conflict"') && namesACopy(line)) return true;
/// return false;
/// ```
///
/// # Errors
/// The system's error when the file cannot be opened or read.
fn names_a_copy_on_disk(path: &Path) -> std::io::Result<bool> {
let mut reader: std::io::BufReader<std::fs::File> = std::io::BufReader::new(std::fs::File::open(path)?);
let mut line: Vec<u8> = Vec::new();
loop {
line.clear();
if reader.read_until(b'\n', &mut line)? == 0 {
return Ok(false);
}
if line.windows(SAVED_CONFLICT_MARK.len()).any(|window| { return window == SAVED_CONFLICT_MARK; }) && line_names_a_copy(line.as_slice()) {
return Ok(true);
}
}
}
/// What:
/// Removes the oldest generations that name no copy on disk until the others fit `budget` bytes, and the second name of any
/// file that has a number.
/// Why:
/// The budget counts what explains nothing on disk: generations without a copy, and files at a private name (a killed
/// rotation's, which are never removed because their lines are in no other place). A generation that names a copy on disk, or
/// cannot be read to see, is outside the budget and stays. A removal another pass did first is the same result, and the pass
/// never waits.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// for (const g of oldestFirst(generations)) { if (total <= budget) break; if (!(await namesACopyOnDisk(g))) { await unlink(g); total -= g.size; } }
/// ```
///
/// # Errors
/// The system's error when the directory cannot be read.
pub(super) fn trim_generations(target: &Path, budget: u64) -> std::io::Result<()> {
let pieces: Vec<Piece> = list_pieces(target)?;
let mut generations: Vec<&Piece> = pieces.iter().filter(|piece| { return piece.number.is_some(); }).collect();
generations.sort_by_key(|piece| { return piece.number; });
let mut total: u64 = 0;
for piece in pieces.iter().filter(|piece| { return piece.number.is_none(); }) {
if generations.iter().any(|generation| { return generation.identity == piece.identity; }) {
// The same file under its generation number: a rotation was killed between its link and its removal.
if let Err(error) = std::fs::remove_file(&piece.path) {
debug(TAG, &format!("the second name {} of a generation could not be removed: {error}", piece.path.display()));
}
} else {
total = total.saturating_add(piece.length);
}
}
let mut candidates: Vec<&Piece> = Vec::new();
for generation in generations {
match names_a_copy_on_disk(generation.path.as_path()) {
Ok(false) => {
total = total.saturating_add(generation.length);
candidates.push(generation);
}
Ok(true) => {}
Err(error) => debug(TAG, &format!("{} was kept: it could not be read to see which copies it names: {error}", generation.path.display())),
}
}
for candidate in candidates {
if total <= budget {
break;
}
match std::fs::remove_file(&candidate.path) {
Ok(()) => total = total.saturating_sub(candidate.length),
// Another pass removed it first.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => total = total.saturating_sub(candidate.length),
Err(error) => debug(TAG, &format!("{} was kept: it could not be removed: {error}", candidate.path.display())),
}
}
return Ok(());
}