mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
A log whose generation isn't its snapshot's was skipped as a leftover of a compaction cut short, but neither generation is under a checksum: one flipped bit made the server serve the older document, report nothing and overwrite the log. Replay such a log over the snapshot instead: replay skips what the snapshot already holds, so a genuine leftover changes nothing, while edits the snapshot lacks are recovered and the damage is reported.
140 lines
6.0 KiB
Rust
140 lines
6.0 KiB
Rust
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
|
|
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
|
|
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
|
|
//! still restores silently.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use figcraft_collab::message::{Access, ClientMsg};
|
|
use figcraft_collab::{Client, Outgoing, Replica, Server};
|
|
use figcraft_collab_net::hub::Backend;
|
|
use figcraft_doc::Document;
|
|
use figcraft_server::config::Config;
|
|
use figcraft_server::disk::{self, LOG, SNAPSHOT};
|
|
use figcraft_server::store::{FILES, Store};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
struct Fixture(PathBuf);
|
|
|
|
impl Fixture {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
|
|
fs::create_dir(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
}
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn page_name(server: &Server) -> String {
|
|
let doc = server.document();
|
|
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
|
|
}
|
|
|
|
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
|
|
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
|
|
fn renamed_and_stopped(data: &Path) -> (String, Server) {
|
|
let mut store = Store::open(data, Config::default().store()).unwrap();
|
|
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
|
|
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
|
|
let id = info.id;
|
|
let mut server = store.open(&id).unwrap();
|
|
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
|
|
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
|
|
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
|
|
let mut client = Client::join(welcome).unwrap();
|
|
store.changed(&id, &mut server);
|
|
let mut edited = client.document().clone();
|
|
let page = edited.first_page().unwrap();
|
|
edited.node_mut(page).unwrap().name = "Kept".into();
|
|
let change = client.edit(&edited).unwrap().unwrap();
|
|
assert!(!server.receive(client.id(), change).is_empty());
|
|
store.changed(&id, &mut server);
|
|
assert!(store.flush().is_empty(), "the edit must reach disk");
|
|
assert_eq!(page_name(&server), "Kept");
|
|
let dir = data.join(FILES).join(&id);
|
|
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
|
|
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
|
|
drop(store);
|
|
(id, server)
|
|
}
|
|
|
|
/// Reopen the data directory: the file's first page name and the problems reported.
|
|
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
|
|
let mut store = Store::open(data, Config::default().store()).unwrap();
|
|
let server = store.open(id).unwrap();
|
|
(page_name(&server), store.problems().to_vec())
|
|
}
|
|
|
|
fn flip(path: &Path, at: usize) {
|
|
let mut bytes = fs::read(path).unwrap();
|
|
bytes[at] ^= 1;
|
|
fs::write(path, bytes).unwrap();
|
|
}
|
|
|
|
/// The generation field's first byte in both headers (after the magic and the format byte).
|
|
const GENERATION_AT: usize = 5;
|
|
|
|
#[test]
|
|
fn an_intact_log_restores_its_edits_without_a_report() {
|
|
let fixture = Fixture::new();
|
|
let (id, _) = renamed_and_stopped(&fixture.0);
|
|
let (name, problems) = reopen(&fixture.0, &id);
|
|
assert_eq!(name, "Kept");
|
|
assert!(problems.is_empty(), "{problems:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
|
|
let fixture = Fixture::new();
|
|
let (id, _) = renamed_and_stopped(&fixture.0);
|
|
let log = fixture.0.join(FILES).join(&id).join(LOG);
|
|
flip(&log, GENERATION_AT);
|
|
// The records themselves still read: only the header's generation changed.
|
|
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
|
|
assert!(read.damaged.is_none() && !read.entries.is_empty());
|
|
let (name, problems) = reopen(&fixture.0, &id);
|
|
assert_eq!(name, "Kept", "the flushed rename must survive");
|
|
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
|
// And it stays: the recovered edit is now in the file's snapshot.
|
|
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
|
|
let fixture = Fixture::new();
|
|
let (id, _) = renamed_and_stopped(&fixture.0);
|
|
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
|
|
flip(&snapshot, GENERATION_AT);
|
|
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
|
|
let (name, problems) = reopen(&fixture.0, &id);
|
|
assert_eq!(name, "Kept", "the flushed rename must survive");
|
|
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
|
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_log_format_is_still_reported() {
|
|
let fixture = Fixture::new();
|
|
let (id, _) = renamed_and_stopped(&fixture.0);
|
|
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
|
|
let (_, problems) = reopen(&fixture.0, &id);
|
|
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
|
|
let fixture = Fixture::new();
|
|
let (id, server) = renamed_and_stopped(&fixture.0);
|
|
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
|
|
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
|
|
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
|
|
let (name, problems) = reopen(&fixture.0, &id);
|
|
assert_eq!(name, "Kept");
|
|
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
|
|
}
|