diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 8641c24faa3afdd6c54e3d058f60aa6dd6a13adb..01cbfca2592e65efc3c102e37665459188671f5a 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -373,3 +373,13 @@ tests independent of the server. Device and simulator builds link for iOS. Native acceptance uses disposable OneNote 2010 clients and Samba; it does not establish on-device execution or physical power-loss durability. + +## Queue measurement + +`cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000` +measures alternating-page saves that retain separate queue IDs, cache reopen, +recovery export and local-file publication. JSON lines record each acknowledgement +and publication latency plus image/cache sizes. The workload verifies every ID +and both final page titles, then independently reopens the recovery archive. +Use an external resource monitor for peak memory; these local-file timings do not +measure SMB or phone performance. diff --git a/crates/notebook/examples/queue_scale.rs b/crates/notebook/examples/queue_scale.rs new file mode 100644 index 0000000000000000000000000000000000000000..6661921661fd22825c40abdeec5d9ff25c96e93b --- /dev/null +++ b/crates/notebook/examples/queue_scale.rs @@ -0,0 +1,172 @@ +use notebook::{EditStatus, Remote, Replica}; +use onestore::{ + CommitError, PageCreation, PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Page, PageObject, Paragraph, text::Edit}, +}; +use std::{io, path::PathBuf, time::Instant}; + +struct FileRemote(PathBuf); + +impl Remote for FileRemote { + fn read(&mut self) -> io::Result> { + onestore::read_file(&self.0) + } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + edit.commit_file(&self.0) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + onestore::confirm_file_snapshot(&self.0, snapshot) + } +} + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + assert_eq!(args.len(), 2, "queue_scale NEW_DIRECTORY EDIT_COUNT"); + let directory = PathBuf::from(&args[0]); + let count: usize = args[1].to_str().unwrap().parse()?; + assert!(count >= 2); + std::fs::create_dir(&directory)?; + let source = onestore::create_section("queue.one", "First", "Fixture")?; + let creation = PageCreation::new(None, Some("Second"), "Fixture")?; + let source = PreparedEdit::create_page(&source, &creation)? + .as_bytes() + .to_vec(); + let pages = { + let store = Store::parse(&source)?; + let index = RevisionIndex::parse(&store)?; + Document::parse(&index)?.pages()? + }; + assert_eq!(pages.len(), 2); + let path = directory.join("cache.sqlite"); + let mut remote = FileRemote(directory.join("queue.one")); + std::fs::write(&remote.0, &source)?; + let cache = Replica::create(&path, &source)?; + let mut ids = Vec::new(); + let mut expected = [String::new(), String::new()]; + let start = Instant::now(); + for n in 0..count { + let edit_start = Instant::now(); + let source = cache.snapshot()?; + let store = Store::parse(&source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let slot = n % 2; + let space = pages[slot].0; + let mut page = Page::from_space(&document, space)?; + let text = page + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline.paragraphs.iter_mut().find_map(|p| p.text_mut()) + } + PageObject::Title(title) => title + .outlines + .iter_mut() + .flat_map(|outline| &mut outline.paragraphs) + .find_map(|p| p.text_mut().filter(|text| text.date_field.is_none())), + _ => None, + }) + .unwrap(); + expected[slot] = format!("Edit {n} 🦀 e\u{301}"); + let end = u32::try_from(text.text.text().encode_utf16().count())?; + let format = text.text.format_at(0)?.clone(); + text.text.apply(Edit { + range: 0..end, + replacement: Paragraph::new(expected[slot].clone(), format), + })?; + let id = cache.save(&source, space, &page, "Fixture")?.unwrap(); + assert!(ids.last().is_none_or(|previous| *previous < id)); + ids.push(id); + println!( + "{}", + serde_json::json!({"phase":"ack","n":n,"id":id, + "ms":edit_start.elapsed().as_secs_f64()*1000.0,"working_bytes":source.len()}) + ); + } + println!( + "{}", + serde_json::json!({"phase":"queued","count":count, + "seconds":start.elapsed().as_secs_f64(),"cache_bytes":std::fs::metadata(&path)?.len()}) + ); + drop(cache); + let start = Instant::now(); + let cache = Replica::open(&path)?; + assert_eq!( + cache + .pending()? + .iter() + .map(|edit| edit.id) + .collect::>(), + ids + ); + let source = cache.snapshot()?; + verify(&source, &pages, &expected)?; + println!( + "{}", + serde_json::json!({"phase":"reopen","seconds":start.elapsed().as_secs_f64()}) + ); + let start = Instant::now(); + cache.export_recovery(directory.join("recovery.sqlite"))?; + println!( + "{}", + serde_json::json!({"phase":"export","seconds":start.elapsed().as_secs_f64()}) + ); + let start = Instant::now(); + for (n, id) in ids.iter().enumerate() { + let step = Instant::now(); + assert!( + matches!(cache.sync_once(&mut remote)?, Some((actual, EditStatus::Published { .. })) if actual == *id) + ); + println!( + "{}", + serde_json::json!({"phase":"publish","n":n,"id":id,"ms":step.elapsed().as_secs_f64()*1000.0}) + ); + } + assert!(cache.pending()?.is_empty()); + let published = cache.snapshot()?; + assert_eq!(onestore::read_file(&remote.0)?, published); + verify(&published, &pages, &expected)?; + drop(cache); + let cache = Replica::open(&path)?; + for id in &ids { + assert!(matches!( + cache.status(*id)?, + Some(EditStatus::Published { .. }) + )); + } + let archive = notebook::Recovery::open(directory.join("recovery.sqlite"))?; + assert_eq!( + archive + .pending()? + .iter() + .map(|edit| edit.id) + .collect::>(), + ids + ); + assert_eq!(archive.snapshot()?, source); + println!( + "{}", + serde_json::json!({"phase":"complete","count":count, + "publish_seconds":start.elapsed().as_secs_f64(),"cache_bytes":std::fs::metadata(&path)?.len(), + "remote_bytes":published.len()}) + ); + Ok(()) +} + +fn verify( + bytes: &[u8], + pages: &[(onestore::ExGuid, onestore::ExGuid)], + expected: &[String; 2], +) -> Result<(), Box> { + let store = Store::parse(bytes)?; + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(&store)?; + index.validate_current()?; + let document = Document::parse(&index)?; + for (slot, (space, _)) in pages.iter().enumerate() { + assert_eq!(Page::from_space(&document, *space)?.title, expected[slot]); + } + Ok(()) +}