diff options
Diffstat (limited to 'src/remake/kind.rs')
| -rw-r--r-- | src/remake/kind.rs | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/src/remake/kind.rs b/src/remake/kind.rs new file mode 100644 index 0000000..329fd09 --- /dev/null +++ b/src/remake/kind.rs @@ -0,0 +1,230 @@ +//! Kinds of remake: what makes one remake differ from another. +//! +//! Everything else about a remake (proposal, settle period, decision, switch, rejoin, +//! replay window, quarantine, archive) is the same for every kind. A kind decides two +//! things only: +//! +//! * the user content of the next epoch's initial document ([`RemakeKind::build`]), +//! which must be deterministic: every node builds it on its own and must produce the +//! same bytes, since its hash names the epoch; +//! * where a write made in the old epoch after the base goes in the new one +//! ([`RemakeKind::translate`]). +//! +//! Protocol data under `_continuum` is not a kind's business: the generic part carries +//! it forward and replays it unchanged. + +use automerge::AutoCommit; +use serde_json::Value as JsonValue; + +use super::snapshot::META_ROOT; +use crate::document::store::{self, StoreError}; + +/// Name of the kind used when a request doesn't name one. +pub(crate) const DEFAULT_KIND: &str = "compact"; + +/// What the coordinator stores in the proposal for every node. +#[derive(Debug, Default)] +pub(crate) struct Prepared { + /// Input to [`RemakeKind::build`]; replicated with the proposal (old epoch only). + pub(crate) content: JsonValue, + /// Input to [`RemakeKind::translate`]; also kept in the new epoch's metadata, so + /// nodes that rejoin later can translate their writes. + pub(crate) translation: JsonValue, +} + +/// Where a path written in the old epoch after the base goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Translation { + /// To the same path (compaction). + Same, + /// To another path. + To(Vec<String>), + /// Nowhere automatically: quarantined for an operator, with the reason. + Quarantine(String), + /// Nowhere: the remake declared the path obsolete. + Drop, +} + +pub(crate) trait RemakeKind: Send + Sync + 'static { + /// Stable name, stored in proposals and epoch metadata. + fn name(&self) -> &'static str; + + /// Coordinator only: validates the kind-specific part of the request. + fn propose(&self, spec: &JsonValue) -> Result<Prepared, String>; + + /// Every node: writes the user content of the next epoch into `target`, a fresh + /// document. `old_at_base` is the old epoch at the base. Must be deterministic and + /// must not write under `_continuum` (anything there is replaced). + fn build( + &self, + old_at_base: &AutoCommit, + content: &JsonValue, + target: &mut AutoCommit, + ) -> Result<(), StoreError>; + + /// Every node: where a user path written after the base goes. + fn translate(&self, path: &[String], translation: &JsonValue) -> Translation; +} + +/// The kind with this name. +pub(crate) fn by_name(name: &str) -> Option<&'static dyn RemakeKind> { + match name { + "compact" => Some(&Compact), + _ => None, + } +} + +/// 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) +} + +/// Compaction: the new document has the old one's content at the base, without its +/// history. Writes keep their paths. +pub(crate) struct Compact; + +impl RemakeKind for Compact { + fn name(&self) -> &'static str { + "compact" + } + + fn propose(&self, spec: &JsonValue) -> Result<Prepared, String> { + match spec { + JsonValue::Null => Ok(Prepared::default()), + JsonValue::Object(fields) if fields.is_empty() => Ok(Prepared::default()), + _ => Err("compaction takes no spec".to_owned()), + } + } + + fn build( + &self, + old_at_base: &AutoCommit, + _content: &JsonValue, + target: &mut AutoCommit, + ) -> Result<(), StoreError> { + store::materialize_into(old_at_base, target, &is_reserved) + } + + fn translate(&self, _path: &[String], _translation: &JsonValue) -> Translation { + Translation::Same + } +} + +/// The translations of several consecutive remakes, applied in order: a node that +/// rejoins several epochs later carries its writes through every remake in between. +#[derive(Default, Clone)] +pub(crate) struct Translator { + steps: Vec<(&'static dyn RemakeKind, JsonValue)>, +} + +impl Translator { + /// One remake. + pub(crate) fn single(kind: &'static dyn RemakeKind, translation: JsonValue) -> Self { + Self { + steps: vec![(kind, translation)], + } + } + + pub(crate) fn push(&mut self, kind: &'static dyn RemakeKind, translation: JsonValue) { + self.steps.push((kind, translation)); + } + + /// Where `path` ends up after all steps. Reserved paths always stay put. + pub(crate) fn translate(&self, path: &[String]) -> Translation { + if is_reserved(path) { + return Translation::Same; + } + let mut current = path.to_vec(); + let mut moved = false; + for (kind, translation) in &self.steps { + match kind.translate(¤t, translation) { + Translation::Same => {} + Translation::To(next) => { + current = next; + moved = true; + } + stop @ (Translation::Quarantine(_) | Translation::Drop) => return stop, + } + } + if moved { + Translation::To(current) + } else { + Translation::Same + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A test kind that moves `a/...` to `b/...`, drops `x`, quarantines the rest. + struct Rename; + + impl RemakeKind for Rename { + fn name(&self) -> &'static str { + "rename-test" + } + fn propose(&self, _spec: &JsonValue) -> Result<Prepared, String> { + Ok(Prepared::default()) + } + fn build( + &self, + _old: &AutoCommit, + _content: &JsonValue, + _target: &mut AutoCommit, + ) -> Result<(), StoreError> { + Ok(()) + } + fn translate(&self, path: &[String], _translation: &JsonValue) -> Translation { + match path.split_first() { + Some((first, rest)) if first == "a" => { + let mut to = vec!["b".to_owned()]; + to.extend_from_slice(rest); + Translation::To(to) + } + Some((first, _)) if first == "b" => Translation::Same, + Some((first, _)) if first == "x" => Translation::Drop, + _ => Translation::Quarantine("no rule".to_owned()), + } + } + } + + fn path(parts: &[&str]) -> Vec<String> { + parts.iter().map(|part| (*part).to_owned()).collect() + } + + #[test] + fn compaction_keeps_paths() { + assert_eq!( + Translator::single(&Compact, JsonValue::Null).translate(&path(&["a", "b"])), + Translation::Same + ); + assert!(Compact.propose(&JsonValue::Null).is_ok()); + assert!(Compact.propose(&serde_json::json!({ "x": 1 })).is_err()); + } + + #[test] + fn steps_compose() { + static RENAME: Rename = Rename; + let mut translator = Translator::default(); + translator.push(&RENAME, JsonValue::Null); + translator.push(&Compact, JsonValue::Null); + translator.push(&RENAME, JsonValue::Null); // b stays b + + assert_eq!( + translator.translate(&path(&["a", "k"])), + Translation::To(path(&["b", "k"])) + ); + assert_eq!(translator.translate(&path(&["x"])), Translation::Drop); + assert!(matches!( + translator.translate(&path(&["other"])), + Translation::Quarantine(_) + )); + // Protocol data is never translated. + assert_eq!( + translator.translate(&path(&[META_ROOT, "forgotten", "pk"])), + Translation::Same + ); + } +} |
