authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-19 21:50:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:20-07:00
logbe6b82da9a9e19b3a47c24655949fd2d66db0800
treeafe4c81369cdf10faf1f1ddb8aff5d220f73bacb
parent3d0dd98f39482111b7e2e32734d71bc806d826c8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

perf: parse a section once per side when saving; store the working image as a patch

A page save parsed the section about twenty times with up to four parsed copies alive: the lowering re-read the unchanged page before every writer pass, the write path parsed and validated the source its caller had just parsed and validated, and squash kept both parsed images alive while checking the result. The lowered page is now cached until a writer changes the image, typed writers hand their validated index to the write layer, parsed sources are released before results are checked, GUID tables are built once from a sorted list and node lists drop their slack. The cache stored base and working whole in one row, so every save rewrote twice the section and every idle poll parsed, validated and rewrote both. working is now the byte ranges where it differs from base (schema 14) and an unchanged remote is recognised by comparison alone. Owner 12 MiB section: 141 ms / +65 MiB to 65 ms / +29 MiB per save. 24 MiB soak section: 780 ms / +506 MiB to 310 ms / +154 MiB; launch with two publications 7.3 s to 2.7 s; cache half the size. Assisted-by: claude-fable-5.1

20 files changed, 394 insertions(+), 147 deletions(-)

crates/notebook/README.md+3-2
......@@ -2,8 +2,9 @@
22
33The application-facing crate: notebook discovery, a durable local replica with
44reconnect reconciliation, external-asset caching, recovery export and optional
5embedded SMB access. The replica stores a complete working image of one section
6and the editor's intents in a local SQLite database. `sync_once` provides a
5embedded SMB access. The replica stores the last observed remote image of one section,
6the working image as its difference from that, and the editor's intents in a
7local SQLite database; a save writes the revision it appended, not the section. `sync_once` provides a
78reconciliation step and `start_sync` owns automatic polling and reconnects. Local
89success does not acknowledge publication to a shared notebook.
910
crates/notebook/src/assets.rs+2-6
......@@ -79,12 +79,8 @@ pub(crate) fn key(filename: &str) -> Result<String> {
7979}
8080
8181fn referenced(connection: &Connection, key: &str) -> Result<bool> {
82 for column in ["working", "base"] {
83 let image: Vec<u8> = connection.query_row(
84 &format!("SELECT {column} FROM replica WHERE id=1"),
85 [],
86 |row| row.get(0),
87 )?;
82 let (base, working) = crate::images::both(connection)?;
83 for image in [working, base] {
8884 let store = Store::parse(&image)?;
8985 let index = RevisionIndex::parse(&store)?;
9086 let document = Document::parse(&index)?;
crates/notebook/src/images.rs created+151
......@@ -0,0 +1,151 @@
1//! The cache's two section images. `base` is stored whole; `working` is stored as the byte
2//! ranges where it differs from `base`, so saving an edit writes the revision it appended
3//! instead of the section.
4
5use crate::Result;
6use rusqlite::{Connection, params};
7use std::io;
8
9const BLOCK: usize = 4096;
10
11/// `image` as its length and the `(offset, bytes)` runs of blocks that differ from `base`;
12/// empty when the images are equal.
13fn difference(base: &[u8], image: &[u8]) -> Vec<u8> {
14 if base == image {
15 return Vec::new();
16 }
17 let mut patch = (image.len() as u64).to_le_bytes().to_vec();
18 let mut run: Option<usize> = None;
19 let close = |patch: &mut Vec<u8>, start: usize, end: usize| {
20 patch.extend_from_slice(&(start as u64).to_le_bytes());
21 patch.extend_from_slice(&((end - start) as u64).to_le_bytes());
22 patch.extend_from_slice(&image[start..end]);
23 };
24 for start in (0..image.len()).step_by(BLOCK) {
25 let end = (start + BLOCK).min(image.len());
26 if base.get(start..end) == Some(&image[start..end]) {
27 if let Some(from) = run.take() {
28 close(&mut patch, from, start);
29 }
30 } else {
31 run.get_or_insert(start);
32 }
33 }
34 if let Some(from) = run {
35 close(&mut patch, from, image.len());
36 }
37 patch
38}
39
40fn restore(mut base: Vec<u8>, patch: &[u8]) -> Result<Vec<u8>> {
41 let damaged = || io::Error::new(io::ErrorKind::InvalidData, "Damaged working image");
42 let Some((length, mut runs)) = patch.split_first_chunk::<8>() else {
43 return if patch.is_empty() {
44 Ok(base)
45 } else {
46 Err(damaged().into())
47 };
48 };
49 base.resize(
50 usize::try_from(u64::from_le_bytes(*length)).map_err(|_| damaged())?,
51 0,
52 );
53 while let Some((header, rest)) = runs.split_first_chunk::<16>() {
54 let offset = usize::try_from(u64::from_le_bytes(header[..8].try_into().unwrap()))
55 .map_err(|_| damaged())?;
56 let size = usize::try_from(u64::from_le_bytes(header[8..].try_into().unwrap()))
57 .map_err(|_| damaged())?;
58 let (bytes, rest) = rest.split_at_checked(size).ok_or_else(damaged)?;
59 base.get_mut(offset..)
60 .and_then(|target| target.get_mut(..size))
61 .ok_or_else(damaged)?
62 .copy_from_slice(bytes);
63 runs = rest;
64 }
65 if runs.is_empty() {
66 Ok(base)
67 } else {
68 Err(damaged().into())
69 }
70}
71
72pub(crate) fn base(connection: &Connection) -> Result<Vec<u8>> {
73 Ok(connection.query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?)
74}
75
76/// `(base, working)`.
77pub(crate) fn both(connection: &Connection) -> Result<(Vec<u8>, Vec<u8>)> {
78 let (base, patch): (Vec<u8>, Vec<u8>) =
79 connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
80 Ok((row.get(0)?, row.get(1)?))
81 })?;
82 let working = restore(base.clone(), &patch)?;
83 Ok((base, working))
84}
85
86pub(crate) fn working(connection: &Connection) -> Result<Vec<u8>> {
87 let (base, patch): (Vec<u8>, Vec<u8>) =
88 connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
89 Ok((row.get(0)?, row.get(1)?))
90 })?;
91 restore(base, &patch)
92}
93
94/// Replaces the working image; `base` is the stored base image.
95pub(crate) fn set_working(connection: &Connection, base: &[u8], working: &[u8]) -> Result<()> {
96 connection.execute(
97 "UPDATE replica SET working=?1 WHERE id=1",
98 [difference(base, working)],
99 )?;
100 Ok(())
101}
102
103/// Replaces the base image, keeping `working` as the working image. An unchanged base
104/// leaves its stored bytes alone.
105pub(crate) fn set_base(
106 connection: &Connection,
107 old: &[u8],
108 base: &[u8],
109 working: &[u8],
110) -> Result<()> {
111 if old == base {
112 return set_working(connection, base, working);
113 }
114 connection.execute(
115 "UPDATE replica SET base=?1, working=?2 WHERE id=1",
116 params![base, difference(base, working)],
117 )?;
118 Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn a_working_image_survives_as_its_difference_from_any_base() {
127 let base: Vec<u8> = (0..3 * BLOCK + 17).map(|i| i as u8).collect();
128 let mut edited = base.clone();
129 edited[5] ^= 1;
130 edited[2 * BLOCK + 1] ^= 1;
131 edited.extend_from_slice(b"appended revision");
132 for image in [
133 base.clone(),
134 edited.clone(),
135 base[..BLOCK + 3].to_vec(),
136 Vec::new(),
137 vec![7; 5 * BLOCK],
138 ] {
139 let patch = difference(&base, &image);
140 assert_eq!(restore(base.clone(), &patch).unwrap(), image);
141 }
142 assert!(difference(&base, &base).is_empty());
143 assert!(difference(&base, &edited).len() < 3 * BLOCK);
144 let patch = difference(&base, &edited);
145 for cut in 1..patch.len() {
146 if let Ok(image) = restore(base.clone(), &patch[..cut]) {
147 assert_ne!(image, edited);
148 }
149 }
150 }
151}
crates/notebook/src/lib.rs+9-27
......@@ -20,6 +20,7 @@ use std::{
2020};
2121
2222mod assets;
23mod images;
2324mod merge;
2425mod pages;
2526mod rebase;
......@@ -59,7 +60,7 @@ pub enum Error {
5960type Result<T> = std::result::Result<T, Error>;
6061
6162const APPLICATION_ID: u32 = 0x4f4e454f;
62const SCHEMA_VERSION: u32 = 13;
63const SCHEMA_VERSION: u32 = 14;
6364
6465/// An edited page model together with the stored model it was edited from.
6566/// `before` is the precondition reconciliation checks against the remote page.
......@@ -217,7 +218,7 @@ impl Replica {
217218 ",
218219 )?;
219220 schema::create(&transaction)?;
220 transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?;
221 transaction.execute("INSERT INTO replica VALUES (1, ?1, x'')", [source])?;
221222 } else {
222223 if application != APPLICATION_ID {
223224 return Err(
......@@ -251,11 +252,7 @@ impl Replica {
251252 .connection
252253 .lock()
253254 .map_err(|_| io::Error::other("Cache owner panicked"))?;
254 Ok(
255 connection.query_row("SELECT working FROM replica WHERE id=1", [], |row| {
256 row.get(0)
257 })?,
258 )
255 images::working(&connection)
259256 }
260257
261258 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
......@@ -294,10 +291,7 @@ impl Replica {
294291 .map_err(|_| io::Error::other("Cache owner panicked"))?;
295292 let transaction =
296293 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
297 let current: Vec<u8> =
298 transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| {
299 row.get(0)
300 })?;
294 let (base, current) = images::both(&transaction)?;
301295 if current != source {
302296 return Err(io::Error::new(
303297 io::ErrorKind::ResourceBusy,
......@@ -334,10 +328,7 @@ impl Replica {
334328 id
335329 ],
336330 )?;
337 transaction.execute(
338 "UPDATE replica SET working=?1 WHERE id=1",
339 [prepared.as_bytes()],
340 )?;
331 images::set_working(&transaction, &base, prepared.as_bytes())?;
341332 transaction.commit()?;
342333 drop(connection);
343334 self.wake_sync();
......@@ -388,10 +379,7 @@ impl Replica {
388379 .lock()
389380 .map_err(|_| io::Error::other("Cache owner panicked"))?;
390381 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
391 let current: Vec<u8> =
392 transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| {
393 row.get(0)
394 })?;
382 let (base, current) = images::both(&transaction)?;
395383 if current != source {
396384 return Err(io::Error::new(
397385 io::ErrorKind::ResourceBusy,
......@@ -410,10 +398,7 @@ impl Replica {
410398 ],
411399 )?;
412400 let id = u64::try_from(transaction.last_insert_rowid()).map_err(io::Error::other)?;
413 transaction.execute(
414 "UPDATE replica SET working=?1 WHERE id=1",
415 [edit.as_bytes()],
416 )?;
401 images::set_working(&transaction, &base, edit.as_bytes())?;
417402 transaction.commit()?;
418403 drop(connection);
419404 self.wake_sync();
......@@ -514,10 +499,7 @@ fn validate_images(connection: &Connection) -> Result<()> {
514499 io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(),
515500 );
516501 }
517 let (base, working): (Vec<u8>, Vec<u8>) =
518 connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
519 Ok((row.get(0)?, row.get(1)?))
520 })?;
502 let (base, working) = images::both(connection)?;
521503 if validate(&base)? != validate(&working)? {
522504 return Err(io::Error::new(
523505 io::ErrorKind::InvalidData,
crates/notebook/src/recovery.rs+6-11
......@@ -58,17 +58,11 @@ impl Recovery {
5858 }
5959
6060 pub fn snapshot(&self) -> Result<Vec<u8>> {
61 Ok(self
62 .connection
63 .query_row("SELECT working FROM replica WHERE id=1", [], |row| {
64 row.get(0)
65 })?)
61 crate::images::working(&self.connection)
6662 }
6763
6864 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {
69 Ok(self
70 .connection
71 .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?)
65 crate::images::base(&self.connection)
7266 }
7367
7468 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
......@@ -150,10 +144,11 @@ fn summary(connection: &Connection) -> Result<RecoverySummary> {
150144 [],
151145 |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)),
152146 )?;
147 let working_bytes = crate::images::working(connection)?.len() as u64;
153148 Ok(connection.query_row(
154149 "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts),
155150 (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts),
156 length(working), length(base) FROM replica WHERE id=1",
151 length(base) FROM replica WHERE id=1",
157152 [],
158153 |row| {
159154 Ok(RecoverySummary {
......@@ -161,8 +156,8 @@ fn summary(connection: &Connection) -> Result<RecoverySummary> {
161156 conflicts: unsigned(row, 1)?,
162157 uncertain_edits: unsigned(row, 2)?,
163158 published_receipts: unsigned(row, 3)?,
164 working_bytes: unsigned(row, 4)?,
165 remote_bytes: unsigned(row, 5)?,
159 working_bytes,
160 remote_bytes: unsigned(row, 4)?,
166161 cached_assets,
167162 cached_asset_bytes,
168163 })
crates/notebook/src/sync.rs+25-22
......@@ -63,7 +63,7 @@ impl Replica {
6363 .connection
6464 .lock()
6565 .map_err(|_| io::Error::other("Cache owner panicked"))?;
66 Ok(connection.query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?)
66 images::base(&connection)
6767 }
6868
6969 /// Reconciles one pending edit, or refreshes the working image when the queue is empty.
......@@ -79,7 +79,12 @@ impl Replica {
7979 }
8080 let _step = Step(self);
8181 let snapshot = remote.read().map_err(Error::RemoteIo)?;
82 let identity = validate(&snapshot)?;
82 // An unchanged remote is the base image, validated when it was stored.
83 let identity = if snapshot == self.remote_snapshot()? {
84 None
85 } else {
86 Some(validate(&snapshot)?)
87 };
8388 let (intent, attempted) = {
8489 let mut connection = self
8590 .connection
......@@ -87,11 +92,10 @@ impl Replica {
8792 .map_err(|_| io::Error::other("Cache owner panicked"))?;
8893 let transaction =
8994 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
90 let base: Vec<u8> =
91 transaction
92 .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?;
93 let base_store = Store::parse(&base)?;
94 if RevisionIndex::parse(&base_store)?.root != identity {
95 let (base, working) = images::both(&transaction)?;
96 if let Some(identity) = identity
97 && RevisionIndex::parse(&Store::parse(&base)?)?.root != identity
98 {
9599 return Err(io::Error::new(
96100 io::ErrorKind::InvalidInput,
97101 "Remote snapshot belongs to another document",
......@@ -105,10 +109,7 @@ impl Replica {
105109 rows.next()?.map(pending_edit).transpose()?
106110 };
107111 let Some(intent) = intent else {
108 transaction.execute(
109 "UPDATE replica SET base=?1, working=?1 WHERE id=1",
110 [&snapshot],
111 )?;
112 images::set_base(&transaction, &base, &snapshot, &snapshot)?;
112113 transaction.commit()?;
113114 return Ok(None);
114115 };
......@@ -123,7 +124,7 @@ impl Replica {
123124 |row| row.get::<_, String>(0),
124125 )
125126 .optional()?;
126 transaction.execute("UPDATE replica SET base=?1 WHERE id=1", [&snapshot])?;
127 images::set_base(&transaction, &base, &snapshot, &working)?;
127128 transaction.commit()?;
128129 (intent, attempted)
129130 };
......@@ -415,10 +416,7 @@ impl Replica {
415416 .lock()
416417 .map_err(|_| io::Error::other("Cache owner panicked"))?;
417418 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
418 let (base, working): (Vec<u8>, Vec<u8>) =
419 transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
420 Ok((row.get(0)?, row.get(1)?))
421 })?;
419 let (base, working) = images::both(&transaction)?;
422420 if working != local || base != remote {
423421 return Err(io::Error::new(
424422 io::ErrorKind::ResourceBusy,
......@@ -464,7 +462,7 @@ impl Replica {
464462 [archive.to_string_lossy().into_owned()],
465463 )?;
466464 transaction.execute("DELETE FROM edits", [])?;
467 transaction.execute("UPDATE replica SET working=base WHERE id=1", [])?;
465 transaction.execute("UPDATE replica SET working=x'' WHERE id=1", [])?;
468466 }
469467 }
470468 transaction.commit()?;
......@@ -499,10 +497,7 @@ impl Replica {
499497 .lock()
500498 .map_err(|_| io::Error::other("Cache owner panicked"))?;
501499 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
502 let (base, working): (Vec<u8>, Vec<u8>) =
503 transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
504 Ok((row.get(0)?, row.get(1)?))
505 })?;
500 let (base, working) = images::both(&transaction)?;
506501 if working != local || base != remote {
507502 return Err(io::Error::new(
508503 io::ErrorKind::ResourceBusy,
......@@ -561,7 +556,15 @@ impl Replica {
561556 params![id, revision.to_string()],
562557 )?;
563558 transaction.execute("DELETE FROM edits WHERE id=?1", [id])?;
564 transaction.execute("UPDATE replica SET base=?1, working=CASE WHEN EXISTS(SELECT 1 FROM edits) THEN working ELSE ?1 END WHERE id=1", [snapshot])?;
559 let (base, working) = images::both(&transaction)?;
560 let pending: bool =
561 transaction.query_row("SELECT EXISTS(SELECT 1 FROM edits)", [], |row| row.get(0))?;
562 images::set_base(
563 &transaction,
564 &base,
565 snapshot,
566 if pending { &working } else { snapshot },
567 )?;
565568 transaction.commit()?;
566569 Ok(())
567570 }
crates/onestore/src/edit.rs+2-2
......@@ -237,7 +237,7 @@ pub fn replace_text(
237237 let Some((page, automatic, title_text)) =
238238 page_title(revision, &pages, Some((object, &changed)))?
239239 else {
240 return crate::write::replace_objects(source, space, &edits);
240 return crate::write::replace_objects(&index, space, &edits);
241241 };
242242 let Kind::Page {
243243 alternate_title, ..
......@@ -296,7 +296,7 @@ pub fn replace_text(
296296 &[]
297297 },
298298 });
299 crate::write::replace_objects(source, space, &edits)
299 crate::write::replace_objects(&index, space, &edits)
300300}
301301
302302pub(crate) fn editable_parents(
crates/onestore/src/formatting.rs+2-2
......@@ -3,7 +3,7 @@ use crate::{
33 create::{current_timestamps, properties, string},
44 document::{Document, Kind},
55 edit::editable_parents,
6 write::{PropertyObject, fresh_guid, write_revision},
6 write::{PropertyObject, fresh_guid, write_revision_on},
77};
88use serde::{Deserialize, Serialize};
99use std::{
......@@ -172,7 +172,7 @@ pub(crate) fn format_text(
172172 }
173173 }
174174 let modified = current_timestamps()?.0.to_le_bytes();
175 write_revision(source, space, |raw| {
175 write_revision_on(&index, space, |raw| {
176176 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
177177 let fields = PropertySets::parse(&target.bytes)?;
178178 if fields.sets[0].iter().any(|p| p.id == 0x24003458) {
crates/onestore/src/insertion.rs+2-2
......@@ -3,7 +3,7 @@ use crate::{
33 create::{current_timestamps, default_text_style, properties, string},
44 document::{Document, Element, Kind},
55 edit::{editable_parents, page_title},
6 write::{PropertyObject, fresh_guid, write_revision},
6 write::{PropertyObject, fresh_guid, write_revision_on},
77};
88use serde::{Deserialize, Serialize};
99use std::{
......@@ -394,7 +394,7 @@ impl Insertion {
394394 drop(view);
395395 title
396396 };
397 write_revision(source, space, |raw| {
397 write_revision_on(&index, space, |raw| {
398398 let mut changed = new;
399399 for id in &ancestors {
400400 let mut object = PropertyObject::from_object(&raw.objects[id])?;
crates/onestore/src/objects.rs+32-21
......@@ -277,6 +277,8 @@ impl<'a> RevisionIndex<'a> {
277277 let mut pending: Vec<&Node<'a>> = revision.nodes.iter().rev().collect();
278278 let mut groups = BTreeSet::new();
279279 let mut defining_table = false;
280 // Entries of the table being defined; the map is built once, at its end.
281 let mut defining: Vec<(u32, [u8; 16])> = Vec::new();
280282 let initial_crc = if self.store.header.file_type == crate::FileType::Section {
281283 u32::MAX
282284 } else {
......@@ -333,6 +335,7 @@ impl<'a> RevisionIndex<'a> {
333335 });
334336 }
335337 table = Arc::new(GlobalIds::new());
338 defining.clear();
336339 defining_table = true;
337340 }
338341 0x24..=0x26 => {
......@@ -343,8 +346,9 @@ impl<'a> RevisionIndex<'a> {
343346 });
344347 }
345348 let first = u32::from_le_bytes(c.read()?);
346 let entries: Vec<_> = match node.id {
347 0x24 => vec![(first, c.read()?)],
349 let start = defining.len();
350 match node.id {
351 0x24 => defining.push((first, c.read()?)),
348352 0x25 | 0x26 => {
349353 let count = if node.id == 0x26 {
350354 u32::from_le_bytes(c.read()?)
......@@ -364,30 +368,28 @@ impl<'a> RevisionIndex<'a> {
364368 message: "Global ID import range exceeds its table",
365369 });
366370 }
367 let entries: Vec<_> = dependency_table
368 .range(first..end)
369 .map(|(from, guid)| (to + from - first, *guid))
370 .collect();
371 if entries.len() as u64 != u64::from(count) {
371 defining.extend(
372 dependency_table
373 .range(first..end)
374 .map(|(from, guid)| (to + from - first, *guid)),
375 );
376 if (defining.len() - start) as u64 != u64::from(count) {
372377 return Err(Error {
373378 offset: node.offset,
374379 message: "Global ID import refers to a missing entry",
375380 });
376381 }
377 entries
378382 }
379383 _ => unreachable!(),
380 };
381 for (index, guid) in entries {
382 if index >= 0xffffff
383 || guid == [0; 16]
384 || Arc::make_mut(&mut table).insert(index, guid).is_some()
385 {
386 return Err(Error {
387 offset: node.offset,
388 message: "Invalid or repeated global ID entry",
389 });
390 }
384 }
385 if defining[start..]
386 .iter()
387 .any(|(index, guid)| *index >= 0xffffff || *guid == [0; 16])
388 {
389 return Err(Error {
390 offset: node.offset,
391 message: "Invalid or repeated global ID entry",
392 });
391393 }
392394 }
393395 0x28 => {
......@@ -398,13 +400,22 @@ impl<'a> RevisionIndex<'a> {
398400 });
399401 }
400402 defining_table = false;
401 let unique: BTreeSet<_> = table.values().collect();
402 if unique.len() != table.len() {
403 defining.sort_unstable();
404 if defining.windows(2).any(|pair| pair[0].0 == pair[1].0) {
405 return Err(Error {
406 offset: node.offset,
407 message: "Invalid or repeated global ID entry",
408 });
409 }
410 let mut guids: Vec<_> = defining.iter().map(|(_, guid)| *guid).collect();
411 guids.sort_unstable();
412 if guids.windows(2).any(|pair| pair[0] == pair[1]) {
403413 return Err(Error {
404414 offset: node.offset,
405415 message: "Global ID table repeats a GUID",
406416 });
407417 }
418 table = Arc::new(defining.drain(..).collect());
408419 }
409420 0x59 | 0x5a => {
410421 let id = if node.id == 0x5a {
crates/onestore/src/outline.rs+3-2
......@@ -3,7 +3,7 @@ use crate::{
33 create::current_timestamps,
44 document::{Document, Kind},
55 edit::{editable_parents, update_title},
6 write::{PropertyObject, write_revision},
6 write::{PropertyObject, write_revision_on},
77};
88use serde::{Deserialize, Serialize};
99use std::collections::{BTreeMap, BTreeSet};
......@@ -105,7 +105,7 @@ impl OutlineEdit {
105105 pending.extend(parents.get(&id).into_iter().flatten().copied());
106106 }
107107 let modified = current_timestamps()?.0.to_le_bytes();
108 write_revision(source, space, |raw| {
108 write_revision_on(&index, space, |raw| {
109109 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
110110 target.set(
111111 &values
......@@ -139,6 +139,7 @@ impl OutlineEdit {
139139#[cfg(test)]
140140mod tests {
141141 use super::*;
142 use crate::write::write_revision;
142143
143144 #[test]
144145 fn protection_on_the_target_or_ancestor_prevents_layout_edits() {
crates/onestore/src/page/write.rs+46-26
......@@ -339,25 +339,34 @@ pub(crate) fn write_page(
339339 if author.contains('\0') {
340340 return Err(invalid("Choose an author name without NUL"));
341341 }
342 let store = Store::parse(source)?;
343 let index = RevisionIndex::parse(&store)?;
344 index.validate_current()?;
345 let document = Document::parse(&index)?;
346 let pages = document.pages_in(space)?;
347 let [page] = pages.as_slice() else {
348 return Err(invalid("Choose an object space containing one active page"));
342 // The parsed source is released before the writers parse their own images.
343 let (page, before, existing) = {
344 let store = Store::parse(source)?;
345 let index = RevisionIndex::parse(&store)?;
346 index.validate_current()?;
347 let document = Document::parse(&index)?;
348 let pages = document.pages_in(space)?;
349 let [page] = pages.as_slice() else {
350 return Err(invalid("Choose an object space containing one active page"));
351 };
352 let existing = index
353 .resolve_active(space)?
354 .objects
355 .keys()
356 .copied()
357 .collect();
358 (*page, Page::from_space(&document, space)?, existing)
349359 };
350 let before = Page::from_space(&document, space)?;
351 let raw = index.resolve_active(space)?;
352360 let mut lowering = Lowering {
353361 image: source.to_vec(),
362 current: Some(before.clone()),
354363 space,
355 page: *page,
364 page,
356365 author,
357366 alias: BTreeMap::new(),
358367 built: BTreeSet::new(),
359368 };
360 lowering.run(&before, after, &raw.objects.keys().copied().collect())?;
369 lowering.run(&before, after, &existing)?;
361370 if lowering.image == source {
362371 return Ok(lowering.image);
363372 }
......@@ -453,6 +462,8 @@ impl<'a> View<'a> {
453462
454463struct Lowering<'a> {
455464 image: Vec<u8>,
465 /// The page as `image` stores it, until a writer changes the image.
466 current: Option<Page>,
456467 space: ExGuid,
457468 page: ExGuid,
458469 author: &'a str,
......@@ -478,15 +489,22 @@ impl Lowering<'_> {
478489 }
479490
480491 fn apply(&mut self, edit: impl FnOnce(&[u8]) -> Result<Vec<u8>, Error>) -> Result<(), Error> {
481 self.image = edit(&self.image)?;
492 let image = edit(&self.image)?;
493 if image != self.image {
494 self.image = image;
495 self.current = None;
496 }
482497 Ok(())
483498 }
484499
485 fn current(&self) -> Result<Page, Error> {
486 let store = Store::parse(&self.image)?;
487 let index = RevisionIndex::parse(&store)?;
488 let document = Document::parse(&index)?;
489 Page::from_space(&document, self.space)
500 fn current(&mut self) -> Result<Page, Error> {
501 if self.current.is_none() {
502 let store = Store::parse(&self.image)?;
503 let index = RevisionIndex::parse(&store)?;
504 let document = Document::parse(&index)?;
505 self.current = Some(Page::from_space(&document, self.space)?);
506 }
507 Ok(self.current.clone().unwrap())
490508 }
491509
492510 fn run(
......@@ -2921,10 +2939,6 @@ pub(crate) fn squash(
29212939 .iter()
29222940 .map(|(model, image)| (*image, *model))
29232941 .collect();
2924 let applied_store = Store::parse(applied)?;
2925 let applied_index = RevisionIndex::parse(&applied_store)?;
2926 // Payloads the typed edits embedded travel into the squashed transaction as well.
2927 let source_store = Store::parse(source)?;
29282942 let declared = |store: &Store<'_>| -> Vec<[u8; 16]> {
29292943 store
29302944 .lists
......@@ -2934,7 +2948,10 @@ pub(crate) fn squash(
29342948 .filter_map(|node| node.payload.get(..16).and_then(|g| g.try_into().ok()))
29352949 .collect()
29362950 };
2937 let existing = declared(&source_store);
2951 let existing = declared(&Store::parse(source)?);
2952 let applied_store = Store::parse(applied)?;
2953 let applied_index = RevisionIndex::parse(&applied_store)?;
2954 // Payloads the typed edits embedded travel into the squashed transaction as well.
29382955 let mut payloads = Vec::new();
29392956 for guid in declared(&applied_store) {
29402957 if !existing.contains(&guid) {
......@@ -3018,10 +3035,13 @@ pub(crate) fn squash(
30183035 }
30193036 Ok(changes)
30203037 };
3021 match protection {
3022 Some(_) => crate::write::append_revisions(source, &payloads, protection, edit),
3023 None => crate::write::write_revisions_with_payloads(source, &payloads, edit),
3024 }
3038 let validate = protection.is_none();
3039 let output = crate::write::build(source, &payloads, protection, validate, edit)?;
3040 // The parsed images are released before the result is parsed.
3041 drop(applied_index);
3042 drop(applied_store);
3043 crate::write::check(&output, validate)?;
3044 Ok(output)
30253045}
30263046
30273047fn remap(object: &mut PropertyObject, rename: &BTreeMap<ExGuid, ExGuid>) -> Result<(), Error> {
crates/onestore/src/paragraph.rs+3-3
......@@ -3,7 +3,7 @@ use crate::{
33 create::{current_timestamps, properties, string},
44 document::{Document, Element, Kind},
55 edit::{editable_parents, update_title},
6 write::{PropertyObject, fresh_guid, write_revision},
6 write::{PropertyObject, fresh_guid, write_revision_on},
77};
88use serde::{Deserialize, Serialize};
99use std::{
......@@ -252,7 +252,7 @@ impl ParagraphSplit {
252252 object.set(&[(0x14001d7a, &modified)])?;
253253 }
254254 update_title(&store, &raw, view, &pages, &mut changed)?;
255 write_revision(source, space, |_| Ok(changed))
255 write_revision_on(&index, space, |_| Ok(changed))
256256 }
257257}
258258
......@@ -682,7 +682,7 @@ impl ParagraphJoin {
682682 pending.extend(parents.get(&id).into_iter().flatten().copied());
683683 }
684684 update_title(&store, &raw, view, &pages, &mut changed)?;
685 write_revision(source, space, |_| Ok(changed))
685 write_revision_on(&index, space, |_| Ok(changed))
686686 }
687687}
688688
crates/onestore/src/store.rs+1
......@@ -505,6 +505,7 @@ impl<'a> Store<'a> {
505505 pending.push(reference);
506506 }
507507 }
508 nodes.shrink_to_fit();
508509 lists.insert(list_id.unwrap(), NodeList { fragments, nodes });
509510 }
510511 Ok(Self {
crates/onestore/src/tree.rs+3-2
......@@ -3,7 +3,7 @@ use crate::{
33 create::{current_timestamps, properties, string},
44 document::{Document, Kind, Revision},
55 edit::{editable_parents, update_title},
6 write::{PropertyObject, fresh_guid, write_revision},
6 write::{PropertyObject, fresh_guid, write_revision_on},
77};
88use serde::{Deserialize, Serialize};
99use std::{
......@@ -371,7 +371,7 @@ impl TreeEdit {
371371 changed.insert(self.object, object);
372372 }
373373 update_title(&store, &raw, view, &pages, &mut changed)?;
374 write_revision(source, space, |_| Ok(changed))
374 write_revision_on(&index, space, |_| Ok(changed))
375375 }
376376}
377377
......@@ -416,6 +416,7 @@ fn checked_path(
416416#[cfg(test)]
417417mod tests {
418418 use super::*;
419 use crate::write::write_revision;
419420 use crate::{Insertion, PreparedEdit};
420421
421422 #[test]
crates/onestore/src/write.rs+82-15
......@@ -305,8 +305,11 @@ pub fn replace_property_bytes(
305305 message: "Property does not contain scalar bytes",
306306 });
307307 }
308 let store = Store::parse(source)?;
309 let index = RevisionIndex::parse(&store)?;
310 index.validate_current()?;
308311 replace_objects(
309 source,
312 &index,
310313 space,
311314 &[ObjectEdit {
312315 object: object_id,
......@@ -316,12 +319,13 @@ pub fn replace_property_bytes(
316319 )
317320}
318321
322/// Patches objects of a source the caller has parsed and validated.
319323pub(crate) fn replace_objects(
320 source: &[u8],
324 index: &RevisionIndex<'_>,
321325 space: ExGuid,
322326 edits: &[ObjectEdit<'_>],
323327) -> Result<Vec<u8>> {
324 write_revision(source, space, |revision| {
328 write_revision_on(index, space, |revision| {
325329 let mut changed = BTreeMap::new();
326330 for edit in edits {
327331 if changed.contains_key(&edit.object) {
......@@ -788,23 +792,86 @@ pub(crate) fn write_revisions_with_payloads(
788792 payloads: &[([u8; 16], &[u8])],
789793 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
790794) -> Result<Vec<u8>> {
791 let store = Store::parse(source)?;
792 RevisionIndex::parse(&store)?.validate_current()?;
793 let output = append_revisions(source, payloads, None, edit)?;
794 let store = Store::parse(&output)?;
795 RevisionIndex::parse(&store)?.validate_current()?;
796 Ok(output)
795 publish(source, payloads, None, true, edit)
797796}
798797
799798/// `write_revisions_with_payloads` without validating that current revisions are
800799/// complete: a protected section is validated by unlocking it, through `protection`.
800#[cfg(feature = "protected")]
801801pub(crate) fn append_revisions(
802802 source: &[u8],
803803 payloads: &[([u8; 16], &[u8])],
804804 protection: Option<&dyn Protection>,
805805 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
806) -> Result<Vec<u8>> {
807 publish(source, payloads, protection, false, edit)
808}
809
810fn publish(
811 source: &[u8],
812 payloads: &[([u8; 16], &[u8])],
813 protection: Option<&dyn Protection>,
814 validate: bool,
815 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
816) -> Result<Vec<u8>> {
817 // The parsed source is released before the result is parsed.
818 let output = build(source, payloads, protection, validate, edit)?;
819 check(&output, validate)?;
820 Ok(output)
821}
822
823/// Parses a written image, and with `validate` requires its current revisions complete.
824pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> {
825 let store = Store::parse(output)?;
826 let index = RevisionIndex::parse(&store)?;
827 if validate {
828 index.validate_current()?;
829 }
830 Ok(())
831}
832
833/// The written image, unchecked: `check` follows once the caller has released whatever
834/// `edit` borrowed.
835pub(crate) fn build(
836 source: &[u8],
837 payloads: &[([u8; 16], &[u8])],
838 protection: Option<&dyn Protection>,
839 validate: bool,
840 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
806841) -> Result<Vec<u8>> {
807842 let store = Store::parse(source)?;
843 let index = RevisionIndex::parse(&store)?;
844 if validate {
845 index.validate_current()?;
846 }
847 build_on(&index, payloads, protection, edit)
848}
849
850/// `write_revision` on a source the caller has parsed and validated.
851pub(crate) fn write_revision_on(
852 index: &RevisionIndex<'_>,
853 space: ExGuid,
854 edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result<BTreeMap<ExGuid, PropertyObject>>,
855) -> Result<Vec<u8>> {
856 let output = build_on(index, &[], None, |index| {
857 let revision = index.resolve(space, index.active(space)?)?;
858 Ok(BTreeMap::from([(
859 space,
860 RevisionEdit::Update(edit(&revision)?),
861 )]))
862 })?;
863 check(&output, true)?;
864 Ok(output)
865}
866
867fn build_on(
868 index: &RevisionIndex<'_>,
869 payloads: &[([u8; 16], &[u8])],
870 protection: Option<&dyn Protection>,
871 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
872) -> Result<Vec<u8>> {
873 let store = index.store;
874 let source = store.data;
808875 let is_section = store.header.file_type == FileType::Section;
809876 if !store.checksum_mismatches.is_empty() {
810877 return Err(Error {
......@@ -812,13 +879,14 @@ pub(crate) fn append_revisions(
812879 message: "Cannot write a file with transaction checksum damage",
813880 });
814881 }
815 let index = RevisionIndex::parse(&store)?;
816882 let resolve = |space, rid| match protection {
817883 Some(protection) => protection.resolve(space, rid),
818884 None => index.resolve(space, rid),
819885 };
820 let changes = edit(&index)?;
821 let mut output = source.to_vec();
886 let changes = edit(index)?;
887 // Room for the revision, so appending does not double the image.
888 let mut output = Vec::with_capacity(source.len() + source.len() / 16 + (1 << 16));
889 output.extend_from_slice(source);
822890 // Native files reserve 1 KiB per transaction-log fragment; a fragment that ends the file
823891 // keeps that room before the first new chunk.
824892 let tail = store.transaction_fragments.last().unwrap().chunk;
......@@ -1348,12 +1416,12 @@ pub(crate) fn append_revisions(
13481416 let space_node = root
13491417 .nodes
13501418 .iter()
1351 .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space))
1419 .find(|node| node.id == 8 && node.fields(store).exguid() == Ok(space))
13521420 .ok_or(Error {
13531421 offset: 0,
13541422 message: "Object space is absent from the root list",
13551423 })?;
1356 let space_list = space_node.referenced_list(&store)?;
1424 let space_list = space_node.referenced_list(store)?;
13571425 let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap();
13581426 let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else {
13591427 unreachable!()
......@@ -1522,6 +1590,5 @@ pub(crate) fn append_revisions(
15221590 message: "File generation counter is exhausted",
15231591 })?;
15241592 output[228..236].copy_from_slice(&generation.to_le_bytes());
1525 RevisionIndex::parse(&Store::parse(&output)?)?;
15261593 Ok(output)
15271594}
tools/cache_images.py created+17
......@@ -0,0 +1,17 @@
1"""The cache's working image, stored as the byte ranges where it differs from the base."""
2import struct
3
4
5def working(connection):
6 base, patch = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone()
7 if not patch: return base
8 length, = struct.unpack_from('<Q', patch)
9 image = bytearray(base[:length].ljust(length, b'\0'))
10 at = 8
11 while at < len(patch):
12 offset, size = struct.unpack_from('<QQ', patch, at)
13 at += 16
14 assert at + size <= len(patch) and offset + size <= length, 'Damaged working image'
15 image[offset:offset + size] = patch[at:at + size]
16 at += size
17 return bytes(image)
tools/offline_publication_crash.py+2-1
......@@ -1,6 +1,7 @@
11#!/usr/bin/env python3
22"""Kill owned processes across local-cache/remote-file publication boundaries."""
33import argparse
4import cache_images
45import hashlib
56import json
67import os
......@@ -186,7 +187,7 @@ def run(source, output):
186187 try:
187188 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
188189 assert connection.execute('PRAGMA foreign_key_check').fetchall() == []
189 local_image = save_image(output, connection.execute('SELECT working FROM replica WHERE id=1').fetchone()[0])
190 local_image = save_image(output, cache_images.working(connection))
190191 finally:
191192 connection.close()
192193 result = {'case': index, 'phase': phase, 'during_confirmation': confirmation, 'before_status': before['status'], 'after_status': after['status'],
tools/test_offline_history.py+2-2
......@@ -141,7 +141,7 @@ class OfflineLedgerTests(unittest.TestCase):
141141 connection = sqlite3.connect(output / 'rust' / f'{actor}.sqlite')
142142 connection.executescript('CREATE TABLE receipts(edit_id INTEGER, revision TEXT); CREATE TABLE edits(id INTEGER); CREATE TABLE attempt(id INTEGER); CREATE TABLE conflicts(id INTEGER); CREATE TABLE replica(id INTEGER, base BLOB, working BLOB);')
143143 connection.executemany('INSERT INTO receipts VALUES (?,?)', [(op+1, f'{actor}-{op}') for op in range(2)])
144 connection.execute('INSERT INTO replica VALUES (1, ?, ?)', (b'opaque image', b'opaque image'))
144 connection.execute('INSERT INTO replica VALUES (1, ?, ?)', (b'opaque image', b''))
145145 connection.commit()
146146 connection.close()
147147 for i in range(4):
......@@ -166,7 +166,7 @@ class OfflineLedgerTests(unittest.TestCase):
166166 connection = sqlite3.connect(output / 'rust/w0.sqlite')
167167 for sql, undo in [("UPDATE receipts SET revision='wrong' WHERE edit_id=1", "UPDATE receipts SET revision='w0-0' WHERE edit_id=1"),
168168 ('INSERT INTO attempt VALUES (1)', 'DELETE FROM attempt'),
169 ("UPDATE replica SET working=X'00'", "UPDATE replica SET working=base")]:
169 ("UPDATE replica SET working=X'00'", "UPDATE replica SET working=X''")]:
170170 connection.execute(sql)
171171 connection.commit()
172172 with self.assertRaises(AssertionError): verify(output)
tools/verify_offline.py+1-1
......@@ -46,7 +46,7 @@ def verify(output, max_gap=120):
4646 persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts'))
4747 assert persisted == {id: event['revision'] for id, event in receipts.items()}, 'SQLite receipts differ from observed acknowledgements'
4848 base, working = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone()
49 assert base == working, 'A drained cache retained a divergent local branch'
49 assert working == b'', 'A drained cache retained a divergent local branch'
5050 caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}
5151 finally:
5252 connection.close()