From 67b5c3ae079f9f524bf0c52521a62811da407ad0 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 20:31:10 +0000 Subject: stats: the writers of the document How many writers (Automerge actors) the document's changes have matters: a writer is a node's run (each start, rejoin or drop makes one, a start keeping the one of before with `--keep-actor` after a clean stop), and every transfer of the whole history carries a signature for each. `/admin/stats` now says it (`document.writers`, and `document_writers` for Prometheus), counted from the changes' metadata alongside the saved size's measurement in the background (at least a minute apart), as that takes time proportional to the document too; an export, which measures the size alone, keeps the count of before. The dashboard's figures show it with the rest. --- src/api/stats.rs | 4 ++++ src/api/stats/prometheus.rs | 8 ++++++++ src/document/mod.rs | 10 ++++++++++ src/swarm/stats.rs | 14 +++++++++++--- 4 files changed, 33 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/api/stats.rs b/src/api/stats.rs index 90b4fc2..37a7447 100644 --- a/src/api/stats.rs +++ b/src/api/stats.rs @@ -101,6 +101,9 @@ struct DocumentStats { saved_bytes_secs_ago: Option, /// How long the last measurement took (copy and save), in milliseconds. saved_bytes_took_ms: Option, + /// Writers (Automerge actors: a node's run each) with changes in the document, + /// counted with `saved_bytes`; absent until first counted. + writers: Option, /// How long takers of the document's lock waited for it, since start: the node's /// work on the one document queues there. lock_wait: crate::document::lock::LockWaits, @@ -360,6 +363,7 @@ pub(crate) async fn collect(state: &AppState, list: &PeerListResponse) -> StatsR saved_bytes: saved.map(|s| s.bytes), saved_bytes_secs_ago: saved.map(|s| s.at.elapsed().as_secs()), saved_bytes_took_ms: saved.map(|s| s.took.as_millis() as u64), + writers: saved.and_then(|s| s.writers), lock_wait: state.doc.waits(), }, catch_up: CatchUpStats { diff --git a/src/api/stats/prometheus.rs b/src/api/stats/prometheus.rs index 0d79d2c..ca3f1af 100644 --- a/src/api/stats/prometheus.rs +++ b/src/api/stats/prometheus.rs @@ -460,6 +460,14 @@ pub(super) fn render(stats: &StatsResponse, list: &PeerListResponse) -> String { "Most heads after applying a sync message, since start.", document.max_heads as f64, ); + if let Some(writers) = document.writers { + out.single( + "document_writers", + "gauge", + "Writers (Automerge actors, a node's run each) with changes in the document; counted in the background.", + writers as f64, + ); + } if let Some(bytes) = document.saved_bytes { out.single( "document_saved_bytes", diff --git a/src/document/mod.rs b/src/document/mod.rs index c183537..a546d63 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -13,6 +13,16 @@ pub(crate) use automerge::{ ScalarValue, }; +/// How many writers (actors) `doc`'s changes have: from the changes' metadata, in +/// time proportional to the document. +pub(crate) fn writers(doc: &Document) -> usize { + doc.get_changes_meta(&[]) + .into_iter() + .map(|meta| meta.actor.into_owned()) + .collect::>() + .len() +} + /// The whole document as bytes, as the engine saves it: a backup, the document /// file's full save, the size a joining node downloads. pub(crate) fn save(doc: &Document) -> Vec { diff --git a/src/swarm/stats.rs b/src/swarm/stats.rs index ed12888..3665b5a 100644 --- a/src/swarm/stats.rs +++ b/src/swarm/stats.rs @@ -99,12 +99,16 @@ impl Propagation { } /// The document's size as saved (compressed), when it was measured and how long that -/// took (copy and save). +/// took (copy and save), and how many writers (Automerge actors) its changes have. #[derive(Debug, Clone, Copy)] pub(crate) struct SavedSize { pub(crate) bytes: u64, pub(crate) at: Instant, pub(crate) took: Duration, + /// Writers with changes in the document, counted with the measurement: a node's + /// run each, so many a transfer of the whole history carries a signature for. None + /// where only the size was measured (an export). + pub(crate) writers: Option, } /// Passing changes on (`swarm::relay`), since start. @@ -434,10 +438,12 @@ impl NodeStats { /// measurement for free. pub(crate) fn saved_size_measured(&self, bytes: u64, took: Duration, version: (u64, Epoch)) { let mut inner = self.lock(); + let writers = inner.saved_size.and_then(|saved| saved.writers); inner.saved_size = Some(SavedSize { bytes, at: Instant::now(), took, + writers, }); inner.measured_version = Some(version); } @@ -494,11 +500,12 @@ pub(crate) fn measure_saved_size(state: &AppState) { // Both steps take time proportional to the document: off the async threads. let measured = match tokio::task::spawn_blocking(move || { let copy = doc.blocking_read().doc.clone(); - crate::document::save(©).len() as u64 + let writers = crate::document::writers(©); + (crate::document::save(©).len() as u64, writers) }) .await { - Ok(bytes) => { + Ok((bytes, writers)) => { let took = start.elapsed(); debug!( bytes, @@ -510,6 +517,7 @@ pub(crate) fn measure_saved_size(state: &AppState) { bytes, at: Instant::now(), took, + writers: Some(writers), }, version, )) -- cgit v1.3.1