| 1 | //! Page creation reconciliation: dependent body saves, uncertain publication, lost anchors, |
| 2 | //! and deterministic multi-actor schedules. |
| 3 | |
| 4 | #[path = "../../onestore/tests/support/ops.rs"] |
| 5 | mod ops; |
| 6 | |
| 7 | use notebook::{EditStatus, Recovery, Replica}; |
| 8 | use onestore::op::SectionOp; |
| 9 | use onestore::{ |
| 10 | ExGuid, PageCreation, RevisionIndex, Store, |
| 11 | document::{Document, Kind}, |
| 12 | }; |
| 13 | use std::collections::BTreeMap; |
| 14 | |
| 15 | #[path = "support/server.rs"] |
| 16 | mod server; |
| 17 | use server::*; |
| 18 | #[path = "../../onestore/tests/support/disk.rs"] |
| 19 | mod disk; |
| 20 | #[path = "support/model_ops.rs"] |
| 21 | mod model_ops; |
| 22 | #[path = "support/page_schedule.rs"] |
| 23 | mod page_schedule; |
| 24 | use page_schedule::body_outline; |
| 25 | #[path = "../../onestore/tests/support/sweep.rs"] |
| 26 | mod sweep; |
| 27 | |
| 28 | #[test] |
| 29 | fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { |
| 30 | for seed in sweep::seeds(0..24, 1) { |
| 31 | let mut random = seed + 1956; |
| 32 | let mut input = Vec::new(); |
| 33 | for step in 0..48 { |
| 34 | random ^= random << 13; |
| 35 | random ^= random >> 7; |
| 36 | random ^= random << 17; |
| 37 | let mut action = random.to_le_bytes(); |
| 38 | if step < 12 { |
| 39 | action[0] = step; |
| 40 | action[1] = 1; |
| 41 | } |
| 42 | input.extend_from_slice(&action); |
| 43 | } |
| 44 | page_schedule::run(&input); |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | #[test] |
| 49 | fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { |
| 50 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); |
| 51 | let directory = tempfile::tempdir().unwrap(); |
| 52 | let mut server = Server::new(&source); |
| 53 | let mut expected = Vec::new(); |
| 54 | for actor in 0..12 { |
| 55 | let path = directory.path().join(format!("{actor}.sqlite")); |
| 56 | let cache = Replica::create(&path, &source).unwrap(); |
| 57 | let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); |
| 58 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 59 | let mut created = cache.page(page.space()).unwrap(); |
| 60 | let body = body_outline(&mut created, "Body"); |
| 61 | let save = model_ops::save_as(&cache, page.space(), &created, model_ops::AUTHOR) |
| 62 | .unwrap() |
| 63 | .unwrap(); |
| 64 | let dependent = model_ops::save(&cache, body, |page| { |
| 65 | model_ops::replace_text(page, body, 4..4, &format!(" {actor}")) |
| 66 | }) |
| 67 | .unwrap() |
| 68 | .unwrap(); |
| 69 | assert!(id < save && save < dependent); |
| 70 | let original = cache.pending().unwrap(); |
| 71 | assert_eq!(original.len(), 3); |
| 72 | assert!( |
| 73 | matches!(&original[0].edit.ops[..], [onestore::op::Op::Section(SectionOp::Create(retained))] if retained == &page) |
| 74 | ); |
| 75 | let local = snapshot(&cache); |
| 76 | drop(cache); |
| 77 | let cache = Replica::open(&path).unwrap(); |
| 78 | assert_eq!(cache.pending().unwrap(), original); |
| 79 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 80 | assert!(matches!( |
| 81 | cache.sync_once(&mut server).unwrap().edit, |
| 82 | Some((published, EditStatus::Published { .. })) if published == dependent |
| 83 | )); |
| 84 | for edit in original { |
| 85 | assert!(matches!( |
| 86 | cache.status(edit.id).unwrap(), |
| 87 | Some(EditStatus::Published { .. }) |
| 88 | )); |
| 89 | } |
| 90 | expected.push((page.space(), page.object(), body, format!("Body {actor}"))); |
| 91 | } |
| 92 | let store = Store::parse(&server.durable).unwrap(); |
| 93 | let index = RevisionIndex::parse(&store).unwrap(); |
| 94 | let document = Document::parse(&index).unwrap(); |
| 95 | let pages = document.pages().unwrap(); |
| 96 | assert_eq!(pages.len(), 13); |
| 97 | for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) { |
| 98 | assert_eq!(*actual, (*sid, *page)); |
| 99 | let space = &document.spaces[sid]; |
| 100 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 101 | assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); |
| 102 | } |
| 103 | assert_eq!(server.publications, 12); |
| 104 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { |
| 105 | std::fs::create_dir(&output).unwrap(); |
| 106 | std::fs::write( |
| 107 | std::path::Path::new(&output).join("pages.one"), |
| 108 | &server.durable, |
| 109 | ) |
| 110 | .unwrap(); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 116 | for fault in [ |
| 117 | Fault::UnknownBefore, |
| 118 | Fault::UnknownAfter, |
| 119 | Fault::PanicBefore, |
| 120 | Fault::PanicAfter, |
| 121 | ] { |
| 122 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); |
| 123 | let directory = tempfile::tempdir().unwrap(); |
| 124 | let path = directory.path().join("pages.sqlite"); |
| 125 | let cache = Replica::create(&path, &source).unwrap(); |
| 126 | let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); |
| 127 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 128 | let local = snapshot(&cache); |
| 129 | let mut server = Server::new(&source); |
| 130 | server.fault = fault; |
| 131 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| 132 | cache.sync_once(&mut server) |
| 133 | })); |
| 134 | let attempted = cache.status(id).unwrap().unwrap(); |
| 135 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); |
| 136 | let archive = directory.path().join("recovery.sqlite"); |
| 137 | cache.export_recovery(&archive).unwrap(); |
| 138 | assert_eq!( |
| 139 | Recovery::open(&archive).unwrap().status(id).unwrap(), |
| 140 | Some(attempted.clone()) |
| 141 | ); |
| 142 | drop(cache); |
| 143 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 144 | let encoded: String = db |
| 145 | .query_row( |
| 146 | "SELECT revisions FROM batches WHERE attempted=1", |
| 147 | [], |
| 148 | |row| row.get(0), |
| 149 | ) |
| 150 | .unwrap(); |
| 151 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); |
| 152 | assert_eq!(revisions.len(), 2); |
| 153 | assert!(revisions.contains_key(&page.space())); |
| 154 | drop(db); |
| 155 | let cache = Replica::open(&path).unwrap(); |
| 156 | let observed = server.visible.clone(); |
| 157 | let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 158 | let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter); |
| 159 | assert_eq!(server.publications, 1); |
| 160 | assert_eq!(server.confirmations, usize::from(visible)); |
| 161 | if visible { |
| 162 | assert!(matches!(result.1, EditStatus::Published { .. })); |
| 163 | assert!(snapshot(&cache) == observed); |
| 164 | assert!(observed[..212] == server.durable[..212]); |
| 165 | assert!(observed[252..] == server.durable[252..]); |
| 166 | } else { |
| 167 | assert_eq!(result, (id, attempted.clone())); |
| 168 | assert_eq!(pages(&snapshot(&cache)), pages(&local)); |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | #[test] |
| 174 | fn surviving_page_revision_alone_does_not_confirm_section_publication() { |
| 175 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); |
| 176 | let directory = tempfile::tempdir().unwrap(); |
| 177 | let path = directory.path().join("pages.sqlite"); |
| 178 | let cache = Replica::create(&path, &source).unwrap(); |
| 179 | let page = PageCreation::new(None, None, "Author").unwrap(); |
| 180 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 181 | let mut server = Server::new(&source); |
| 182 | server.fault = Fault::UnknownAfter; |
| 183 | assert!(cache.sync_once(&mut server).is_err()); |
| 184 | let attempted = cache.status(id).unwrap().unwrap(); |
| 185 | drop(cache); |
| 186 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 187 | let encoded: String = db |
| 188 | .query_row( |
| 189 | "SELECT revisions FROM batches WHERE attempted=1", |
| 190 | [], |
| 191 | |row| row.get(0), |
| 192 | ) |
| 193 | .unwrap(); |
| 194 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); |
| 195 | drop(db); |
| 196 | let complete = server.visible.clone(); |
| 197 | let (&section, &retired) = revisions |
| 198 | .iter() |
| 199 | .find(|(sid, _)| **sid != page.space()) |
| 200 | .unwrap(); |
| 201 | let store = Store::parse(&complete).unwrap(); |
| 202 | let mut positions = Vec::new(); |
| 203 | for list in store.lists.values() { |
| 204 | for node in &list.nodes { |
| 205 | if node.id == 0x1e |
| 206 | && node.payload[..16] == retired.guid |
| 207 | && node.payload[16..20] == retired.n.to_le_bytes() |
| 208 | { |
| 209 | positions.push(node.offset); |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | assert_eq!(positions.len(), 1); |
| 214 | // Replacing only the revision identity models maintenance retiring the section proof. |
| 215 | let at = positions[0] + 4; |
| 216 | server.visible[at] ^= 0x40; |
| 217 | // A rewrite publishes a new file version (MS-ONESTORE 2.3.1). |
| 218 | server.visible[212] ^= 1; |
| 219 | let store = Store::parse(&server.visible).unwrap(); |
| 220 | let index = RevisionIndex::parse(&store).unwrap(); |
| 221 | index.validate_current().unwrap(); |
| 222 | assert!(!index.spaces[&section].revisions.contains_key(&retired)); |
| 223 | assert!( |
| 224 | index.spaces[&page.space()] |
| 225 | .revisions |
| 226 | .contains_key(&revisions[&page.space()]) |
| 227 | ); |
| 228 | let cache = Replica::open(&path).unwrap(); |
| 229 | assert_eq!( |
| 230 | cache.sync_once(&mut server).unwrap().edit, |
| 231 | Some((id, attempted.clone())) |
| 232 | ); |
| 233 | assert_eq!(server.confirmations, 0); |
| 234 | assert_eq!(server.publications, 1); |
| 235 | server.visible = complete; |
| 236 | assert!(matches!( |
| 237 | cache.sync_once(&mut server).unwrap().edit, |
| 238 | Some((_, EditStatus::Published { .. })) |
| 239 | )); |
| 240 | assert_eq!(server.publications, 1); |
| 241 | } |
| 242 | |
| 243 | /// A page created before a page the remote moved goes before the next page the remote left |
| 244 | /// in place, its content under the identities it was made with. |
| 245 | #[test] |
| 246 | fn a_moved_page_anchor_places_the_page_without_regenerating_dependent_identities() { |
| 247 | let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); |
| 248 | let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); |
| 249 | let store = Store::parse(source).unwrap(); |
| 250 | let index = RevisionIndex::parse(&store).unwrap(); |
| 251 | let pages = Document::parse(&index).unwrap().pages().unwrap(); |
| 252 | let directory = tempfile::tempdir().unwrap(); |
| 253 | let path = directory.path().join("pages.sqlite"); |
| 254 | let cache = Replica::create(&path, source).unwrap(); |
| 255 | let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); |
| 256 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 257 | let mut created = cache.page(page.space()).unwrap(); |
| 258 | let body = body_outline(&mut created, "Retained body"); |
| 259 | let save = model_ops::save_as(&cache, page.space(), &created, "Author") |
| 260 | .unwrap() |
| 261 | .unwrap(); |
| 262 | let local = snapshot(&cache); |
| 263 | let mut server = Server::new(remote); |
| 264 | assert!(matches!( |
| 265 | cache.sync_once(&mut server).unwrap().edit, |
| 266 | Some((published, EditStatus::Published { .. })) if published == save |
| 267 | )); |
| 268 | assert!(matches!( |
| 269 | cache.status(id).unwrap(), |
| 270 | Some(EditStatus::Published { .. }) |
| 271 | )); |
| 272 | assert_eq!(server.publications, 1); |
| 273 | assert_eq!( |
| 274 | model_ops::page_of(&server.durable, page.space()), |
| 275 | model_ops::page_of(&local, page.space()) |
| 276 | ); |
| 277 | let arena = onestore::Arena::default(); |
| 278 | let listed = onestore::Section::open(&arena, server.durable.clone()) |
| 279 | .unwrap() |
| 280 | .pages() |
| 281 | .unwrap(); |
| 282 | // The remote made `child` and `grandchild` subpages; `Renamed` stayed. |
| 283 | let at = listed |
| 284 | .iter() |
| 285 | .position(|listed| listed.0 == pages[6].0) |
| 286 | .unwrap(); |
| 287 | assert_eq!(listed[at - 1].0, page.space()); |
| 288 | let store = Store::parse(&server.durable).unwrap(); |
| 289 | let index = RevisionIndex::parse(&store).unwrap(); |
| 290 | let document = Document::parse(&index).unwrap(); |
| 291 | let space = &document.spaces[&page.space()]; |
| 292 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 293 | assert!(matches!(&view.nodes[&body].kind, |
| 294 | Kind::RichText { text, .. } if text == "Retained body")); |
| 295 | } |
| 296 | |
| 297 | /// Edits of a page the remote removed put the local version back where it stood. |
| 298 | #[test] |
| 299 | fn a_page_the_remote_removed_comes_back_where_it_stood() { |
| 300 | let source = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); |
| 301 | let order = |image: &[u8]| -> Vec<(ExGuid, String, u32)> { |
| 302 | let arena = onestore::Arena::default(); |
| 303 | onestore::Section::open(&arena, image.to_vec()) |
| 304 | .unwrap() |
| 305 | .pages() |
| 306 | .unwrap() |
| 307 | }; |
| 308 | let pages = order(source); |
| 309 | // A subpage without subpages of its own and with a page after it, so it has a position |
| 310 | // and a level to return to. |
| 311 | let at = (0..pages.len() - 1) |
| 312 | .find(|at| { |
| 313 | pages[*at].2 > 1 |
| 314 | && pages[*at + 1].2 <= pages[*at].2 |
| 315 | && model_ops::page_of(source, pages[*at].0) |
| 316 | .objects |
| 317 | .iter() |
| 318 | .any(|object| matches!(object, onestore::page::PageObject::Outline(_))) |
| 319 | }) |
| 320 | .expect("a subpage with body text"); |
| 321 | let (space, _, level) = pages[at].clone(); |
| 322 | let text = model_ops::page_of(source, space) |
| 323 | .objects |
| 324 | .iter() |
| 325 | .find_map(|object| match object { |
| 326 | onestore::page::PageObject::Outline(outline) => { |
| 327 | outline.paragraphs[0].text().map(|t| t.id) |
| 328 | } |
| 329 | _ => None, |
| 330 | }) |
| 331 | .unwrap(); |
| 332 | let directory = tempfile::tempdir().unwrap(); |
| 333 | let cache = Replica::create(directory.path().join("pages.sqlite"), source).unwrap(); |
| 334 | model_ops::save(&cache, text, |page| { |
| 335 | model_ops::replace_text(page, text, 0..0, "Kept ") |
| 336 | }) |
| 337 | .unwrap() |
| 338 | .unwrap(); |
| 339 | let local = cache.page(space).unwrap(); |
| 340 | let removed = ops::section_op(source, SectionOp::Delete([space].to_vec())) |
| 341 | .unwrap() |
| 342 | .as_bytes() |
| 343 | .to_vec(); |
| 344 | let mut server = Server::new(&removed); |
| 345 | assert!(matches!( |
| 346 | cache.sync_once(&mut server).unwrap().edit, |
| 347 | Some((_, EditStatus::Published { .. })) |
| 348 | )); |
| 349 | assert_eq!( |
| 350 | server.publications, 1, |
| 351 | "a removed page is not edited in place" |
| 352 | ); |
| 353 | let published = order(&server.durable); |
| 354 | assert!(published.iter().all(|(page, ..)| *page != space)); |
| 355 | assert_eq!(published.len(), pages.len()); |
| 356 | let (restored, title, restored_level) = published[at].clone(); |
| 357 | assert_eq!( |
| 358 | (title.as_str(), restored_level), |
| 359 | (pages[at].1.as_str(), level) |
| 360 | ); |
| 361 | let page = model_ops::page_of(&server.durable, restored); |
| 362 | let texts = |page: &onestore::page::Page| -> Vec<String> { |
| 363 | page.objects |
| 364 | .iter() |
| 365 | .filter_map(|object| match object { |
| 366 | onestore::page::PageObject::Outline(outline) => Some(outline), |
| 367 | _ => None, |
| 368 | }) |
| 369 | .flat_map(|outline| &outline.paragraphs) |
| 370 | .filter_map(|p| p.text().map(|t| t.text.text().to_owned())) |
| 371 | .collect() |
| 372 | }; |
| 373 | assert_eq!(texts(&page), texts(&local)); |
| 374 | assert!(texts(&page)[0].starts_with("Kept ")); |
| 375 | let others: Vec<_> = published |
| 376 | .iter() |
| 377 | .map(|(page, ..)| *page) |
| 378 | .filter(|page| *page != restored) |
| 379 | .collect(); |
| 380 | let before: Vec<_> = pages |
| 381 | .iter() |
| 382 | .map(|(page, ..)| *page) |
| 383 | .filter(|page| *page != space) |
| 384 | .collect(); |
| 385 | assert_eq!(others, before); |
| 386 | assert_eq!(pages_of(&cache), published); |
| 387 | } |
| 388 | |
| 389 | fn pages_of(cache: &Replica) -> Vec<(ExGuid, String, u32)> { |
| 390 | cache.pages().unwrap() |
| 391 | } |