| 1 | //! The append-only log: every fact grove knows, in order. |
| 2 | //! |
| 3 | //! File layout: an 8-byte header `grovelg1`, then records. Each record is |
| 4 | //! |
| 5 | //! ```text |
| 6 | //! length: u32 little-endian (payload bytes) |
| 7 | //! length check: 4 bytes (first 4 bytes of BLAKE3(length)) |
| 8 | //! payload check: 8 bytes (first 8 bytes of BLAKE3(payload)) |
| 9 | //! payload |
| 10 | //! ``` |
| 11 | //! |
| 12 | //! An append is acknowledged only once flushed to disk. A crash mid-append can leave a torn |
| 13 | //! last record: a prefix of it, or (on some filesystems) zeros where its bytes should be. |
| 14 | //! Opening the log recognises exactly those cases and cuts the file back to the last whole |
| 15 | //! record. Anything else that fails a check is damage, and opening refuses rather than guess; |
| 16 | //! the separate length check means a damaged length can't pass for a torn tail and take the |
| 17 | //! records after it with it. While open, the log holds an exclusive lock on its file. |
| 18 | |
| 19 | use crate::blake3; |
| 20 | use std::fs::{File, OpenOptions, TryLockError}; |
| 21 | use std::io::{self, Read, Seek, SeekFrom, Write}; |
| 22 | use std::path::Path; |
| 23 | |
| 24 | const HEADER: &[u8; 8] = b"grovelg1"; |
| 25 | const FRAME: usize = 4 + 4 + 8; |
| 26 | /// Largest record grove will write or read: 64 MiB. |
| 27 | pub const MAX_RECORD: usize = 64 << 20; |
| 28 | |
| 29 | pub struct Log { |
| 30 | file: File, |
| 31 | len: u64, |
| 32 | count: u64, |
| 33 | } |
| 34 | |
| 35 | #[derive(Debug)] |
| 36 | pub enum OpenError { |
| 37 | Io(io::Error), |
| 38 | Locked, |
| 39 | NotALog, |
| 40 | Damaged { offset: u64, reason: &'static str }, |
| 41 | } |
| 42 | |
| 43 | impl std::fmt::Display for OpenError { |
| 44 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { |
| 45 | match self { |
| 46 | OpenError::Io(e) => write!(f, "{e}"), |
| 47 | OpenError::Locked => write!(f, "another grove process has this store open"), |
| 48 | OpenError::NotALog => write!(f, "not a grove log"), |
| 49 | OpenError::Damaged { offset, reason } => write!(f, "log damaged at byte {offset}: {reason}"), |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | impl From<io::Error> for OpenError { |
| 55 | fn from(e: io::Error) -> Self { |
| 56 | OpenError::Io(e) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// What opening found, besides the records. |
| 61 | #[derive(Debug, Default, PartialEq)] |
| 62 | pub struct Recovery { |
| 63 | /// Bytes cut from the end because the last record was torn. |
| 64 | pub truncated: u64, |
| 65 | } |
| 66 | |
| 67 | fn check(payload: &[u8]) -> [u8; 8] { |
| 68 | let h = blake3::hash(payload); |
| 69 | let mut c = [0; 8]; |
| 70 | c.copy_from_slice(&h[..8]); |
| 71 | c |
| 72 | } |
| 73 | |
| 74 | fn len_check(len: [u8; 4]) -> [u8; 4] { |
| 75 | let h = blake3::hash(&len); |
| 76 | [h[0], h[1], h[2], h[3]] |
| 77 | } |
| 78 | |
| 79 | /// True if a crash could have left these bytes at the end of the file: nothing but zeros. |
| 80 | fn zeros(bytes: &[u8]) -> bool { |
| 81 | bytes.iter().all(|&b| b == 0) |
| 82 | } |
| 83 | |
| 84 | impl Log { |
| 85 | /// Open (creating if needed) and read every record. |
| 86 | pub fn open(path: &Path) -> Result<(Log, Vec<Vec<u8>>, Recovery), OpenError> { |
| 87 | let mut file = OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?; |
| 88 | match file.try_lock() { |
| 89 | Ok(()) => {} |
| 90 | Err(TryLockError::WouldBlock) => return Err(OpenError::Locked), |
| 91 | Err(TryLockError::Error(e)) => return Err(e.into()), |
| 92 | } |
| 93 | |
| 94 | let mut bytes = Vec::new(); |
| 95 | file.read_to_end(&mut bytes)?; |
| 96 | if bytes.is_empty() { |
| 97 | file.write_all(HEADER)?; |
| 98 | file.sync_all()?; |
| 99 | sync_parent(path)?; |
| 100 | return Ok((Log { file, len: HEADER.len() as u64, count: 0 }, Vec::new(), Recovery::default())); |
| 101 | } |
| 102 | if bytes.len() < HEADER.len() || &bytes[..HEADER.len()] != HEADER { |
| 103 | return Err(OpenError::NotALog); |
| 104 | } |
| 105 | |
| 106 | let mut records = Vec::new(); |
| 107 | let mut at = HEADER.len(); |
| 108 | let mut recovery = Recovery::default(); |
| 109 | while at < bytes.len() { |
| 110 | let rest = &bytes[at..]; |
| 111 | let damaged = |reason| Err(OpenError::Damaged { offset: at as u64, reason }); |
| 112 | if rest.len() < FRAME { |
| 113 | // A frame cut short: the crash came while writing it. |
| 114 | recovery.truncated = rest.len() as u64; |
| 115 | break; |
| 116 | } |
| 117 | let len_bytes: [u8; 4] = rest[..4].try_into().unwrap(); |
| 118 | if len_check(len_bytes) != rest[4..8] { |
| 119 | if zeros(rest) { |
| 120 | recovery.truncated = rest.len() as u64; |
| 121 | break; |
| 122 | } |
| 123 | return damaged("record header fails its check"); |
| 124 | } |
| 125 | let len = u32::from_le_bytes(len_bytes) as usize; |
| 126 | if len > MAX_RECORD { |
| 127 | return damaged("record length out of range"); |
| 128 | } |
| 129 | if rest.len() < FRAME + len { |
| 130 | // A sound header with the payload cut short. |
| 131 | recovery.truncated = rest.len() as u64; |
| 132 | break; |
| 133 | } |
| 134 | let payload = &rest[FRAME..FRAME + len]; |
| 135 | if check(payload) != rest[8..FRAME] { |
| 136 | // Torn only if this is the last record; anything after it means real damage. |
| 137 | if FRAME + len == rest.len() || zeros(&rest[FRAME + len..]) { |
| 138 | recovery.truncated = rest.len() as u64; |
| 139 | break; |
| 140 | } |
| 141 | return damaged("record fails its check"); |
| 142 | } |
| 143 | records.push(payload.to_vec()); |
| 144 | at += FRAME + len; |
| 145 | } |
| 146 | |
| 147 | if recovery.truncated > 0 { |
| 148 | file.set_len(at as u64)?; |
| 149 | file.sync_all()?; |
| 150 | } |
| 151 | file.seek(SeekFrom::Start(at as u64))?; |
| 152 | let count = records.len() as u64; |
| 153 | Ok((Log { file, len: at as u64, count }, records, recovery)) |
| 154 | } |
| 155 | |
| 156 | /// Append one record and flush it to disk. Returns the record's index. |
| 157 | pub fn append(&mut self, payload: &[u8]) -> io::Result<u64> { |
| 158 | if payload.len() > MAX_RECORD { |
| 159 | return Err(io::Error::new(io::ErrorKind::InvalidInput, "record too large")); |
| 160 | } |
| 161 | let mut frame = Vec::with_capacity(FRAME + payload.len()); |
| 162 | let len = (payload.len() as u32).to_le_bytes(); |
| 163 | frame.extend_from_slice(&len); |
| 164 | frame.extend_from_slice(&len_check(len)); |
| 165 | frame.extend_from_slice(&check(payload)); |
| 166 | frame.extend_from_slice(payload); |
| 167 | let result = self.file.write_all(&frame).and_then(|_| self.file.sync_data()); |
| 168 | if let Err(e) = result { |
| 169 | // Leave the file as it was before this append, so the next one starts clean. |
| 170 | let _ = self.file.set_len(self.len); |
| 171 | let _ = self.file.seek(SeekFrom::Start(self.len)); |
| 172 | return Err(e); |
| 173 | } |
| 174 | self.len += frame.len() as u64; |
| 175 | self.count += 1; |
| 176 | Ok(self.count - 1) |
| 177 | } |
| 178 | |
| 179 | pub fn len(&self) -> u64 { |
| 180 | self.len |
| 181 | } |
| 182 | |
| 183 | pub fn count(&self) -> u64 { |
| 184 | self.count |
| 185 | } |
| 186 | |
| 187 | pub fn is_empty(&self) -> bool { |
| 188 | self.count == 0 |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /// Flush a directory entry (a newly created or renamed file) to disk. |
| 193 | pub fn sync_parent(path: &Path) -> io::Result<()> { |
| 194 | match path.parent() { |
| 195 | Some(dir) if !dir.as_os_str().is_empty() => File::open(dir)?.sync_all(), |
| 196 | _ => Ok(()), |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | #[cfg(test)] |
| 201 | mod tests { |
| 202 | use super::*; |
| 203 | use crate::testdir::TestDir; |
| 204 | |
| 205 | fn reopen(path: &Path) -> (Log, Vec<Vec<u8>>, Recovery) { |
| 206 | Log::open(path).unwrap() |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn append_and_replay() { |
| 211 | let dir = TestDir::new("log-replay"); |
| 212 | let path = dir.path().join("log"); |
| 213 | { |
| 214 | let (mut log, recs, _) = reopen(&path); |
| 215 | assert!(recs.is_empty()); |
| 216 | assert_eq!(log.append(b"one").unwrap(), 0); |
| 217 | assert_eq!(log.append(b"").unwrap(), 1); |
| 218 | assert_eq!(log.append(&vec![7u8; 100_000]).unwrap(), 2); |
| 219 | } |
| 220 | let (log, recs, rec) = reopen(&path); |
| 221 | assert_eq!(recs, vec![b"one".to_vec(), Vec::new(), vec![7u8; 100_000]]); |
| 222 | assert_eq!(log.count(), 3); |
| 223 | assert_eq!(rec, Recovery::default()); |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn second_open_is_locked_out() { |
| 228 | let dir = TestDir::new("log-lock"); |
| 229 | let path = dir.path().join("log"); |
| 230 | let _held = reopen(&path); |
| 231 | assert!(matches!(Log::open(&path), Err(OpenError::Locked))); |
| 232 | } |
| 233 | |
| 234 | #[test] |
| 235 | fn torn_tail_is_cut_at_every_byte() { |
| 236 | // Simulate a crash at every possible point while writing the third record. |
| 237 | let dir = TestDir::new("log-torn"); |
| 238 | let full = dir.path().join("full"); |
| 239 | { |
| 240 | let (mut log, _, _) = reopen(&full); |
| 241 | log.append(b"first").unwrap(); |
| 242 | log.append(b"second").unwrap(); |
| 243 | log.append(b"third record").unwrap(); |
| 244 | } |
| 245 | let bytes = std::fs::read(&full).unwrap(); |
| 246 | let two_records = HEADER.len() + 2 * FRAME + b"first".len() + b"second".len(); |
| 247 | for cut in two_records..bytes.len() { |
| 248 | let path = dir.path().join(format!("cut{cut}")); |
| 249 | std::fs::write(&path, &bytes[..cut]).unwrap(); |
| 250 | let (mut log, recs, rec) = reopen(&path); |
| 251 | assert_eq!(recs, vec![b"first".to_vec(), b"second".to_vec()], "cut at {cut}"); |
| 252 | assert_eq!(rec.truncated, (cut - two_records) as u64); |
| 253 | // The log is usable again afterwards. |
| 254 | log.append(b"after").unwrap(); |
| 255 | drop(log); |
| 256 | assert_eq!(reopen(&path).1.len(), 3); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | #[test] |
| 261 | fn garbage_in_last_record_is_cut() { |
| 262 | let dir = TestDir::new("log-garbage-tail"); |
| 263 | let path = dir.path().join("log"); |
| 264 | { |
| 265 | let (mut log, _, _) = reopen(&path); |
| 266 | log.append(b"keep").unwrap(); |
| 267 | log.append(b"lose").unwrap(); |
| 268 | } |
| 269 | let mut bytes = std::fs::read(&path).unwrap(); |
| 270 | let n = bytes.len(); |
| 271 | bytes[n - 1] ^= 0xff; // the tail record's payload no longer matches its check |
| 272 | std::fs::write(&path, &bytes).unwrap(); |
| 273 | let (_, recs, rec) = reopen(&path); |
| 274 | assert_eq!(recs, vec![b"keep".to_vec()]); |
| 275 | assert!(rec.truncated > 0); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn damage_before_the_tail_is_refused() { |
| 280 | let dir = TestDir::new("log-damage"); |
| 281 | let path = dir.path().join("log"); |
| 282 | { |
| 283 | let (mut log, _, _) = reopen(&path); |
| 284 | log.append(b"first").unwrap(); |
| 285 | log.append(b"second").unwrap(); |
| 286 | } |
| 287 | let mut bytes = std::fs::read(&path).unwrap(); |
| 288 | bytes[HEADER.len() + FRAME] ^= 0xff; // inside the first payload |
| 289 | std::fs::write(&path, &bytes).unwrap(); |
| 290 | assert!(matches!(Log::open(&path), Err(OpenError::Damaged { .. }))); |
| 291 | // And nothing was cut. |
| 292 | assert_eq!(std::fs::read(&path).unwrap(), bytes); |
| 293 | } |
| 294 | |
| 295 | #[test] |
| 296 | fn damaged_length_does_not_eat_later_records() { |
| 297 | let dir = TestDir::new("log-bad-length"); |
| 298 | let path = dir.path().join("log"); |
| 299 | { |
| 300 | let (mut log, _, _) = reopen(&path); |
| 301 | log.append(b"first").unwrap(); |
| 302 | log.append(b"second").unwrap(); |
| 303 | log.append(b"third").unwrap(); |
| 304 | } |
| 305 | let mut bytes = std::fs::read(&path).unwrap(); |
| 306 | bytes[HEADER.len() + 1] = 0x40; // first record now claims to be 16 MiB long |
| 307 | std::fs::write(&path, &bytes).unwrap(); |
| 308 | assert!(matches!(Log::open(&path), Err(OpenError::Damaged { .. }))); |
| 309 | assert_eq!(std::fs::read(&path).unwrap(), bytes, "nothing cut"); |
| 310 | } |
| 311 | |
| 312 | #[test] |
| 313 | fn zero_filled_tail_is_cut() { |
| 314 | // Some filesystems extend the file before the data lands; a crash leaves zeros. |
| 315 | let dir = TestDir::new("log-zeros"); |
| 316 | let path = dir.path().join("log"); |
| 317 | { |
| 318 | let (mut log, _, _) = reopen(&path); |
| 319 | log.append(b"kept").unwrap(); |
| 320 | } |
| 321 | let mut bytes = std::fs::read(&path).unwrap(); |
| 322 | bytes.extend_from_slice(&[0u8; 300]); |
| 323 | std::fs::write(&path, &bytes).unwrap(); |
| 324 | let (_, recs, rec) = reopen(&path); |
| 325 | assert_eq!(recs, vec![b"kept".to_vec()]); |
| 326 | assert_eq!(rec.truncated, 300); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn not_a_log() { |
| 331 | let dir = TestDir::new("log-foreign"); |
| 332 | let path = dir.path().join("log"); |
| 333 | std::fs::write(&path, b"hello world").unwrap(); |
| 334 | assert!(matches!(Log::open(&path), Err(OpenError::NotALog))); |
| 335 | } |
| 336 | } |