summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/api/mod.rs7
-rw-r--r--src/api/peers.rs12
-rw-r--r--src/api/stats.rs34
-rw-r--r--src/api/stats/prometheus.rs49
-rw-r--r--src/config/import.rs113
-rw-r--r--src/config/mod.rs100
-rw-r--r--src/document/file.rs362
-rw-r--r--src/document/mod.rs1
-rw-r--r--src/document/replica.rs4
-rw-r--r--src/files.rs213
-rw-r--r--src/force.rs72
-rw-r--r--src/main.rs216
-rw-r--r--src/node.rs174
-rw-r--r--src/remake/api.rs293
-rw-r--r--src/remake/catchup.rs34
-rw-r--r--src/remake/coordinator.rs32
-rw-r--r--src/remake/delta.rs17
-rw-r--r--src/remake/kind.rs60
-rw-r--r--src/remake/local.rs25
-rw-r--r--src/remake/mod.rs1
-rw-r--r--src/remake/records.rs95
-rw-r--r--src/remake/snapshot.rs58
-rw-r--r--src/remake/switch.rs7
-rw-r--r--src/remake/vote.rs2
-rw-r--r--src/simtest/mod.rs292
-rw-r--r--src/simtest/timing.rs285
-rw-r--r--src/state.rs86
-rw-r--r--src/swarm/blob.rs8
-rw-r--r--src/swarm/bootstrap.rs386
-rw-r--r--src/swarm/members.rs2
-rw-r--r--src/swarm/mod.rs1
-rw-r--r--src/swarm/peer.rs96
-rw-r--r--src/swarm/source.rs111
-rw-r--r--src/swarm/state_file.rs185
-rw-r--r--src/swarm/sync.rs14
-rw-r--r--src/swarm/topology.rs20
-rw-r--r--src/transport/mod.rs27
-rw-r--r--src/transport/mycelium.rs68
-rw-r--r--src/transport/sim.rs448
39 files changed, 3646 insertions, 364 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 1fba48b..affb9ef 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -120,8 +120,11 @@ fn parse_write_path(
if path.first().map(String::as_str) != Some(crate::remake::snapshot::META_ROOT) {
return Ok(path);
}
- let epochs = path.len() < 2 || path[1] == crate::remake::snapshot::EPOCHS;
- if epochs || !state.protocol_writers.contains(&user.0) {
+ // `_continuum` itself, epoch metadata and the swarm ID: nobody.
+ let fixed = path.len() < 2
+ || path[1] == crate::remake::snapshot::EPOCHS
+ || path[1] == crate::remake::snapshot::SWARM;
+ if fixed || !state.protocol_writers.contains(&user.0) {
warn!(user = %user.0, method, path = %key, "Refused a write to protocol data");
return Err(StatusCode::FORBIDDEN);
}
diff --git a/src/api/peers.rs b/src/api/peers.rs
index 919f187..dc4bd3b 100644
--- a/src/api/peers.rs
+++ b/src/api/peers.rs
@@ -100,8 +100,8 @@ pub(crate) struct PeerListResponse {
#[derive(Debug, Deserialize)]
pub(crate) struct DeletePeerQuery {
- #[serde(default)]
- force: bool,
+ /// The checks to override (`force`): `alive`.
+ force: Option<String>,
}
#[utoipa::path(
@@ -197,12 +197,12 @@ pub(crate) async fn list_peers(
tag = "Peers",
params(
("peer" = String, Path, description = "Peer public key"),
- ("force" = Option<bool>, Query, description = "Also forget a peer that is currently alive; it returns on its next message")
+ ("force" = Option<String>, Query, description = "`true` or `alive`: also forget a peer that is currently alive; it returns on its next message")
),
responses(
(status = 204, description = "Swarm-wide forget recorded"),
(status = 400, description = "Not a public key, or the local node's own key"),
- (status = 409, description = "Peer is alive; pass force=true"),
+ (status = 409, description = "Peer is alive; pass force=alive"),
(status = 503, description = "Node is not ready")
)
)]
@@ -217,6 +217,8 @@ pub(crate) async fn delete_peer(
if peer.is_empty() || peer == state.local_id() {
return Err(StatusCode::BAD_REQUEST);
}
+ let force = crate::force::Force::parse(query.force.as_deref(), &["alive"])
+ .map_err(|_| StatusCode::BAD_REQUEST)?;
// Unknown locally is fine: other nodes may still know the peer.
if let Some(peer_state_mutex) = state.get_peer_state(&peer).await {
@@ -224,7 +226,7 @@ pub(crate) async fn delete_peer(
.lock()
.await
.is_alive(&state.timing, Instant::now())
- && !query.force
+ && !force.allows("alive")
{
return Err(StatusCode::CONFLICT);
}
diff --git a/src/api/stats.rs b/src/api/stats.rs
index 838cc0f..fa11b45 100644
--- a/src/api/stats.rs
+++ b/src/api/stats.rs
@@ -9,6 +9,8 @@ use axum::{
http::{HeaderMap, header},
response::{IntoResponse, Response},
};
+use std::collections::BTreeMap;
+
use serde::Serialize;
use super::peers::EpochInfo;
@@ -35,6 +37,21 @@ pub(crate) struct StatsResponse {
/// The peers listed in /peers together (a forgotten peer's are gone).
incidents: Incidents,
propagation: PropagationStats,
+ /// This node's own files (`files`), by kind.
+ disk: BTreeMap<&'static str, DiskStats>,
+}
+
+/// Reads and writes of one kind of file, since the node started.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct DiskStats {
+ reads: u64,
+ read_bytes: u64,
+ writes: u64,
+ written_bytes: u64,
+ appends: u64,
+ appended_bytes: u64,
+ errors: u64,
}
/// How long changes written elsewhere took from their commit there until applied here,
@@ -195,5 +212,22 @@ async fn collect(state: &AppState) -> StatsResponse {
.map(|(le_ms, count)| PropagationBucket { le_ms, count })
.collect(),
},
+ disk: state
+ .files
+ .counts()
+ .into_iter()
+ .map(|(kind, counts)| {
+ let stats = DiskStats {
+ reads: counts.reads,
+ read_bytes: counts.read_bytes,
+ writes: counts.writes,
+ written_bytes: counts.written_bytes,
+ appends: counts.appends,
+ appended_bytes: counts.appended_bytes,
+ errors: counts.errors,
+ };
+ (kind.as_str(), stats)
+ })
+ .collect(),
}
}
diff --git a/src/api/stats/prometheus.rs b/src/api/stats/prometheus.rs
index f68ca85..74c18e9 100644
--- a/src/api/stats/prometheus.rs
+++ b/src/api/stats/prometheus.rs
@@ -54,6 +54,45 @@ impl Out {
}
}
+const DISK_COUNTERS: usize = 7;
+
+/// Name, help and value of each counter of one kind of file.
+fn disk_counters(c: &super::DiskStats) -> [(&'static str, &'static str, u64); DISK_COUNTERS] {
+ [
+ (
+ "disk_reads_total",
+ "Reads of this node's own files.",
+ c.reads,
+ ),
+ (
+ "disk_read_bytes_total",
+ "Bytes read from them.",
+ c.read_bytes,
+ ),
+ ("disk_writes_total", "Whole files written.", c.writes),
+ (
+ "disk_written_bytes_total",
+ "Bytes of them.",
+ c.written_bytes,
+ ),
+ (
+ "disk_appends_total",
+ "Appends to this node's own files.",
+ c.appends,
+ ),
+ (
+ "disk_appended_bytes_total",
+ "Bytes appended.",
+ c.appended_bytes,
+ ),
+ (
+ "disk_errors_total",
+ "Failed reads, writes and appends.",
+ c.errors,
+ ),
+ ]
+}
+
fn escape(value: &str) -> String {
value
.replace('\\', "\\\\")
@@ -297,6 +336,16 @@ pub(super) async fn render(state: &AppState, stats: &StatsResponse) -> String {
stats.stale_hello_replies as f64,
);
+ for counter in 0..DISK_COUNTERS {
+ for (i, (kind, counts)) in stats.disk.iter().enumerate() {
+ let (name, help, value) = disk_counters(counts)[counter];
+ if i == 0 {
+ out.family(name, "counter", help);
+ }
+ out.sample(name, &[("file", *kind)], value as f64);
+ }
+ }
+
let propagation = &stats.propagation;
out.family(
"propagation_delay_seconds",
diff --git a/src/config/import.rs b/src/config/import.rs
index 895204a..4602705 100644
--- a/src/config/import.rs
+++ b/src/config/import.rs
@@ -3,7 +3,10 @@
//!
//! * `env:NAME`: an environment variable;
//! * `file:PATH`: a file's contents;
-//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`).
+//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`);
+//! * `template:TEXT`: text with importers in it, `${env:NAME}`, `${file:PATH}` or
+//! `${cmd:COMMAND}`, each replaced by its content (`$$` is a `$`; a placeholder ends
+//! at the first `}`), e.g. `template:node-${env:HOSTNAME}`.
//!
//! The content is text (trailing line breaks removed), converted like a value written
//! on the command line; with `+json` (`env+json:NAME`, …) it is parsed as JSON instead.
@@ -11,8 +14,9 @@
//! On the command line, a value with one of these prefixes is imported; `literal:`
//! passes the rest as is. In configuration files, an importer is a table:
//! `{ env = "NAME" }`, `{ file = "PATH" }`, `{ command = "COMMAND" }` (or an argument
-//! list, run without a shell), each with an optional `format = "json"`. Relative paths
-//! are relative to the file, and commands run in its directory.
+//! list, run without a shell), `{ template = "TEXT" }`, each with an optional
+//! `format = "json"`. Relative paths are relative to the file, and commands run in its
+//! directory, also inside a template.
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -38,6 +42,10 @@ pub(crate) enum Source {
argv: Vec<String>,
dir: Option<PathBuf>,
},
+ Template {
+ template: String,
+ dir: Option<PathBuf>,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -73,6 +81,10 @@ pub(crate) fn parse_argument(raw: &str) -> Raw<'_> {
command: rest.to_owned(),
dir: None,
},
+ "template" => Source::Template {
+ template: rest.to_owned(),
+ dir: None,
+ },
_ => return Raw::Literal(raw),
};
Raw::Import(Importer { source, format })
@@ -83,7 +95,7 @@ pub(crate) fn parse_table(
object: &JsonMap<String, JsonValue>,
dir: &Path,
) -> Result<Option<Importer>> {
- let sources = ["env", "file", "command"]
+ let sources = ["env", "file", "command", "template"]
.into_iter()
.filter(|key| object.contains_key(*key))
.count();
@@ -91,11 +103,11 @@ pub(crate) fn parse_table(
return Ok(None);
}
if sources > 1 {
- bail!("an importer takes exactly one of env, file and command");
+ bail!("an importer takes exactly one of env, file, command and template");
}
if let Some(unknown) = object
.keys()
- .find(|key| !["env", "file", "command", "format"].contains(&key.as_str()))
+ .find(|key| !["env", "file", "command", "template", "format"].contains(&key.as_str()))
{
bail!("unknown importer key {unknown:?}");
}
@@ -115,6 +127,11 @@ pub(crate) fn parse_table(
Source::Env(text("env")?)
} else if object.contains_key("file") {
Source::File(dir.join(text("file")?))
+ } else if object.contains_key("template") {
+ Source::Template {
+ template: text("template")?,
+ dir: Some(dir.to_owned()),
+ }
} else {
match &object["command"] {
JsonValue::String(command) => Source::Shell {
@@ -153,6 +170,7 @@ impl Importer {
Source::File(path) => format!("file{format}:{}", path.display()),
Source::Shell { command, .. } => format!("cmd{format}:{command}"),
Source::Argv { argv, .. } => format!("cmd{format}:{}", argv.join(" ")),
+ Source::Template { template, .. } => format!("template{format}:{template}"),
}
}
@@ -174,6 +192,7 @@ impl Importer {
process.args(&argv[1..]);
run(process, dir.as_deref(), &argv.join(" "))?
}
+ Source::Template { template, dir } => expand(template, dir.as_deref())?,
};
let text = text.trim_end_matches(['\n', '\r']);
Ok(match self.format {
@@ -192,6 +211,55 @@ pub(crate) enum Imported {
Json(JsonValue),
}
+/// A template with its placeholders replaced by their content.
+fn expand(template: &str, dir: Option<&Path>) -> Result<String> {
+ let mut out = String::new();
+ let mut rest = template;
+ while let Some(at) = rest.find('$') {
+ out.push_str(&rest[..at]);
+ let after = &rest[at + 1..];
+ if let Some(after) = after.strip_prefix('$') {
+ out.push('$');
+ rest = after;
+ } else if let Some(after) = after.strip_prefix('{') {
+ let end = after
+ .find('}')
+ .ok_or_else(|| anyhow!("unterminated placeholder in template {template:?}"))?;
+ let placeholder = &after[..end];
+ let Raw::Import(mut importer) = parse_argument(placeholder) else {
+ bail!(
+ "placeholder {placeholder:?} in template {template:?} isn't env:, file: or cmd:"
+ );
+ };
+ match &mut importer.source {
+ Source::File(path) => {
+ if let Some(dir) = dir {
+ *path = dir.join(&*path);
+ }
+ }
+ Source::Shell { dir: run_in, .. } => *run_in = dir.map(Path::to_owned),
+ Source::Template { .. } => bail!("templates don't nest: {placeholder:?}"),
+ Source::Env(_) | Source::Argv { .. } => {}
+ }
+ if importer.format != Format::Text {
+ bail!(
+ "placeholder {placeholder:?} must be text; +json applies to the whole template"
+ );
+ }
+ match importer.fetch()? {
+ Imported::Text(text) => out.push_str(&text),
+ Imported::Json(_) => unreachable!("text importer"),
+ }
+ rest = &after[end + 1..];
+ } else {
+ out.push('$');
+ rest = after;
+ }
+ }
+ out.push_str(rest);
+ Ok(out)
+}
+
fn run(mut process: Command, dir: Option<&Path>, shown: &str) -> Result<String> {
if let Some(dir) = dir {
process.current_dir(dir);
@@ -240,6 +308,39 @@ mod tests {
})
);
assert_eq!(parse_argument("env+yaml:X"), Raw::Literal("env+yaml:X"));
+ assert_eq!(
+ parse_argument("template:node-${env:HOST}"),
+ Raw::Import(Importer {
+ source: Source::Template {
+ template: "node-${env:HOST}".into(),
+ dir: None
+ },
+ format: Format::Text
+ })
+ );
+ }
+
+ #[test]
+ fn templates() {
+ let expanded = |template: &str| expand(template, None);
+ assert_eq!(expanded("a-${cmd:echo b}-c $$5 $x").unwrap(), "a-b-c $5 $x");
+ assert_eq!(
+ expanded("${cmd:printf one}${cmd:printf two}").unwrap(),
+ "onetwo"
+ );
+ assert!(expanded("${cmd:echo b").is_err());
+ assert!(expanded("${plain}").is_err());
+ assert!(expanded("${env+json:X}").is_err());
+ assert!(expanded("${template:x}").is_err());
+
+ let json = Importer {
+ source: Source::Template {
+ template: "[\"${cmd:printf a}\", 1]".into(),
+ dir: None,
+ },
+ format: Format::Json,
+ };
+ assert_eq!(json.fetch().unwrap(), Imported::Json(json!(["a", 1])));
}
#[test]
diff --git a/src/config/mod.rs b/src/config/mod.rs
index ef28542..538689e 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -65,6 +65,9 @@ const fn option(
}
}
+/// What `--force` may override at start (`force`).
+pub(crate) const FORCE: &[&str] = &["swarmId", "badDocumentFile", "replaceDocumentFile"];
+
/// Every option. Names are the long option names and the configuration file keys.
pub(crate) const OPTIONS: &[OptionSpec] = &[
option(
@@ -86,7 +89,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
Kind::List,
"USER,[USER...]",
None,
- "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata is never writable.",
+ "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable.",
),
option(
"debug-api",
@@ -100,14 +103,70 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
Kind::List,
"STRING,[STRING...]",
None,
- "How to reach a node of the swarm to join: a Mycelium public key, a Mycelium IPv6 address, or a host name with an AAAA record for one. Without seeds, the node starts a new swarm (genesis).",
+ "How to reach a node of the swarm to join: a Mycelium public key, a Mycelium IPv6 address, or a host name with an AAAA record for one. A node keeps trying until it has joined.",
+ ),
+ option(
+ "genesis",
+ Kind::Flag,
+ "BOOL",
+ Some("false"),
+ "Start a new swarm, as its genesis node, instead of joining one through seeds. Every start with it starts another new swarm: drop it once the swarm exists, and give the node seeds instead. Can't be combined with seeds. With a --state-file that remembers a swarm, it starts that swarm again instead (empty; nodes of it still running merge with this one).",
+ ),
+ option(
+ "force",
+ Kind::List,
+ "NAME,[NAME...]",
+ None,
+ "Override checks that otherwise refuse to start: true for all, or names: swarmId (a document to start on is of another swarm than the state file remembers), badDocumentFile (an unusable document file: moved aside, start without it), replaceDocumentFile (recover-from while the document file holds a document).",
+ ),
+ option(
+ "recover-from",
+ Kind::Text,
+ "PATH",
+ None,
+ "Start the swarm again from a backup (as GET /admin/document saves it): this node loads it, on the swarm and epoch it names, and is ready at once; other nodes join it. Members of the swarm still running merge with it, or it rejoins them if they moved on to a newer epoch (the backup is then dropped).",
+ ),
+ option(
+ "remake",
+ Kind::Text,
+ "KIND[:PATH]",
+ None,
+ "With --genesis: make the new swarm's document as a remake of this kind would, instead of starting it empty: import:PATH (an Automerge document, e.g. a backup, without its history and protocol data) or replace:PATH (a JSON document, the store root); compact changes nothing.",
+ ),
+ option(
+ "document-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "This node's document on disk. Read at start: the node resumes with it, ready at once. Kept up to date while ready: changes are appended every --document-append-secs, and the file is rewritten after a switch, a rejoin or a restart, and when the appended changes outgrew it.",
+ ),
+ option(
+ "document-append-secs",
+ Kind::Integer,
+ "SECONDS",
+ Some("5"),
+ "How often changes are appended to the document file.",
+ ),
+ option(
+ "document-rewrite-secs",
+ Kind::Integer,
+ "SECONDS",
+ Some("3600"),
+ "Shortest time between rewrites of the document file because its appended changes outgrew its full save (a new epoch or a restart rewrites it at once).",
+ ),
+ option(
+ "state-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "File where this node remembers its swarm across restarts: the swarm ID and some members to rejoin through (not the document). Written once ready, then rarely. Without it, a restarted node knows only its configured seeds.",
),
option(
"swarm-key-file",
Kind::Text,
"PATH",
None,
- "Pre-shared swarm key: only nodes that know it may join (raw bytes, at least 16; e.g. `head -c 32 /dev/urandom`). Without it, anyone who knows the swarm ID can.",
+ "Pre-shared swarm key: only nodes that know it may join (raw bytes, at least 16; e.g. `head -c 32 /dev/urandom`). Without it, any node that can reach a member may join.",
),
option(
"alias",
@@ -213,6 +272,18 @@ pub(crate) struct Settings {
pub(crate) debug_api: bool,
#[serde(default)]
pub(crate) seeds: Vec<String>,
+ #[serde(default)]
+ pub(crate) genesis: bool,
+ pub(crate) state_file: Option<PathBuf>,
+ pub(crate) document_file: Option<PathBuf>,
+ #[serde(default = "default_document_append_secs")]
+ pub(crate) document_append_secs: u64,
+ #[serde(default = "default_document_rewrite_secs")]
+ pub(crate) document_rewrite_secs: u64,
+ pub(crate) recover_from: Option<PathBuf>,
+ pub(crate) remake: Option<String>,
+ #[serde(default)]
+ pub(crate) force: Vec<String>,
pub(crate) swarm_key_file: Option<PathBuf>,
pub(crate) alias: Option<String>,
#[serde(default = "default_keepalive_secs")]
@@ -236,6 +307,12 @@ pub(crate) struct Settings {
fn default_bind_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 3000))
}
+fn default_document_append_secs() -> u64 {
+ 5
+}
+fn default_document_rewrite_secs() -> u64 {
+ 3600
+}
fn default_keepalive_secs() -> u64 {
60
}
@@ -286,12 +363,29 @@ impl Settings {
);
}
}
+ crate::force::Force::parse(self.force.iter().map(String::as_str), FORCE)
+ .map_err(|reason| anyhow!("force: {reason}"))?;
+ // Contradictions, not overrides: an error wherever the options come from.
+ if self.genesis && !self.seeds.is_empty() {
+ bail!(
+ "genesis starts a new swarm, so there are no seeds to join through; unset seeds (--unset seeds) or genesis"
+ );
+ }
+ if self.remake.is_some() && !self.genesis {
+ bail!("remake makes a new swarm's document; it needs genesis");
+ }
+ if self.genesis && self.recover_from.is_some() {
+ bail!("recover-from starts the swarm from a backup; it can't be combined with genesis");
+ }
if self.debug_epoch.is_some() && !self.debug_api {
bail!("debug-epoch needs debug-api");
}
if self.neighbours < 2 {
bail!("neighbours must be at least 2");
}
+ if self.document_append_secs == 0 || self.document_rewrite_secs == 0 {
+ bail!("document-append-secs and document-rewrite-secs must be at least 1");
+ }
if self.keepalive_secs == 0 {
bail!("keepalive-secs must be at least 1");
}
diff --git a/src/document/file.rs b/src/document/file.rs
new file mode 100644
index 0000000..a773502
--- /dev/null
+++ b/src/document/file.rs
@@ -0,0 +1,362 @@
+//! The document file (`--document-file`): this node's document on disk, so a restart
+//! resumes with it instead of starting empty.
+//!
+//! Format: the line `continuum-document/1`, a header line (JSON: the actor IDs this
+//! node wrote with, so a later rejoin replays those writes as its own), then records,
+//! each a little-endian `u32` length and that many bytes: first a full save of the
+//! document, then increments (the changes after the heads written before). A record cut
+//! short by a crash is dropped when reading.
+//!
+//! Written by a worker (`run`), only while the node is ready and not rejoining (a rejoin
+//! keeps the old file until it is done):
+//! * every `--document-append-secs`, what changed is appended;
+//! * the whole file is rewritten, atomically, when the epoch or this node's actors
+//! changed (a switch, a rejoin, a restart), and when the appended increments outgrew
+//! the full save, at most every `--document-rewrite-secs`;
+//! * at shutdown, what changed is appended.
+
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
+
+use anyhow::{Context, Result, anyhow, bail};
+use automerge::{ActorId, AutoCommit, ChangeHash};
+use serde::{Deserialize, Serialize};
+use tokio::time::Instant;
+use tracing::{debug, error, warn};
+
+use crate::document::replica::Epoch;
+use crate::files::{FileKind, Files};
+use crate::state::AppState;
+
+const MAGIC: &[u8] = b"continuum-document/1\n";
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+struct Header {
+ /// Hex actor IDs.
+ #[serde(default)]
+ actors: Vec<String>,
+}
+
+/// A document file as read at start.
+pub(crate) struct Loaded {
+ /// Under a fresh actor.
+ pub(crate) doc: AutoCommit,
+ /// The actors this node wrote with before.
+ pub(crate) actors: Vec<ActorId>,
+}
+
+/// Reads the document file; None if there is none. An error if it can't be read or
+/// isn't one: the caller decides (`--force badDocumentFile`).
+pub(crate) fn load(path: &Path, files: &Files) -> Result<Option<Loaded>> {
+ let Some(bytes) = files
+ .read(FileKind::Document, path)
+ .with_context(|| format!("Reading the document file {}", path.display()))?
+ else {
+ return Ok(None);
+ };
+ let (loaded, dropped) =
+ parse(&bytes).with_context(|| format!("The document file {}", path.display()))?;
+ if dropped > 0 {
+ warn!(
+ path = %path.display(),
+ bytes = dropped,
+ "Dropped a record cut short at the end of the document file (a crash while appending)"
+ );
+ }
+ Ok(Some(loaded))
+}
+
+/// The file's content, and how many bytes at its end were cut short.
+fn parse(bytes: &[u8]) -> Result<(Loaded, usize)> {
+ let rest = bytes
+ .strip_prefix(MAGIC)
+ .ok_or_else(|| anyhow!("not a continuum document file"))?;
+ let line_end = rest
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .ok_or_else(|| anyhow!("no header"))?;
+ let header: Header = serde_json::from_slice(&rest[..line_end]).context("header")?;
+ let actors = header
+ .actors
+ .iter()
+ .map(|hex| {
+ ActorId::try_from(hex.as_str())
+ .map_err(|_| anyhow!("invalid actor {hex:?} in the header"))
+ })
+ .collect::<Result<Vec<_>>>()?;
+
+ let mut records = &rest[line_end + 1..];
+ let mut doc: Option<AutoCommit> = None;
+ while !records.is_empty() {
+ let Some((length, after)) = records.split_first_chunk::<4>() else {
+ break;
+ };
+ let length = u32::from_le_bytes(*length) as usize;
+ if after.len() < length {
+ break;
+ }
+ let (record, after) = after.split_at(length);
+ match &mut doc {
+ None => doc = Some(AutoCommit::load(record).context("the full save")?),
+ Some(doc) => {
+ doc.load_incremental(record).context("an increment")?;
+ }
+ }
+ records = after;
+ }
+ let Some(doc) = doc else {
+ bail!("no full save");
+ };
+ let loaded = Loaded {
+ doc: doc.with_actor(ActorId::random()),
+ actors,
+ };
+ Ok((loaded, records.len()))
+}
+
+fn record(bytes: &[u8]) -> Vec<u8> {
+ let mut out = Vec::with_capacity(bytes.len() + 4);
+ out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
+ out.extend_from_slice(bytes);
+ out
+}
+
+/// A whole file: header and one full save.
+fn whole(actors: &[ActorId], save: &[u8]) -> Result<Vec<u8>> {
+ let header = Header {
+ actors: actors.iter().map(ActorId::to_hex_string).collect(),
+ };
+ let mut out = MAGIC.to_vec();
+ out.extend(serde_json::to_vec(&header)?);
+ out.push(b'\n');
+ out.extend(record(save));
+ Ok(out)
+}
+
+/// The document file and what was last written to it.
+pub(crate) struct DocumentFile {
+ path: PathBuf,
+ files: Arc<Files>,
+ append_every: Duration,
+ rewrite_every: Duration,
+ // A std mutex: never held across an await.
+ written: Mutex<Option<Written>>,
+}
+
+#[derive(Debug, Clone)]
+struct Written {
+ epoch: Epoch,
+ /// This node's actors when the file was last rewritten (`RemakeState::own_actors`).
+ actors: usize,
+ heads: Vec<ChangeHash>,
+ /// The full save.
+ base_bytes: u64,
+ /// Appended since.
+ tail_bytes: u64,
+ rewritten_at: Instant,
+}
+
+impl DocumentFile {
+ pub(crate) fn new(
+ path: PathBuf,
+ files: Arc<Files>,
+ append_every: Duration,
+ rewrite_every: Duration,
+ ) -> Self {
+ Self {
+ path,
+ files,
+ append_every,
+ rewrite_every,
+ written: Mutex::new(None),
+ }
+ }
+
+ fn lock(&self) -> MutexGuard<'_, Option<Written>> {
+ self.written
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+}
+
+/// Keeps the document file up to date. Runs for as long as the node does.
+pub(crate) async fn run(state: AppState) {
+ let Some(file) = state.document_file.clone() else {
+ return;
+ };
+ let mut tick = tokio::time::interval(file.append_every);
+ tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+ loop {
+ tick.tick().await;
+ flush(&state).await;
+ }
+}
+
+/// Writes what is due now (see the module docs). Also called at shutdown.
+pub(crate) async fn flush(state: &AppState) {
+ let Some(file) = state.document_file.clone() else {
+ return;
+ };
+ if !state.is_ready().await || state.remake.is_rejoining() {
+ return;
+ }
+ let own = state.remake.own_actors();
+ let (epoch, heads) = {
+ let replica = state.doc.read().await;
+ (replica.epoch, replica.heads().to_vec())
+ };
+ let written = file.lock().clone();
+ let reason = match &written {
+ None => Some("first write"),
+ Some(w) if w.epoch != epoch => Some("new epoch"),
+ Some(w) if w.actors != own.len() => Some("new actor"),
+ Some(w)
+ if w.tail_bytes > w.base_bytes && w.rewritten_at.elapsed() >= file.rewrite_every =>
+ {
+ Some("increments outgrew the full save")
+ }
+ Some(_) => None,
+ };
+ match (reason, written) {
+ (Some(reason), _) => rewrite(state, &file, own, reason).await,
+ (None, Some(written)) if written.heads != heads => append(state, &file, written).await,
+ (None, _) => {}
+ }
+}
+
+async fn rewrite(state: &AppState, file: &Arc<DocumentFile>, own: Vec<ActorId>, reason: &str) {
+ let start = Instant::now();
+ let (epoch, mut copy) = {
+ let replica = state.doc.read().await;
+ (replica.epoch, replica.doc.clone())
+ };
+ let own_count = own.len();
+ let writer = Arc::clone(file);
+ let written = tokio::task::spawn_blocking(move || -> Result<(Vec<ChangeHash>, u64)> {
+ let heads = copy.get_heads();
+ // Only the actors with changes in this document matter for a later rejoin;
+ // the current one in any case.
+ let present: HashSet<ActorId> = copy
+ .get_changes(&[])
+ .iter()
+ .map(|change| change.actor_id().clone())
+ .collect();
+ let current = copy.get_actor().clone();
+ let actors: Vec<ActorId> = own
+ .into_iter()
+ .filter(|actor| *actor == current || present.contains(actor))
+ .collect();
+ let save = copy.save();
+ let bytes = whole(&actors, &save)?;
+ writer
+ .files
+ .write_atomic(FileKind::Document, &writer.path, &bytes)?;
+ Ok((heads, save.len() as u64))
+ })
+ .await;
+ match written {
+ Ok(Ok((heads, base_bytes))) => {
+ debug!(
+ reason,
+ bytes = base_bytes,
+ took_ms = start.elapsed().as_millis() as u64,
+ "Rewrote the document file"
+ );
+ *file.lock() = Some(Written {
+ epoch,
+ actors: own_count,
+ heads,
+ base_bytes,
+ tail_bytes: 0,
+ rewritten_at: Instant::now(),
+ });
+ }
+ Ok(Err(error)) => {
+ error!(path = %file.path.display(), error = %format!("{error:#}"), "Couldn't rewrite the document file");
+ }
+ Err(error) => error!(error = %error, "Document file rewrite task failed"),
+ }
+}
+
+async fn append(state: &AppState, file: &Arc<DocumentFile>, written: Written) {
+ let (heads, increment) = {
+ let mut replica = state.doc.write().await;
+ // Switched since `flush` looked: the next one rewrites the file instead. An
+ // increment of another epoch's document must never be appended.
+ if replica.epoch != written.epoch {
+ return;
+ }
+ let heads = replica.heads().to_vec();
+ (heads, replica.doc.save_after(&written.heads))
+ };
+ let bytes = record(&increment);
+ let writer = Arc::clone(file);
+ let appended = tokio::task::spawn_blocking(move || {
+ writer
+ .files
+ .append(FileKind::Document, &writer.path, &bytes)
+ })
+ .await;
+ match appended {
+ Ok(Ok(())) => {
+ let mut guard = file.lock();
+ if let Some(current) = guard
+ .as_mut()
+ .filter(|current| current.heads == written.heads)
+ {
+ current.heads = heads;
+ current.tail_bytes += increment.len() as u64 + 4;
+ debug!(
+ bytes = increment.len(),
+ tail_bytes = current.tail_bytes,
+ base_bytes = current.base_bytes,
+ "Appended to the document file"
+ );
+ }
+ }
+ Ok(Err(error)) => {
+ // The file may end in a partial record now; the next rewrite replaces it.
+ error!(path = %file.path.display(), error = %error, "Couldn't append to the document file; rewriting it next");
+ *file.lock() = None;
+ }
+ Err(error) => error!(error = %error, "Document file append task failed"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use automerge::{ROOT, ReadDoc, transaction::Transactable};
+
+ #[test]
+ fn whole_then_increments() {
+ let mut doc = AutoCommit::new();
+ doc.put(ROOT, "a", 1_i64).unwrap();
+ doc.commit();
+ let actor = doc.get_actor().clone();
+ let heads = doc.get_heads();
+ let mut bytes = whole(std::slice::from_ref(&actor), &doc.save()).unwrap();
+
+ doc.put(ROOT, "b", 2_i64).unwrap();
+ doc.commit();
+ bytes.extend(record(&doc.save_after(&heads)));
+
+ let (loaded, dropped) = parse(&bytes).unwrap();
+ assert_eq!(dropped, 0);
+ assert_eq!(loaded.actors, [actor.clone()]);
+ assert_ne!(loaded.doc.get_actor(), &actor);
+ assert!(loaded.doc.get(ROOT, "b").unwrap().is_some());
+
+ // A record cut short by a crash is dropped.
+ let mut cut = bytes.clone();
+ cut.extend(record(b"whatever")[..6].iter());
+ let (loaded, dropped) = parse(&cut).unwrap();
+ assert_eq!(dropped, 6);
+ assert!(loaded.doc.get(ROOT, "b").unwrap().is_some());
+
+ assert!(parse(b"something else").is_err());
+ assert!(parse(&bytes[..MAGIC.len() + 3]).is_err());
+ }
+}
diff --git a/src/document/mod.rs b/src/document/mod.rs
index ce24815..0da1d67 100644
--- a/src/document/mod.rs
+++ b/src/document/mod.rs
@@ -1,5 +1,6 @@
//! The replicated document: the replica with its epoch, and the store semantics
//! (merged-view paths over JSON) the HTTP API and remakes work with.
+pub(crate) mod file;
pub mod replica;
pub mod store;
diff --git a/src/document/replica.rs b/src/document/replica.rs
index e24f93c..db47223 100644
--- a/src/document/replica.rs
+++ b/src/document/replica.rs
@@ -86,6 +86,9 @@ impl fmt::Debug for Epoch {
/// [`Replica::heads`].
pub struct Replica {
pub epoch: Epoch,
+ /// How many times this node changed epoch (switch, rejoin, abandoned rejoin) in this
+ /// run: messages carry it, so a late one can't flip a peer's view of our epoch back.
+ pub epoch_seq: u32,
pub doc: AutoCommit,
heads: Vec<ChangeHash>,
}
@@ -94,6 +97,7 @@ impl Replica {
pub fn new(epoch: Epoch, doc: AutoCommit) -> Self {
let mut replica = Self {
epoch,
+ epoch_seq: 0,
doc,
heads: Vec::new(),
};
diff --git a/src/files.rs b/src/files.rs
new file mode 100644
index 0000000..58fd043
--- /dev/null
+++ b/src/files.rs
@@ -0,0 +1,213 @@
+//! The files this node reads and writes for itself, and how much it did (`/admin/stats`,
+//! `disk`): the state file (`swarm::state_file`), the document file
+//! (`document::file`) and a backup to recover from (`swarm::bootstrap`). Not the
+//! Mycelium daemon's files, configuration or logs.
+//!
+//! Writes replace a file atomically: a temporary file next to it, flushed to disk, then
+//! renamed over it, so a crash leaves the old content or the new one. Appends add to the
+//! end and are flushed to disk before they count as done; a crash may leave the last one
+//! cut short, which the file's reader has to expect.
+
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, MutexGuard};
+
+/// What a file is, for the counts.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum FileKind {
+ State,
+ Document,
+ Backup,
+ /// The input of a genesis remake (`--genesis --remake`).
+ Remake,
+}
+
+impl FileKind {
+ pub(crate) const ALL: [FileKind; 4] = [
+ FileKind::State,
+ FileKind::Document,
+ FileKind::Backup,
+ FileKind::Remake,
+ ];
+
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ FileKind::State => "state",
+ FileKind::Document => "document",
+ FileKind::Backup => "backup",
+ FileKind::Remake => "remake",
+ }
+ }
+
+ fn index(self) -> usize {
+ self as usize
+ }
+}
+
+/// Counts for one kind of file, since the node started.
+#[derive(Clone, Copy, Debug, Default)]
+pub(crate) struct Counts {
+ pub(crate) reads: u64,
+ pub(crate) read_bytes: u64,
+ /// Whole files written.
+ pub(crate) writes: u64,
+ pub(crate) written_bytes: u64,
+ pub(crate) appends: u64,
+ pub(crate) appended_bytes: u64,
+ /// Reads, writes or appends that failed (a missing file read is no failure).
+ pub(crate) errors: u64,
+}
+
+/// Reads and writes this node's files, counting per kind.
+#[derive(Default)]
+pub(crate) struct Files {
+ // A std mutex: every access is short and never spans an await.
+ counts: Mutex<[Counts; FileKind::ALL.len()]>,
+}
+
+impl Files {
+ fn lock(&self) -> MutexGuard<'_, [Counts; FileKind::ALL.len()]> {
+ self.counts
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ fn count(&self, kind: FileKind, f: impl FnOnce(&mut Counts)) {
+ f(&mut self.lock()[kind.index()]);
+ }
+
+ /// The file's content; None if it doesn't exist.
+ pub(crate) fn read(&self, kind: FileKind, path: &Path) -> std::io::Result<Option<Vec<u8>>> {
+ match std::fs::read(path) {
+ Ok(bytes) => {
+ self.count(kind, |counts| {
+ counts.reads += 1;
+ counts.read_bytes += bytes.len() as u64;
+ });
+ Ok(Some(bytes))
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+ Err(error) => {
+ self.count(kind, |counts| counts.errors += 1);
+ Err(error)
+ }
+ }
+ }
+
+ /// Replaces the file's content atomically (see the module docs).
+ pub(crate) fn write_atomic(
+ &self,
+ kind: FileKind,
+ path: &Path,
+ bytes: &[u8],
+ ) -> std::io::Result<()> {
+ let result = write_atomic(path, bytes);
+ self.count(kind, |counts| match &result {
+ Ok(()) => {
+ counts.writes += 1;
+ counts.written_bytes += bytes.len() as u64;
+ }
+ Err(_) => counts.errors += 1,
+ });
+ result
+ }
+
+ /// Adds `bytes` to the end of the file (which must exist), flushed to disk.
+ pub(crate) fn append(&self, kind: FileKind, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
+ let result = (|| {
+ let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
+ file.write_all(bytes)?;
+ file.sync_data()
+ })();
+ self.count(kind, |counts| match &result {
+ Ok(()) => {
+ counts.appends += 1;
+ counts.appended_bytes += bytes.len() as u64;
+ }
+ Err(_) => counts.errors += 1,
+ });
+ result
+ }
+
+ /// Moves an unusable file aside (`<path><suffix>`), returning where to.
+ pub(crate) fn move_aside(&self, path: &Path, suffix: &str) -> std::io::Result<PathBuf> {
+ let aside = with_suffix(path, suffix);
+ std::fs::rename(path, &aside)?;
+ Ok(aside)
+ }
+
+ /// The counts per kind.
+ pub(crate) fn counts(&self) -> Vec<(FileKind, Counts)> {
+ let counts = *self.lock();
+ FileKind::ALL
+ .into_iter()
+ .map(|kind| (kind, counts[kind.index()]))
+ .collect()
+ }
+}
+
+fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
+ let temporary = with_suffix(path, ".tmp");
+ {
+ let mut file = std::fs::File::create(&temporary)?;
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ }
+ std::fs::rename(&temporary, path)?;
+ sync_parent(path);
+ Ok(())
+}
+
+/// Makes a rename in `path`'s directory durable (best effort: not every platform syncs
+/// directories).
+fn sync_parent(path: &Path) {
+ if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
+ if let Ok(dir) = std::fs::File::open(dir) {
+ let _ = dir.sync_all();
+ }
+ }
+}
+
+fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
+ let mut name = path.as_os_str().to_owned();
+ name.push(suffix);
+ PathBuf::from(name)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn writes_reads_and_counts() {
+ let dir = std::env::temp_dir().join(format!("continuum-files-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("state.json");
+ let files = Files::default();
+
+ assert_eq!(files.read(FileKind::State, &path).unwrap(), None);
+ files.write_atomic(FileKind::State, &path, b"one").unwrap();
+ files
+ .write_atomic(FileKind::State, &path, b"three")
+ .unwrap();
+ assert_eq!(
+ files.read(FileKind::State, &path).unwrap().as_deref(),
+ Some(&b"three"[..])
+ );
+ assert!(!with_suffix(&path, ".tmp").exists());
+
+ let (kind, state) = files.counts()[0];
+ assert_eq!(kind, FileKind::State);
+ assert_eq!((state.writes, state.written_bytes), (2, 8));
+ assert_eq!((state.reads, state.read_bytes, state.errors), (1, 5, 0));
+ files.append(FileKind::State, &path, b"!").unwrap();
+ assert_eq!(
+ files.read(FileKind::State, &path).unwrap().as_deref(),
+ Some(&b"three!"[..])
+ );
+ assert_eq!(files.counts()[0].1.appended_bytes, 1);
+ assert_eq!(files.counts()[1].1.reads, 0);
+
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+}
diff --git a/src/force.rs b/src/force.rs
new file mode 100644
index 0000000..40f0ffc
--- /dev/null
+++ b/src/force.rs
@@ -0,0 +1,72 @@
+//! `force`: overriding checks that otherwise refuse. Conflicts are errors by default;
+//! each check that can be overridden has a name, and `force` lists the names to
+//! override, or is `true` for all of them (`false`, or nothing, for none).
+//!
+//! The same format everywhere: the `?force=` query parameter of the API and the
+//! `--force` option. Names are camelCase, like the API's fields (`switchDelaySecs`),
+//! on the command line too. An unknown name is an error, so a typo can't silently
+//! override nothing.
+
+/// The checks a `force` value overrides.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub(crate) struct Force {
+ all: bool,
+ names: Vec<&'static str>,
+}
+
+impl Force {
+ /// Parses `values` (each `true`, `false`, or a comma-separated list of names),
+ /// allowing only the names in `known`.
+ pub(crate) fn parse<'a>(
+ values: impl IntoIterator<Item = &'a str>,
+ known: &[&'static str],
+ ) -> Result<Self, String> {
+ let mut force = Force::default();
+ for value in values {
+ for name in value.split(',').map(str::trim) {
+ match name {
+ "" | "false" => {}
+ "true" => force.all = true,
+ name => match known.iter().find(|known| **known == name) {
+ Some(known) => force.names.push(known),
+ None => {
+ return Err(format!(
+ "unknown force {name:?}: use true, or a list of {}",
+ known.join(", ")
+ ));
+ }
+ },
+ }
+ }
+ }
+ Ok(force)
+ }
+
+ /// Whether the check `name` is overridden.
+ pub(crate) fn allows(&self, name: &str) -> bool {
+ self.all || self.names.contains(&name)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const KNOWN: &[&str] = &["switchDelaySecs", "supersede"];
+
+ #[test]
+ fn all_some_or_none() {
+ let all = Force::parse(["true"], KNOWN).unwrap();
+ assert!(all.allows("supersede") && all.allows("switchDelaySecs"));
+
+ let some = Force::parse(["supersede"], KNOWN).unwrap();
+ assert!(some.allows("supersede") && !some.allows("switchDelaySecs"));
+ let list = Force::parse(["switchDelaySecs, supersede"], KNOWN).unwrap();
+ assert!(list.allows("supersede") && list.allows("switchDelaySecs"));
+
+ for none in [vec![], vec!["false"], vec![""]] {
+ assert!(!Force::parse(none, KNOWN).unwrap().allows("supersede"));
+ }
+ assert!(Force::parse(["swarm-id"], KNOWN).is_err());
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 217a9a4..e494497 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,37 +1,37 @@
//! continuum: a replicated Automerge key-value store synchronized over Mycelium.
//!
-//! main.rs only wires things together: arguments, state construction, background
-//! tasks, bootstrap and the HTTP server.
+//! main.rs only wires things together: arguments, the starting document, the node
+//! (`node`), bootstrap and the HTTP server.
-use std::collections::HashMap;
use std::future::IntoFuture;
use std::sync::Arc;
-use std::sync::atomic::AtomicBool;
use std::time::Duration;
use anyhow::{Context, Result};
use automerge::AutoCommit;
-use tokio::sync::{RwLock, broadcast, mpsc, watch};
-use tracing::{debug, error, info, trace, warn};
+use tokio::sync::watch;
+use tracing::{debug, error, info, warn};
mod api;
mod backoff;
mod config;
mod document;
+mod files;
+mod force;
mod logging;
+mod node;
mod remake;
+#[cfg(test)]
+mod simtest;
mod state;
mod swarm;
mod transport;
use api::auth::AuthState;
-use document::replica::{Epoch, Replica};
-use state::{AppState, NodeLifecycle, Timing};
-use swarm::{bootstrap, peer, sync};
+use document::replica::Epoch;
+use swarm::bootstrap;
use transport::Transport;
-use transport::firewall::{Firewall, FirewallTransport};
use transport::mycelium::{self, MyceliumSetup, MyceliumSpawnConfig};
-use transport::traffic::{CountingTransport, Traffic};
#[tokio::main]
async fn main() -> Result<()> {
@@ -48,7 +48,7 @@ async fn main() -> Result<()> {
let swarm_key = match &args.swarm_key_file {
Some(path) => Some(Arc::new(swarm::auth::SwarmKey::from_file(path)?)),
None => {
- warn!("No swarm key: any node that learns the swarm ID can join");
+ warn!("No swarm key: any node that can reach a member may join");
None
}
};
@@ -86,124 +86,139 @@ async fn main() -> Result<()> {
"Using Mycelium REST API"
);
- let seeds: Vec<String> = args.seeds.clone();
+ let force = force::Force::parse(args.force.iter().map(String::as_str), config::FORCE)
+ .map_err(anyhow::Error::msg)?;
+ let files = Arc::new(files::Files::default());
+ let mut seeds: Vec<String> = args.seeds.clone();
+ let (state_file, remembered) = match &args.state_file {
+ Some(path) => {
+ let (file, remembered) =
+ swarm::state_file::StateFile::open(path.clone(), Arc::clone(&files));
+ (Some(Arc::new(file)), remembered)
+ }
+ None => (None, None),
+ };
+ if let Some(remembered) = &remembered {
+ info!(
+ swarm_id = %remembered.swarm_id,
+ seeds = remembered.seeds.len(),
+ "Remembering a swarm from the state file"
+ );
+ for seed in &remembered.seeds {
+ if !seeds.contains(seed) {
+ seeds.push(seed.clone());
+ }
+ }
+ } else if args.genesis && state_file.is_none() {
+ warn!(
+ "--genesis without --state-file: every restart with --genesis starts another new swarm"
+ );
+ }
debug!(seed_count = seeds.len(), "Loaded bootstrap configuration");
- let (sync_tx, sync_rx) = mpsc::unbounded_channel();
- let (change_tx, _) = watch::channel(0_u64);
- let (change_events, _) = broadcast::channel(256);
-
- let initial_epoch = match args.debug_epoch {
- Some(generation) => Epoch {
+ let starting = bootstrap::starting_document(
+ &bootstrap::StartOptions {
+ recover_from: args.recover_from.as_deref(),
+ document_file: args.document_file.as_deref(),
+ genesis: args.genesis,
+ remembered: remembered.as_ref().map(|r| r.swarm_id.as_str()),
+ },
+ &files,
+ &force,
+ )?;
+ let (initial_doc, starting_epoch, starting_swarm, earlier_actors) = match starting {
+ Some(starting) => (
+ starting.doc,
+ Some(starting.epoch),
+ Some((starting.swarm_id, starting.origin)),
+ starting.earlier_actors,
+ ),
+ None => (AutoCommit::new(), None, None, Vec::new()),
+ };
+ let mut initial_doc = initial_doc;
+ if let Some(spec) = &args.remake {
+ remake::remake_genesis(spec, &files, &mut initial_doc)
+ .with_context(|| format!("Making the genesis document (--remake {spec})"))?;
+ info!(remake = %spec, "Made the genesis document");
+ }
+ let initial_epoch = match (starting_epoch, args.debug_epoch) {
+ (Some(epoch), _) => epoch,
+ (None, Some(generation)) => Epoch {
generation,
id: [0xee; 32],
},
- None => Epoch::GENESIS,
+ (None, None) => Epoch::GENESIS,
};
- let (epoch_tx, _) = watch::channel(initial_epoch);
debug!(epoch = %initial_epoch, "Local epoch");
- let firewall = Arc::new(Firewall::default());
- let traffic = Arc::new(Traffic::default());
-
let mycelium_transport = Arc::new(mycelium::MyceliumTransport::connect(mycelium_setup).await?);
debug!(local_id = mycelium_transport.local_id(), "Transport ready");
- let state = AppState {
- doc: Arc::new(RwLock::new(Replica::new(initial_epoch, AutoCommit::new()))),
- epoch_tx,
- lifecycle: Arc::new(RwLock::new(NodeLifecycle::Joining {
- discovered_swarm_id: None,
- })),
- peer_states: Arc::new(RwLock::new(HashMap::new())),
- // Counted outside the firewall: what it drops outbound counts as sent (it plays a
- // lossy network), what it drops inbound never arrived.
- transport: Arc::new(CountingTransport::new(
- Arc::new(FirewallTransport::new(
- mycelium_transport,
- Arc::clone(&firewall),
- )),
- Arc::clone(&traffic),
- )),
- firewall,
- traffic,
- stats: Default::default(),
- sync_events: sync_tx,
- change_tx,
- initial_sync_done: Arc::new(AtomicBool::new(false)),
- change_events,
- timing: Timing::from_keepalive(Duration::from_secs(args.keepalive_secs)),
- remake: Arc::new(remake::RemakeState::default()),
- swarm_key,
- run_id: uuid::Uuid::new_v4().into_bytes(),
+ let document_file = args.document_file.clone().map(|path| {
+ Arc::new(document::file::DocumentFile::new(
+ path,
+ Arc::clone(&files),
+ Duration::from_secs(args.document_append_secs),
+ Duration::from_secs(args.document_rewrite_secs),
+ ))
+ });
+ let node = node::Node::new(node::NodeConfig {
+ transport: mycelium_transport,
+ initial_epoch,
+ initial_doc,
+ earlier_actors,
+ timing: state::Timing::from_keepalive(Duration::from_secs(args.keepalive_secs)),
neighbours_k: args.neighbours,
alias: args.alias.clone(),
electorate_window: Duration::from_secs(args.electorate_window_days * 24 * 3600),
- blobs: Arc::new(swarm::blob::Blobs::new(args.blob_chunk_kib * 1024)),
- protocol_writers: Arc::new(args.protocol_writers.iter().cloned().collect()),
- config: Arc::new(config.report()),
- neighbours: Default::default(),
- pulling: Default::default(),
- announce_pending: Default::default(),
- rtt: Default::default(),
- throughput: Default::default(),
- stability: Default::default(),
- stale: Default::default(),
- catch_up_source: Default::default(),
- catch_up_preference: Default::default(),
- };
-
- trace!("Application state initialized");
-
- let consumer_state = state.clone();
- tokio::spawn(async move {
- let inner_state = consumer_state.clone();
- let _ = consumer_state
- .transport
- .start_consumer(Box::new(move |sender, msg, _payload_len| {
- let state_clone = inner_state.clone();
- Box::pin(async move { peer::handle_message(&state_clone, sender, msg).await })
- }))
- .await;
- });
- trace!("Mycelium consumer task spawned");
-
- let sync_state = state.clone();
- tokio::spawn(async move {
- sync::run_sync_worker(sync_state, sync_rx).await;
+ blob_chunk_bytes: args.blob_chunk_kib * 1024,
+ swarm_key,
+ protocol_writers: args.protocol_writers.iter().cloned().collect(),
+ files,
+ state_file,
+ document_file,
+ config_report: config.report(),
});
- trace!("Sync worker task spawned");
+ let state = node.state.clone();
- let peer_worker_state = state.clone();
- tokio::spawn(async move {
- peer::run_peer_worker(peer_worker_state).await;
- });
- trace!("Peer worker task spawned");
+ // Join the remembered swarm and no other; HELLOs claim it. A document to start on
+ // names its swarm itself (checked against the remembered one above).
+ let start = match starting_swarm {
+ Some((swarm_id, origin)) => {
+ state.set_expected_swarm_id(swarm_id).await?;
+ bootstrap::Start::FromDocument(origin)
+ }
+ None => {
+ if let Some(remembered) = remembered {
+ state.set_expected_swarm_id(remembered.swarm_id).await?;
+ }
+ if args.genesis {
+ bootstrap::Start::Genesis
+ } else {
+ bootstrap::Start::Join
+ }
+ }
+ };
- // Switches to committed epochs and catches up with newer ones. Starts before
- // bootstrapping: a node joining a compacted swarm can only finish its initial sync
- // after fetching the current epoch from a peer.
- tokio::spawn(remake::run_worker(state.clone()));
- trace!("Remake worker task spawned");
+ node.spawn_workers();
+ let joining = bootstrap::bootstrap(&state, &seeds, start);
tokio::select! {
- joined = bootstrap::bootstrap(&state, &seeds) => joined.context("Joining swarm")?,
+ joined = joining => joined.context("Joining swarm")?,
_ = stopped(stop.clone()) => {
info!("Stopped while joining the swarm");
return Ok(());
}
}
- // Start the change publisher after bootstrapping: streams only exist from then on,
- // and it avoids diffing the initial sync.
- let publisher_state = state.clone();
- tokio::spawn(async move { sync::run_change_publisher(publisher_state).await });
+ node::spawn_ready_workers(&state);
info!(
swarm_id = ?state.swarm_id().await,
ready = state.is_ready().await,
"Bootstrap completed"
);
+ swarm::state_file::update(&state, true).await;
let auth_state = AuthState::new(&args.htpasswd);
let app = api::router(state.clone(), auth_state, args.debug_api);
@@ -222,6 +237,7 @@ async fn main() -> Result<()> {
});
let server_fut = server_builder.into_future();
+ let stopping = state.clone();
tokio::pin!(server_fut);
tokio::select! {
@@ -247,6 +263,8 @@ async fn main() -> Result<()> {
}
info!("HTTP API server stopped");
+ swarm::state_file::update(&stopping, true).await;
+ document::file::flush(&stopping).await;
Ok(())
}
diff --git a/src/node.rs b/src/node.rs
new file mode 100644
index 0000000..57d961c
--- /dev/null
+++ b/src/node.rs
@@ -0,0 +1,174 @@
+//! Building a node and starting its workers: what `main` does once it has read the
+//! configuration and connected to Mycelium, and what the simulator (`transport::sim`)
+//! does for every simulated node. Everything here is independent of where the
+//! transport and the settings come from.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+use std::sync::atomic::AtomicBool;
+use std::time::Duration;
+
+use automerge::{ActorId, AutoCommit};
+use tokio::sync::{RwLock, broadcast, mpsc, watch};
+use tracing::trace;
+
+use crate::document::file::DocumentFile;
+use crate::document::replica::{Epoch, Replica};
+use crate::files::Files;
+use crate::remake;
+use crate::state::{AppState, NodeLifecycle, Timing};
+use crate::swarm::auth::SwarmKey;
+use crate::swarm::state_file::StateFile;
+use crate::swarm::sync::SyncEvent;
+use crate::swarm::{self, peer, sync};
+use crate::transport::Transport;
+use crate::transport::firewall::{Firewall, FirewallTransport};
+use crate::transport::traffic::{CountingTransport, Traffic};
+
+/// What a node is built from.
+pub(crate) struct NodeConfig {
+ /// The node's transport, without the counting and firewall layers (added here).
+ pub(crate) transport: Arc<dyn Transport>,
+ pub(crate) initial_epoch: Epoch,
+ pub(crate) initial_doc: AutoCommit,
+ /// Actors this node wrote with in earlier runs (`--document-file`).
+ pub(crate) earlier_actors: Vec<ActorId>,
+ /// `Timing::from_keepalive(--keepalive)`.
+ pub(crate) timing: Timing,
+ pub(crate) neighbours_k: usize,
+ pub(crate) alias: Option<String>,
+ pub(crate) electorate_window: Duration,
+ pub(crate) blob_chunk_bytes: u32,
+ pub(crate) swarm_key: Option<Arc<SwarmKey>>,
+ pub(crate) protocol_writers: HashSet<String>,
+ pub(crate) files: Arc<Files>,
+ pub(crate) state_file: Option<Arc<StateFile>>,
+ pub(crate) document_file: Option<Arc<DocumentFile>>,
+ /// For `GET /admin/config`.
+ pub(crate) config_report: serde_json::Value,
+}
+
+/// A built node whose workers haven't started yet.
+pub(crate) struct Node {
+ pub(crate) state: AppState,
+ sync_rx: mpsc::UnboundedReceiver<SyncEvent>,
+}
+
+impl Node {
+ pub(crate) fn new(config: NodeConfig) -> Self {
+ let (sync_tx, sync_rx) = mpsc::unbounded_channel();
+ let (change_tx, _) = watch::channel(0_u64);
+ let (change_events, _) = broadcast::channel(256);
+ let (epoch_tx, _) = watch::channel(config.initial_epoch);
+ let firewall = Arc::new(Firewall::default());
+ let traffic = Arc::new(Traffic::default());
+ let initial_actor = config.initial_doc.get_actor().clone();
+
+ let state = AppState {
+ doc: Arc::new(RwLock::new(Replica::new(
+ config.initial_epoch,
+ config.initial_doc,
+ ))),
+ epoch_tx,
+ lifecycle: Arc::new(RwLock::new(NodeLifecycle::Joining {
+ discovered_swarm_id: None,
+ })),
+ peer_states: Arc::new(RwLock::new(HashMap::new())),
+ // Counted outside the firewall: what it drops outbound counts as sent (it
+ // plays a lossy network), what it drops inbound never arrived.
+ transport: Arc::new(CountingTransport::new(
+ Arc::new(FirewallTransport::new(
+ config.transport,
+ Arc::clone(&firewall),
+ )),
+ Arc::clone(&traffic),
+ )),
+ firewall,
+ traffic,
+ stats: Default::default(),
+ sync_events: sync_tx,
+ change_tx,
+ initial_sync_done: Arc::new(AtomicBool::new(false)),
+ change_events,
+ timing: config.timing,
+ remake: Arc::new(remake::RemakeState::default()),
+ swarm_key: config.swarm_key,
+ run_id: uuid::Uuid::new_v4().into_bytes(),
+ neighbours_k: config.neighbours_k,
+ alias: config.alias,
+ electorate_window: config.electorate_window,
+ blobs: Arc::new(swarm::blob::Blobs::new(config.blob_chunk_bytes)),
+ protocol_writers: Arc::new(config.protocol_writers),
+ state_file: config.state_file,
+ document_file: config.document_file,
+ files: config.files,
+ config: Arc::new(config.config_report),
+ neighbours: Default::default(),
+ topology_changed: Default::default(),
+ pulling: Default::default(),
+ announce_pending: Default::default(),
+ rtt: Default::default(),
+ throughput: Default::default(),
+ stability: Default::default(),
+ stale: Default::default(),
+ catch_up_source: Default::default(),
+ catch_up_preference: Default::default(),
+ catch_up_avoided: Default::default(),
+ };
+ for actor in config.earlier_actors {
+ state.remake.add_own_actor(actor);
+ }
+ state.remake.add_own_actor(initial_actor);
+ trace!("Application state initialized");
+ Self { state, sync_rx }
+ }
+
+ /// Starts what runs from the start: the message consumer and the sync, peer and
+ /// remake workers. The remake worker starts before bootstrapping: a node joining a
+ /// compacted swarm can only finish its initial sync after fetching the current epoch
+ /// from a peer.
+ pub(crate) fn spawn_workers(self) -> AppState {
+ let Self { state, sync_rx } = self;
+
+ let consumer_state = state.clone();
+ tokio::spawn(async move {
+ let inner_state = consumer_state.clone();
+ let _ = consumer_state
+ .transport
+ .start_consumer(Box::new(move |sender, msg, _payload_len| {
+ let state_clone = inner_state.clone();
+ Box::pin(async move { peer::handle_message(&state_clone, sender, msg).await })
+ }))
+ .await;
+ });
+ trace!("Message consumer task spawned");
+
+ let sync_state = state.clone();
+ tokio::spawn(async move {
+ sync::run_sync_worker(sync_state, sync_rx).await;
+ });
+ trace!("Sync worker task spawned");
+
+ let peer_worker_state = state.clone();
+ tokio::spawn(async move {
+ peer::run_peer_worker(peer_worker_state).await;
+ });
+ trace!("Peer worker task spawned");
+
+ tokio::spawn(remake::run_worker(state.clone()));
+ trace!("Remake worker task spawned");
+
+ tokio::spawn(swarm::topology::run_worker(state.clone()));
+ trace!("Topology worker task spawned");
+
+ state
+ }
+}
+
+/// Starts what runs once bootstrap is done: the change publisher (streams only exist
+/// from then on, and it avoids diffing the initial sync) and the document file writer.
+pub(crate) fn spawn_ready_workers(state: &AppState) {
+ let publisher_state = state.clone();
+ tokio::spawn(async move { sync::run_change_publisher(publisher_state).await });
+ tokio::spawn(crate::document::file::run(state.clone()));
+}
diff --git a/src/remake/api.rs b/src/remake/api.rs
index 826c81e..0428a43 100644
--- a/src/remake/api.rs
+++ b/src/remake/api.rs
@@ -17,7 +17,8 @@
//! can't commit it any more anyway (`coordinator`). `DELETE /admin/remakes` does the
//! same for the undecided remake of the local epoch, without having to know its ID.
//!
-//! Request body for POST (all optional):
+//! Request body for POST (all optional; `baseHeads` not for `compact`, whose base is
+//! fixed when it commits):
//! `{ "kind": "compact", "spec": null, "baseHeads": ["<hex>", …],
//! "replayWindow": {"epochs": 1, "seconds": 604800},
//! "archiveRetention": {"epochs": 2, "seconds": 2592000},
@@ -72,6 +73,7 @@ use super::records::{
};
use super::{Host, kind, vote};
use crate::document::store::StoreError;
+use crate::force::Force;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct WaitQuery {
@@ -79,12 +81,20 @@ pub(crate) struct WaitQuery {
wait: bool,
}
+#[derive(Debug, Deserialize)]
+pub(crate) struct AbortQuery {
+ #[serde(default)]
+ wait: bool,
+ /// The checks to override (`force`): `coordinatorActive`.
+ force: Option<String>,
+}
+
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StartQuery {
#[serde(default)]
wait: bool,
- /// The checks to override (`Force`).
+ /// The checks to override (`force`).
force: Option<String>,
// Only for an import uploaded as `application/octet-stream`, which has no JSON
// body to carry them:
@@ -159,41 +169,63 @@ struct StartRequest {
switch_delay_secs: Option<u64>,
}
-/// The checks `?force=` overrides: `true` all of them, or a comma-separated list.
-#[derive(Debug, Default, Clone, Copy)]
-struct Force {
- switch_delay: bool,
- supersede: bool,
+/// What `?force=` may override on a proposal (`force`).
+const FORCE: &[&str] = &["switchDelaySecs", "supersede", "coordinatorActive"];
+/// What `?force=` may override on an abort.
+const ABORT_FORCE: &[&str] = &["coordinatorActive"];
+
+/// When a node other than the coordinator may abort an expired remake: a whole grace
+/// period after it expired. Its coordinator stops committing at expiry
+/// (`coordinator::decide`), so an abort from elsewhere and a commit can only cross if
+/// their clocks are more than a grace period apart.
+fn abortable_at(record: &Record, grace: u64) -> u64 {
+ record
+ .proposal
+ .settle_until
+ .saturating_add(grace.saturating_mul(2))
}
-impl Force {
- fn parse(value: Option<&str>) -> Result<Self, String> {
- let mut force = Force::default();
- match value.map(str::trim) {
- None | Some("" | "false") => {}
- Some("true") => {
- force = Force {
- switch_delay: true,
- supersede: true,
- };
- }
- Some(list) => {
- for name in list.split(',').map(str::trim) {
- match name {
- "switchDelaySecs" => force.switch_delay = true,
- "supersede" => force.supersede = true,
- other => {
- return Err(format!(
- "unknown force {other:?}: use true, or a list of \
- switchDelaySecs and supersede"
- ));
- }
- }
- }
- }
- }
- Ok(force)
+/// Why this node may not abort expired `record` now, and the `force` name that would
+/// override it (none for one that only time cures).
+fn abort_obstacle(
+ record: &Record,
+ me: &str,
+ active_peers: &HashSet<String>,
+ force: &Force,
+ now: u64,
+ grace: u64,
+) -> Option<(String, Option<&'static str>)> {
+ let at = abortable_at(record, grace);
+ if now < at {
+ return Some((
+ format!(
+ "only its coordinator may abort it until a grace period after it expired ({}s from now)",
+ (at - now).div_ceil(1000)
+ ),
+ None,
+ ));
+ }
+ let coordinator = &record.proposal.coordinator;
+ if coordinator != me && active_peers.contains(coordinator) && !force.allows("coordinatorActive")
+ {
+ return Some((
+ format!(
+ "its coordinator {coordinator} is an active peer, so it is stuck rather than gone (look at its log); add force=coordinatorActive to abort it anyway"
+ ),
+ Some("coordinatorActive"),
+ ));
}
+ None
+}
+
+/// The active peers this node sees.
+async fn active_peers<H: Host>(host: &H) -> HashSet<String> {
+ host.peer_summaries()
+ .await
+ .into_iter()
+ .filter(|peer| peer.active)
+ .map(|peer| peer.id)
+ .collect()
}
/// An expired remake of the epoch a proposal would supersede.
@@ -201,11 +233,20 @@ impl Force {
struct ExpiredRemake {
id: String,
coordinator: String,
+ coordinator_active: bool,
/// When it expired (Unix milliseconds).
since: u64,
+ /// When another node may abort it (`abortable_at`).
+ abortable_at: u64,
tally: vote::Tally,
}
+/// Superseding expired remakes, as the proposal's `force` asks.
+struct Supersede {
+ force: Force,
+ active_peers: HashSet<String>,
+}
+
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Report {
@@ -222,9 +263,15 @@ struct Report {
enum StartError {
#[error("remake {0} is already open for this epoch")]
Open(String),
- /// Expired remakes still hold votes; superseding them takes `force=supersede`.
- #[error("expired remakes of this epoch still hold votes")]
- Expired(Vec<ExpiredRemake>),
+ /// Expired remakes still hold votes, and aren't superseded: not asked to
+ /// (`force=supersede`), or not possible yet (`abort_obstacle`).
+ #[error("{message}")]
+ Expired {
+ message: String,
+ remakes: Vec<ExpiredRemake>,
+ /// The `force` name that would override it, if any.
+ force: Option<&'static str>,
+ },
/// The proposer votes for its own remake, so it must be free to.
#[error("this node can't vote for a new remake: {0}")]
VoteHeld(String),
@@ -332,7 +379,7 @@ async fn start_kind<H: Host>(
if !host.is_node_ready().await {
return error_response(StatusCode::SERVICE_UNAVAILABLE, "node is not ready", None);
}
- let force = match Force::parse(query.force.as_deref()) {
+ let force = match Force::parse(query.force.as_deref(), FORCE) {
Ok(force) => force,
Err(reason) => return error_response(StatusCode::BAD_REQUEST, reason, None),
};
@@ -395,6 +442,15 @@ async fn start_kind<H: Host>(
}
};
+ if kind.base_at_commit() && request.base_heads.is_some() {
+ return error_response(
+ StatusCode::BAD_REQUEST,
+ format!(
+ "a {kind_name} remake fixes its base when it commits; baseHeads is not accepted"
+ ),
+ None,
+ );
+ }
let base = match &request.base_heads {
None => None,
Some(heads) => {
@@ -426,7 +482,7 @@ async fn start_kind<H: Host>(
let keepalive = dormant_after / 4;
let switch_delay = request.switch_delay_secs.map(Duration::from_secs);
match switch_delay {
- Some(delay) if delay < keepalive && force.switch_delay => {
+ Some(delay) if delay < keepalive && force.allows("switchDelaySecs") => {
warn!(
switch_delay_secs = delay.as_secs(),
keepalive_secs = keepalive.as_secs(),
@@ -454,7 +510,8 @@ async fn start_kind<H: Host>(
let mut proposal = Proposal {
coordinator: host.node_id(),
from_epoch: host.current_epoch().into(),
- base_heads: Vec::new(),
+ // Filled in when the proposal is written, unless the kind fixes it at the commit.
+ base_heads: None,
policy: Policy {
replay_window: request
.replay_window
@@ -491,7 +548,10 @@ async fn start_kind<H: Host>(
id.clone(),
proposal,
base,
- force.supersede,
+ Supersede {
+ force: force.clone(),
+ active_peers: active_peers(&host).await,
+ },
now,
grace,
));
@@ -516,33 +576,28 @@ async fn start_kind<H: Host>(
Some(json!({ "remake": open })),
);
}
- Err(StartError::Expired(expired)) => {
- let active: HashSet<String> = host
- .peer_summaries()
- .await
- .into_iter()
- .filter(|peer| peer.active)
- .map(|peer| peer.id)
- .collect();
- let me = host.node_id();
- let expired: Vec<JsonValue> = expired
+ Err(StartError::Expired {
+ message,
+ remakes,
+ force,
+ }) => {
+ let expired: Vec<JsonValue> = remakes
.iter()
.map(|remake| {
json!({
"id": remake.id,
"coordinator": remake.coordinator,
- "coordinatorActive": remake.coordinator == me
- || active.contains(&remake.coordinator),
+ "coordinatorActive": remake.coordinator_active,
"expiredAt": remake.since,
+ "abortableAt": remake.abortable_at,
"tally": remake.tally.to_string(),
})
})
.collect();
return error_response(
StatusCode::CONFLICT,
- "an expired remake of this epoch still holds votes: abort it, or add \
- ?force=supersede to abort it and propose in its place",
- Some(json!({ "expired": expired, "force": "supersede" })),
+ message,
+ Some(json!({ "expired": expired, "force": force })),
);
}
Err(error @ StartError::VoteHeld(_)) => {
@@ -590,7 +645,7 @@ async fn propose<H: Host>(
id: String,
mut proposal: Proposal,
base: Option<Vec<ChangeHash>>,
- supersede: bool,
+ supersede: Supersede,
now: u64,
grace: u64,
) -> Result<Record, StartError> {
@@ -598,6 +653,7 @@ async fn propose<H: Host>(
let electorate_host = host.clone();
let me = host.node_id();
let started = host.remake().started_at() + grace;
+ let base_at_commit = kind::by_name(&proposal.kind).is_some_and(|kind| kind.base_at_commit());
let record = host
.commit_doc(move |doc| -> Result<Record, StartError> {
let mut all = records::load_all(doc);
@@ -620,18 +676,50 @@ async fn propose<H: Host>(
.map(|(id, _)| id)
.collect();
if !expired.is_empty() {
- if !supersede {
- let details = all
- .iter()
+ let remakes = || {
+ all.iter()
.filter(|(id, _)| expired.contains(&id))
.map(|(id, record)| ExpiredRemake {
id: id.clone(),
coordinator: record.proposal.coordinator.clone(),
+ coordinator_active: supersede
+ .active_peers
+ .contains(&record.proposal.coordinator),
since: record.proposal.settle_until.saturating_add(grace),
+ abortable_at: abortable_at(record, grace),
tally: vote::tally(id, record, &all, &register),
})
- .collect();
- return Err(StartError::Expired(details));
+ .collect()
+ };
+ if !supersede.force.allows("supersede") {
+ return Err(StartError::Expired {
+ message: "an expired remake of this epoch still holds votes: abort it, \
+ or add ?force=supersede to abort it and propose in its place"
+ .to_owned(),
+ remakes: remakes(),
+ force: Some("supersede"),
+ });
+ }
+ let obstacle =
+ all.iter()
+ .filter(|(id, _)| expired.contains(&id))
+ .find_map(|(id, record)| {
+ let (why, force) = abort_obstacle(
+ record,
+ &me,
+ &supersede.active_peers,
+ &supersede.force,
+ now,
+ grace,
+ )?;
+ Some((format!("can't supersede remake {id}: {why}"), force))
+ });
+ if let Some((message, force)) = obstacle {
+ return Err(StartError::Expired {
+ message,
+ remakes: remakes(),
+ force,
+ });
}
let decision = Decision::Aborted {
reason: format!("superseded by remake {record_id}"),
@@ -656,22 +744,22 @@ async fn propose<H: Host>(
}
// The base is the requested heads, or everything before the proposal (the
- // proposal itself is never in it).
- let base = match base {
- Some(base) => {
- if let Some(unknown) = base
- .iter()
- .find(|head| doc.get_change_by_hash(head).is_none())
- {
- return Err(StartError::UnknownBase(unknown.to_string()));
+ // proposal itself is never in it); or, for a compaction, left to the commit.
+ if !base_at_commit {
+ let base = match base {
+ Some(base) => {
+ if let Some(unknown) = base
+ .iter()
+ .find(|head| doc.get_change_by_hash(head).is_none())
+ {
+ return Err(StartError::UnknownBase(unknown.to_string()));
+ }
+ base
}
- base
- }
- None => doc.get_heads(),
- };
- let mut heads: Vec<String> = base.iter().map(|h| h.to_string()).collect();
- heads.sort();
- proposal.base_heads = heads;
+ None => doc.get_heads(),
+ };
+ proposal.base_heads = Some(records::encode_heads(&base));
+ }
// We vote for our own remake, so we are always in the electorate.
let mut electorate = electorate_host.electorate(doc, now);
@@ -731,16 +819,16 @@ pub(crate) async fn get<H: Host>(
pub(crate) async fn abort<H: Host>(
State(host): State<H>,
Path(id): Path<String>,
- Query(query): Query<WaitQuery>,
+ Query(query): Query<AbortQuery>,
) -> Response {
- abort_by_id(host, id, query.wait).await
+ abort_by_id(host, id, query).await
}
/// `DELETE /admin/remakes`: aborts whichever remake of the local epoch is still
/// undecided (proposed or expired), the oldest if there are several.
pub(crate) async fn abort_current<H: Host>(
State(host): State<H>,
- Query(query): Query<WaitQuery>,
+ Query(query): Query<AbortQuery>,
) -> Response {
let local = EpochRecord::from(host.current_epoch());
let open = host
@@ -756,7 +844,7 @@ pub(crate) async fn abort_current<H: Host>(
.await;
match open {
- Some(id) => abort_by_id(host, id, query.wait).await,
+ Some(id) => abort_by_id(host, id, query).await,
None => error_response(
StatusCode::NOT_FOUND,
"no remake of the local epoch is in progress",
@@ -765,14 +853,19 @@ pub(crate) async fn abort_current<H: Host>(
}
}
-async fn abort_by_id<H: Host>(host: H, id: String, wait: bool) -> Response {
+async fn abort_by_id<H: Host>(host: H, id: String, query: AbortQuery) -> Response {
+ let force = match Force::parse(query.force.as_deref(), ABORT_FORCE) {
+ Ok(force) => force,
+ Err(reason) => return error_response(StatusCode::BAD_REQUEST, reason, None),
+ };
+ let wait = query.wait;
let Some(current) = load_report(&host, &id).await else {
return error_response(StatusCode::NOT_FOUND, "no such remake", None);
};
match current.phase {
Phase::Proposed => {}
- Phase::Expired => return abort_expired(&host, &id).await,
+ Phase::Expired => return abort_expired(&host, current, &force).await,
Phase::Committed => {
return error_response(
StatusCode::CONFLICT,
@@ -823,9 +916,30 @@ async fn abort_by_id<H: Host>(host: H, id: String, wait: bool) -> Response {
}
}
-/// Aborts expired remake `id` from this node: its coordinator is gone, and can't commit
-/// it any more (`coordinator`). Answers with the final report.
-async fn abort_expired<H: Host>(host: &H, id: &str) -> Response {
+/// Aborts expired remake `current` from this node: its coordinator is gone, and can't
+/// commit it any more (`coordinator`), unless `abort_obstacle` says otherwise. Answers
+/// with the final report.
+async fn abort_expired<H: Host>(host: &H, current: Report, force: &Force) -> Response {
+ let grace = grace(host);
+ if let Some((why, force)) = abort_obstacle(
+ &current.record,
+ &host.node_id(),
+ &active_peers(host).await,
+ force,
+ unix_millis(),
+ grace,
+ ) {
+ return error_response(
+ StatusCode::CONFLICT,
+ format!("can't abort it yet: {why}"),
+ Some(json!({
+ "remake": current,
+ "abortableAt": abortable_at(&current.record, grace),
+ "force": force,
+ })),
+ );
+ }
+ let id = current.id.as_str();
let record_id = id.to_owned();
let decision = Decision::Aborted {
reason: format!(
@@ -834,11 +948,12 @@ async fn abort_expired<H: Host>(host: &H, id: &str) -> Response {
),
decided_at: unix_millis(),
};
- let grace = grace(host);
let written = host
.commit_doc(move |doc| -> Result<bool, StoreError> {
- let expired = records::load(doc, &record_id)
- .is_some_and(|record| record.phase(unix_millis(), grace) == Phase::Expired);
+ let now = unix_millis();
+ let expired = records::load(doc, &record_id).is_some_and(|record| {
+ record.phase(now, grace) == Phase::Expired && now >= abortable_at(&record, grace)
+ });
if expired {
records::write_decision(doc, &record_id, &decision)?;
}
diff --git a/src/remake/catchup.rs b/src/remake/catchup.rs
index cad5a22..631fb25 100644
--- a/src/remake/catchup.rs
+++ b/src/remake/catchup.rs
@@ -31,7 +31,7 @@ use std::collections::{BTreeMap, HashMap};
use std::convert::Infallible;
use std::time::Duration;
-use automerge::{AutoCommit, ChangeHash};
+use automerge::{ActorId, AutoCommit, ChangeHash};
use tokio::time::Instant;
use tracing::{debug, error, info, warn};
@@ -177,8 +177,12 @@ async fn start<H: Host>(host: &H, local: Epoch, target: Epoch, attempts: u32) {
host.set_stale_view(Some((local, copy)));
}
let was_ready = host.suspend().await;
+ let actor = ActorId::random();
+ host.remake().add_own_actor(actor.clone());
let old = match host
- .switch_epoch(target, |_live| Ok::<_, Infallible>(AutoCommit::new()))
+ .switch_epoch(target, move |_live| {
+ Ok::<_, Infallible>(AutoCommit::new().with_actor(actor))
+ })
.await
{
Ok(old) => old,
@@ -276,7 +280,9 @@ async fn finish<H: Host>(host: &H) {
// Everything heavy runs on a copy, without a lock.
let copy = host.read_doc(|doc| doc.clone()).await;
- let prepared = tokio::task::spawn_blocking(move || prepare(copy, old, previous, target)).await;
+ let own = host.remake().own_actors();
+ let prepared =
+ tokio::task::spawn_blocking(move || prepare(copy, old, previous, target, own)).await;
let (old, metadata, plan, own_missing, had_history) = match prepared {
Ok(Prepared::Ready {
@@ -376,6 +382,8 @@ async fn finish<H: Host>(host: &H) {
},
);
host.remake().set_catch_up(None);
+ let actor = host.read_doc(|doc| doc.get_actor().clone()).await;
+ host.remake().reset_own_actors(actor);
if was_ready {
host.resume().await;
}
@@ -395,7 +403,15 @@ async fn finish<H: Host>(host: &H) {
}
/// Verifies the synced document and decides about the old document's own changes.
-fn prepare(mut copy: AutoCommit, mut old: AutoCommit, previous: Epoch, target: Epoch) -> Prepared {
+/// `own`: this node's actors (`RemakeState::own_actors`); the old document's own actor
+/// counts in any case.
+fn prepare(
+ mut copy: AutoCommit,
+ mut old: AutoCommit,
+ previous: Epoch,
+ target: Epoch,
+ mut own: Vec<ActorId>,
+) -> Prepared {
if copy.get_change_by_hash(&ChangeHash(target.id)).is_none() {
return Prepared::NotYet(old);
}
@@ -421,7 +437,7 @@ fn prepare(mut copy: AutoCommit, mut old: AutoCommit, previous: Epoch, target: E
.ok()
.filter(|next| next.previous == previous);
- let own = old.get_actor().clone();
+ own.push(old.get_actor().clone());
let clock = next
.as_ref()
.map_or_else(BTreeMap::new, |next| next.clock.clone());
@@ -573,7 +589,7 @@ mod tests {
#[test]
fn replays_within_the_window() {
let (old, swarm, target) = scenario(&json!({}));
- match prepare(swarm, old, Epoch::GENESIS, target) {
+ match prepare(swarm, old, Epoch::GENESIS, target, Vec::new()) {
Prepared::Ready {
plan: Plan::Replay(paths, _),
own_missing,
@@ -589,7 +605,7 @@ mod tests {
#[test]
fn quarantines_outside_the_window() {
let (old, swarm, target) = scenario(&json!({ "replayWindow": { "epochs": 0 } }));
- match prepare(swarm, old, Epoch::GENESIS, target) {
+ match prepare(swarm, old, Epoch::GENESIS, target, Vec::new()) {
Prepared::Ready {
plan: Plan::Quarantine { paths, .. },
..
@@ -602,7 +618,7 @@ mod tests {
fn new_node_has_nothing_to_replay() {
let (_, swarm, target) = scenario(&json!({}));
assert!(matches!(
- prepare(swarm, AutoCommit::new(), Epoch::GENESIS, target),
+ prepare(swarm, AutoCommit::new(), Epoch::GENESIS, target, Vec::new()),
Prepared::Ready {
plan: Plan::Nothing,
had_history: false,
@@ -615,7 +631,7 @@ mod tests {
fn waits_for_the_target_epoch() {
let (old, _, target) = scenario(&json!({}));
assert!(matches!(
- prepare(AutoCommit::new(), old, Epoch::GENESIS, target),
+ prepare(AutoCommit::new(), old, Epoch::GENESIS, target, Vec::new()),
Prepared::NotYet(_)
));
}
diff --git a/src/remake/coordinator.rs b/src/remake/coordinator.rs
index 9bcf8ba..427de08 100644
--- a/src/remake/coordinator.rs
+++ b/src/remake/coordinator.rs
@@ -12,7 +12,7 @@
//! The decision is written in the same step that checks the records once more, so a
//! commit never overrides an abort request or a competitor's commit that arrived while
//! the snapshot was being built. Nor is a commit written once the remake has expired:
-//! from then on, any node may abort it (`api`), and the two mustn't race.
+//! a grace period later, any node may abort it (`api`), and the two mustn't race.
//!
//! Committing builds the snapshot at the base heads and records the new epoch, so
//! every node can verify its own build against it.
@@ -141,7 +141,23 @@ fn obstacle(id: &str, record: &Record, all: &[(String, Record)]) -> Option<Strin
}
async fn commit<H: Host>(host: &H, id: &str, record: &Record) -> Result<(), StoreError> {
- let Some(mut request) = record.snapshot_request(id) else {
+ let content = match &record.proposal.content {
+ Some(reference) => match content::ensure(host, reference).await {
+ Ok(bytes) => Some(bytes),
+ Err(reason) => return decide_abort(host, id, reason).await,
+ },
+ None => None,
+ };
+
+ // Clone under the read lock, build without any lock.
+ let mut copy = host.read_doc(|doc| doc.clone()).await;
+ // A base left to the commit is what this node has now. Every node that sees the
+ // decision has it too: the decision change depends on it.
+ let (base, fixed_now) = match &record.proposal.base_heads {
+ None => (Some(copy.get_heads()), true),
+ Some(heads) => (records::decode_heads(heads), false),
+ };
+ let Some(mut request) = base.and_then(|base| record.snapshot_request_at(id, base)) else {
return decide_abort(
host,
id,
@@ -149,15 +165,8 @@ async fn commit<H: Host>(host: &H, id: &str, record: &Record) -> Result<(), Stor
)
.await;
};
- if let Some(reference) = &record.proposal.content {
- match content::ensure(host, reference).await {
- Ok(bytes) => request.content = Some(bytes),
- Err(reason) => return decide_abort(host, id, reason).await,
- }
- }
-
- // Clone under the read lock, build without any lock.
- let copy = host.read_doc(|doc| doc.clone()).await;
+ let base_heads = fixed_now.then(|| records::encode_heads(&request.base_heads));
+ request.content = content;
let mut snapshot = match snapshot::build_detached(copy, request).await {
Ok((snapshot, _copy)) => snapshot,
Err(error) => {
@@ -170,6 +179,7 @@ async fn commit<H: Host>(host: &H, id: &str, record: &Record) -> Result<(), Stor
let decision = Decision::Committed {
to_epoch: to_epoch.into(),
+ base_heads,
snapshot_bytes,
decided_at: records::unix_millis(),
};
diff --git a/src/remake/delta.rs b/src/remake/delta.rs
index c8f6b7c..51cc409 100644
--- a/src/remake/delta.rs
+++ b/src/remake/delta.rs
@@ -159,22 +159,23 @@ pub(crate) struct Missing {
/// Splits `doc`'s history against a snapshot `clock`: a change (actor, seq) is in the
/// snapshot iff seq <= clock[actor].
///
-/// Only changes this node wrote itself (actor `own`) are worth replaying: other nodes'
-/// changes after the base were replayed by those nodes when they switched, and replaying
-/// them again would overwrite newer writes in the new epoch with stale ones.
+/// Only changes this node wrote itself (any of the actors in `own`: one per run and
+/// epoch) are worth replaying when it rejoins: other nodes' changes after the base were
+/// replayed by the nodes that switched, and replaying them again would overwrite newer
+/// writes in the new epoch with stale ones.
pub(crate) fn missing_from_snapshot(
doc: &mut AutoCommit,
clock: &BTreeMap<String, u64>,
- own: &ActorId,
+ own: &[ActorId],
) -> Missing {
- let own_hex = own.to_string();
+ let own_hex: HashSet<String> = own.iter().map(ActorId::to_string).collect();
let changes: Vec<(ChangeHash, Vec<ChangeHash>, bool)> = doc
.get_changes(&[])
.iter()
.map(|change| {
let actor = change.actor_id().to_string();
let in_snapshot = clock.get(&actor).is_some_and(|&seq| change.seq() <= seq);
- let own_missing = actor == own_hex && !in_snapshot;
+ let own_missing = own_hex.contains(&actor) && !in_snapshot;
(change.hash(), change.deps().to_vec(), own_missing)
})
.collect();
@@ -370,7 +371,7 @@ mod tests {
doc.commit();
let after = doc.get_heads();
- let missing = missing_from_snapshot(&mut doc, &clock, &own);
+ let missing = missing_from_snapshot(&mut doc, &clock, std::slice::from_ref(&own));
assert_eq!(missing.own, 1);
assert_eq!(missing.before, with_foreign);
assert_ne!(missing.before, base);
@@ -381,7 +382,7 @@ mod tests {
// Nothing of ours missing: nothing to replay.
let full: BTreeMap<String, u64> = [(own.to_string(), 2)].into_iter().collect();
- let none = missing_from_snapshot(&mut doc, &full, &own);
+ let none = missing_from_snapshot(&mut doc, &full, std::slice::from_ref(&own));
assert_eq!(none.own, 0);
let mut heads = after.clone();
heads.sort();
diff --git a/src/remake/kind.rs b/src/remake/kind.rs
index 8131f7f..42bdd88 100644
--- a/src/remake/kind.rs
+++ b/src/remake/kind.rs
@@ -17,6 +17,9 @@
//! Protocol data under `_continuum` is not a kind's business: the generic part carries
//! it forward and replays it unchanged.
+use std::path::Path;
+
+use anyhow::{Context, anyhow, bail};
use automerge::AutoCommit;
use base64::engine::{Engine, general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize};
@@ -24,6 +27,7 @@ use serde_json::Value as JsonValue;
use super::snapshot::{META_ROOT, canonical};
use crate::document::store::{self, StoreError};
+use crate::files::{FileKind, Files};
/// Name of the kind used when a request doesn't name one.
pub(crate) const DEFAULT_KIND: &str = "compact";
@@ -71,6 +75,14 @@ pub(crate) trait RemakeKind: Send + Sync + 'static {
/// Every node: where a user path written after the base goes.
fn translate(&self, path: &[String], translation: &JsonValue) -> Translation;
+
+ /// Whether the coordinator fixes the base when it commits instead of when it
+ /// proposes (no `baseHeads` then). Right for a kind whose content is the old
+ /// document at the base: the later the base, the fewer writes every switching node
+ /// replays as fresh values (whole lists and texts, concurrently on every node).
+ fn base_at_commit(&self) -> bool {
+ false
+ }
}
/// The kind with this name.
@@ -83,6 +95,50 @@ pub(crate) fn by_name(name: &str) -> Option<&'static dyn RemakeKind> {
}
}
+/// Builds a new swarm's genesis content into `target` (`--genesis --remake SPEC`), the
+/// way a remake of that kind builds a new epoch from an empty one:
+/// * `import:PATH`: an Automerge document, e.g. a backup (its content, without history
+/// and protocol data);
+/// * `replace:PATH`: a JSON document, the store root;
+/// * `compact`: nothing (an empty document compacts to itself).
+pub(crate) fn remake_genesis(
+ spec: &str,
+ files: &Files,
+ target: &mut AutoCommit,
+) -> anyhow::Result<()> {
+ let (name, path) = match spec.split_once(':') {
+ Some((name, path)) => (name, Some(Path::new(path))),
+ None => (spec, None),
+ };
+ let kind = by_name(name).ok_or_else(|| anyhow!("unknown remake kind {name:?}"))?;
+ let request = match (kind.name(), path) {
+ ("compact", None) => return Ok(()),
+ ("compact", Some(_)) => bail!("compact takes no file"),
+ (_, None) => bail!("{name} needs a file: {name}:PATH"),
+ (name, Some(path)) => {
+ let bytes = files
+ .read(FileKind::Remake, path)
+ .with_context(|| format!("Reading {}", path.display()))?
+ .with_context(|| format!("{} doesn't exist", path.display()))?;
+ match name {
+ "import" => serde_json::json!({ "document": BASE64.encode(&bytes) }),
+ "replace" => {
+ let document: JsonValue = serde_json::from_slice(&bytes)
+ .with_context(|| format!("{} is not JSON", path.display()))?;
+ serde_json::json!({ "document": document })
+ }
+ other => bail!("{other} can't make a genesis document"),
+ }
+ }
+ };
+ let prepared = kind
+ .propose(&request)
+ .map_err(|reason| anyhow!("{name}: {reason}"))?;
+ kind.build(&AutoCommit::new(), prepared.content.as_deref(), target)?;
+ target.commit();
+ Ok(())
+}
+
/// Paths owned by the protocol rather than by any kind.
pub(crate) fn is_reserved(path: &[String]) -> bool {
path.first().is_some_and(|root| root == META_ROOT)
@@ -97,6 +153,10 @@ impl RemakeKind for Compact {
"compact"
}
+ fn base_at_commit(&self) -> bool {
+ true
+ }
+
fn propose(&self, spec: &JsonValue) -> Result<Prepared, String> {
match spec {
JsonValue::Null => Ok(Prepared::default()),
diff --git a/src/remake/local.rs b/src/remake/local.rs
index 04ac0b0..65a2735 100644
--- a/src/remake/local.rs
+++ b/src/remake/local.rs
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
-use automerge::AutoCommit;
+use automerge::{ActorId, AutoCommit};
use serde::Serialize;
use tokio::time::Instant;
@@ -55,6 +55,11 @@ struct Inner {
content_fetches: HashSet<[u8; 32]>,
/// Remakes this node coordinates now (`coordinator`).
coordinating: HashSet<String>,
+ /// The actors this node writes or wrote with in the current document: this run's,
+ /// and those of earlier runs it knows of (`--document-file`). Its own writes, for a
+ /// rejoin. A switch or rejoin starts the list over with the new document's actor, so
+ /// it only grows with restarts within one epoch.
+ own_actors: Vec<ActorId>,
}
/// Content no remake refers to any more is kept this long after it was stored, for
@@ -147,6 +152,24 @@ impl RemakeState {
self.started_at
}
+ /// This node writes with `actor` from now on (or did, in an earlier run).
+ pub(crate) fn add_own_actor(&self, actor: ActorId) {
+ let mut inner = self.lock();
+ if !inner.own_actors.contains(&actor) {
+ inner.own_actors.push(actor);
+ }
+ }
+
+ /// The current document is a new one, written with `actor` only (a switch or rejoin
+ /// is done).
+ pub(crate) fn reset_own_actors(&self, actor: ActorId) {
+ self.lock().own_actors = vec![actor];
+ }
+
+ pub(crate) fn own_actors(&self) -> Vec<ActorId> {
+ self.lock().own_actors.clone()
+ }
+
/// Whether this node starts coordinating `id` now (false if it already does).
pub(crate) fn start_coordinating(&self, id: &str) -> bool {
self.lock().coordinating.insert(id.to_owned())
diff --git a/src/remake/mod.rs b/src/remake/mod.rs
index ca17956..9085475 100644
--- a/src/remake/mod.rs
+++ b/src/remake/mod.rs
@@ -32,6 +32,7 @@ pub(crate) mod snapshot;
mod switch;
mod vote;
+pub(crate) use kind::remake_genesis;
pub(crate) use local::RemakeState;
pub(crate) use switch::run_worker;
diff --git a/src/remake/records.rs b/src/remake/records.rs
index cd3e5be..283f612 100644
--- a/src/remake/records.rs
+++ b/src/remake/records.rs
@@ -8,9 +8,10 @@
//! * `abortRequested`: written by any node on an operator's request. It is an input to
//! the coordinator's decision, not a decision itself.
//!
-//! Once a remake has expired, its coordinator no longer commits it, and any node may
-//! abort it on an operator's request (`api`). Should clocks disagree by more than the
-//! grace margin, a commit and such an abort could still be written concurrently. Such
+//! Once a remake has expired, its coordinator no longer commits it, and a grace period
+//! later any node may abort it on an operator's request (`api`). Should clocks disagree
+//! by more than that grace period, a commit and such an abort could still be written
+//! concurrently. Such
//! a record is *contested*: it reads as committed, and nodes don't switch to it on
//! their own.
//!
@@ -130,7 +131,9 @@ pub(crate) struct Proposal {
pub(crate) coordinator: String,
pub(crate) from_epoch: EpochRecord,
/// Hex change hashes, sorted. The proposal record itself is not part of the base.
- pub(crate) base_heads: Vec<String>,
+ /// None for a kind whose base is fixed when it commits (`Decision::Committed`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) base_heads: Option<Vec<String>>,
pub(crate) policy: Policy,
pub(crate) settle_ms: u64,
pub(crate) proposed_at: u64,
@@ -181,13 +184,19 @@ fn default_kind() -> String {
DEFAULT_KIND.to_owned()
}
-impl Proposal {
- pub(crate) fn base_change_hashes(&self) -> Option<Vec<ChangeHash>> {
- self.base_heads
- .iter()
- .map(|hex| decode_hex32(hex).map(ChangeHash))
- .collect()
- }
+/// Hex change hashes as hashes; None if one is unreadable.
+pub(crate) fn decode_heads(heads: &[String]) -> Option<Vec<ChangeHash>> {
+ heads
+ .iter()
+ .map(|hex| decode_hex32(hex).map(ChangeHash))
+ .collect()
+}
+
+/// Hashes as sorted hex, as records keep them.
+pub(crate) fn encode_heads(heads: &[ChangeHash]) -> Vec<String> {
+ let mut hex: Vec<String> = heads.iter().map(ChangeHash::to_string).collect();
+ hex.sort();
+ hex
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -196,6 +205,9 @@ pub(crate) enum Decision {
#[serde(rename_all = "camelCase")]
Committed {
to_epoch: EpochRecord,
+ /// The base, when the proposal leaves it to the commit (sorted hex).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ base_heads: Option<Vec<String>>,
snapshot_bytes: u64,
decided_at: u64,
},
@@ -275,13 +287,39 @@ impl Record {
}
}
+ /// The base: the proposal's, or for a kind that fixes it at the commit, the
+ /// decision's. None until committed then, or if unreadable.
+ pub(crate) fn base(&self) -> Option<Vec<ChangeHash>> {
+ let heads = match (&self.proposal.base_heads, &self.decision) {
+ (Some(heads), _) => heads,
+ (
+ None,
+ Some(Decision::Committed {
+ base_heads: Some(heads),
+ ..
+ }),
+ ) => heads,
+ _ => return None,
+ };
+ decode_heads(heads)
+ }
+
/// Everything needed to (re)build this remake's snapshot but the content (which the
- /// caller adds, `content::ensure`); None if the proposal is unreadable.
+ /// caller adds, `content::ensure`); None if the proposal or base is unreadable.
pub(crate) fn snapshot_request(&self, id: &str) -> Option<SnapshotRequest> {
+ self.snapshot_request_at(id, self.base()?)
+ }
+
+ /// The same, at `base` (the coordinator fixing a base at the commit).
+ pub(crate) fn snapshot_request_at(
+ &self,
+ id: &str,
+ base: Vec<ChangeHash>,
+ ) -> Option<SnapshotRequest> {
Some(SnapshotRequest {
remake_id: id.to_owned(),
previous: self.proposal.from_epoch.to_epoch()?,
- base_heads: self.proposal.base_change_hashes()?,
+ base_heads: base,
policy: serde_json::to_value(&self.proposal.policy).ok()?,
proposed_at: self.proposal.proposed_at,
kind: self.proposal.kind.clone(),
@@ -499,7 +537,7 @@ mod tests {
Proposal {
coordinator: "node-a".into(),
from_epoch: Epoch::GENESIS.into(),
- base_heads: vec!["ab".repeat(32)],
+ base_heads: Some(vec!["ab".repeat(32)]),
policy: Policy {
replay_window: Policy::default_replay_window(),
archive_retention: Policy::default_archive_retention(),
@@ -535,6 +573,7 @@ mod tests {
generation: 1,
id: "cd".repeat(32),
},
+ base_heads: None,
snapshot_bytes: 123,
decided_at: 11_000,
};
@@ -552,7 +591,7 @@ mod tests {
let (id, record) = &all[0];
assert_eq!(id, "c-1");
assert_eq!(record.proposal.policy, p.policy);
- assert_eq!(record.proposal.base_change_hashes().unwrap().len(), 1);
+ assert_eq!(record.base().unwrap().len(), 1);
assert!(record.abort_requested.is_some());
assert_eq!(record.votes.keys().collect::<Vec<_>>(), ["node-b"]);
assert_eq!(record.proposal.quorum, 2);
@@ -608,6 +647,31 @@ mod tests {
}
#[test]
+ fn a_base_fixed_at_the_commit() {
+ let mut record = Record {
+ proposal: Proposal {
+ base_heads: None,
+ ..proposal(10_000)
+ },
+ decision: None,
+ abort_requested: None,
+ contested: false,
+ votes: BTreeMap::new(),
+ };
+ assert_eq!(record.base(), None);
+ record.decision = Some(Decision::Committed {
+ to_epoch: EpochRecord {
+ generation: 1,
+ id: "cd".repeat(32),
+ },
+ base_heads: Some(vec!["ef".repeat(32)]),
+ snapshot_bytes: 1,
+ decided_at: 2,
+ });
+ assert_eq!(record.base(), Some(vec![ChangeHash([0xef; 32])]));
+ }
+
+ #[test]
fn disagreeing_decisions_are_contested() {
let mut base = AutoCommit::new();
write_proposal(&mut base, "c-1", &proposal(10_000)).unwrap();
@@ -618,6 +682,7 @@ mod tests {
generation: 1,
id: "cd".repeat(32),
},
+ base_heads: None,
snapshot_bytes: 1,
decided_at: 2,
};
diff --git a/src/remake/snapshot.rs b/src/remake/snapshot.rs
index e50a2a2..20569e0 100644
--- a/src/remake/snapshot.rs
+++ b/src/remake/snapshot.rs
@@ -38,6 +38,8 @@ pub(crate) const REMAKES: &str = "remakes";
/// The votes register (`records`).
pub(crate) const VOTES: &str = "votes";
pub(crate) const EPOCHS: &str = "epochs";
+/// The swarm ID, written once by the genesis node (`swarm::bootstrap`).
+pub(crate) const SWARM: &str = "swarm";
/// Everything that determines a snapshot besides the base content.
pub(crate) struct SnapshotParams<'a> {
@@ -224,6 +226,38 @@ pub(crate) fn verify_document(doc: &mut AutoCommit, epoch: Epoch) -> Result<Epoc
parse_metadata(&at_snapshot, epoch.generation)
}
+/// The epoch `doc` is on: genesis if it holds no epoch metadata; otherwise the newest
+/// generation it has metadata for, whose snapshot change is the document's only change
+/// without dependencies (verified). For a document loaded from a backup.
+pub(crate) fn epoch_of(doc: &mut AutoCommit) -> Result<Epoch, String> {
+ let generation = store::keys_at(doc, &[META_ROOT.to_owned(), EPOCHS.to_owned()])
+ .map_err(|error| error.to_string())?
+ .iter()
+ .filter_map(|key| key.parse::<u64>().ok())
+ .max();
+ let Some(generation) = generation.filter(|generation| *generation > 0) else {
+ return Ok(Epoch::GENESIS);
+ };
+ let roots: Vec<ChangeHash> = doc
+ .get_changes(&[])
+ .iter()
+ .filter(|change| change.deps().is_empty())
+ .map(|change| change.hash())
+ .collect();
+ let [root] = roots.as_slice() else {
+ return Err(format!(
+ "generation {generation} needs exactly one change without dependencies (its snapshot), found {}",
+ roots.len()
+ ));
+ };
+ let epoch = Epoch {
+ generation,
+ id: root.0,
+ };
+ verify_document(doc, epoch)?;
+ Ok(epoch)
+}
+
/// Metadata of an earlier generation, as the current document holds it. Snapshots
/// carry the metadata of all earlier epochs forward (only remake records stay
/// behind), so a node many epochs behind can still find the epoch that followed its own.
@@ -521,6 +555,27 @@ mod tests {
}
#[test]
+ fn epoch_of_a_backup() {
+ let mut a = doc(b"actor-a");
+ store::write_path(&mut a, &["k".into()], &json!(1)).unwrap();
+ a.commit();
+ assert_eq!(epoch_of(&mut a), Ok(Epoch::GENESIS));
+
+ let heads = a.get_heads();
+ let policy = json!({});
+ let mut snap = build(&mut a, &params(&heads, &policy)).unwrap();
+ // Saved and loaded, with a write since, as a backup would be.
+ let mut later = snap
+ .doc
+ .fork()
+ .with_actor(ActorId::from(b"actor-b".as_slice()));
+ store::write_path(&mut later, &["k".into()], &json!(2)).unwrap();
+ later.commit();
+ let mut backup = AutoCommit::load(&later.save()).unwrap();
+ assert_eq!(epoch_of(&mut backup), Ok(snap.epoch));
+ }
+
+ #[test]
fn metadata_written_and_remake_records_dropped() {
let mut a = doc(b"actor-a");
store::write_path(
@@ -695,7 +750,8 @@ mod tests {
assert!(verify_document(&mut fetched, wrong).is_err());
let own = late.get_actor().clone();
- let missing = delta::missing_from_snapshot(&mut late, &metadata.clock, &own);
+ let missing =
+ delta::missing_from_snapshot(&mut late, &metadata.clock, std::slice::from_ref(&own));
assert_eq!(missing.own, 1);
let heads = late.get_heads();
let mut adopted = fetched.with_actor(ActorId::from(&b"late-node-next"[..]));
diff --git a/src/remake/switch.rs b/src/remake/switch.rs
index cf5ea75..1a4dbf9 100644
--- a/src/remake/switch.rs
+++ b/src/remake/switch.rs
@@ -205,6 +205,10 @@ async fn switch<H: Host>(
.take_prebuilt(id)
.filter(|snapshot| snapshot.epoch == expected);
+ let actor = ActorId::random();
+ host.remake().add_own_actor(actor.clone());
+ let new_actor = actor.clone();
+
// 2. Build (or reuse), verify and replay without holding any lock.
let (new_doc, copy_heads, mut stats) = tokio::task::spawn_blocking(
move || -> Result<(AutoCommit, Vec<ChangeHash>, ReplayStats), String> {
@@ -222,7 +226,7 @@ async fn switch<H: Host>(
}
// A fresh actor: this node's writes in the new epoch.
- let mut doc = snapshot.doc.with_actor(ActorId::random());
+ let mut doc = snapshot.doc.with_actor(actor);
// Keep this remake's record visible in the new epoch, for its history.
if carry_record {
@@ -242,6 +246,7 @@ async fn switch<H: Host>(
// 3. Swap, replaying what arrived since the copy.
let (old, late) = swap_in(host, expected, new_doc, copy_heads, late_translator).await?;
stats.absorb(late);
+ host.remake().reset_own_actors(new_actor);
// 4. Writes the remake had no place for wait for an operator (the old document
// holds them); the old document is kept as configured either way.
diff --git a/src/remake/vote.rs b/src/remake/vote.rs
index 16de87a..b4936dc 100644
--- a/src/remake/vote.rs
+++ b/src/remake/vote.rs
@@ -304,7 +304,7 @@ mod tests {
proposal: Proposal {
coordinator: electorate[0].to_owned(),
from_epoch: Epoch::GENESIS.into(),
- base_heads: Vec::new(),
+ base_heads: Some(Vec::new()),
policy: Policy {
replay_window: Policy::default_replay_window(),
archive_retention: Policy::default_archive_retention(),
diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs
new file mode 100644
index 0000000..18975aa
--- /dev/null
+++ b/src/simtest/mod.rs
@@ -0,0 +1,292 @@
+//! Tests of whole nodes on the simulated network (`transport::sim`): several nodes in
+//! one process, built and started as `main` does (`node`), talking through the
+//! Mycelium wire encoding.
+//!
+//! The clock is real (see `transport::sim` for why), so the nodes run with a short
+//! keepalive and tests wait for conditions with a timeout instead of advancing time.
+//! Timeouts are generous: a test fails only if something doesn't happen at all.
+//!
+//! `RUST_LOG=continuum=debug cargo test simtest -- --nocapture` shows the nodes' logs
+//! (all nodes interleaved; messages name peers by ID).
+
+mod timing;
+
+use std::collections::HashSet;
+use std::future::Future;
+use std::sync::Arc;
+use std::time::Duration;
+
+use automerge::{AutoCommit, ChangeHash};
+use serde_json::Value as JsonValue;
+use tokio::time::Instant;
+
+use crate::document::replica::Epoch;
+use crate::document::store;
+use crate::files::Files;
+use crate::node::{self, Node, NodeConfig};
+use crate::state::{AppState, PeerState, Timing};
+use crate::swarm::bootstrap::{self, Start};
+use crate::transport::sim::SimNet;
+
+/// Keepalive of simulated nodes: dormancy after 1 s, probes of dormant peers every
+/// 2.5 s (`Timing::new`).
+pub(crate) const KEEPALIVE: Duration = Duration::from_millis(250);
+/// First wait for a sync answer before sending again (10 s in production, where large
+/// messages take a while): a lost sync message costs a test 1 s, a heal 2 s.
+pub(crate) const SYNC_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
+/// How long the simulated net may take to deliver a message: well above its delays.
+/// A catch-up source that stops sending sync messages is given up after this.
+pub(crate) const DELIVERY_LIMIT: Duration = Duration::from_secs(3);
+
+/// A simulated swarm: the net and the nodes started on it.
+pub(crate) struct Sim {
+ pub(crate) net: SimNet,
+ /// Every node started, for the description of a failed wait.
+ nodes: std::sync::Mutex<Vec<(String, AppState)>>,
+}
+
+impl Sim {
+ pub(crate) fn new(seed: u64) -> Self {
+ init_logging();
+ Self {
+ net: SimNet::new(seed),
+ nodes: Default::default(),
+ }
+ }
+
+ /// Starts node `id` with the test defaults: builds it, starts its workers, and
+ /// bootstraps it in the background (through `seeds`, other nodes' IDs). Returns at
+ /// once; [`wait_ready`] waits for it.
+ pub(crate) fn start(&self, id: &str, seeds: &[&str], start: Start) -> AppState {
+ self.start_with(id, seeds, start, |_| {})
+ }
+
+ /// [`start`](Self::start), with the configuration adjusted first.
+ pub(crate) fn start_with(
+ &self,
+ id: &str,
+ seeds: &[&str],
+ start: Start,
+ adjust: impl FnOnce(&mut NodeConfig),
+ ) -> AppState {
+ let mut config = NodeConfig {
+ transport: self.net.transport(id),
+ initial_epoch: Epoch::GENESIS,
+ initial_doc: AutoCommit::new(),
+ earlier_actors: Vec::new(),
+ timing: Timing {
+ delivery_limit: DELIVERY_LIMIT,
+ ..Timing::new(KEEPALIVE, SYNC_REPLY_TIMEOUT)
+ },
+ neighbours_k: 8,
+ alias: Some(id.to_owned()),
+ electorate_window: Duration::from_secs(30 * 24 * 3600),
+ blob_chunk_bytes: 64 * 1024,
+ swarm_key: None,
+ protocol_writers: HashSet::new(),
+ files: Arc::new(Files::default()),
+ state_file: None,
+ document_file: None,
+ config_report: JsonValue::Null,
+ };
+ adjust(&mut config);
+ let state = Node::new(config).spawn_workers();
+ self.nodes
+ .lock()
+ .unwrap()
+ .push((id.to_owned(), state.clone()));
+
+ let seeds: Vec<String> = seeds.iter().map(|seed| (*seed).to_owned()).collect();
+ let bootstrapping = state.clone();
+ let id = id.to_owned();
+ tokio::spawn(async move {
+ if let Err(error) = bootstrap::bootstrap(&bootstrapping, &seeds, start).await {
+ panic!("bootstrapping {id} failed: {error:#}");
+ }
+ node::spawn_ready_workers(&bootstrapping);
+ });
+ state
+ }
+}
+
+/// Logs to the test output if `RUST_LOG` is set.
+fn init_logging() {
+ if std::env::var_os("RUST_LOG").is_some() {
+ let _ = tracing_subscriber::fmt()
+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
+ .with_test_writer()
+ .compact()
+ .try_init();
+ }
+}
+
+impl Sim {
+ /// Waits until `condition` holds, checking every 20 ms. After `timeout`, panics with
+ /// a description of every node (`describe`).
+ pub(crate) async fn wait_until<F, Fut>(&self, what: &str, timeout: Duration, mut condition: F)
+ where
+ F: FnMut() -> Fut,
+ Fut: Future<Output = bool>,
+ {
+ let deadline = Instant::now() + timeout;
+ loop {
+ if condition().await {
+ return;
+ }
+ if Instant::now() >= deadline {
+ panic!(
+ "timed out after {timeout:?} waiting until {what}\n{}",
+ self.describe().await
+ );
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ }
+
+ /// Waits until every node is ready.
+ pub(crate) async fn wait_ready(&self, nodes: &[&AppState], timeout: Duration) {
+ self.wait_until("every node is ready", timeout, move || async move {
+ for node in nodes {
+ if !node.is_ready().await {
+ return false;
+ }
+ }
+ true
+ })
+ .await;
+ }
+
+ /// Waits until every node has the same heads.
+ pub(crate) async fn wait_converged(&self, nodes: &[&AppState], timeout: Duration) {
+ self.wait_until(
+ "every node has the same heads",
+ timeout,
+ move || async move {
+ let first = heads(nodes[0]).await;
+ for node in &nodes[1..] {
+ if heads(node).await != first {
+ return false;
+ }
+ }
+ true
+ },
+ )
+ .await;
+ }
+
+ /// Every node's state as far as sync and liveness go, and what the net counted.
+ pub(crate) async fn describe(&self) -> String {
+ use std::fmt::Write;
+
+ let nodes = self.nodes.lock().unwrap().clone();
+ let now = Instant::now();
+ let ago = |at: Option<Instant>| at.map(|at| now.saturating_duration_since(at));
+ let mut out = format!("net: {:?}\n", self.net.counts());
+ for (id, node) in &nodes {
+ let heads: Vec<String> = heads(node)
+ .await
+ .iter()
+ .map(|head| head.to_string()[..8].to_owned())
+ .collect();
+ let _ = writeln!(
+ out,
+ "node {id}: ready={} epoch={} heads={heads:?} initial_sync_done={}",
+ node.is_ready().await,
+ node.local_epoch(),
+ node.initial_sync_done
+ .load(std::sync::atomic::Ordering::Acquire),
+ );
+ let _ = writeln!(
+ out,
+ " view: {:?}",
+ *node
+ .neighbours
+ .read()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ );
+ let peers: Vec<_> = node
+ .peer_states
+ .read()
+ .await
+ .iter()
+ .map(|(peer, state)| (peer.clone(), Arc::clone(state)))
+ .collect();
+ for (peer, peer_state) in peers {
+ let p = peer_state.lock().await;
+ let _ = writeln!(
+ out,
+ " peer {peer}: neighbour={} leads={} standing={:?} liveness={:?} hello={:?} \
+ reports_ready={:?} epoch={:?} last_seen_ago={:?} last_sync_rx_ago={:?} \
+ unreachable={} their_heads={:?} in_flight={} sending={} reply_due_in={:?} \
+ retry_at={:?}",
+ crate::swarm::topology::is_neighbour(node, &peer),
+ p.leads,
+ p.standing,
+ p.liveness,
+ p.hello_state,
+ p.reports_ready,
+ p.epoch,
+ ago(p.last_seen),
+ ago(p.last_sync_rx),
+ p.is_unreachable(&node.timing, now),
+ p.sync.state.their_heads.as_ref().map(|heads| heads
+ .iter()
+ .map(|head| head.to_string()[..8].to_owned())
+ .collect::<Vec<_>>()),
+ p.sync.state.in_flight,
+ p.sync.sending.is_some(),
+ p.sync
+ .reply
+ .as_ref()
+ .map(|wait| wait.deadline.saturating_duration_since(now)),
+ p.sync.retry_at.map(|at| at.saturating_duration_since(now)),
+ );
+ }
+ }
+ out
+ }
+}
+
+fn path(path: &str) -> Vec<String> {
+ path.split('/').map(str::to_owned).collect()
+}
+
+/// Writes `value` at `path` (`a/b/c`) as one change, as the API does.
+pub(crate) async fn write(node: &AppState, at: &str, value: JsonValue) {
+ let at = path(at);
+ node.mutate_doc(move |doc| store::write_path(doc, &at, &value))
+ .await
+ .expect("write");
+}
+
+/// The value at `path` (`a/b/c`), if any.
+pub(crate) async fn read(node: &AppState, at: &str) -> Option<JsonValue> {
+ store::read_path(&node.doc.read().await.doc, &path(at)).expect("read")
+}
+
+pub(crate) async fn heads(node: &AppState) -> Vec<ChangeHash> {
+ let mut heads = node.doc.read().await.heads().to_vec();
+ heads.sort_unstable();
+ heads
+}
+
+/// Whether `node` has `peer` among its neighbours as computed from the member list.
+/// Before that list is known everyone counts as a neighbour, so this waits for it.
+pub(crate) fn has_neighbour(node: &AppState, peer: &str) -> bool {
+ node.neighbours
+ .read()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .is_some()
+ && crate::swarm::topology::is_neighbour(node, peer)
+}
+
+/// Something about `node`'s state for `peer`; None if it doesn't know the peer.
+pub(crate) async fn peer<T>(
+ node: &AppState,
+ peer: &str,
+ f: impl FnOnce(&PeerState) -> T,
+) -> Option<T> {
+ let peer_state = node.get_peer_state(peer).await?;
+ let peer_state = peer_state.lock().await;
+ Some(f(&peer_state))
+}
diff --git a/src/simtest/timing.rs b/src/simtest/timing.rs
new file mode 100644
index 0000000..7810f28
--- /dev/null
+++ b/src/simtest/timing.rs
@@ -0,0 +1,285 @@
+//! The timing rules of `peers.md` on whole nodes: joining and converging, dormancy and
+//! return, and late messages that must not undo what newer ones said.
+
+use std::time::Duration;
+
+use serde_json::json;
+
+use super::{Sim, has_neighbour, heads, peer, read, write};
+use crate::document::replica::Epoch;
+use crate::state::{AppState, Liveness};
+use crate::swarm::bootstrap::Start;
+use crate::transport::sim::LinkParams;
+use crate::transport::{HeadsSummary, HelloRequest, Keepalive, MessageKind, ParsedMessage, RunId};
+
+/// Long enough for anything that should happen within a few keepalives.
+const SOON: Duration = Duration::from_secs(10);
+/// With lost messages: several reply timeouts (1 s, doubling) and heals in a row.
+const LOSSY: Duration = Duration::from_secs(30);
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn three_nodes_join_and_converge() {
+ let sim = Sim::new(1);
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let c = sim.start("c", &["a"], Start::Join);
+ let (a, b, c) = (&a, &b, &c);
+ sim.wait_ready(&[a, b, c], SOON).await;
+
+ let swarm = a.swarm_id().await;
+ assert!(swarm.is_some());
+ assert_eq!(b.swarm_id().await, swarm);
+ assert_eq!(c.swarm_id().await, swarm);
+
+ // A write anywhere reaches everyone.
+ write(b, "users/bob", json!({"name": "Bob"})).await;
+ sim.wait_until("c has b's write", SOON, move || async move {
+ read(c, "users/bob/name").await == Some(json!("Bob"))
+ })
+ .await;
+
+ // Concurrent writes merge to one document.
+ write(a, "users/alice", json!(1)).await;
+ write(c, "users/carol", json!(3)).await;
+ sim.wait_converged(&[a, b, c], SOON).await;
+ for node in [a, b, c] {
+ assert_eq!(read(node, "users/alice").await, Some(json!(1)));
+ assert_eq!(read(node, "users/carol").await, Some(json!(3)));
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn converges_with_duplicated_and_reordered_messages() {
+ let sim = Sim::new(2);
+ sim.net.set_default_link(LinkParams {
+ duplicate: 0.3,
+ delay: (Duration::from_millis(1), Duration::from_millis(40)),
+ ..LinkParams::default()
+ });
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let c = sim.start("c", &["a"], Start::Join);
+ let (a, b, c) = (&a, &b, &c);
+ sim.wait_ready(&[a, b, c], SOON).await;
+
+ for round in 0..10 {
+ write(a, &format!("a/{round}"), json!(round)).await;
+ write(b, &format!("b/{round}"), json!(round)).await;
+ write(c, &format!("c/{round}"), json!(round)).await;
+ }
+ sim.wait_converged(&[a, b, c], SOON).await;
+ for node in [a, b, c] {
+ for prefix in ["a", "b", "c"] {
+ assert_eq!(read(node, &format!("{prefix}/9")).await, Some(json!(9)));
+ }
+ }
+ assert!(sim.net.counts().duplicated > 0);
+}
+
+/// Lost messages are made up for: sync messages by the reply timeout, sessions out of
+/// step by the keepalive heal. Joining under loss too: HELLOs are retried.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn converges_over_lossy_links() {
+ let sim = Sim::new(5);
+ sim.net.set_default_link(LinkParams {
+ loss: 0.15,
+ duplicate: 0.05,
+ delay: (Duration::from_millis(1), Duration::from_millis(30)),
+ ..LinkParams::default()
+ });
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let c = sim.start("c", &["a"], Start::Join);
+ let (a, b, c) = (&a, &b, &c);
+ sim.wait_ready(&[a, b, c], LOSSY).await;
+
+ for round in 0..10 {
+ write(a, &format!("a/{round}"), json!(round)).await;
+ write(b, &format!("b/{round}"), json!(round)).await;
+ write(c, &format!("c/{round}"), json!(round)).await;
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+ sim.wait_converged(&[a, b, c], LOSSY).await;
+ for node in [a, b, c] {
+ for prefix in ["a", "b", "c"] {
+ assert_eq!(read(node, &format!("{prefix}/9")).await, Some(json!(9)));
+ }
+ }
+ assert!(sim.net.counts().lost > 0);
+}
+
+/// R24: a catch-up source that is alive (it answers handshakes and keepalives) but whose
+/// sync messages never arrive is given up after three unanswered sync messages and
+/// `delivery_limit` without one, and another peer takes over.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn stalled_catch_up_source_is_replaced() {
+ let sim = Sim::new(6);
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let (a, b) = (&a, &b);
+ sim.wait_ready(&[a, b], SOON).await;
+ write(a, "data", json!("x")).await;
+ sim.wait_converged(&[a, b], SOON).await;
+
+ // c joins through a, so a becomes its source (the first ready peer it hears
+ // from); b it learns of from a.
+ sim.net.set_link(
+ "a",
+ "c",
+ LinkParams {
+ drop: vec![MessageKind::Sync],
+ ..LinkParams::default()
+ },
+ );
+ let c = sim.start("c", &["a"], Start::Join);
+ let c = &c;
+ sim.wait_ready(&[c], LOSSY).await;
+
+ assert_eq!(read(c, "data").await, Some(json!("x")));
+ let sources = c.stats.snapshot().catch_up_sources;
+ assert!(sources >= 2, "{sources} catch-up sources");
+}
+
+/// A peer that hears nothing for `dormant_after` is dormant; once messages flow again
+/// it is active, re-handshakes, and syncs what it missed.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn peer_goes_dormant_and_returns() {
+ let sim = Sim::new(3);
+ let a = sim.start("a", &[], Start::Genesis);
+ let b = sim.start("b", &["a"], Start::Join);
+ let (a, b) = (&a, &b);
+ sim.wait_ready(&[a, b], SOON).await;
+ // Only neighbours are judged, and a node joins the member list (which neighbours
+ // are computed from) within 5 s of becoming ready: until then the other doesn't
+ // count it as a neighbour.
+ sim.wait_until(
+ "a and b are each other's neighbours",
+ SOON,
+ move || async move { has_neighbour(a, "b") && has_neighbour(b, "a") },
+ )
+ .await;
+
+ sim.net.take_down("b");
+ sim.wait_until("a finds b dormant", SOON, move || is_dormant(a, "b"))
+ .await;
+ sim.wait_until("b finds a dormant", SOON, move || is_dormant(b, "a"))
+ .await;
+ let dormancies = peer(a, "b", |p| p.incidents.dormancies).await.unwrap();
+ assert!(dormancies >= 1);
+
+ // Written while apart.
+ write(a, "while/apart", json!(true)).await;
+ assert_ne!(heads(a).await, heads(b).await);
+
+ sim.net.bring_up("b");
+ sim.wait_until("a finds b active", SOON, move || async move {
+ !is_dormant(a, "b").await
+ })
+ .await;
+ sim.wait_until("b finds a active", SOON, move || async move {
+ !is_dormant(b, "a").await
+ })
+ .await;
+ sim.wait_until("b has what a wrote meanwhile", SOON, move || async move {
+ read(b, "while/apart").await == Some(json!(true))
+ })
+ .await;
+ sim.wait_converged(&[a, b], SOON).await;
+}
+
+async fn is_dormant(node: &AppState, other: &str) -> bool {
+ peer(node, other, |p| {
+ matches!(p.liveness, Liveness::Dormant { .. })
+ })
+ .await
+ == Some(true)
+}
+
+/// R17: a message sent before the peer's last epoch change and delivered late doesn't
+/// flip the epoch we recorded for it back; a new run of the peer starts its count over.
+/// The peer is a "ghost" whose messages are injected, so their order is the test's.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn late_epoch_does_not_flip_a_peers_epoch() {
+ let sim = Sim::new(4);
+ let a = sim.start("a", &[], Start::Genesis);
+ sim.wait_ready(&[&a], SOON).await;
+ let swarm_id = a.swarm_id().await.unwrap();
+
+ let epoch = |generation: u64| Epoch {
+ generation,
+ id: [generation as u8; 32],
+ };
+ let hello = |run: RunId, epoch: Epoch, epoch_seq: u32| {
+ ParsedMessage::Hello(HelloRequest {
+ swarm_id: Some(swarm_id.clone()),
+ epoch,
+ epoch_seq,
+ auth: Vec::new(),
+ ready: true,
+ run_id: run,
+ })
+ };
+ let ping = |epoch: Epoch, epoch_seq: u32| {
+ ParsedMessage::Ping(Keepalive {
+ heads: HeadsSummary::of(Vec::new()),
+ epoch,
+ epoch_seq,
+ ready: true,
+ syncs_with_you: false,
+ })
+ };
+ let a = &a;
+ let recorded = move || async move {
+ peer(a, "ghost", |p| (p.epoch, p.epoch_seq))
+ .await
+ .expect("a knows the ghost")
+ };
+
+ let first_run: RunId = [1; 16];
+ sim.net
+ .inject("ghost", "a", &hello(first_run, Epoch::GENESIS, 0))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(Epoch::GENESIS), Some(0)));
+
+ // The ghost switched twice; we hear of the second switch first.
+ sim.net
+ .inject("ghost", "a", &ping(epoch(2), 2))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(2)));
+
+ // The PING from between the switches arrives late: ignored.
+ sim.net
+ .inject("ghost", "a", &ping(epoch(1), 1))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(2)));
+ // So is one from before both.
+ sim.net
+ .inject("ghost", "a", &ping(Epoch::GENESIS, 0))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(2)));
+
+ // The ghost restarted: its new run counts from 0 again.
+ let second_run: RunId = [2; 16];
+ sim.net
+ .inject("ghost", "a", &hello(second_run, epoch(2), 0))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(2)), Some(0)));
+ assert_eq!(
+ peer(a, "ghost", |p| p.run_id).await.unwrap(),
+ Some(second_run)
+ );
+ sim.net
+ .inject("ghost", "a", &ping(epoch(3), 1))
+ .await
+ .unwrap();
+ assert_eq!(recorded().await, (Some(epoch(3)), Some(1)));
+
+ // One ahead peer isn't enough for a ready node to rejoin.
+ assert_eq!(a.local_epoch(), Epoch::GENESIS);
+ assert!(a.is_ready().await);
+}
diff --git a/src/state.rs b/src/state.rs
index 9c5552f..78b058c 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -127,8 +127,14 @@ pub(crate) struct PeerState {
/// from it in between (None while they match).
pub(crate) diverged_since: Option<Instant>,
- /// Epoch the peer advertised in its most recent message.
+ /// Epoch the peer advertised in its most recent message (late ones aside: see
+ /// `epoch_seq`).
pub(crate) epoch: Option<Epoch>,
+ /// The peer's `epoch_seq` with `epoch`: how many times it changed epoch in its run.
+ /// An epoch with a lower one is older than what we have, delivered late.
+ pub(crate) epoch_seq: Option<u32>,
+ /// The peer's run, from its HELLO or HELLO_REPLY: a new one starts `epoch_seq` over.
+ pub(crate) run_id: Option<crate::transport::RunId>,
/// Whether the peer was ready according to its most recent sync message or keepalive.
pub(crate) reports_ready: Option<bool>,
@@ -215,18 +221,22 @@ pub(crate) struct SyncReplyWait {
/// First wait for a sync answer; generous, since Mycelium delivers large messages in
/// many small packets.
const SYNC_REPLY_TIMEOUT: Duration = Duration::from_secs(10);
-/// Upper bound (Mycelium gives up on a message after five minutes).
-const SYNC_REPLY_TIMEOUT_MAX: Duration = Duration::from_secs(300);
+/// Upper bound, as a multiple of the first wait: 5 minutes (Mycelium gives up on a
+/// message after five minutes).
+const SYNC_REPLY_TIMEOUT_MAX_FACTOR: u32 = 30;
+/// How long Mycelium tries to deliver a message before dropping it.
+const MYCELIUM_DELIVERY_LIMIT: Duration = Duration::from_secs(300);
impl SyncSession {
- pub(crate) fn new() -> Self {
+ /// `reply_backoff`: waits for sync answers (`Timing::sync_reply_backoff`).
+ pub(crate) fn new(reply_backoff: Backoff) -> Self {
Self {
state: sync::State::new(),
sending: None,
retry_at: None,
send_backoff: Backoff::new(SEND_RETRY, SEND_RETRY_MAX),
reply: None,
- reply_backoff: Backoff::new(SYNC_REPLY_TIMEOUT, SYNC_REPLY_TIMEOUT_MAX),
+ reply_backoff,
}
}
@@ -315,13 +325,13 @@ impl SyncSession {
}
impl PeerState {
- pub(crate) fn new(leads: bool) -> Self {
+ pub(crate) fn new(leads: bool, timing: &Timing) -> Self {
Self {
leads,
standing: Standing::default(),
hello_state: HelloState::default(),
hello_backoff: Backoff::new(HELLO_RETRY, HELLO_RETRY_MAX),
- sync: SyncSession::new(),
+ sync: SyncSession::new(timing.sync_reply_backoff()),
liveness: Liveness::Active,
added_at: Instant::now(),
last_seen: None,
@@ -330,6 +340,8 @@ impl PeerState {
last_resync_at: None,
diverged_since: None,
epoch: None,
+ epoch_seq: None,
+ run_id: None,
reports_ready: None,
syncs_with_us: None,
reported_heads: None,
@@ -342,7 +354,10 @@ impl PeerState {
/// Start Automerge sync with this peer from scratch. A message still being handed to
/// Mycelium was generated against the old state; its outcome is then ignored.
pub(crate) fn reset_sync(&mut self) {
- self.sync = SyncSession::new();
+ // The same waits for answers, from the start.
+ let mut reply_backoff = self.sync.reply_backoff;
+ reply_backoff.reset();
+ self.sync = SyncSession::new(reply_backoff);
}
/// An overdue sync answer from an established, active peer (see
@@ -517,6 +532,12 @@ pub(crate) struct Timing {
pub(crate) dormant_probe: Duration,
/// While catching up: PING the source after this much silence (`swarm::source`).
pub(crate) catch_up_probe: Duration,
+ /// First wait for an answer to a sync message before sending again; doubles while
+ /// answers stay out (`SyncSession::reply_backoff`).
+ pub(crate) sync_reply_timeout: Duration,
+ /// How long the transport tries to deliver a message before dropping it (Mycelium:
+ /// five minutes): a peer silent for longer has nothing on the way to us any more.
+ pub(crate) delivery_limit: Duration,
}
impl Timing {
@@ -534,18 +555,34 @@ impl Timing {
// WAN, mobile or metered links, or 100+ nodes | 60-120 s | At 100 nodes and 30 s it’s about 6–7 messages/s per node. Fine, but rarely worth it on battery or metered links.
// Several hundred nodes or more | - | The full mesh becomes the problem in itself, not the interval. Message counts grow O(N²) across the swarm, which calls for partial views (SWIM-style random probing and syncing with a subset of peers).
pub(crate) fn from_keepalive(keepalive: Duration) -> Self {
+ Self::new(keepalive, SYNC_REPLY_TIMEOUT)
+ }
+
+ /// [`from_keepalive`](Self::from_keepalive) with another first sync reply timeout:
+ /// for tests, whose messages don't travel for seconds.
+ pub(crate) fn new(keepalive: Duration, sync_reply_timeout: Duration) -> Self {
Self {
keepalive,
dormant_after: keepalive * 4, // dormant_after >= 4* keepalive to tolerate three lost PINGs in a row
// >= 2 keepalives, so a second exchange confirms the difference; and >= twice
// the first sync reply timeout, so a lost message's sender resends it first.
- resync_after: (keepalive * 2).max(SYNC_REPLY_TIMEOUT * 2),
+ resync_after: (keepalive * 2).max(sync_reply_timeout * 2),
dormant_probe: keepalive * 10, // >= 10*: dormant peers cost almost nothing, but still rejoin within minutes
// Seconds, not a keepalive interval: a dead source should cost little time.
// Only the source gets these, and only while catching up.
catch_up_probe: (keepalive / 30).clamp(Duration::from_secs(1), Duration::from_secs(5)),
+ sync_reply_timeout,
+ delivery_limit: MYCELIUM_DELIVERY_LIMIT,
}
}
+
+ /// Waits for sync answers: `sync_reply_timeout`, doubling up to 30 times as much.
+ pub(crate) fn sync_reply_backoff(&self) -> Backoff {
+ Backoff::new(
+ self.sync_reply_timeout,
+ self.sync_reply_timeout * SYNC_REPLY_TIMEOUT_MAX_FACTOR,
+ )
+ }
}
#[derive(Clone)]
@@ -583,8 +620,8 @@ pub(crate) struct AppState {
/// Per-node remake state (prebuilt snapshots, switch status, archive).
pub(crate) remake: Arc<RemakeState>,
- /// Pre-shared key every member must prove in the handshake; None admits anyone who
- /// knows the swarm ID.
+ /// Pre-shared key every member must prove in the handshake; None admits any node
+ /// that reaches us.
pub(crate) swarm_key: Option<Arc<crate::swarm::auth::SwarmKey>>,
/// Random per run of this node; our HELLOs carry it and replies must echo it.
@@ -602,6 +639,12 @@ pub(crate) struct AppState {
/// HTTP API users who may write protocol data through the store API
/// (`--protocol-writers`).
pub(crate) protocol_writers: Arc<std::collections::HashSet<String>>,
+ /// This node's own files, and how much it read and wrote them.
+ pub(crate) files: Arc<crate::files::Files>,
+ /// This node's document on disk (`--document-file`), if configured.
+ pub(crate) document_file: Option<Arc<crate::document::file::DocumentFile>>,
+ /// What this node remembers across restarts (`--state-file`), if configured.
+ pub(crate) state_file: Option<Arc<crate::swarm::state_file::StateFile>>,
/// The effective configuration with sources, imported values left out
/// (`config::Loaded::report`), for `GET /admin/config`.
pub(crate) config: Arc<serde_json::Value>,
@@ -618,6 +661,9 @@ pub(crate) struct AppState {
pub(crate) announce_pending: Arc<std::sync::Mutex<Option<std::collections::HashSet<String>>>>,
/// Our neighbours, once known (`swarm::topology`).
pub(crate) neighbours: Arc<std::sync::RwLock<Option<crate::swarm::topology::NeighbourView>>>,
+ /// Something the neighbours are computed from may have changed: the member list or a
+ /// peer's reachability (`swarm::topology::run_worker`).
+ pub(crate) topology_changed: Arc<tokio::sync::Notify>,
/// While rejoining a newer epoch, the previous document, served read-only (and
/// marked stale) instead of answering 503.
@@ -627,6 +673,9 @@ pub(crate) struct AppState {
pub(crate) catch_up_source: Arc<std::sync::Mutex<Option<CatchUpSource>>>,
/// Since when a closer peer has had a head start to become the source.
pub(crate) catch_up_preference: Arc<std::sync::Mutex<Option<Instant>>>,
+ /// A catch-up source given up for not answering our sync messages, and until when it
+ /// isn't chosen again (`swarm::source`).
+ pub(crate) catch_up_avoided: Arc<std::sync::Mutex<Option<(String, Instant)>>>,
}
/// The peer a catching-up node syncs with first (`swarm::source`).
@@ -635,6 +684,12 @@ pub(crate) struct CatchUpSource {
pub(crate) peer: String,
/// When it was chosen or last sent us anything.
pub(crate) last_heard: Instant,
+ /// When it was chosen or last sent us a sync message: progress, not just liveness.
+ pub(crate) last_sync: Instant,
+ /// Our sync messages to it in a row whose answer didn't come in time.
+ pub(crate) reply_timeouts: u32,
+ /// When we last warned that catching up from it stalls.
+ pub(crate) last_warned: Option<Instant>,
/// Our last probe PING to it.
pub(crate) last_probe: Option<Instant>,
/// Its keepalives in a row saying it wouldn't sync with us.
@@ -670,6 +725,12 @@ impl AppState {
*self.epoch_tx.borrow()
}
+ /// The local epoch and `Replica::epoch_seq`, read together.
+ pub(crate) async fn epoch_stamp(&self) -> (Epoch, u32) {
+ let replica = self.doc.read().await;
+ (replica.epoch, replica.epoch_seq)
+ }
+
pub(crate) async fn is_ready(&self) -> bool {
matches!(*self.lifecycle.read().await, NodeLifecycle::Ready { .. })
}
@@ -779,7 +840,7 @@ impl AppState {
/// State for a peer we haven't known so far (not inserted).
pub(crate) fn new_peer_state(&self, pk: &str) -> Arc<Mutex<PeerState>> {
let leads = crate::swarm::pair::leads(self.local_id(), pk);
- Arc::new(Mutex::new(PeerState::new(leads)))
+ Arc::new(Mutex::new(PeerState::new(leads, &self.timing)))
}
pub(crate) fn notify_store_changed(&self) {
@@ -1207,6 +1268,7 @@ impl Host for AppState {
let new_doc = f(&mut replica.doc)?;
let old_doc = std::mem::replace(&mut replica.doc, new_doc);
replica.epoch = epoch;
+ replica.epoch_seq += 1;
replica.refresh_heads();
// Updated under the same lock, so the cached epoch never disagrees
// with the document for anyone holding the lock.
diff --git a/src/swarm/blob.rs b/src/swarm/blob.rs
index 8c891ae..42cfbf3 100644
--- a/src/swarm/blob.rs
+++ b/src/swarm/blob.rs
@@ -171,8 +171,10 @@ async fn prepare(state: &AppState, id: BlobId) -> Option<Arc<Prepared>> {
}
async fn reply(state: &AppState, peer: &str, id: BlobId, body: BlobBody) {
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
let message = ParsedMessage::Blob(BlobMessage {
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
id,
body,
});
@@ -223,8 +225,10 @@ impl Drop for Registration<'_> {
}
async fn request(state: &AppState, peer: &str, id: BlobId, chunks: Vec<u32>) {
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
let message = ParsedMessage::Blob(BlobMessage {
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
id,
body: BlobBody::Request { chunks },
});
diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs
index 27ffdd8..5e9ac06 100644
--- a/src/swarm/bootstrap.rs
+++ b/src/swarm/bootstrap.rs
@@ -1,55 +1,116 @@
//! Joining a swarm (or creating one as the genesis node).
+//!
+//! A node starts a new swarm only with `--genesis`. Otherwise it joins through its
+//! seeds and keeps trying until it is ready: seeds that don't resolve are resolved
+//! again with backoff, and HELLOs to resolved ones are retried by the peer worker. A
+//! node without seeds waits (nothing to join yet) and says so now and then.
+//!
+//! The genesis node records the swarm ID in the document (`_continuum/swarm`), so
+//! snapshots carry it forward and every backup names its swarm.
+//!
+//! A node recovering from a backup (`--recover-from`) starts on the backup's document,
+//! swarm and epoch instead, and is ready at once, like a genesis node with content.
use std::sync::atomic::Ordering;
use std::time::Duration;
-use anyhow::{Result, bail};
-use tracing::{debug, error, info, warn};
+use std::collections::HashSet;
+use std::path::Path;
+
+use anyhow::{Context, Result, bail};
+use automerge::{ActorId, AutoCommit};
+use serde_json::Value as JsonValue;
+use tokio::time::Instant;
+use tracing::{debug, info, warn};
use uuid::Uuid;
+use crate::document;
+use crate::document::replica::Epoch;
+use crate::document::store;
+use crate::files::{FileKind, Files};
+use crate::force::Force;
+use crate::remake::snapshot::{self, META_ROOT, SWARM};
use crate::state::AppState;
use crate::swarm::peer::{remember_peer, send_hello_once};
-const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(300);
-const BOOTSTRAP_RETRY: Duration = Duration::from_millis(500);
+const BOOTSTRAP_POLL: Duration = Duration::from_millis(500);
+/// How often a node that is still joining says why.
+const BOOTSTRAP_REPORT: Duration = Duration::from_secs(300);
+/// Retrying seeds that didn't resolve: first after this, doubling up to the maximum.
+const RESOLVE_RETRY: Duration = Duration::from_secs(5);
+const RESOLVE_RETRY_MAX: Duration = Duration::from_secs(300);
-pub(crate) async fn bootstrap(state: &AppState, seeds: &[String]) -> Result<()> {
- if seeds.is_empty() {
- info!("Initializing genesis node (no seeds configured)");
+/// How this node starts.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Start {
+ /// Join a swarm through seeds (or remembered members).
+ Join,
+ /// Start a swarm, empty (`--genesis`).
+ Genesis,
+ /// Start on a document already loaded as the replica (`StartingDocument`).
+ FromDocument(Origin),
+}
+
+pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start) -> Result<()> {
+ if let Start::FromDocument(origin) = start {
+ let Some(swarm_id) = state.swarm_id().await else {
+ bail!("starting on a document without a swarm ID");
+ };
+ info!(
+ swarm_id = %swarm_id,
+ epoch = %state.local_epoch(),
+ "Starting on {}, ready at once",
+ origin.describe()
+ );
state.initial_sync_done.store(true, Ordering::Release);
- state.mark_ready(Uuid::new_v4().to_string()).await?;
+ state.mark_ready(swarm_id).await?;
+ // Members of the swarm still running, if any: they merge with this node, or it
+ // rejoins them if they moved on to a newer epoch.
+ resolve_seeds(state, &mut seeds.to_vec(), &mut HashSet::new()).await;
return Ok(());
}
-
- info!(
- timeout_seconds = BOOTSTRAP_TIMEOUT.as_secs(),
- "Joining existing swarm"
- );
-
- let mut peers = Vec::new();
-
- for seed in seeds {
- match state.transport.resolve_seed(seed).await {
- Ok(peer) => {
- debug!(seed = %seed, peer = %peer, "Resolved seed");
- peers.push(peer)
+ if start == Start::Genesis {
+ // A remembered swarm starts again under its ID: nodes of it still running then
+ // merge with this one instead of ending up in another swarm.
+ let swarm_id = match state.swarm_id().await {
+ Some(remembered) => {
+ warn!(
+ swarm_id = %remembered,
+ "--genesis with a remembered swarm: starting it again, empty; nodes of it still running merge with this one"
+ );
+ remembered
}
- Err(e) => warn!(seed = %seed, error = %e, "Ignoring seed argument"),
- }
- }
-
- if peers.is_empty() {
- bail!("None of the configured seeds could be resolved");
+ None => {
+ let swarm_id = Uuid::new_v4().to_string();
+ info!(swarm_id = %swarm_id, "Starting a new swarm as its genesis node (--genesis)");
+ swarm_id
+ }
+ };
+ let recorded = JsonValue::String(swarm_id.clone());
+ state
+ .mutate_doc(move |doc| store::write_path(doc, &swarm_id_path(), &recorded))
+ .await
+ .context("Recording the swarm ID")?;
+ state.initial_sync_done.store(true, Ordering::Release);
+ state.mark_ready(swarm_id).await?;
+ // Remembered members, if any, to find those still running.
+ resolve_seeds(state, &mut seeds.to_vec(), &mut HashSet::new()).await;
+ return Ok(());
}
- for peer in peers {
- remember_peer(state, &peer).await;
- if let Err(error) = send_hello_once(state, &peer).await {
- warn!(peer = %peer, error = %format!("{error:#}"), "Initial HELLO failed; will retry");
- }
+ if seeds.is_empty() {
+ warn!(
+ "No seeds: nothing to join. Add --seeds to join a swarm, or --genesis to start a new one"
+ );
+ } else {
+ info!(seeds = seeds.len(), "Joining existing swarm");
}
- let deadline = tokio::time::Instant::now() + BOOTSTRAP_TIMEOUT;
+ let mut unresolved: Vec<String> = seeds.to_vec();
+ let mut failing = HashSet::new();
+ let mut resolve_retry = RESOLVE_RETRY;
+ let mut next_resolve = Instant::now();
+ let mut next_report = Instant::now() + BOOTSTRAP_REPORT;
loop {
if let Some(swarm_id) = state.swarm_id().await {
// A node joining a compacted swarm rejoins the newer epoch first; it is
@@ -61,39 +122,244 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String]) -> Result<()>
}
}
- if tokio::time::Instant::now() >= deadline {
- let mut peer_diagnostics: Vec<String> = Vec::new();
+ let now = Instant::now();
+ if !unresolved.is_empty() && now >= next_resolve {
+ resolve_seeds(state, &mut unresolved, &mut failing).await;
+ if !unresolved.is_empty() {
+ next_resolve = now + resolve_retry;
+ resolve_retry = (resolve_retry * 2).min(RESOLVE_RETRY_MAX);
+ }
+ }
+ if now >= next_report {
+ next_report = now + BOOTSTRAP_REPORT;
+ report_joining(state, seeds, &unresolved).await;
+ }
+ tokio::time::sleep(BOOTSTRAP_POLL).await;
+ }
+}
- let peer_states = state.peer_states.read().await.clone();
+fn swarm_id_path() -> Vec<String> {
+ vec![META_ROOT.to_owned(), SWARM.to_owned()]
+}
- for (peer, peer_state_mutex) in &peer_states {
- let peer_state = peer_state_mutex.lock().await;
- let validated_swarm = peer_state.standing.swarm_id();
- peer_diagnostics.push(format!(
- "{peer}: validated_swarm={:?}, epoch={:?}",
- validated_swarm, peer_state.epoch,
- ))
+/// The swarm ID the document names, if any.
+fn read_swarm_id(doc: &AutoCommit) -> Option<String> {
+ match store::read_path(doc, &swarm_id_path()) {
+ Ok(Some(JsonValue::String(id))) if !id.is_empty() => Some(id),
+ _ => None,
+ }
+}
+
+/// Where a document to start from came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Origin {
+ /// A backup to recover from (`--recover-from`).
+ Backup,
+ /// This node's document file (`--document-file`).
+ DocumentFile,
+}
+
+impl Origin {
+ fn describe(self) -> &'static str {
+ match self {
+ Origin::Backup => "the backup",
+ Origin::DocumentFile => "the document file",
+ }
+ }
+}
+
+/// A document the node starts from, ready at once: on the swarm and epoch it names.
+pub(crate) struct StartingDocument {
+ /// Under a fresh actor.
+ pub(crate) doc: AutoCommit,
+ pub(crate) epoch: Epoch,
+ pub(crate) swarm_id: String,
+ pub(crate) origin: Origin,
+ /// The actors this node wrote with before (from the document file).
+ pub(crate) earlier_actors: Vec<ActorId>,
+}
+
+/// What `--recover-from` and `--document-file` say about where to start.
+pub(crate) struct StartOptions<'a> {
+ pub(crate) recover_from: Option<&'a Path>,
+ pub(crate) document_file: Option<&'a Path>,
+ pub(crate) genesis: bool,
+ /// The swarm the state file remembers.
+ pub(crate) remembered: Option<&'a str>,
+}
+
+/// The document to start from, if any, checked against the other options. Conflicts
+/// are errors unless forced (`force`):
+/// * an unreadable document file (`badDocumentFile`: moved aside, start without it);
+/// * a backup to recover from while the document file holds a document
+/// (`replaceDocumentFile`: the backup wins, and the file is rewritten from it);
+/// * a document of another swarm than the state file remembers (`swarmId`).
+///
+/// A document file holding a document with `--genesis` is an error: move it away.
+pub(crate) fn starting_document(
+ options: &StartOptions<'_>,
+ files: &Files,
+ force: &Force,
+) -> Result<Option<StartingDocument>> {
+ let from_file = match options.document_file {
+ None => None,
+ Some(path) => match document::file::load(path, files) {
+ Ok(loaded) => loaded,
+ Err(error) if force.allows("badDocumentFile") => {
+ warn!(error = %format!("{error:#}"), "Unusable document file; moving it aside and starting without it (--force badDocumentFile)");
+ let aside = files.move_aside(path, ".bad").with_context(|| {
+ format!("Moving the document file {} aside", path.display())
+ })?;
+ warn!(moved_to = %aside.display(), "Moved the document file aside");
+ None
+ }
+ Err(error) => {
+ bail!(
+ "{error:#}; add --force badDocumentFile to move it aside and start without it"
+ )
}
- let local_swarm = state.swarm_id().await;
- let local_epoch = state.local_epoch();
- let initial_sync_done = state.initial_sync_done.load(Ordering::Acquire);
- error!(
- local_swarm = ?local_swarm,
- local_epoch = %local_epoch,
- initial_sync_done,
- peers = %peer_diagnostics.join("; "),
- timeout_seconds = BOOTSTRAP_TIMEOUT.as_secs(),
- "Bootstrap timed out"
+ },
+ };
+ let backup = match options.recover_from {
+ None => None,
+ Some(path) => Some(load_backup(path, files)?),
+ };
+
+ let (doc, origin, earlier_actors) = match (backup, from_file) {
+ (Some(_), Some(_)) if !force.allows("replaceDocumentFile") => {
+ bail!(
+ "recover-from would replace the document in the document file; add --force replaceDocumentFile to recover the backup anyway"
);
+ }
+ (Some(backup), from_file) => {
+ if from_file.is_some() {
+ warn!(
+ "Recovering the backup instead of the document file's document (--force replaceDocumentFile)"
+ );
+ }
+ (backup, Origin::Backup, Vec::new())
+ }
+ (None, Some(loaded)) => (loaded.doc, Origin::DocumentFile, loaded.actors),
+ (None, None) => return Ok(None),
+ };
+ if options.genesis && origin == Origin::DocumentFile {
+ bail!(
+ "The document file holds a document of a swarm, and genesis would start a new one; move the file away to start a new swarm"
+ );
+ }
+ let starting = identify(doc, origin, earlier_actors)?;
+
+ if let Some(remembered) = options.remembered.filter(|id| *id != starting.swarm_id) {
+ let what = origin.describe();
+ if !force.allows("swarmId") {
bail!(
- "Bootstrap timed out after {:?}; local_swarm={:?}; local_epoch={}; initial_sync_done={}; peers=[{}]. Refusing to create a genesis document.",
- BOOTSTRAP_TIMEOUT,
- local_swarm,
- local_epoch,
- initial_sync_done,
- peer_diagnostics.join("; ")
+ "{what} is of swarm {}, but the state file remembers swarm {remembered}; add --force swarmId to start on {what}'s swarm anyway",
+ starting.swarm_id
);
}
- tokio::time::sleep(BOOTSTRAP_RETRY).await;
+ warn!(
+ document = %starting.swarm_id,
+ remembered = %remembered,
+ "Starting on {what}'s swarm, not the remembered one (--force swarmId)"
+ );
+ }
+ Ok(Some(starting))
+}
+
+/// The swarm and epoch `doc` is on.
+fn identify(
+ mut doc: AutoCommit,
+ origin: Origin,
+ earlier_actors: Vec<ActorId>,
+) -> Result<StartingDocument> {
+ let what = origin.describe();
+ let Some(swarm_id) = read_swarm_id(&doc) else {
+ bail!("{what} names no swarm (no _continuum/swarm); it predates swarm IDs in documents");
+ };
+ let epoch =
+ snapshot::epoch_of(&mut doc).map_err(|reason| anyhow::anyhow!("{what}: {reason}"))?;
+ Ok(StartingDocument {
+ doc,
+ epoch,
+ swarm_id,
+ origin,
+ earlier_actors,
+ })
+}
+
+/// A backup to recover from, as `GET /admin/document` saves it, under a fresh actor.
+fn load_backup(path: &Path, files: &Files) -> Result<AutoCommit> {
+ let bytes = files
+ .read(FileKind::Backup, path)
+ .with_context(|| format!("Reading the backup {}", path.display()))?
+ .with_context(|| format!("The backup {} doesn't exist", path.display()))?;
+ Ok(AutoCommit::load(&bytes)
+ .with_context(|| format!("Loading the backup {}", path.display()))?
+ .with_actor(ActorId::random()))
+}
+
+/// Resolves the seeds in `unresolved`, says HELLO to each that resolves, and keeps the
+/// rest for later. Warns once per seed that fails (`failing` remembers which), and says
+/// when such a seed resolves after all.
+async fn resolve_seeds(
+ state: &AppState,
+ unresolved: &mut Vec<String>,
+ failing: &mut HashSet<String>,
+) {
+ let mut still = Vec::new();
+ for seed in unresolved.drain(..) {
+ match state.transport.resolve_seed(&seed).await {
+ Ok(peer) => {
+ if failing.remove(&seed) {
+ info!(seed = %seed, peer = %peer, "Seed resolves now");
+ } else {
+ debug!(seed = %seed, peer = %peer, "Resolved seed");
+ }
+ remember_peer(state, &peer).await;
+ if let Err(error) = send_hello_once(state, &peer).await {
+ warn!(peer = %peer, error = %format!("{error:#}"), "Initial HELLO failed; will retry");
+ }
+ }
+ Err(error) => {
+ if failing.insert(seed.clone()) {
+ warn!(seed = %seed, error = %error, "Seed doesn't resolve; trying again until it does");
+ } else {
+ debug!(seed = %seed, error = %error, "Seed still doesn't resolve");
+ }
+ still.push(seed);
+ }
+ }
}
+ *unresolved = still;
+}
+
+/// Why the node is still joining, for the log.
+async fn report_joining(state: &AppState, seeds: &[String], unresolved: &[String]) {
+ if seeds.is_empty() {
+ warn!(
+ "Still no seeds: nothing to join. Add --seeds to join a swarm, or --genesis to start a new one"
+ );
+ return;
+ }
+ let mut peer_diagnostics: Vec<String> = Vec::new();
+ let peer_states = state.peer_states.read().await.clone();
+ for (peer, peer_state_mutex) in &peer_states {
+ let peer_state = peer_state_mutex.lock().await;
+ peer_diagnostics.push(format!(
+ "{peer}: validated_swarm={:?}, epoch={:?}",
+ peer_state.standing.swarm_id(),
+ peer_state.epoch,
+ ));
+ }
+ // Awaited outside the macro: its arguments aren't Send, and a spawned bootstrap
+ // (the simulator's) must be.
+ let local_swarm = state.swarm_id().await;
+ warn!(
+ local_swarm = ?local_swarm,
+ local_epoch = %state.local_epoch(),
+ initial_sync_done = state.initial_sync_done.load(Ordering::Acquire),
+ unresolved_seeds = ?unresolved,
+ peers = %peer_diagnostics.join("; "),
+ "Still joining the swarm; trying on"
+ );
}
diff --git a/src/swarm/members.rs b/src/swarm/members.rs
index 221437e..895e0cc 100644
--- a/src/swarm/members.rs
+++ b/src/swarm/members.rs
@@ -25,7 +25,7 @@ use crate::state::{AppState, unix_millis};
const META_ROOT: &str = "_continuum";
-fn members_dir() -> Vec<String> {
+pub(crate) fn members_dir() -> Vec<String> {
vec![META_ROOT.to_owned(), "members".to_owned()]
}
diff --git a/src/swarm/mod.rs b/src/swarm/mod.rs
index adb5d1c..d424529 100644
--- a/src/swarm/mod.rs
+++ b/src/swarm/mod.rs
@@ -11,6 +11,7 @@ pub(crate) mod relay;
pub(crate) mod rtt;
pub(crate) mod source;
pub(crate) mod stability;
+pub(crate) mod state_file;
pub(crate) mod stats;
pub(crate) mod sync;
pub(crate) mod throughput;
diff --git a/src/swarm/peer.rs b/src/swarm/peer.rs
index 3d7da32..e8248bf 100644
--- a/src/swarm/peer.rs
+++ b/src/swarm/peer.rs
@@ -110,11 +110,12 @@ pub(crate) async fn send_ping(state: &AppState, peer: &str) -> Result<()> {
pub(crate) async fn local_keepalive(state: &AppState, peer: &str) -> transport::Keepalive {
// Heads and epoch come from the same locked replica, so they always match. The heads
// are cached (`Replica`), so the read lock is enough.
- let (heads, epoch) = {
+ let (heads, epoch, epoch_seq) = {
let replica = state.doc.read().await;
(
transport::HeadsSummary::of(replica.heads().to_vec()),
replica.epoch,
+ replica.epoch_seq,
)
};
let syncs_with_you = match state.get_peer_state(peer).await {
@@ -129,6 +130,7 @@ pub(crate) async fn local_keepalive(state: &AppState, peer: &str) -> transport::
transport::Keepalive {
heads,
epoch,
+ epoch_seq,
ready: state.is_ready().await,
syncs_with_you,
}
@@ -276,6 +278,7 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> {
.as_ref()
.map(|key| key.hello_tag(swarm_id.as_deref().unwrap_or(""), state.local_id(), peer))
.unwrap_or_default();
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
state.lock_rtt().hello_sent(peer, Instant::now());
let result = state
.transport
@@ -283,7 +286,8 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> {
peer,
&ParsedMessage::Hello(transport::HelloRequest {
swarm_id,
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
auth,
ready: state.is_ready().await,
run_id: state.run_id,
@@ -295,7 +299,11 @@ async fn transmit_hello(state: &AppState, peer: &str) -> Result<()> {
let mut peer_state = peer_state_mutex.lock().await;
if peer_state.hello_state == HelloState::Sending {
+ let was_unreachable = peer_state.unreachable_since.is_some();
peer_state.hello_sent(Instant::now());
+ if !was_unreachable && peer_state.unreachable_since.is_some() {
+ state.topology_changed.notify_one();
+ }
let wait = match peer_state.liveness {
Liveness::Dormant { .. } => state.timing.dormant_probe,
Liveness::Active => peer_state.hello_backoff.next(),
@@ -354,14 +362,17 @@ async fn send_hello_reply(
.map(|key| key.hello_reply_tag(&swarm_id, state.local_id(), peer))
.unwrap_or_default();
let ready = state.is_ready().await;
+ let (epoch, epoch_seq) = state.epoch_stamp().await;
let reply = transport::HelloReply {
swarm_id,
peers,
- epoch: state.local_epoch(),
+ epoch,
+ epoch_seq,
auth,
ready,
run_id,
away_ms: away.map(|away| away.as_millis() as u64),
+ sender_run_id: state.run_id,
};
debug!(to = %peer, ready, "Sending HELLO_REPLY");
@@ -399,15 +410,24 @@ async fn discover_peers(state: &AppState, from_peer: &str, advertised_peers: &[S
}
/// New neighbours get our HELLO (we may not have known them before: the member list is
-/// how a partial mesh discovers them). Former neighbours get no more HELLOs, keepalives
-/// or sync openings from us; their session is reset, so we don't keep pushing changes
-/// to them either. They are still answered if they contact us.
-async fn apply_neighbour_change(state: &AppState, change: topology::NeighbourChange) {
+/// how a partial mesh discovers them) and a sync opening. The opening matters for a
+/// neighbour whose handshake is already complete, e.g. one that returned from dormancy
+/// while it wasn't our neighbour: it was only answered then, and with no HELLO to send,
+/// nothing else would open sync until the keepalive heal (`resync_after`). Former
+/// neighbours get no more HELLOs, keepalives or sync openings from us; their session is
+/// reset, so we don't keep pushing changes to them either. They are still answered if
+/// they contact us.
+pub(crate) async fn apply_neighbour_change(state: &AppState, change: topology::NeighbourChange) {
for peer in &change.added {
remember_peer(state, peer).await;
if let Err(error) = send_hello_once(state, peer).await {
warn!(peer = %peer, error = %format!("{error:#}"), "HELLO to new neighbour failed; will retry");
}
+ // Skipped there unless the handshake is complete; otherwise its reply restarts
+ // sync anyway.
+ let _ = state
+ .sync_events
+ .send(SyncEvent::PeerNeedsSync(peer.clone()));
}
for peer in &change.removed {
if let Some(peer_state) = state.get_peer_state(peer).await {
@@ -427,12 +447,20 @@ pub(crate) async fn run_peer_worker(state: AppState) {
info!("Starting peer maintenance worker");
let mut last_forget_check: Option<Instant> = None;
+ let mut was_ready = false;
loop {
tick.tick().await;
// While joining, keep handshakes fast; dormancy would slow probes to minutes.
let ready = state.is_ready().await;
+ if ready && !was_ready {
+ // Just became ready: register in the member list (and do the other periodic
+ // checks) now rather than at the next check. Until it is registered, other
+ // members don't count this node as a neighbour.
+ last_forget_check = None;
+ }
+ was_ready = ready;
let peers: Vec<(String, Arc<Mutex<PeerState>>)> = state
.peer_states
@@ -461,12 +489,14 @@ pub(crate) async fn run_peer_worker(state: AppState) {
if let Some(changes) = unanswered {
debug!(peer = %peer, changes, "No answer to our sync message; sending again");
+ crate::swarm::source::reply_timed_out(&state, peer);
let _ = state
.sync_events
.send(SyncEvent::PeerNeedsSync(peer.clone()));
}
if became_dormant {
+ state.topology_changed.notify_one();
warn!(
peer = %peer,
silent_secs = state.timing.dormant_after.as_secs(),
@@ -493,6 +523,8 @@ pub(crate) async fn run_peer_worker(state: AppState) {
crate::swarm::members::ensure_registered(&state).await;
crate::swarm::stability::tick(&state).await;
crate::swarm::stats::measure_saved_size(&state);
+ crate::swarm::source::warn_if_stalled(&state);
+ crate::swarm::state_file::update(&state, false).await;
{
let known = state.peer_states.read().await;
state.traffic.retain(|peer| known.contains_key(peer));
@@ -502,9 +534,8 @@ pub(crate) async fn run_peer_worker(state: AppState) {
.unwrap_or_else(|poisoned| poisoned.into_inner())
.retain(|peer| known.contains_key(peer));
}
- if let Some(change) = topology::refresh(&state).await {
- apply_neighbour_change(&state, change).await;
- }
+ // Fallback for what doesn't notify (`topology::run_worker`).
+ state.topology_changed.notify_one();
last_forget_check = Some(Instant::now());
}
}
@@ -581,9 +612,20 @@ pub(crate) async fn peer_swarm(state: &AppState, peer: &str) -> Option<String> {
// --- epochs ------------------------------------------------------------------
-/// Records the epoch a peer advertised and compares it with ours. Returns true when both
-/// are on the same epoch, the only case in which the documents may be synced.
-async fn observe_peer_epoch(state: &AppState, peer: &str, remote: Epoch) -> bool {
+/// Records the epoch a peer advertised and compares it with ours. Returns true when the
+/// message's epoch is ours, the only case in which its documents may be synced.
+///
+/// `seq` is the peer's `epoch_seq` with it. A message with a lower one than we have was
+/// sent before the peer's last epoch change and delivered late: its epoch isn't
+/// recorded, so it can't flip our view of the peer back. The count starts over when the
+/// peer restarts, which its HELLO or HELLO_REPLY tells by a new run (`run`).
+async fn observe_peer_epoch(
+ state: &AppState,
+ peer: &str,
+ remote: Epoch,
+ seq: u32,
+ run: Option<transport::RunId>,
+) -> bool {
let local = state.local_epoch();
let relation = local.relation_to(&remote);
@@ -593,6 +635,21 @@ async fn observe_peer_epoch(state: &AppState, peer: &str, remote: Epoch) -> bool
let previous = {
let mut peer_state = peer_state_mutex.lock().await;
+ if let Some(run) = run.filter(|run| peer_state.run_id != Some(*run)) {
+ peer_state.run_id = Some(run);
+ peer_state.epoch_seq = None;
+ }
+ if peer_state.epoch_seq.is_some_and(|newest| seq < newest) {
+ debug!(
+ peer = %peer,
+ epoch = %remote,
+ seq,
+ newest = ?peer_state.epoch_seq,
+ "Ignoring a late message's epoch"
+ );
+ return relation == EpochRelation::Same;
+ }
+ peer_state.epoch_seq = Some(seq);
let previous = peer_state.epoch.replace(remote);
if previous.is_some_and(|previous| previous != remote) {
// The peer switched documents; our sync state describes its old one. Count
@@ -749,6 +806,10 @@ pub(crate) async fn handle_message(
let peer_state_mutex = state.get_or_create_peer_state(&peer).await;
let mut peer_state = peer_state_mutex.lock().await;
let previously_seen = peer_state.last_seen;
+ if peer_state.unreachable_since.is_some() {
+ // Reachable again (`mark_seen` clears it).
+ state.topology_changed.notify_one();
+ }
(peer_state.mark_seen(Instant::now()), previously_seen)
};
crate::swarm::source::heard_from(state, &peer);
@@ -757,7 +818,13 @@ pub(crate) async fn handle_message(
info!(peer = %peer, "Dormant peer is active again; re-handshaking");
}
- let same_epoch = observe_peer_epoch(state, &peer, message.epoch()).await;
+ let run = match &message {
+ transport::ParsedMessage::Hello(hello) => Some(hello.run_id),
+ transport::ParsedMessage::HelloReply(reply) => Some(reply.sender_run_id),
+ _ => None,
+ };
+ let same_epoch =
+ observe_peer_epoch(state, &peer, message.epoch(), message.epoch_seq(), run).await;
//debug!(from = %peer, message = ?message, "Received message");
@@ -807,6 +874,7 @@ pub(crate) async fn handle_message(
debug!(peer = %peer, epoch = %envelope.epoch, "Dropping sync message from another epoch");
return Ok(());
}
+ crate::swarm::source::synced_from(state, &peer);
handle_sync(state, &peer, envelope).await?;
}
transport::ParsedMessage::Ping(remote) => {
diff --git a/src/swarm/source.rs b/src/swarm/source.rs
index 378c40a..9eac445 100644
--- a/src/swarm/source.rs
+++ b/src/swarm/source.rs
@@ -15,12 +15,22 @@
//! is quiet for `Timing::catch_up_probe`, we PING it; after three unanswered probes, or
//! when it says it isn't ready, or twice in a row that it wouldn't sync with us (the
//! first time may be a handshake still under way), the next peer takes over.
+//!
+//! Alive isn't enough: a source that answers keepalives but not our sync messages (a
+//! big answer lost on the way, a stuck sync state) would keep us catching up forever.
+//! So it is also given up after three of our sync messages in a row went unanswered,
+//! once nothing from it can still be on the way: no sync message from it for
+//! `Timing::delivery_limit`, after which Mycelium has delivered or dropped anything it
+//! sent before. It isn't chosen again for another `delivery_limit`, so another peer
+//! takes over rather than the same one stalling again; if it is the only candidate,
+//! catching up waits that long before trying it again. Meanwhile, every minute without a sync message from it is logged as a
+//! warning.
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::time::Instant;
-use tracing::{debug, info};
+use tracing::{debug, info, warn};
use crate::state::{AppState, CatchUpSource, PeerState};
use crate::swarm::sync::SyncEvent;
@@ -35,6 +45,12 @@ const UNANSWERED_PROBES: u32 = 3;
/// Keepalives in a row saying "I wouldn't sync with you" before the source is given up.
const REFUSALS: u32 = 2;
+/// Unanswered sync messages in a row before the source may be given up.
+const REPLY_TIMEOUTS: u32 = 3;
+
+/// How often a stalled catch-up is logged.
+const STALL_WARNING: Duration = Duration::from_secs(60);
+
fn lock_preference(state: &AppState) -> std::sync::MutexGuard<'_, Option<Instant>> {
state
.catch_up_preference
@@ -42,6 +58,13 @@ fn lock_preference(state: &AppState) -> std::sync::MutexGuard<'_, Option<Instant
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
+fn lock_avoided(state: &AppState) -> std::sync::MutexGuard<'_, Option<(String, Instant)>> {
+ state
+ .catch_up_avoided
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
fn catching_up(state: &AppState) -> bool {
!state.initial_sync_done.load(Ordering::Acquire)
}
@@ -60,12 +83,20 @@ pub(crate) fn defer(state: &AppState, peer: &str, peer_state: &PeerState) -> Opt
return None;
}
let stall = state.timing.catch_up_probe * UNANSWERED_PROBES;
+ let now = Instant::now();
+ let avoided_until = lock_avoided(state)
+ .as_ref()
+ .filter(|(avoided, until)| avoided == peer && now < *until)
+ .map(|(_, until)| *until);
+ if let Some(until) = avoided_until {
+ // Given up for not answering; the timer brings it back once it may be chosen.
+ return Some(until - now);
+ }
if peer_state.reports_ready == Some(false) || peer_state.syncs_with_us == Some(false) {
// Not a candidate; its next message re-evaluates this, the timer is a fallback.
return Some(stall);
}
- let now = Instant::now();
let mut source = lock(state);
let quiet = match &*source {
Some(current) if current.peer == peer => return None,
@@ -115,6 +146,9 @@ pub(crate) fn defer(state: &AppState, peer: &str, peer_state: &PeerState) -> Opt
*source = Some(CatchUpSource {
peer: peer.to_owned(),
last_heard: now,
+ last_sync: now,
+ reply_timeouts: 0,
+ last_warned: None,
last_probe: None,
refusals: 0,
});
@@ -128,6 +162,78 @@ pub(crate) fn heard_from(state: &AppState, peer: &str) {
}
}
+/// A sync message from `peer` arrived: progress, if it is the source.
+pub(crate) fn synced_from(state: &AppState, peer: &str) {
+ if let Some(current) = lock(state).as_mut().filter(|current| current.peer == peer) {
+ current.last_sync = Instant::now();
+ current.reply_timeouts = 0;
+ }
+}
+
+/// Our sync message to `peer` went unanswered (`SyncSession` reply deadline). Gives up
+/// the source after `REPLY_TIMEOUTS` in a row, once nothing from it can be on the way.
+pub(crate) fn reply_timed_out(state: &AppState, peer: &str) {
+ if !catching_up(state) {
+ return;
+ }
+ let given_up = {
+ let mut source = lock(state);
+ let Some(current) = source.as_mut().filter(|current| current.peer == peer) else {
+ return;
+ };
+ current.reply_timeouts += 1;
+ let silent = current.last_sync.elapsed();
+ let give_up =
+ current.reply_timeouts >= REPLY_TIMEOUTS && silent >= state.timing.delivery_limit;
+ let timeouts = current.reply_timeouts;
+ if give_up {
+ *source = None;
+ *lock_avoided(state) = Some((
+ peer.to_owned(),
+ Instant::now() + state.timing.delivery_limit,
+ ));
+ }
+ give_up.then_some((timeouts, silent))
+ };
+ if let Some((timeouts, silent)) = given_up {
+ warn!(
+ peer = %peer,
+ reply_timeouts = timeouts,
+ silent_secs = silent.as_secs(),
+ "Catch-up source doesn't answer our sync messages; catching up from another peer"
+ );
+ resync_everyone(state);
+ }
+}
+
+/// Warns, at most every `STALL_WARNING`, while the source sends no sync messages. Called
+/// by the peer worker on its maintenance tick.
+pub(crate) fn warn_if_stalled(state: &AppState) {
+ if !catching_up(state) {
+ return;
+ }
+ let mut source = lock(state);
+ let Some(current) = source.as_mut() else {
+ return;
+ };
+ let silent = current.last_sync.elapsed();
+ if silent < STALL_WARNING
+ || current
+ .last_warned
+ .is_some_and(|at| at.elapsed() < STALL_WARNING)
+ {
+ return;
+ }
+ current.last_warned = Some(Instant::now());
+ warn!(
+ peer = %current.peer,
+ silent_secs = silent.as_secs(),
+ reply_timeouts = current.reply_timeouts,
+ heard_secs_ago = current.last_heard.elapsed().as_secs(),
+ "Catching up stalls: no sync message from the catch-up source"
+ );
+}
+
/// What `peer` said about itself, in a sync message (`syncs_with_us: None`) or a
/// keepalive. Gives up the source if that rules it out.
pub(crate) fn reported(state: &AppState, peer: &str, ready: bool, syncs_with_us: Option<bool>) {
@@ -188,6 +294,7 @@ pub(crate) fn probe_due(state: &AppState, peer: &str, now: Instant) -> bool {
pub(crate) fn finished(state: &AppState, from: &str) {
*lock(state) = None;
*lock_preference(state) = None;
+ *lock_avoided(state) = None;
let state = state.clone();
let from = from.to_owned();
tokio::spawn(async move {
diff --git a/src/swarm/state_file.rs b/src/swarm/state_file.rs
new file mode 100644
index 0000000..62a6dbe
--- /dev/null
+++ b/src/swarm/state_file.rs
@@ -0,0 +1,185 @@
+//! The state file (`--state-file`): what a node remembers across restarts, so it
+//! rejoins its swarm on its own: the swarm ID and some members to reach it through.
+//! Not the document: a restarted node still starts empty and syncs.
+//!
+//! Written rarely: once the node is ready, then when the remembered members change
+//! (checked every few minutes, written at most once an hour), and at shutdown if they
+//! changed since, atomically (`files`).
+
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use tokio::time::Instant;
+use tracing::{error, info, warn};
+
+use crate::files::{FileKind, Files};
+use crate::state::AppState;
+use crate::swarm::members;
+
+/// Members remembered as seeds.
+const MAX_SEEDS: usize = 16;
+/// How often the remembered members are compared with the registry.
+const CHECK_INTERVAL: Duration = Duration::from_secs(300);
+/// Writes at least this far apart, but for the first one and at shutdown.
+const MIN_WRITE_INTERVAL: Duration = Duration::from_secs(3600);
+
+/// The file's content.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct Remembered {
+ pub(crate) swarm_id: String,
+ /// Public keys of members to join through (sorted).
+ #[serde(default)]
+ pub(crate) seeds: Vec<String>,
+}
+
+pub(crate) struct StateFile {
+ path: PathBuf,
+ files: Arc<Files>,
+ // A std mutex: never held across an await.
+ inner: Mutex<Inner>,
+}
+
+#[derive(Default)]
+struct Inner {
+ written: Option<Remembered>,
+ written_at: Option<Instant>,
+ checked_at: Option<Instant>,
+}
+
+impl StateFile {
+ /// Opens `path`, returning what it remembers. A missing file remembers nothing; an
+ /// unreadable one is moved aside (`.bad`) and logged, and remembers nothing either.
+ pub(crate) fn open(path: PathBuf, files: Arc<Files>) -> (Self, Option<Remembered>) {
+ let remembered = match files.read(FileKind::State, &path) {
+ Ok(Some(bytes)) => match serde_json::from_slice::<Remembered>(&bytes) {
+ Ok(remembered) if !remembered.swarm_id.is_empty() => Some(remembered),
+ Ok(_) => None,
+ Err(error) => {
+ error!(
+ path = %path.display(),
+ error = %error,
+ "Unreadable state file; moving it aside and remembering nothing"
+ );
+ match files.move_aside(&path, ".bad") {
+ Ok(aside) => {
+ warn!(moved_to = %aside.display(), "Moved the state file aside")
+ }
+ Err(error) => warn!(error = %error, "Couldn't move the state file aside"),
+ }
+ None
+ }
+ },
+ Ok(None) => None,
+ Err(error) => {
+ error!(path = %path.display(), error = %error, "Couldn't read the state file; remembering nothing");
+ None
+ }
+ };
+ let file = Self {
+ path,
+ files,
+ inner: Mutex::new(Inner {
+ written: remembered.clone(),
+ ..Inner::default()
+ }),
+ };
+ (file, remembered)
+ }
+
+ fn lock(&self) -> MutexGuard<'_, Inner> {
+ self.inner
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ fn write(&self, remembered: &Remembered) -> std::io::Result<()> {
+ let mut bytes = serde_json::to_vec_pretty(remembered)?;
+ bytes.push(b'\n');
+ self.files.write_atomic(FileKind::State, &self.path, &bytes)
+ }
+}
+
+/// Brings the state file up to date if it is due: `now` for the first write and at
+/// shutdown, otherwise only every few minutes and at most once an hour. Called by the
+/// peer worker on its maintenance tick.
+pub(crate) async fn update(state: &AppState, now: bool) {
+ let Some(file) = state.state_file.clone() else {
+ return;
+ };
+ if !state.is_ready().await {
+ return;
+ }
+ let Some(swarm_id) = state.swarm_id().await else {
+ return;
+ };
+ {
+ let mut inner = file.lock();
+ if !now
+ && inner
+ .checked_at
+ .is_some_and(|at| at.elapsed() < CHECK_INTERVAL)
+ {
+ return;
+ }
+ inner.checked_at = Some(Instant::now());
+ }
+
+ let seeds = {
+ let replica = state.doc.read().await;
+ seeds(&replica.doc, state.local_id(), state.electorate_window)
+ };
+ let remembered = Remembered { swarm_id, seeds };
+
+ {
+ let inner = file.lock();
+ if inner.written.as_ref() == Some(&remembered) {
+ return;
+ }
+ let first = inner.written_at.is_none();
+ if !now
+ && !first
+ && inner
+ .written_at
+ .is_some_and(|at| at.elapsed() < MIN_WRITE_INTERVAL)
+ {
+ return;
+ }
+ }
+
+ let writer = Arc::clone(&file);
+ let content = remembered.clone();
+ match tokio::task::spawn_blocking(move || writer.write(&content)).await {
+ Ok(Ok(())) => {
+ info!(
+ path = %file.path.display(),
+ seeds = remembered.seeds.len(),
+ "Wrote the state file"
+ );
+ let mut inner = file.lock();
+ inner.written = Some(remembered);
+ inner.written_at = Some(Instant::now());
+ }
+ Ok(Err(error)) => {
+ error!(path = %file.path.display(), error = %error, "Couldn't write the state file");
+ }
+ Err(error) => error!(error = %error, "State file write task failed"),
+ }
+}
+
+/// Members to rejoin through: those alive within the electorate window, stable ones
+/// first, at most `MAX_SEEDS`, sorted. Changes only when members join, leave or change
+/// tier, so the file rarely does.
+fn seeds(doc: &automerge::AutoCommit, me: &str, window: Duration) -> Vec<String> {
+ let now = crate::state::unix_millis();
+ let (mut stable, transient): (Vec<String>, Vec<String>) = members::electorate(doc, now, window)
+ .into_iter()
+ .filter(|pk| pk != me)
+ .partition(|pk| members::tier(doc, pk) == members::Tier::Stable);
+ stable.extend(transient);
+ stable.truncate(MAX_SEEDS);
+ stable.sort();
+ stable
+}
diff --git a/src/swarm/sync.rs b/src/swarm/sync.rs
index 80d6d56..d4503bb 100644
--- a/src/swarm/sync.rs
+++ b/src/swarm/sync.rs
@@ -141,10 +141,10 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<Option<transport::EncodedMessage>> {
// Label the message with the epoch of the document it was generated
// from, read under the same lock.
- let (epoch, message) = {
+ let (epoch, epoch_seq, message) = {
let mut replica = doc.blocking_write();
let mut peer_state = psm.blocking_lock();
- let epoch = replica.epoch;
+ let (epoch, epoch_seq) = (replica.epoch, replica.epoch_seq);
let sent_before = peer_state.sync.state.sent_hashes.clone();
let message = {
let sync_doc = replica.doc.sync();
@@ -178,7 +178,7 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> {
regenerate: false,
});
}
- (epoch, message)
+ (epoch, epoch_seq, message)
};
let Some(message) = message else {
return Ok(None);
@@ -186,6 +186,7 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> {
let envelope = transport::SyncEnvelope {
swarm_id,
epoch,
+ epoch_seq,
message,
ready,
};
@@ -607,6 +608,13 @@ pub(crate) async fn run_change_publisher(state: AppState) {
warn!(path = ?path, "Concurrent writes produced a conflict");
}
+ if paths
+ .iter()
+ .any(|path| crate::swarm::topology::affects_neighbours(path))
+ {
+ state.topology_changed.notify_one();
+ }
+
// Fails only when nobody is subscribed.
let _ = state.change_events.send(Arc::new(ChangeSet { paths }));
}
diff --git a/src/swarm/topology.rs b/src/swarm/topology.rs
index 57856b6..b40b120 100644
--- a/src/swarm/topology.rs
+++ b/src/swarm/topology.rs
@@ -107,6 +107,26 @@ pub(crate) struct NeighbourChange {
pub(crate) removed: Vec<String>,
}
+/// Recomputes the neighbours whenever something they depend on may have changed
+/// (`AppState::topology_changed`): the member list (`swarm::sync::run_change_publisher`),
+/// a peer becoming unreachable or reachable again (`swarm::peer`), and every 5 s as a
+/// fallback, which also covers unreachability expiring after `dormant_probe`. A burst of
+/// notifications coalesces into one recomputation.
+pub(crate) async fn run_worker(state: AppState) {
+ loop {
+ state.topology_changed.notified().await;
+ if let Some(change) = refresh(&state).await {
+ crate::swarm::peer::apply_neighbour_change(&state, change).await;
+ }
+ }
+}
+
+/// Whether a change at `path` may change the neighbours: one in the member list, or the
+/// root (the whole document replaced, as after an epoch switch).
+pub(crate) fn affects_neighbours(path: &[String]) -> bool {
+ path.is_empty() || path.starts_with(&members::members_dir())
+}
+
/// Recomputes the neighbours once ready, if the member list, the reachable members or
/// `k` changed.
pub(crate) async fn refresh(state: &AppState) -> Option<NeighbourChange> {
diff --git a/src/transport/mod.rs b/src/transport/mod.rs
index c5769ca..c3db962 100644
--- a/src/transport/mod.rs
+++ b/src/transport/mod.rs
@@ -11,6 +11,8 @@ pub use crate::document::replica::Epoch;
pub mod firewall;
pub mod mycelium;
+#[cfg(test)]
+pub(crate) mod sim;
pub mod traffic;
/// Random identifier of one run of a node (from start to exit).
@@ -86,6 +88,8 @@ pub struct Keepalive {
/// Only comparable between nodes on the same epoch.
pub heads: HeadsSummary,
pub epoch: Epoch,
+ /// See `SyncEnvelope::epoch_seq`.
+ pub epoch_seq: u32,
/// The sender is ready (same meaning as `SyncEnvelope::ready`).
pub ready: bool,
/// The sender would sync with the recipient: counts it as an active member on its
@@ -131,6 +135,8 @@ impl std::fmt::Display for BlobId {
pub struct BlobMessage {
/// The sender's epoch (every message carries it).
pub epoch: Epoch,
+ /// See `SyncEnvelope::epoch_seq`.
+ pub epoch_seq: u32,
pub id: BlobId,
pub body: BlobBody,
}
@@ -215,6 +221,17 @@ impl ParsedMessage {
ParsedMessage::Blob(blob) => blob.epoch,
}
}
+
+ /// `SyncEnvelope::epoch_seq`, as every message carries it.
+ pub fn epoch_seq(&self) -> u32 {
+ match self {
+ ParsedMessage::Hello(hello) => hello.epoch_seq,
+ ParsedMessage::HelloReply(reply) => reply.epoch_seq,
+ ParsedMessage::Sync(envelope) => envelope.epoch_seq,
+ ParsedMessage::Ping(keepalive) | ParsedMessage::Pong(keepalive) => keepalive.epoch_seq,
+ ParsedMessage::Blob(blob) => blob.epoch_seq,
+ }
+ }
}
#[derive(Debug, Clone)]
@@ -223,6 +240,8 @@ pub struct HelloRequest {
/// another swarm doesn't answer.
pub swarm_id: Option<String>,
pub epoch: Epoch,
+ /// See `SyncEnvelope::epoch_seq`.
+ pub epoch_seq: u32,
/// Proof of the swarm key (`swarm::auth`); empty without one.
pub auth: Vec<u8>,
/// The sender is ready (same meaning as `SyncEnvelope::ready`).
@@ -236,6 +255,8 @@ pub struct HelloReply {
pub swarm_id: String,
pub peers: Vec<String>,
pub epoch: Epoch,
+ /// See `SyncEnvelope::epoch_seq`.
+ pub epoch_seq: u32,
/// Proof of the swarm key (`swarm::auth`); empty without one.
pub auth: Vec<u8>,
/// The sender is ready.
@@ -246,6 +267,8 @@ pub struct HelloReply {
/// How long before this HELLO the sender had last heard from the recipient, if ever
/// (in milliseconds): tells a restarted node how long it was away.
pub away_ms: Option<u64>,
+ /// The sender's own run (as its HELLOs carry it): a new one means it restarted.
+ pub sender_run_id: RunId,
}
#[derive(Debug, Clone)]
@@ -253,6 +276,10 @@ pub struct SyncEnvelope {
pub swarm_id: String,
/// Epoch of the document the message was generated from.
pub epoch: Epoch,
+ /// How many times the sender changed epoch in its current run, when it sent this
+ /// (`Replica::epoch_seq`). Messages may arrive late and out of order: the receiver
+ /// ignores an epoch older than the newest it has seen in the same session.
+ pub epoch_seq: u32,
pub message: Message,
/// The sender was ready when it generated the message: its document is the swarm's,
/// not one it is still filling (joining, rejoining). Only such a peer can tell a
diff --git a/src/transport/mycelium.rs b/src/transport/mycelium.rs
index 82e6016..efab475 100644
--- a/src/transport/mycelium.rs
+++ b/src/transport/mycelium.rs
@@ -561,13 +561,7 @@ impl Transport for MyceliumTransport {
}
fn encode(&self, peer: &str, message: &ParsedMessage) -> Result<EncodedMessage> {
- let (outbound, payload_len) = format_message(peer, message)?;
- Ok(EncodedMessage {
- peer: peer.to_owned(),
- body: serde_json::to_vec(&outbound)?.into(),
- kind: message.kind(),
- payload_len,
- })
+ encode_message(peer, message)
}
async fn send_encoded(&self, message: &EncodedMessage) -> Result<()> {
@@ -712,6 +706,26 @@ impl Transport for MyceliumTransport {
}
}
+/// A message as the Mycelium REST API takes it (the JSON body of an outbound message).
+/// Also the simulator's wire format (`transport::sim`), so tests go through the same
+/// encoding.
+pub(crate) fn encode_message(peer: &str, message: &ParsedMessage) -> Result<EncodedMessage> {
+ let (outbound, payload_len) = format_message(peer, message)?;
+ Ok(EncodedMessage {
+ peer: peer.to_owned(),
+ body: serde_json::to_vec(&outbound)?.into(),
+ kind: message.kind(),
+ payload_len,
+ })
+}
+
+/// The message in a body made by [`encode_message`].
+#[cfg(test)]
+pub(crate) fn decode_message(body: &[u8]) -> Result<ParsedMessage> {
+ let outbound: OutboundMessage = serde_json::from_slice(body)?;
+ Ok(decode_payload(&outbound.topic, &outbound.payload)?)
+}
+
fn encode_topic(topic: &str) -> String {
BASE64.encode(topic.as_bytes())
}
@@ -802,16 +816,20 @@ fn format_message(
struct EpochShadow {
generation: u64,
id: ByteBuf,
+ /// The sender's epoch changes in its current run (`SyncEnvelope::epoch_seq`).
+ #[serde(default)]
+ seq: u32,
}
-fn epoch_to_shadow(epoch: &Epoch) -> EpochShadow {
+fn epoch_to_shadow(epoch: &Epoch, seq: u32) -> EpochShadow {
EpochShadow {
generation: epoch.generation,
id: ByteBuf::from(epoch.id.to_vec()),
+ seq,
}
}
-fn epoch_from_shadow(shadow: EpochShadow) -> Result<Epoch, TransportError> {
+fn epoch_from_shadow(shadow: &EpochShadow) -> Result<Epoch, TransportError> {
if shadow.generation == 0 && shadow.id.is_empty() {
return Ok(Epoch::GENESIS);
}
@@ -850,7 +868,7 @@ fn keepalive_to_bytes(msg: &Keepalive) -> Result<Vec<u8>, rmp_serde::encode::Err
rmp_serde::to_vec(&KeepaliveShadow {
heads,
digest,
- epoch: epoch_to_shadow(&msg.epoch),
+ epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
ready: msg.ready,
syncs_with_you: msg.syncs_with_you,
})
@@ -880,7 +898,8 @@ fn bytes_to_keepalive(bytes: &[u8]) -> Result<Keepalive, TransportError> {
};
Ok(Keepalive {
heads,
- epoch: epoch_from_shadow(shadow.epoch)?,
+ epoch: epoch_from_shadow(&shadow.epoch)?,
+ epoch_seq: shadow.epoch.seq,
ready: shadow.ready,
syncs_with_you: shadow.syncs_with_you,
})
@@ -908,6 +927,7 @@ pub struct HelloReplyShadow<'a> {
ready: bool,
run_id: ByteBuf,
away_ms: Option<u64>,
+ sender_run_id: ByteBuf,
}
#[derive(Serialize, Deserialize)]
@@ -955,7 +975,7 @@ mod automerge_msg_codec {
pub fn hello_request_to_bytes(msg: &HelloRequest) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec(&HelloRequestShadow {
swarm_id: msg.swarm_id.clone(),
- epoch: epoch_to_shadow(&msg.epoch),
+ epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
auth: ByteBuf::from(msg.auth.clone()),
ready: msg.ready,
run_id: ByteBuf::from(msg.run_id.to_vec()),
@@ -965,7 +985,8 @@ pub fn bytes_to_hello_request(bytes: &[u8]) -> Result<HelloRequest, TransportErr
let shadow: HelloRequestShadow = rmp_serde::from_slice(bytes)?;
Ok(HelloRequest {
swarm_id: shadow.swarm_id,
- epoch: epoch_from_shadow(shadow.epoch)?,
+ epoch: epoch_from_shadow(&shadow.epoch)?,
+ epoch_seq: shadow.epoch.seq,
auth: shadow.auth.into_vec(),
ready: shadow.ready,
run_id: run_id_from_shadow(&shadow.run_id)?,
@@ -984,11 +1005,12 @@ pub fn hello_reply_to_bytes(msg: &HelloReply) -> Result<Vec<u8>, rmp_serde::enco
let shadow = HelloReplyShadow {
swarm_id: Cow::Borrowed(&msg.swarm_id),
peers: msg.peers.clone(), // Vec must be cloned for serialization wrapper matching
- epoch: epoch_to_shadow(&msg.epoch),
+ epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
auth: ByteBuf::from(msg.auth.clone()),
ready: msg.ready,
run_id: ByteBuf::from(msg.run_id.to_vec()),
away_ms: msg.away_ms,
+ sender_run_id: ByteBuf::from(msg.sender_run_id.to_vec()),
};
rmp_serde::to_vec(&shadow)
}
@@ -997,11 +1019,13 @@ pub fn bytes_to_hello_reply(bytes: &[u8]) -> Result<HelloReply, TransportError>
Ok(HelloReply {
swarm_id: shadow.swarm_id.into_owned(),
peers: shadow.peers,
- epoch: epoch_from_shadow(shadow.epoch)?,
+ epoch: epoch_from_shadow(&shadow.epoch)?,
+ epoch_seq: shadow.epoch.seq,
auth: shadow.auth.into_vec(),
ready: shadow.ready,
run_id: run_id_from_shadow(&shadow.run_id)?,
away_ms: shadow.away_ms,
+ sender_run_id: run_id_from_shadow(&shadow.sender_run_id)?,
})
}
@@ -1010,7 +1034,7 @@ pub fn sync_envelope_to_bytes(msg: &SyncEnvelope) -> Result<Vec<u8>, rmp_serde::
let shadow = SyncEnvelopeShadow {
swarm_id: Cow::Borrowed(&msg.swarm_id),
message: msg.message.clone(), // Clone required by automerge_msg_codec ownership rules
- epoch: epoch_to_shadow(&msg.epoch),
+ epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
ready: msg.ready,
};
rmp_serde::to_vec(&shadow)
@@ -1019,7 +1043,8 @@ pub fn bytes_to_sync_envelope(bytes: &[u8]) -> Result<SyncEnvelope, TransportErr
let shadow: SyncEnvelopeShadow = rmp_serde::from_slice(bytes)?;
Ok(SyncEnvelope {
swarm_id: shadow.swarm_id.into_owned(),
- epoch: epoch_from_shadow(shadow.epoch)?,
+ epoch: epoch_from_shadow(&shadow.epoch)?,
+ epoch_seq: shadow.epoch.seq,
message: shadow.message,
ready: shadow.ready,
})
@@ -1084,7 +1109,7 @@ fn blob_to_bytes(msg: &BlobMessage) -> Result<Vec<u8>, rmp_serde::encode::Error>
BlobBody::Missing => BlobBodyShadow::Missing,
};
rmp_serde::to_vec(&BlobShadow {
- epoch: epoch_to_shadow(&msg.epoch),
+ epoch: epoch_to_shadow(&msg.epoch, msg.epoch_seq),
id,
body,
})
@@ -1123,7 +1148,8 @@ fn bytes_to_blob(bytes: &[u8]) -> Result<BlobMessage, TransportError> {
BlobBodyShadow::Missing => BlobBody::Missing,
};
Ok(BlobMessage {
- epoch: epoch_from_shadow(shadow.epoch)?,
+ epoch: epoch_from_shadow(&shadow.epoch)?,
+ epoch_seq: shadow.epoch.seq,
id,
body,
})
@@ -1181,6 +1207,7 @@ mod tests {
generation: u64::MAX,
id: [0xff; 32],
},
+ epoch_seq: u32::MAX,
ready: true,
syncs_with_you: true,
};
@@ -1203,6 +1230,7 @@ mod tests {
let id = BlobId::Change(ChangeHash([9; 32]));
let chunk = BlobMessage {
epoch,
+ epoch_seq: 4,
id,
body: BlobBody::Chunk {
index: 5,
@@ -1215,12 +1243,14 @@ mod tests {
let decoded = bytes_to_blob(&bytes).unwrap();
assert_eq!(decoded.id, id);
assert_eq!(decoded.epoch, epoch);
+ assert_eq!(decoded.epoch_seq, 4);
assert!(
matches!(decoded.body, BlobBody::Chunk { index: 5, ref data } if data.len() == 64 * 1024)
);
let manifest = BlobMessage {
epoch,
+ epoch_seq: 4,
id,
body: BlobBody::Manifest {
size: 100,
diff --git a/src/transport/sim.rs b/src/transport/sim.rs
new file mode 100644
index 0000000..1556d69
--- /dev/null
+++ b/src/transport/sim.rs
@@ -0,0 +1,448 @@
+//! A simulated network for tests: many nodes in one process, each with a
+//! [`SimTransport`] on a shared [`SimNet`].
+//!
+//! Messages go through the Mycelium wire encoding (`mycelium::encode_message`), so the
+//! codec is exercised as in production. The net delays, loses and duplicates them per
+//! link, from a seeded generator; nodes can be taken down (everything to and from them
+//! is lost) and brought up again; and a test can inject a message as if a node had sent
+//! it, e.g. one that arrives late.
+//!
+//! Time is tokio's real clock, not a paused one: a node's document work runs on the
+//! blocking pool (`spawn_blocking`), which a paused clock doesn't wait for, so timers
+//! would fire while the work is still running. Tests use a short keepalive instead.
+//! Delays and losses come from the seed; when a message's turn comes still depends on
+//! the scheduler, so a run is reproducible in its network decisions, not in its
+//! interleavings.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
+
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+use bytes::Bytes;
+use tokio::sync::{mpsc, oneshot};
+use tracing::{debug, warn};
+
+use super::mycelium::{decode_message, encode_message};
+use super::{EncodedMessage, MessageHandler, MessageKind, ParsedMessage, Transport};
+
+/// How one direction of a link behaves.
+#[derive(Clone, Debug)]
+pub(crate) struct LinkParams {
+ /// Probability that a message is lost.
+ pub(crate) loss: f64,
+ /// Probability that a message arrives twice (each copy with its own delay).
+ pub(crate) duplicate: f64,
+ /// Delay, uniform in this range. Overlapping delays reorder messages.
+ pub(crate) delay: (Duration, Duration),
+ /// Messages of these kinds are always lost: a peer that answers some messages but
+ /// not others (e.g. keepalives but not sync).
+ pub(crate) drop: Vec<MessageKind>,
+}
+
+impl Default for LinkParams {
+ /// A clean, fast link: nothing lost, a few milliseconds.
+ fn default() -> Self {
+ Self {
+ loss: 0.0,
+ duplicate: 0.0,
+ delay: (Duration::from_millis(1), Duration::from_millis(5)),
+ drop: Vec::new(),
+ }
+ }
+}
+
+/// A message on its way into a node.
+struct Delivery {
+ from: String,
+ body: Bytes,
+ payload_len: usize,
+ /// Told once the node has handled the message (injections wait for it).
+ handled: Option<oneshot::Sender<()>>,
+}
+
+/// What the net counted, for assertions.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) struct NetCounts {
+ pub(crate) sent: u64,
+ pub(crate) delivered: u64,
+ pub(crate) lost: u64,
+ pub(crate) duplicated: u64,
+}
+
+struct NetInner {
+ rng: SplitMix64,
+ default_link: LinkParams,
+ links: HashMap<(String, String), LinkParams>,
+ down: HashSet<String>,
+ inboxes: HashMap<String, mpsc::UnboundedSender<Delivery>>,
+ counts: NetCounts,
+}
+
+/// The network all simulated nodes share. Cloning is cheap.
+#[derive(Clone)]
+pub(crate) struct SimNet {
+ inner: Arc<Mutex<NetInner>>,
+}
+
+impl SimNet {
+ pub(crate) fn new(seed: u64) -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(NetInner {
+ rng: SplitMix64(seed),
+ default_link: LinkParams::default(),
+ links: HashMap::new(),
+ down: HashSet::new(),
+ inboxes: HashMap::new(),
+ counts: NetCounts::default(),
+ })),
+ }
+ }
+
+ fn lock(&self) -> MutexGuard<'_, NetInner> {
+ self.inner
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ /// A transport for a new node `id` on this net.
+ pub(crate) fn transport(&self, id: &str) -> Arc<SimTransport> {
+ let (tx, rx) = mpsc::unbounded_channel();
+ let previous = self.lock().inboxes.insert(id.to_owned(), tx);
+ assert!(previous.is_none(), "node {id} is already on the net");
+ Arc::new(SimTransport {
+ id: id.to_owned(),
+ net: self.clone(),
+ inbox: tokio::sync::Mutex::new(Some(rx)),
+ })
+ }
+
+ /// How links without their own parameters behave.
+ pub(crate) fn set_default_link(&self, params: LinkParams) {
+ self.lock().default_link = params;
+ }
+
+ /// How messages from `from` to `to` are carried (one direction).
+ pub(crate) fn set_link(&self, from: &str, to: &str, params: LinkParams) {
+ self.lock()
+ .links
+ .insert((from.to_owned(), to.to_owned()), params);
+ }
+
+ /// Everything to and from `id` is lost until [`bring_up`](Self::bring_up), including
+ /// messages already on their way. The node itself keeps running.
+ pub(crate) fn take_down(&self, id: &str) {
+ self.lock().down.insert(id.to_owned());
+ }
+
+ pub(crate) fn bring_up(&self, id: &str) {
+ self.lock().down.remove(id);
+ }
+
+ pub(crate) fn counts(&self) -> NetCounts {
+ self.lock().counts
+ }
+
+ /// Hands a message to the net: decides now whether and when it arrives.
+ fn send(&self, from: &str, message: &EncodedMessage) {
+ let delays = {
+ let mut guard = self.lock();
+ let inner = &mut *guard;
+ inner.counts.sent += 1;
+ if inner.down.contains(from) || inner.down.contains(&message.peer) {
+ inner.counts.lost += 1;
+ return;
+ }
+ let params = inner
+ .links
+ .get(&(from.to_owned(), message.peer.clone()))
+ .unwrap_or(&inner.default_link)
+ .clone();
+ if params.drop.contains(&message.kind) || inner.rng.chance(params.loss) {
+ inner.counts.lost += 1;
+ return;
+ }
+ let copies = if inner.rng.chance(params.duplicate) {
+ inner.counts.duplicated += 1;
+ 2
+ } else {
+ 1
+ };
+ (0..copies)
+ .map(|_| inner.rng.duration_in(params.delay))
+ .collect::<Vec<_>>()
+ };
+ for delay in delays {
+ let net = self.clone();
+ let delivery = Delivery {
+ from: from.to_owned(),
+ body: message.body.clone(),
+ payload_len: message.payload_len,
+ handled: None,
+ };
+ let to = message.peer.clone();
+ tokio::spawn(async move {
+ tokio::time::sleep(delay).await;
+ net.arrive(&to, delivery);
+ });
+ }
+ }
+
+ /// The message reaches `to`, unless either end went down meanwhile.
+ fn arrive(&self, to: &str, delivery: Delivery) {
+ let mut guard = self.lock();
+ let inner = &mut *guard;
+ if inner.down.contains(to) || inner.down.contains(&delivery.from) {
+ inner.counts.lost += 1;
+ return;
+ }
+ // An unknown recipient (an address no node has) loses the message, as
+ // Mycelium would.
+ let Some(inbox) = inner.inboxes.get(to) else {
+ inner.counts.lost += 1;
+ return;
+ };
+ if inbox.send(delivery).is_ok() {
+ inner.counts.delivered += 1;
+ } else {
+ inner.counts.lost += 1;
+ }
+ }
+
+ /// Delivers `message` to `to` as if `from` had sent it, now, bypassing links and
+ /// `take_down`; returns once `to` has handled it. `from` needn't be a node on the
+ /// net. For messages a real node wouldn't send at that moment, e.g. one sent long
+ /// ago and delivered late.
+ pub(crate) async fn inject(&self, from: &str, to: &str, message: &ParsedMessage) -> Result<()> {
+ let encoded = encode_message(to, message)?;
+ let (handled_tx, handled_rx) = oneshot::channel();
+ let delivery = Delivery {
+ from: from.to_owned(),
+ body: encoded.body,
+ payload_len: encoded.payload_len,
+ handled: Some(handled_tx),
+ };
+ {
+ let inner = self.lock();
+ let Some(inbox) = inner.inboxes.get(to) else {
+ bail!("no node {to} on the net");
+ };
+ if inbox.send(delivery).is_err() {
+ bail!("node {to} has no consumer running");
+ }
+ }
+ handled_rx.await?;
+ Ok(())
+ }
+
+ fn is_known(&self, id: &str) -> bool {
+ self.lock().inboxes.contains_key(id)
+ }
+}
+
+/// One node's end of the [`SimNet`]. Its identity is the ID it was created with; seeds
+/// are node IDs.
+pub(crate) struct SimTransport {
+ id: String,
+ net: SimNet,
+ inbox: tokio::sync::Mutex<Option<mpsc::UnboundedReceiver<Delivery>>>,
+}
+
+#[async_trait]
+impl Transport for SimTransport {
+ fn local_id(&self) -> &str {
+ &self.id
+ }
+
+ /// A seed is a node ID; one no node has doesn't resolve (yet).
+ async fn resolve_seed(&self, seed: &str) -> Result<String> {
+ if self.net.is_known(seed) {
+ Ok(seed.to_owned())
+ } else {
+ bail!("no node {seed} on the net")
+ }
+ }
+
+ fn encode(&self, peer: &str, message: &ParsedMessage) -> Result<EncodedMessage> {
+ encode_message(peer, message)
+ }
+
+ /// Always accepted: the net loses messages silently, like Mycelium once it has
+ /// taken them.
+ async fn send_encoded(&self, message: &EncodedMessage) -> Result<()> {
+ self.net.send(&self.id, message);
+ Ok(())
+ }
+
+ /// Handles messages one at a time, in arrival order, as the Mycelium consumer does.
+ async fn start_consumer(&self, handler: MessageHandler) -> Result<()> {
+ let Some(mut inbox) = self.inbox.lock().await.take() else {
+ bail!("consumer of {} already started", self.id);
+ };
+ while let Some(delivery) = inbox.recv().await {
+ match decode_message(&delivery.body) {
+ Ok(message) => {
+ debug!(node = %self.id, from = %delivery.from, message = ?message, "Simulated delivery");
+ if let Err(error) = handler(delivery.from, message, delivery.payload_len).await
+ {
+ warn!(node = %self.id, error = %format!("{error:#}"), "Failed to process inbound message");
+ }
+ }
+ Err(error) => {
+ warn!(node = %self.id, error = %format!("{error:#}"), "Undecodable simulated message");
+ }
+ }
+ if let Some(handled) = delivery.handled {
+ let _ = handled.send(());
+ }
+ }
+ Ok(())
+ }
+
+ /// A simulated node has no daemon that could exit.
+ async fn wait_for_exit(&self) -> std::io::Result<std::process::ExitStatus> {
+ std::future::pending().await
+ }
+}
+
+/// A small, seedable generator (SplitMix64): enough for network decisions, and no
+/// dependency.
+struct SplitMix64(u64);
+
+impl SplitMix64 {
+ fn next_u64(&mut self) -> u64 {
+ self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ let mut z = self.0;
+ z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ z ^ (z >> 31)
+ }
+
+ /// Uniform in [0, 1).
+ fn next_f64(&mut self) -> f64 {
+ (self.next_u64() >> 11) as f64 / (1_u64 << 53) as f64
+ }
+
+ fn chance(&mut self, probability: f64) -> bool {
+ probability > 0.0 && self.next_f64() < probability
+ }
+
+ fn duration_in(&mut self, (min, max): (Duration, Duration)) -> Duration {
+ if max <= min {
+ return min;
+ }
+ min + (max - min).mul_f64(self.next_f64())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::document::replica::Epoch;
+ use crate::transport::{HeadsSummary, Keepalive};
+
+ fn ping() -> ParsedMessage {
+ ParsedMessage::Ping(Keepalive {
+ heads: HeadsSummary::of(Vec::new()),
+ epoch: Epoch::GENESIS,
+ epoch_seq: 0,
+ ready: true,
+ syncs_with_you: false,
+ })
+ }
+
+ /// Collects what a node receives.
+ fn record(transport: Arc<SimTransport>) -> Arc<Mutex<Vec<String>>> {
+ let received = Arc::new(Mutex::new(Vec::new()));
+ let sink = Arc::clone(&received);
+ tokio::spawn(async move {
+ transport
+ .start_consumer(Box::new(move |from, _message, _len| {
+ sink.lock().unwrap().push(from);
+ Box::pin(async { Ok(()) })
+ }))
+ .await
+ });
+ received
+ }
+
+ #[tokio::test]
+ async fn delivers_decodes_and_loses_when_down() {
+ let net = SimNet::new(1);
+ let a = net.transport("a");
+ let received = record(net.transport("b"));
+
+ let settle = || tokio::time::sleep(Duration::from_millis(30));
+ a.send_message("b", &ping()).await.unwrap();
+ settle().await;
+ net.take_down("b");
+ a.send_message("b", &ping()).await.unwrap();
+ settle().await;
+ net.bring_up("b");
+ a.send_message("b", &ping()).await.unwrap();
+ settle().await;
+
+ assert_eq!(*received.lock().unwrap(), vec!["a", "a"]);
+ let counts = net.counts();
+ assert_eq!((counts.sent, counts.delivered, counts.lost), (3, 2, 1));
+ assert_eq!(a.resolve_seed("b").await.unwrap(), "b");
+ assert!(a.resolve_seed("c").await.is_err());
+ }
+
+ #[tokio::test]
+ async fn loss_and_duplication_follow_the_seed() {
+ let run = |seed| async move {
+ let net = SimNet::new(seed);
+ net.set_default_link(LinkParams {
+ loss: 0.3,
+ duplicate: 0.2,
+ ..LinkParams::default()
+ });
+ let a = net.transport("a");
+ let _b = net.transport("b");
+ for _ in 0..200 {
+ a.send_message("b", &ping()).await.unwrap();
+ }
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ net.counts()
+ };
+ let first = run(7).await;
+ assert_eq!(first, run(7).await);
+ assert!(first.lost > 30 && first.lost < 90, "{first:?}");
+ assert!(first.duplicated > 10, "{first:?}");
+ }
+
+ #[tokio::test]
+ async fn a_link_overrides_the_default_in_one_direction() {
+ let net = SimNet::new(1);
+ net.set_link(
+ "a",
+ "b",
+ LinkParams {
+ loss: 1.0,
+ ..LinkParams::default()
+ },
+ );
+ let a = net.transport("a");
+ let b = net.transport("b");
+ let at_a = record(Arc::clone(&a));
+ let at_b = record(Arc::clone(&b));
+
+ a.send_message("b", &ping()).await.unwrap();
+ b.send_message("a", &ping()).await.unwrap();
+ tokio::time::sleep(Duration::from_millis(30)).await;
+
+ assert!(at_b.lock().unwrap().is_empty());
+ assert_eq!(*at_a.lock().unwrap(), vec!["b"]);
+ }
+
+ #[tokio::test]
+ async fn inject_returns_once_handled() {
+ let net = SimNet::new(1);
+ let received = record(net.transport("b"));
+ net.inject("ghost", "b", &ping()).await.unwrap();
+ assert_eq!(*received.lock().unwrap(), vec!["ghost"]);
+ assert!(net.inject("ghost", "nobody", &ping()).await.is_err());
+ }
+}