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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! The byte-level walk over v2 frames.
//!
//! Split out of `replay.rs` when that file hit the workspace's 500-line
//! ceiling for the third time in a day — the third trip is the signal to
//! cut, not to shave another comment. The boundary is the one that was
//! already there: this file decides what a stretch of bytes IS, and
//! `replay.rs` decides what to do about it and what to report.
use std::io::{self, Read};
use kevy_resp::Argv;
use crate::replay_txn::{TxnMarker, txn_marker};
/// Outcome of an AOF replay run — drives the summary log shape (rendered
/// in `replay_log.rs`).
#[derive(Clone)]
pub(crate) enum ReplayStop {
Clean,
TruncatedTail,
/// A record's length field claimed `claimed` bytes with only `available`
/// left in the file. EOF really was hit, so this is a short read — but a
/// partial frame from a crash mid-append is at most one record, and a
/// claim of megabytes with megabytes behind it is corruption wearing
/// truncation's clothes. Separated from `TruncatedTail` so the operator
/// message stops calling it "a partial frame (crash mid-append,
/// recoverable)": CI printed exactly that for 27,485,178 bytes.
LengthOutranFile {
claimed: u64,
available: u64,
},
CorruptFrame(String),
}
// The summary line lives in `replay_log.rs` (500-LOC split); the corrupt
// WARN branch is unconditional there, the informational branches honor
// `quiet_info`.
/// Outcome of the streaming v2 record walk.
pub(crate) struct V2Walk {
pub(crate) stop: ReplayStop,
/// Absolute offset after the last applied record.
pub(crate) pos: u64,
pub(crate) replayed: u64,
pub(crate) preview: [u8; 16],
pub(crate) preview_len: usize,
/// Frames seen since a transaction's begin marker, held back until
/// its commit marker arrives. `None` = not inside a transaction.
///
/// This is where atomicity actually happens. Group commit only
/// defers the fsync; frames still reach the kernel when the write
/// buffer fills, so a crash inside a transaction bigger than that
/// buffer leaves whole, valid, individually-replayable frames on
/// disk — measured at 6393/20000. Holding them until the commit
/// marker makes "was this transaction finished" a property of the
/// log rather than of how much of it happened to be flushed.
pub(crate) txn: Option<Vec<Argv>>,
/// Where the open transaction's begin marker starts.
pub(crate) txn_at: u64,
/// Zeros from the last record to the end of the file: the unused part
/// of a mapped log's preallocation, not data.
pub(crate) zero_tail: u64,
/// Transactions dropped because the log ended before their commit
/// marker. Surfaced in the report rather than passed over silently.
pub(crate) txn_discarded: u64,
}
impl V2Walk {
/// A walk that has applied nothing yet, positioned at `pos`.
pub(crate) fn at(pos: u64) -> V2Walk {
V2Walk {
txn: None,
txn_at: 0,
zero_tail: 0,
txn_discarded: 0,
stop: ReplayStop::Clean,
pos,
replayed: 0,
preview: [0u8; 16],
preview_len: 0,
}
}
}
impl V2Walk {
/// Where the log's settled part ends: before a transaction the log
/// ended inside of, whose records replay dropped. A log reopened for
/// appends must continue from here — appended after an open begin
/// marker, new records would be read as part of that transaction and
/// dropped with it.
pub(crate) fn settled_end(&self) -> u64 {
if self.txn.is_some() { self.txn_at } else { self.pos }
}
}
/// Capture up to 16 bytes of the offending bytes for the WARN preview.
pub(crate) fn preview_of(bytes: &[u8], out: &mut [u8; 16]) -> usize {
let n = bytes.len().min(out.len());
out[..n].copy_from_slice(&bytes[..n]);
n
}
/// A length field the file could not honour.
fn outran(claimed: u32, available: usize) -> ReplayStop {
ReplayStop::LengthOutranFile { claimed: u64::from(claimed), available: available as u64 }
}
/// The sequential record walk: read `[len][crc][payload]` envelopes from
/// `r`, verify, apply. Peak memory is O(largest record) — the streaming
/// property the whole v2 replay exists for.
pub(crate) fn walk_v2(
r: &mut impl Read,
start_pos: u64,
apply: &mut Option<Sink<'_>>,
) -> io::Result<V2Walk> {
let mut w = V2Walk::at(start_pos);
let mut payload: Vec<u8> = Vec::new();
// one argv for the whole walk: its buffers stay warm across frames
let mut args = Argv::default();
w.stop = loop {
let (len, crc) = match next_header(r, &mut w)? {
Ok(h) => h,
Err(stop) => break stop,
};
payload.clear();
payload.resize(len as usize, 0);
match read_fully(r, &mut payload) {
Ok(n) if n < payload.len() => break outran(len, n),
Ok(_) => {}
Err(e) => return Err(e),
}
if crate::crc32c::crc32c(&payload) != crc {
w.preview_len = preview_of(&payload, &mut w.preview);
break ReplayStop::CorruptFrame(String::from("record checksum mismatch"));
}
if !apply_record(&payload, len, &mut args, apply, &mut w) {
break ReplayStop::CorruptFrame(String::from(
"checksummed record does not hold exactly one command",
));
}
};
Ok(w)
}
/// Parse-and-apply one checksum-valid record payload; `false` = the
/// payload is not exactly one command (a lying record).
pub(crate) fn apply_record(
payload: &[u8],
len: u32,
args: &mut Argv,
apply: &mut Option<Sink<'_>>,
w: &mut V2Walk,
) -> bool {
match kevy_resp::parse_command_into(payload, args) {
Ok(Some(used)) if used == payload.len() => {
// which snapshot the log continues was settled before replay
if crate::log_base::base_of(args).is_none() {
route_frame(args, apply, w);
w.replayed += 1;
}
w.pos += 8 + u64::from(len);
true
}
_ => {
w.preview_len = preview_of(payload, &mut w.preview);
false
}
}
}
/// Apply one parsed command, or hold it inside an open transaction, or
/// act on a transaction marker.
fn route_frame(args: &mut Argv, apply: &mut Option<Sink<'_>>, w: &mut V2Walk) {
match txn_marker(args) {
Some(TxnMarker::Begin) => {
// A begin inside a begin cannot happen from this
// writer; if a log ever shows one, the outer
// transaction was never committed — drop it.
if w.txn.take().is_some() {
w.txn_discarded += 1;
}
w.txn = Some(Vec::new());
w.txn_at = w.pos;
}
Some(TxnMarker::Commit) => {
if let Some(buffered) = w.txn.take()
&& let Some(f) = apply.as_mut()
{
for mut a in buffered {
f.deliver(&mut a);
}
}
}
None => match w.txn.as_mut() {
Some(buf) => buf.push(std::mem::take(args)),
None => {
if let Some(f) = apply.as_mut() {
f.deliver(args);
}
}
},
}
}
/// Where replayed frames go: moved out to an owner, or lent in place, in
/// which case the frame's buffers are reused for the next record.
pub(crate) enum Sink<'a> {
Owned(&'a mut dyn FnMut(Argv)),
InPlace(&'a mut dyn FnMut(&mut Argv)),
}
impl Sink<'_> {
pub(crate) fn deliver(&mut self, frame: &mut Argv) {
match self {
Sink::Owned(f) => f(std::mem::take(frame)),
Sink::InPlace(f) => f(frame),
}
}
}
/// Read the next record header: its `(len, crc)`, or why the walk stops
/// here — a clean end, a zero tail, a torn or impossible header.
fn next_header(r: &mut impl Read, w: &mut V2Walk) -> io::Result<Result<(u32, u32), ReplayStop>> {
let mut header = [0u8; 8];
match read_fully(r, &mut header)? {
0 => return Ok(Err(ReplayStop::Clean)),
n if n < header.len() && header[..n].iter().all(|&b| b == 0) => {
w.zero_tail = n as u64;
return Ok(Err(ReplayStop::Clean));
}
n if n < header.len() => return Ok(Err(ReplayStop::TruncatedTail)),
_ => {}
}
let len = u32::from_le_bytes(header[..4].try_into().expect("header is a fixed-size array"));
let crc = u32::from_le_bytes(header[4..].try_into().expect("header is a fixed-size array"));
// no record has length 0, so zeros from here to the end of the file
// are the unused preallocation of a mapped log; zeros followed by
// anything else are damage
if len == 0
&& crc == 0
&& let Some(rest) = zeros_to_end(r)?
{
w.zero_tail = header.len() as u64 + rest;
return Ok(Err(ReplayStop::Clean));
}
if len == 0 || len > crate::record::MAX_RECORD {
w.preview_len = preview_of(&header, &mut w.preview);
return Ok(Err(ReplayStop::CorruptFrame(String::from("record length out of range"))));
}
Ok(Ok((len, crc)))
}
/// Read `r` to its end; `Some(bytes read)` when every one was zero.
fn zeros_to_end(r: &mut impl Read) -> io::Result<Option<u64>> {
let mut buf = vec![0u8; 64 * 1024];
let mut n = 0u64;
loop {
let got = read_fully(r, &mut buf)?;
if buf[..got].iter().any(|&b| b != 0) {
return Ok(None);
}
n += got as u64;
if got < buf.len() {
return Ok(Some(n));
}
}
}
/// `read_exact` that reports a clean-vs-partial short read instead of
/// erroring: returns the bytes actually read (0 = clean EOF at a record
/// boundary, partial = torn tail).
pub(crate) fn read_fully<R: Read>(r: &mut R, buf: &mut [u8]) -> io::Result<usize> {
let mut n = 0;
while n < buf.len() {
match r.read(&mut buf[n..]) {
Ok(0) => break,
Ok(k) => n += k,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
/// A read that fails for a reason other than the end fails the walk:
/// on the header itself, or while checking that zeros run to the end.
#[test]
fn a_failing_read_is_an_error_not_an_end() {
let dir = kevy_tmpdir::TmpDir::new("walk-read-dir");
let not_a_file = || std::fs::File::open(dir.path()).unwrap();
let mut w = V2Walk::at(0);
assert!(next_header(&mut not_a_file(), &mut w).is_err());
let mut zeros_then_fail = (&[0u8; 8][..]).chain(not_a_file());
assert!(next_header(&mut zeros_then_fail, &mut w).is_err());
assert_eq!(w.zero_tail, 0, "an unread tail is not counted as zeros");
}
}