From 2a4316f4a256012120337064df150944880b6453 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 22:25:53 +0000 Subject: document file: undo the clean stop's mark before deciding the actor At start, a node on a document file appends an Opened record before it writes anything, so that a Closed record before it no longer says the file holds every change of the actor it names. It does so now before deciding which actor to write as, and DocumentFile::open says whether it could: where the append fails (the disk full, permissions), the file still ends with Closed, and later appends likely fail too. A crash, or a clean stop whose last append failed (close then sees the file behind and writes no mark), would leave that Closed standing for a run the file never got. So with --keep-actor, the node writes on as the actor of before only where the file was marked as opened; else it writes as a fresh actor and warns. --- docs/lifecycle.md | 5 +++-- src/document/file.rs | 20 +++++++++++++------- src/main.rs | 32 ++++++++++++++++++-------------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/docs/lifecycle.md b/docs/lifecycle.md index e09ef01..d703fe3 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -93,8 +93,9 @@ document file holding a document is an error with no override. by a crash is dropped when reading. A node starting from it is ready at once; sync merges what it missed, or it rejoins a newer epoch. A node writes as a fresh actor each start, unless `--keep-actor` and it stopped cleanly (the file marks a clean - stop, and the mark is undone as the node starts on it): then it writes on as the actor - of before, as nothing it wrote is missing. Fewer actors mean fewer signatures in every + stop, and the mark is undone as the node starts on it, before it decides its actor): + then it writes on as the actor of before, as nothing it wrote is missing. Where the + mark can't be undone (the disk full, say), it writes as a fresh one. Fewer actors mean fewer signatures in every transfer of the epoch's history (a writer each); a crash, a drop, or a switch or a rejoin still starts a fresh one. * **A backup** (`GET /admin/document`) is the whole document with its history, naming diff --git a/src/document/file.rs b/src/document/file.rs index 56114f5..1a33918 100644 --- a/src/document/file.rs +++ b/src/document/file.rs @@ -399,16 +399,22 @@ impl DocumentFile { /// Marks the file as opened (`Record::Opened`), at start, before the node writes: /// a `Closed` before it no longer says the file holds everything (a crash from here - /// on leaves it without one). Nothing where there is no file yet. - pub(crate) fn open(&self) { + /// on leaves it without one). Nothing where there is no file yet. Whether the file + /// now ends without a `Closed`: where the mark couldn't be appended, it still says + /// the run of before stopped cleanly, and the node must not write on as its actor. + pub(crate) fn open(&self) -> bool { if !self.path.exists() { - return; + return true; } - if let Err(error) = - self.files - .append(FileKind::Document, &self.path, &record(Record::Opened, &[])) + match self + .files + .append(FileKind::Document, &self.path, &record(Record::Opened, &[])) { - error!(path = %self.path.display(), error = %error, "Couldn't mark the document file as opened"); + Ok(()) => true, + Err(error) => { + error!(path = %self.path.display(), error = %error, "Couldn't mark the document file as opened"); + false + } } } diff --git a/src/main.rs b/src/main.rs index 7c819e5..d8704a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,13 +184,30 @@ async fn main() -> Result<()> { None => (Document::new(), None, None, Vec::new(), Vec::new(), None), }; let mut initial_doc = initial_doc; + let document_file = args.document_file.clone().map(|path| { + Arc::new(document::file::DocumentFile::new( + path, + Arc::clone(&files), + args.document_append, + args.document_rewrite, + )) + }); + // A clean stop's mark undone before anything is written, and before deciding the + // actor: a crash from here on leaves a file that doesn't say so. A file that still + // says so (the mark not undone) gets a fresh actor. + let opened = document_file.as_ref().is_none_or(|file| file.open()); // Writing on as the actor of before, where this node stopped cleanly: everything it // wrote as that one is in the document, so no change of it comes twice. - match resumable.filter(|_| args.keep_actor) { + match resumable.filter(|_| args.keep_actor && opened) { Some(actor) => { info!(actor = %actor, "Writing on as the actor of before (--keep-actor)"); initial_doc.set_actor(actor); } + None if args.keep_actor && !opened => { + warn!( + "Writing as a fresh actor: the document file couldn't be marked as opened (--keep-actor)" + ); + } None if args.keep_actor && starting_epoch.is_some() => { info!("Writing as a fresh actor: this node didn't stop cleanly (--keep-actor)"); } @@ -239,19 +256,6 @@ async fn main() -> Result<()> { let mut transports = transports.into_iter(); let (scheme, transport) = transports.next().context("No transports")?; - let document_file = args.document_file.clone().map(|path| { - Arc::new(document::file::DocumentFile::new( - path, - Arc::clone(&files), - args.document_append, - args.document_rewrite, - )) - }); - // A clean stop's mark undone before anything is written: a crash from here on - // leaves a file that doesn't say so. - if let Some(file) = &document_file { - file.open(); - } let node = node::Node::new(node::NodeConfig { transport, identity: Some(transport::identity::IdentityConfig { -- cgit v1.3.1