From ee2a01255be7cb0f0d17bce9c5f6776ec7dbf67a Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:10:48 +0200 Subject: [PATCH 01/85] Move persistence related fields into the existing persistence struct. --- src/persistence/persist.rs | 10 ++++++---- src/persistence/restore.rs | 8 ++++---- src/persistence/zone.rs | 23 +++++++++++++++++------ src/zone/mod.rs | 15 --------------- src/zone/state/mod.rs | 10 ++++++++-- src/zone/state/v1.rs | 4 ++-- 6 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 757c382ad..96b2102b2 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -40,7 +40,7 @@ pub fn persist_loaded( // TODO: Compact diffs when idle? let destination = { let mut handle = zone.write_handle(center); - let next_idx = handle.state.persisted_loaded_diff_paths.len(); + let next_idx = handle.state.persistence.loaded_diff_paths.len(); let destination = center .config .zone_state_dir @@ -48,7 +48,8 @@ pub fn persist_loaded( handle .state - .persisted_loaded_diff_paths + .persistence + .loaded_diff_paths .push(destination.clone().into()); destination @@ -144,7 +145,7 @@ pub fn persist_signed( // TODO: Compact diffs when idle? let destination = { let mut handle = zone.write_handle(center); - let next_idx = handle.state.persisted_signed_diff_paths.len(); + let next_idx = handle.state.persistence.signed_diff_paths.len(); let destination = center .config .zone_state_dir @@ -152,7 +153,8 @@ pub fn persist_signed( handle .state - .persisted_signed_diff_paths + .persistence + .signed_diff_paths .push(destination.clone().into()); destination diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 451df08bf..fc8242464 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -41,7 +41,7 @@ pub fn restore_loaded( restorer: &mut LoadedZoneRestorer, ) -> io::Result { let mut state = zone.write(center); - if state.persisted_loaded_diff_paths.is_empty() { + if state.persistence.loaded_diff_paths.is_empty() { return io::Result::Ok(false); } @@ -57,7 +57,7 @@ pub fn restore_loaded( .config .zone_state_dir .join(format!("{}.loaded.0", zone.name)); - let count = state.persisted_loaded_diff_paths.len(); + let count = state.persistence.loaded_diff_paths.len(); let mut buf = Vec::::new(); drop(state); @@ -146,7 +146,7 @@ pub fn restore_signed( restorer: &mut SignedZoneRestorer, ) -> io::Result { let state = zone.read(); - if state.persisted_signed_diff_paths.is_empty() { + if state.persistence.signed_diff_paths.is_empty() { return io::Result::Ok(false); } @@ -159,7 +159,7 @@ pub fn restore_signed( .config .zone_state_dir .join(format!("{}.signed.0", zone.name)); - let count = state.persisted_signed_diff_paths.len(); + let count = state.persistence.signed_diff_paths.len(); let mut buf = Vec::::new(); drop(state); diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 7e3e6a46d..1f4fdca66 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -1,6 +1,6 @@ //! Zone-specific persistence management. -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use cascade_zonedata::{ LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister, SignedZoneRestorer, @@ -107,7 +107,7 @@ impl ZonePersistenceHandle<'_> { let mut handle = zone.write_handle(¢er); trace!( "Restored diffs: {:?}", - handle.state.persisted_loaded_diff_paths + handle.state.persistence.loaded_diff_paths ); handle.storage().finish_signed_restoration(restored); handle.state.persistence.ongoing.finish(); @@ -226,9 +226,10 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { // the corresponding files on disk and remove any diffs that we loaded // into memory. for p in state - .persisted_loaded_diff_paths + .persistence + .loaded_diff_paths .iter() - .chain(state.persisted_signed_diff_paths.iter()) + .chain(state.persistence.signed_diff_paths.iter()) { if p.exists() && p.starts_with(center.config.zone_state_dir.as_std_path()) { info!( @@ -243,8 +244,8 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { } } } - state.persisted_loaded_diff_paths.clear(); - state.persisted_signed_diff_paths.clear(); + state.persistence.loaded_diff_paths.clear(); + state.persistence.signed_diff_paths.clear(); state.storage.diffs.clear(); } @@ -255,4 +256,14 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { pub struct PersistenceState { /// Ongoing persist/restore operations. pub ongoing: BackgroundTasks, + + /// Locations of persisted unsigned zone diffs to enable IXFR from + /// the upstream to resume on restart, and to enable a complete latest + /// unsigned version of the zone to be reconstituted. + pub loaded_diff_paths: Vec, + + /// Locations of persisted signed zone diffs to ensure IXFR out toward + /// downstreams is still possible after restart, and to enable a complete + /// latest signed version of the zone to be reconsituted. + pub signed_diff_paths: Vec, } diff --git a/src/zone/mod.rs b/src/zone/mod.rs index ef4b51eb9..71f5135a4 100644 --- a/src/zone/mod.rs +++ b/src/zone/mod.rs @@ -1,7 +1,6 @@ //! Zone-specific state and management. use std::collections::HashSet; -use std::path::PathBuf; use std::{ borrow::Borrow, cmp::Ordering, @@ -380,18 +379,6 @@ pub struct ZoneState { /// History of interesting events that occurred for this zone. pub history: Vec, - /// Locations of persisted unsigned zone diffs to enable IXFR from - /// the upstream to resume on restart, and to enable a complete latest - /// unsigned version of the zone to be reconstituted. - // TODO: Move into `PersistenceState`. - pub persisted_loaded_diff_paths: Vec, - - /// Locations of persisted signed zone diffs to ensure IXFR out toward - /// downstreams is still possible after restart, and to enable a complete - /// latest signed version of the zone to be reconsituted. - // TODO: Move into `PersistenceState`. - pub persisted_signed_diff_paths: Vec, - /// Loading new versions of the zone. pub loader: LoaderState, @@ -455,8 +442,6 @@ impl Default for ZoneState { signer: Default::default(), storage: Default::default(), persistence: Default::default(), - persisted_loaded_diff_paths: Default::default(), - persisted_signed_diff_paths: Default::default(), } } } diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index c9cd83ef2..da1753de3 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -16,6 +16,7 @@ use tracing::warn; use crate::{ loader::zone::LoaderState, + persistence::zone::PersistenceState, policy::{Policy, PolicyVersion}, tsig::TsigStore, zone::ZoneState, @@ -114,6 +115,12 @@ impl Spec { } let policy = policy.map(|p| p.latest.clone()); + let persistence = PersistenceState { + loaded_diff_paths: persisted_loaded_diffs, + signed_diff_paths: persisted_signed_diffs, + ..Default::default() + }; + Ok(ZoneState { policy, last_published, @@ -127,8 +134,7 @@ impl Spec { previous_serial, loader, history, - persisted_loaded_diff_paths: persisted_loaded_diffs, - persisted_signed_diff_paths: persisted_signed_diffs, + persistence, ..Default::default() }) } diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 5c97292dd..281b6ade8 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -125,8 +125,8 @@ impl Spec { last_signature_refresh: zone.last_signature_refresh.clone(), previous_serial: zone.previous_serial, history: zone.history.clone(), - persisted_loaded_diffs: zone.persisted_loaded_diff_paths.clone(), - persisted_signed_diffs: zone.persisted_signed_diff_paths.clone(), + persisted_loaded_diffs: zone.persistence.loaded_diff_paths.clone(), + persisted_signed_diffs: zone.persistence.signed_diff_paths.clone(), } } } From ae8293cef60e61d2ec595d1d83638785fc96e40d Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:00:27 +0200 Subject: [PATCH 02/85] Move IXFR in-memory diff management into new IxfrZoneDiffs. Also: - Fix: signed_restore() was not using the actual persisted paths but was constructing them from assumptions. - Fix: persist with signed diffs the unsigned diff serial that it relates to, rather than hoping that the unsigned diff at a particular offset in the unsigned diff collection is the right one. - Fix: serve IXFR responses based on actually fetching the right diffs by soa serial, walking from serial to serial without gaps. --- src/persistence/persist.rs | 84 ++++-------------- src/persistence/restore.rs | 122 ++++++++------------------ src/persistence/zone.rs | 169 ++++++++++++++++++++++++++++++++++--- src/server/service.rs | 108 +++++++++--------------- src/zone/state/mod.rs | 5 +- src/zone/state/v1.rs | 9 +- src/zone/storage.rs | 12 +-- 7 files changed, 267 insertions(+), 242 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 96b2102b2..caeae288a 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -30,7 +30,8 @@ pub fn persist_loaded( center: &Arc
, persister: LoadedZonePersister, ) -> LoadedZonePersisted { - if !persister.loaded_diff().is_empty() { + let loaded_diff = persister.loaded_diff(); + if !loaded_diff.is_empty() { // Determine the path to write to and update the record of written // paths here as we don't want to give responsibility for working with // ZoneState to the persistence crate. Accumulate a set of diffs per @@ -80,42 +81,16 @@ pub fn persist_loaded( // the Option is Some the referred to path can just be deleted. save_state_now(center, zone); - persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); - } - - // Store the loaded diff in-memory for serving IXFR out. - - let loaded_diff = persister.loaded_diff(); + persist_to_file(destination.as_std_path(), loaded_diff.clone()); - // Only store a diff if something has changed compared to the previous - // version of the loaded zone, otherwise this is not a diff to a previous - // version of the zone but actually a snapshot of the zone after having - // been loaded for the first time. If the SOA serial didn't change - // (which is legal for a loaded zone) don't store a diff because the IXFR - // protocol requires a SOA serial number change so we won't be able to - // serve the diff anyway. - if !loaded_diff.is_empty() && loaded_diff.removed_soa.is_some() { - // Store anything that changed when the zone was re-loaded, i.e. - // unsigned zone content changes. Note that the SOA SERIAL is not - // required to change unless using 'keep' policy and so we should not - // require the SOA to have been removed and a new one added. - let mut handle = zone.write_handle(center); - - let loaded_only_diff = (persister.loaded_diff().clone(), DiffData::new().into()); - trace!( - "Storing IXFR in-memory diff for SOA loaded serial -{:?}:+{:?}", - loaded_only_diff - .0 - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - loaded_only_diff - .0 - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - ); - handle.state.storage.diffs.push(loaded_only_diff); + if loaded_diff.removed_soa.is_some() && loaded_diff.removed_soa != loaded_diff.added_soa { + let mut handle = zone.write_handle(center); + handle + .state + .storage + .diffs + .store_loaded_diff(loaded_diff.clone()); + } } persister.mark_complete() @@ -135,6 +110,9 @@ pub fn persist_signed( if !persister.signed_diff().is_empty() { let loaded_diff = persister.loaded_diff(); let signed_diff = persister.signed_diff(); + let loaded_serial = loaded_diff + .map(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)) + .flatten(); // Determine the path to write to and update the record of written // paths here as we don't want to give responsibility for working with @@ -155,7 +133,7 @@ pub fn persist_signed( .state .persistence .signed_diff_paths - .push(destination.clone().into()); + .push((destination.clone().into(), loaded_serial)); destination }; @@ -228,41 +206,11 @@ pub fn persist_signed( // diff to the in-memory collection without dropping an existing // diff first. - let mut action = "Storing new"; - if let Some(potentially_partial_diff) = handle.state.storage.diffs.last() - && potentially_partial_diff.1.is_empty() - { - // Remove the partial diff, it will be replaced by a - // complete diff. - let _partial_diff = handle.state.storage.diffs.pop(); - action = "Updating existing"; - } - - let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); - trace!( - "{action} IXFR in-memory diff for SOA loaded serial -{:?}:+{:?} -> signed serial -{:?}:+{:?}", - loaded_diff - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - loaded_diff - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - signed_diff - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - signed_diff - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - ); handle .state .storage .diffs - .push((loaded_diff, signed_diff.clone())); + .store_signed_diff(loaded_serial, signed_diff.clone()); } } diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index fc8242464..3a96b8a8e 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -8,8 +8,8 @@ use std::{ }; use cascade_zonedata::{ - DiffData, LoadedZonePatcher, LoadedZoneRestorer, RegularRecord, SignedZonePatcher, - SignedZoneRestorer, SoaRecord, + LoadedZonePatcher, LoadedZoneRestorer, RegularRecord, SignedZonePatcher, SignedZoneRestorer, + SoaRecord, }; use domain::{ new::{ @@ -107,10 +107,7 @@ pub fn restore_loaded( if let Some(diff) = restorer.take_diff() { // Store the loaded diff to be used as part of serving an IXFR. let mut state = zone.write(center); - state - .storage - .diffs - .push((diff.into(), DiffData::new().into())); + state.storage.diffs.store_loaded_diff(diff.into()); trace!( "Stored IXFR loaded diff for SOA serial {} from file '{loaded_source}': serial {start_serial} -> {end_serial}", @@ -155,21 +152,24 @@ pub fn restore_signed( // unsigned integer number and loads that file plus N more, where the // final number in the path is replaced by the previous number plus one // each time. - let signed_source = center - .config - .zone_state_dir - .join(format!("{}.signed.0", zone.name)); - let count = state.persistence.signed_diff_paths.len(); + // let signed_source = center + // .config + // .zone_state_dir + // .join(format!("{}.signed.0", zone.name)); + let paths = state.persistence.signed_diff_paths.clone(); + trace!("Restoring from paths: {paths:?}"); + let signed_source = paths[0].0.as_path(); + let count = paths.len(); let mut buf = Vec::::new(); drop(state); // Process the initial "signed" AXFR wire format dump. - let (soa, records) = - load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).map_err(|err| { - io::Error::other(format!( - "Loading snapshot from '{signed_source}' failed: {err}" - )) - })?; + let (soa, records) = load_axfr_wire_dump(&signed_source, &mut buf).map_err(|err| { + io::Error::other(format!( + "Loading snapshot from '{}' failed: {err}", + signed_source.display() + )) + })?; let mut signed_replacer = restorer.fill().ok_or(io::Error::other( "Internal error: Could not acquire replacer".to_string(), ))?; @@ -177,8 +177,10 @@ pub fn restore_signed( signed_replacer.set_records(records).unwrap(); signed_replacer.apply().unwrap(); trace!( - "Restored signed snapshot for SOA serial {} for zone '{}' from file '{signed_source}'", - soa.rdata.serial, zone.name + "Restored signed snapshot for SOA serial {} for zone '{}' from file '{}'", + soa.rdata.serial, + zone.name, + signed_source.display() ); if count == 1 { @@ -186,7 +188,6 @@ pub fn restore_signed( } // Process zero or more "signed" IXFR wire format dumps. - let mut source = signed_source.to_path_buf(); let mut all_serials = vec![]; // Load each diff and apply it to the zone, retrieving a single DiffData @@ -195,84 +196,33 @@ pub fn restore_signed( // DiffData's will be used to respond to IXFR requests, while at the same // time also building up the entire signed zone that should be served for // AXFR requests. - for i in 1..count { + for (i, (p, loaded_serial)) in paths[1..].iter().enumerate() { + let source = p.display(); let mut signed_patcher = restorer .patch() .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - source.set_extension(i.to_string()); - let (start_serial, end_serial) = - load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { - apply_ixfr_event_to_signed_data(&mut signed_patcher, event); - }) - .map_err(|err| io::Error::other(format!("Loading diff '{source}' failed: {err}")))?; + let (start_serial, end_serial) = load_ixfr_wire_dump(p.as_path(), &mut buf, |event| { + apply_ixfr_event_to_signed_data(&mut signed_patcher, event); + }) + .map_err(|err| io::Error::other(format!("Loading diff '{source}' failed: {err}")))?; signed_patcher.next_patchset().map_err(|err| { io::Error::other(format!("Internal error: Next patchset failed: {err}")) })?; + trace!("Loaded serial for restored diff #{i} = {loaded_serial:?}"); + signed_patcher .apply() .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; if let Some(signed_diff) = restorer.take_diff() { let mut state = zone.write(center); - // Get the diff pair (loaded diff and missing signed diff) that - // this signed diff needs to be inserted into. If the signed diff - // was caused by incremental signing then a loaded diff won't have - // been available to restore, we need to use an empty loaded diff - // in that case. - if let Some(partial_diff) = state.storage.diffs.get_mut(i - 1) { - // Insert the signed diff alongside the loaded diff, unless - // the signed diff already unexpectedly exists. - assert!(partial_diff.1.is_empty()); - partial_diff.1 = signed_diff.into(); - trace!( - "Updated signed part of IXFR in-memory diff for SOA loaded serial -{:?}:+{:?} -> signed serial -{:?}:+{:?} from file '{signed_source}'", - partial_diff - .0 - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - partial_diff - .0 - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - partial_diff - .1 - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - partial_diff - .1 - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - ); - } else { - let loaded_diff = Arc::new(DiffData::new()); - trace!( - "Storing IXFR in-memory diff for SOA loaded serial -{:?}:+{:?} -> signed serial -{:?}:+{:?} from file '{signed_source}'", - loaded_diff - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - loaded_diff - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - signed_diff - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - signed_diff - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.rdata.serial), - ); - state.storage.diffs.push((loaded_diff, signed_diff.into())); - } + state + .storage + .diffs + .store_signed_diff(*loaded_serial, signed_diff.into()); } let start_serial: u32 = start_serial.into(); @@ -280,8 +230,10 @@ pub fn restore_signed( all_serials.push((start_serial, end_serial)); } trace!( - "Restored signed diff for SOA serial {} for zone '{}' from file '{signed_source}' with diff serials: {all_serials:?}", - soa.rdata.serial, zone.name + "Restored signed diff for SOA serial {} for zone '{}' from file '{}' with diff serials: {all_serials:?}", + soa.rdata.serial, + zone.name, + signed_source.display() ); info!("Restored signed zone snapshot and {} diffs", count - 1); diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 1f4fdca66..55a9fdb09 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -1,10 +1,11 @@ //! Zone-specific persistence management. -use std::{path::PathBuf, sync::Arc}; +use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; use cascade_zonedata::{ - LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister, SignedZoneRestorer, + DiffData, LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister, SignedZoneRestorer, }; +use domain::new::base::Serial; use tracing::{debug, info, trace, trace_span, warn}; use crate::{ @@ -225,12 +226,13 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { // We can't use the persisted data so remove the paths from state, remove // the corresponding files on disk and remove any diffs that we loaded // into memory. - for p in state - .persistence - .loaded_diff_paths - .iter() - .chain(state.persistence.signed_diff_paths.iter()) - { + for p in state.persistence.loaded_diff_paths.iter().chain( + state + .persistence + .signed_diff_paths + .iter() + .map(|(p, _serial)| p), + ) { if p.exists() && p.starts_with(center.config.zone_state_dir.as_std_path()) { info!( "Removing unusable persisted zone data file '{}'", @@ -249,7 +251,7 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { state.storage.diffs.clear(); } -//----------- PersistenceState ------------------------------------------------- +//----------- PersistenceState ----------------------------------------------- /// State related to data persistence for a zone. #[derive(Debug, Default)] @@ -264,6 +266,151 @@ pub struct PersistenceState { /// Locations of persisted signed zone diffs to ensure IXFR out toward /// downstreams is still possible after restart, and to enable a complete - /// latest signed version of the zone to be reconsituted. - pub signed_diff_paths: Vec, + /// latest signed version of the zone to be reconsituted. For each path + /// we also remember the associated loaded zone serial otherwise we lose + /// track of which loaded serial the signed diff relates to. + // TODO: Split out the path to the snapshot from the paths to the diffs + // as only the diffs should have a serial stored with them, and it should + // not be optional. + pub signed_diff_paths: Vec<(PathBuf, Option)>, +} + +//----------- IxfrZoneDiffs -------------------------------------------------- + +/// The set of diffs for a single zone, to be used to serve IXFR responses to +/// clients. +/// +/// A new diff is added to this set once the loaded or signed change to the +/// zone is approved at a pipeline review stage. +/// +/// Diffs are stored as a loaded and signed pair which can be in one of the +/// following states: +/// +/// - loaded diff without signed diff: a change to the loaded part of the zone +/// was approved but the pipeline has not yet progressed as far as changing +/// and approving the signed part of the zone. +/// +/// - loaded diff and signed diff: a change to the loaded part of the zone +/// was approved and the pipeline progressed to also updating the signed part +/// of the zone to correspond to the loaded zone changes and those signed +/// changes were also approved. This is recorded by updating an existing +/// loaded diff without signed diff entry in the collection of diffs. +/// +/// - signed diff without loaded diff: a change to the signed part of the zone +/// was approved without any change to the loaded part of the zone, e.g. +/// because the signer policy settings were changed or the zone had to be +/// re-signed using new keys or because signatures nearing expiration had to +/// be regenerated. +/// +/// The diffs should form a continuous chain, with one diff moving from SOA +/// serial N to N+1 and the next diff moving from N+1 to N+2. +/// +/// Can be fairly cheaply cloned which does involve the cost of cloning the +/// inner Vec but does not require creating actual copies of the diff data +/// because each diff is stored internally as an Arc. +#[derive(Clone, Default)] +pub struct IxfrZoneDiffs { + /// Diffs in the loaded part of the zone from one serial number to + /// another. Indexed by the serial number of the loaded zone the + /// diff belongs to. + loaded_diffs: BTreeMap>, + + /// Diffs in the signed part of the zone from one serial number to + /// another, along with the serial number of the loaded diff they + /// correspond to (if any, as a re-signed zone has no corresponding change + /// in the loaded zone). Indexed by the serial number of the signed zone + /// the diff belongs to. + signed_diffs: BTreeMap, Option)>, +} + +impl IxfrZoneDiffs { + pub fn new() -> Self { + Default::default() + } + + pub fn clear(&mut self) { + self.loaded_diffs.clear(); + self.signed_diffs.clear(); + } + + pub fn num_loaded_diffs(&self) -> usize { + self.loaded_diffs.len() + } + + pub fn num_signed_diffs(&self) -> usize { + self.signed_diffs.len() + } + + pub fn store_loaded_diff(&mut self, diff: Arc) { + let from_serial = diff.removed_soa.as_ref().map(|s| s.rdata.serial).unwrap(); + let to_serial = diff.added_soa.as_ref().map(|s| s.rdata.serial).unwrap(); + let old = self.loaded_diffs.insert(from_serial.into(), diff); + log_stored_diff("loaded", old.is_some(), from_serial, to_serial); + } + + pub fn store_signed_diff(&mut self, loaded_serial: Option, diff: Arc) { + let from_serial = diff.removed_soa.as_ref().map(|s| s.rdata.serial).unwrap(); + let to_serial = diff.added_soa.as_ref().map(|s| s.rdata.serial).unwrap(); + let old = self + .signed_diffs + .insert(from_serial.into(), (diff, loaded_serial.map(|s| s.into()))); + log_stored_diff("signed", old.is_some(), from_serial, to_serial); + } + + pub fn get(&self, from_serial: Serial) -> Vec<(Arc, Arc)> { + let mut diffs = vec![]; + let mut wanted_from: u32 = from_serial.into(); + while let Some((signed_diff, loaded_serial)) = self.signed_diffs.get(&wanted_from) { + let loaded_diff = if let Some(loaded_serial) = loaded_serial { + self.loaded_diffs + .get(&loaded_serial) + .cloned() + // We really should have the diff + .unwrap() + } else { + // No loaded diff is associated with this signed diff, so + // use an empty diff + Default::default() + }; + wanted_from = signed_diff.added_soa.as_ref().unwrap().rdata.serial.into(); + diffs.push((loaded_diff, signed_diff.clone())); + } + diffs + } +} + +impl std::fmt::Display for IxfrZoneDiffs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, (key, diff)) in self.loaded_diffs.iter().enumerate() { + let from = diff.removed_soa.as_ref().map(|s| s.rdata.serial); + let to = diff.added_soa.as_ref().map(|s| s.rdata.serial); + writeln!( + f, + "IxfrZoneDiffs: loaded #{i}: serial (diff key {key}) -{from:?}+{to:?} (-{}+{} records)", + diff.removed_records.len(), + diff.added_records.len(), + )?; + } + + for (i, (key, (diff, loaded_serial))) in self.signed_diffs.iter().enumerate() { + let from = diff.removed_soa.as_ref().map(|s| s.rdata.serial); + let to = diff.added_soa.as_ref().map(|s| s.rdata.serial); + writeln!( + f, + "IxfrZoneDiffs: signed #{i}: serial (diff key {key}, loaded serial {loaded_serial:?}) -{from:?}+{to:?} (-{}+{} records)", + diff.removed_records.len(), + diff.added_records.len(), + )?; + } + + std::fmt::Result::Ok(()) + } +} + +fn log_stored_diff(r#type: &'static str, updating: bool, from: Serial, to: Serial) { + if updating { + trace!("Updating existing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); + } else { + trace!("Storing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); + } } diff --git a/src/server/service.rs b/src/server/service.rs index 9f90f78ac..53a8fa424 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -430,22 +430,22 @@ mod compat { match mode { ServiceMode::LoadedReview => { - let mut diffs = Vec::with_capacity(1); if let Some(loaded_diff) = zone_state.storage.current_loaded_diff() { - let empty_signed_diff = Arc::new(DiffData::new()); - diffs.push((loaded_diff, empty_signed_diff)); + vec![(loaded_diff, DiffData::new().into())] + } else { + vec![] } - diffs } ServiceMode::SignedReview => { - let mut diffs = Vec::with_capacity(1); - if let Some(signed_diff) = zone_state.storage.current_signed_diff() { - let empty_loaded_diff = Arc::new(DiffData::new()); - diffs.push((empty_loaded_diff, signed_diff)); + match ( + zone_state.storage.current_loaded_diff(), + zone_state.storage.current_signed_diff(), + ) { + (Some(loaded_diff), Some(signed_diff)) => vec![(loaded_diff, signed_diff)], + _ => vec![], } - diffs } - ServiceMode::Publication => zone_state.storage.diffs.clone(), + ServiceMode::Publication => zone_state.storage.diffs.get(client_soa.serial), } }; @@ -463,60 +463,35 @@ mod compat { // messages. if tracing::enabled!(Level::DEBUG) { - debug!("IXFR out: client serial: {}", client_soa.serial); + let zone_state = zone.handle.read(); debug!( - "IXFR out: {} diffs available for zone {}:", - diffs.len(), + "IXFR out: client has serial {} for zone {}", + client_soa.serial, zone.handle.name + ); + debug!( + "IXFR out: server has {} loaded diffs and {} signed diffs for zone {}", + zone_state.storage.diffs.num_loaded_diffs(), + zone_state.storage.diffs.num_signed_diffs(), zone.handle.name ); - for (i, (loaded_diff, signed_diff)) in diffs.iter().enumerate() { - debug!( - "IXFR out: Diff #{i}: loaded serial -{:?}+{:?} => signed serial -{:?}+{:?}, loaded -{}+{}, signed -{}+{}", - loaded_diff - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.0.rdata.serial), - loaded_diff - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.0.rdata.serial), - signed_diff - .removed_soa - .as_ref() - .map(|soa_rr| soa_rr.0.rdata.serial), - signed_diff - .added_soa - .as_ref() - .map(|soa_rr| soa_rr.0.rdata.serial), - loaded_diff.removed_records.len(), - loaded_diff.added_records.len(), - signed_diff.removed_records.len(), - signed_diff.added_records.len(), + trace!("IXFR diffs available:\n{}", zone_state.storage.diffs); + trace!("IXFR diffs selected:"); + for (i, (loaded, signed)) in diffs.iter().enumerate() { + trace!( + "Selected: #{i}: loaded diff: -{:?}+{:?} (-{}+{} records), signed diff: -{:?}+{:?} (-{}+{} records)", + loaded.removed_soa.as_ref().map(|s| s.rdata.serial), + loaded.added_soa.as_ref().map(|s| s.rdata.serial), + loaded.removed_records.len(), + loaded.added_records.len(), + signed.removed_soa.as_ref().map(|s| s.rdata.serial), + signed.added_soa.as_ref().map(|s| s.rdata.serial), + signed.removed_records.len(), + signed.added_records.len(), ); } } - // Find the diff, if we have it, that removes the SOA serial number - // that the client currently has. That will be the start of the diff - // that we need to serve. The SOA serial has to match the one seen - // by the client, i.e. we need to know if the client requested the - // IXFR from a loaded review server and thus the client SOA serial - // should be matched against a loaded SOA serial, or if the client - // requested the IXFR from a signed review or publication server in - // which case the client SOA serial should be matched against a signed - // SOA serial. - let start_idx = { - diffs.iter().position(|(loaded_diff, signed_diff)| { - let d = if mode == ServiceMode::LoadedReview { - loaded_diff - } else { - signed_diff - }; - d.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) - }) - }; - - let Some(start_idx) = start_idx else { + if diffs.is_empty() { debug!( "Falling back from IXFR to AXFR because no diff is available for zone '{}' from serial {}", zone.handle.name, client_soa.serial, @@ -557,9 +532,7 @@ mod compat { let mut last_removed_soa = None; let mut last_added_soa = None; - for (i, (loaded_diff, signed_diff)) in diffs[start_idx..].iter().enumerate() { - let abs_idx = start_idx + i; - + for (i, (loaded_diff, signed_diff)) in diffs.iter().enumerate() { // Select the appropriate diff as the SOA source to use. let soa_source_diff = if mode == ServiceMode::LoadedReview { loaded_diff @@ -593,7 +566,7 @@ mod compat { && let Some(last_removed_soa) = last_removed_soa && removed_soa == last_removed_soa { - trace!("Skipping unchanged loaded diff #{abs_idx}."); + trace!("Skipping unchanged loaded diff #{i}."); continue; } @@ -605,12 +578,11 @@ mod compat { } if tracing::enabled!(Level::TRACE) { - trace_diff_pair(abs_idx, loaded_diff, signed_diff); + trace_diff_pair(i, loaded_diff, signed_diff); } - trace!("Serving diff #{abs_idx} for loaded review server IXFR out",); - if mode == ServiceMode::LoadedReview { + trace!("Serving diff #{i} for loaded review server IXFR out"); // Remove old records. rrs.push(removed_soa.clone().into()); rrs.extend(soa_source_diff.removed_records.clone()); @@ -619,6 +591,11 @@ mod compat { rrs.push(added_soa.clone().into()); rrs.extend(soa_source_diff.added_records.clone()); } else { + if mode == ServiceMode::SignedReview { + trace!("Serving diff #{i} for signed review server IXFR out"); + } else { + trace!("Serving diff #{i} for publication server IXFR out"); + } // Remove old records. rrs.push(removed_soa.clone().into()); rrs.extend(loaded_diff.removed_records.clone()); @@ -681,8 +658,7 @@ mod compat { fn trace_diff_pair(diff_idx: usize, loaded_diff: &Arc, signed_diff: &Arc) { fn trace_diff(prefix: &str, diff_idx: usize, d: &Arc) { trace!( - "{prefix} diff #{}: {:?}->{:?}: {:?}->{:?}", - diff_idx, + "{prefix} diff #{diff_idx}: {:?}->{:?}: {:?}->{:?}", d.removed_soa.as_ref().map(|s| s.rdata.serial), d.added_soa.as_ref().map(|s| s.rdata.serial), d.removed_records diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index da1753de3..077b98e65 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -117,7 +117,10 @@ impl Spec { let persistence = PersistenceState { loaded_diff_paths: persisted_loaded_diffs, - signed_diff_paths: persisted_signed_diffs, + signed_diff_paths: persisted_signed_diffs + .into_iter() + .map(|(d, s)| (d, s.map(|s| domain::new::base::Serial::from(s.0)))) + .collect(), ..Default::default() }; diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 281b6ade8..e8af191d1 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -104,7 +104,7 @@ pub struct Spec { /// Locations of persisted signed zone diffs to ensure IXFR out toward /// downstreams is still possible after restart, and to enable a complete /// latest signed version of the zone to be reconsituted. - pub persisted_signed_diffs: Vec, + pub persisted_signed_diffs: Vec<(PathBuf, Option)>, } //--- Conversion @@ -126,7 +126,12 @@ impl Spec { previous_serial: zone.previous_serial, history: zone.history.clone(), persisted_loaded_diffs: zone.persistence.loaded_diff_paths.clone(), - persisted_signed_diffs: zone.persistence.signed_diff_paths.clone(), + persisted_signed_diffs: zone + .persistence + .signed_diff_paths + .iter() + .map(|(p, s)| (p.clone(), s.map(|s| domain::base::Serial(u32::from(s))))) + .collect(), } } } diff --git a/src/zone/storage.rs b/src/zone/storage.rs index a6eef8c0b..173d0a3b8 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -36,6 +36,7 @@ use tracing::{info, trace, trace_span, warn}; use crate::{ center::Center, + persistence::zone::IxfrZoneDiffs, server::{LoadedReviewServer, PublicationServer, SignedReviewServer}, util::BackgroundTasks, zone::{HistoricalEvent, LastPublished, Zone, ZoneHandle, ZoneState}, @@ -914,15 +915,8 @@ pub struct StorageState { // current i.e. published zone instance. pub published_loaded_soa: Option, - /// Diffs from one serial to another. Each diff consists of changes in the - /// loaded part and changes in the signed part. - /// - /// A loaded diff is not available if the zone was re-signed due to - /// changes in signing key or to refresh expiring signatures. - /// - /// A signed diff is not available if the zone has been re-loaded and has - /// not yet been signed, e.g. is held at review or signing is in-progress. - pub diffs: Vec<(Arc, Arc)>, + /// Diffs from one serial to another for responding to IXFR requests. + pub diffs: IxfrZoneDiffs, /// Ongoing background tasks. /// From 959639e1b338ea4483246591a97b46b8ec3a5434 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:01:23 +0200 Subject: [PATCH 03/85] Update RustDoc comment to match recent code changes. --- src/persistence/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index c40ae8132..edb6fd507 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -45,8 +45,8 @@ //! `StorageState::diffs` so that it can be served in response to an IXFR //! request from a downstream nameserver. //! - The path that the diff file was written to is appended to -//! `ZoneState::persisted_loaded_diff_paths` or -//! `ZoneState::persisted_signed_diff_paths` and the zone state is +//! `ZoneState::persistence.loaded_diff_paths` or +//! `ZoneState::persistence.signed_diff_paths` and the zone state is //! immediately saved to disk. //! //! # Panics From 0c92a60ecb76c55bf0ce61cf8e8a8663570c87ef Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:03:03 +0200 Subject: [PATCH 04/85] Replace tuple with struct with named fields. --- src/persistence/zone.rs | 108 +++++++++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 55a9fdb09..8195839a2 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -268,23 +268,31 @@ pub struct PersistenceState { /// downstreams is still possible after restart, and to enable a complete /// latest signed version of the zone to be reconsituted. For each path /// we also remember the associated loaded zone serial otherwise we lose - /// track of which loaded serial the signed diff relates to. - // TODO: Split out the path to the snapshot from the paths to the diffs - // as only the diffs should have a serial stored with them, and it should - // not be optional. + /// track of which loaded serial the signed diff relates to. Only signed + /// diffs triggered by a change in the loaded zone actually has an + /// associated loaded diff serial. pub signed_diff_paths: Vec<(PathBuf, Option)>, } //----------- IxfrZoneDiffs -------------------------------------------------- -/// The set of diffs for a single zone, to be used to serve IXFR responses to -/// clients. +/// The set of diffs for a single zone, to be used to serve IXFR responses +/// from the publication server to clients. +/// +/// Note: These diffs are not currently used to serve IXFR responses from +/// review servers to clients as during review the current diff is already +/// available to the review server via the current state of the zone storage +/// state machine. /// /// A new diff is added to this set once the loaded or signed change to the /// zone is approved at a pipeline review stage. /// -/// Diffs are stored as a loaded and signed pair which can be in one of the -/// following states: +/// Loaded and signed diffs are stored separetely, as they are produced +/// separately. In order to reply to an IXFR request both the signed diff +/// and the loaded diff that corresponds to it, if any, are needed. There is +/// therefore a relationship between signed diffs and loaded diffs. +/// +/// Diffs are added at three separate moments in the zone pipeline lifecycle: /// /// - loaded diff without signed diff: a change to the loaded part of the zone /// was approved but the pipeline has not yet progressed as far as changing @@ -293,8 +301,7 @@ pub struct PersistenceState { /// - loaded diff and signed diff: a change to the loaded part of the zone /// was approved and the pipeline progressed to also updating the signed part /// of the zone to correspond to the loaded zone changes and those signed -/// changes were also approved. This is recorded by updating an existing -/// loaded diff without signed diff entry in the collection of diffs. +/// changes were also approved. /// /// - signed diff without loaded diff: a change to the signed part of the zone /// was approved without any change to the loaded part of the zone, e.g. @@ -303,24 +310,49 @@ pub struct PersistenceState { /// be regenerated. /// /// The diffs should form a continuous chain, with one diff moving from SOA -/// serial N to N+1 and the next diff moving from N+1 to N+2. +/// serial N to N+1 and the next diff moving from N+1 to N+2. The chain should +/// begin at the SOA serial that the client currently has, and continue up to +/// and including the SOA serial of the latest published version of the zone. /// -/// Can be fairly cheaply cloned which does involve the cost of cloning the -/// inner Vec but does not require creating actual copies of the diff data -/// because each diff is stored internally as an Arc. -#[derive(Clone, Default)] +/// For signed diffs finding the right diff to serve is easy as the SOA +/// serial of the signed zone corresponds to the SOA serial provided by the +/// client. For loaded diffs however they contain the loaded serial number +/// which may differ to that of the signed serial number (not in the case of +/// 'keep' serial policy however). As we receive the loaded and signed diffs +/// at different moments in the zone pipeline lifecycle we need to keep track +/// when receiving a signed diff of which loaded SOA serial the signed diff +/// relates to, so that we can later serve them together. +#[derive(Default)] pub struct IxfrZoneDiffs { /// Diffs in the loaded part of the zone from one serial number to - /// another. Indexed by the serial number of the loaded zone the - /// diff belongs to. + /// another. Indexed by the serial number being removed from the loaded + /// zone the diff belongs to. loaded_diffs: BTreeMap>, /// Diffs in the signed part of the zone from one serial number to - /// another, along with the serial number of the loaded diff they - /// correspond to (if any, as a re-signed zone has no corresponding change - /// in the loaded zone). Indexed by the serial number of the signed zone - /// the diff belongs to. - signed_diffs: BTreeMap, Option)>, + /// another, along with the serial number being removed from the + /// loaded diff they correspond to (if any, as a re-signed zone has no + /// corresponding change in the loaded zone). Indexed by the serial number + /// being removed from the the signed zone the diff belongs to. + signed_diffs: BTreeMap, +} + +struct RelatedSignedDiff { + /// The signed diff. + diff: Arc, + + /// The removed serial number of the loaded diff that this signed diff + /// relates to, if any. + related_loaded_serial: Option, +} + +impl RelatedSignedDiff { + fn new(diff: Arc, loaded_serial: Option) -> Self { + Self { + diff, + related_loaded_serial: loaded_serial.map(Into::into), + } + } } impl IxfrZoneDiffs { @@ -351,17 +383,18 @@ impl IxfrZoneDiffs { pub fn store_signed_diff(&mut self, loaded_serial: Option, diff: Arc) { let from_serial = diff.removed_soa.as_ref().map(|s| s.rdata.serial).unwrap(); let to_serial = diff.added_soa.as_ref().map(|s| s.rdata.serial).unwrap(); - let old = self - .signed_diffs - .insert(from_serial.into(), (diff, loaded_serial.map(|s| s.into()))); + let related_diff = RelatedSignedDiff::new(diff, loaded_serial); + let old = self.signed_diffs.insert(from_serial.into(), related_diff); log_stored_diff("signed", old.is_some(), from_serial, to_serial); } pub fn get(&self, from_serial: Serial) -> Vec<(Arc, Arc)> { let mut diffs = vec![]; + let mut wanted_from: u32 = from_serial.into(); - while let Some((signed_diff, loaded_serial)) = self.signed_diffs.get(&wanted_from) { - let loaded_diff = if let Some(loaded_serial) = loaded_serial { + while let Some(signed_related_diff) = self.signed_diffs.get(&wanted_from) { + let loaded_diff = if let Some(loaded_serial) = signed_related_diff.related_loaded_serial + { self.loaded_diffs .get(&loaded_serial) .cloned() @@ -372,9 +405,21 @@ impl IxfrZoneDiffs { // use an empty diff Default::default() }; - wanted_from = signed_diff.added_soa.as_ref().unwrap().rdata.serial.into(); - diffs.push((loaded_diff, signed_diff.clone())); + + // Update wanted_from so that on the next iteration of the loop we + // fetch the diff that removes the SOA serial that this diff adds. + wanted_from = signed_related_diff + .diff + .added_soa + .as_ref() + .unwrap() + .rdata + .serial + .into(); + + diffs.push((loaded_diff, signed_related_diff.diff.clone())); } + diffs } } @@ -392,7 +437,10 @@ impl std::fmt::Display for IxfrZoneDiffs { )?; } - for (i, (key, (diff, loaded_serial))) in self.signed_diffs.iter().enumerate() { + for (i, (key, related_signed_diff)) in self.signed_diffs.iter().enumerate() { + let diff = &related_signed_diff.diff; + let loaded_serial = &related_signed_diff.related_loaded_serial; + let from = diff.removed_soa.as_ref().map(|s| s.rdata.serial); let to = diff.added_soa.as_ref().map(|s| s.rdata.serial); writeln!( From 5a678fd5554ffe9a43ac97da6bf9d6b6eb6031b1 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:04:04 +0200 Subject: [PATCH 05/85] Improve comments and check if fetched diff matches expectations. --- src/server/service.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/server/service.rs b/src/server/service.rs index 53a8fa424..aee88fc55 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -406,13 +406,6 @@ mod compat { // Remember the latest SOA. let new_soa = viewer.soa().clone(); - // https://datatracker.ietf.org/doc/html/rfc1995#section-4 - // 4. Response Format - // "If incremental zone transfer is not available, the entire zone - // is returned. The first and the last RR of the response is the - // SOA record of the zone. I.e. the behavior is the same as an - // AXFR response except the query type is IXFR." - // https://datatracker.ietf.org/doc/html/rfc1995#section-2 // 2. Brief Description of the Protocol // "If an IXFR query with the same or newer version number than that @@ -465,8 +458,8 @@ mod compat { if tracing::enabled!(Level::DEBUG) { let zone_state = zone.handle.read(); debug!( - "IXFR out: client has serial {} for zone {}", - client_soa.serial, zone.handle.name + "IXFR out: client has serial {} for zone {}, server has {}", + client_soa.serial, zone.handle.name, our_soa_serial, ); debug!( "IXFR out: server has {} loaded diffs and {} signed diffs for zone {}", @@ -491,7 +484,25 @@ mod compat { } } - if diffs.is_empty() { + // https://datatracker.ietf.org/doc/html/rfc1995#section-4 + // 4. Response Format + // "If incremental zone transfer is not available, the entire zone + // is returned. The first and the last RR of the response is the + // SOA record of the zone. I.e. the behavior is the same as an + // AXFR response except the query type is IXFR." + if diffs.is_empty() || + // The starting diff SOA serial must match that of the client + diffs + .first() + .unwrap() + .1 + .removed_soa + .as_ref() + .map(|s| &s.rdata.serial) + != Some(&client_soa.serial) + // The ending diff SOA serial must match that of the zone + || diffs.last().unwrap().1.added_soa.as_ref().map(|s| s.rdata.serial) != Some(viewer.soa().rdata.serial) + { debug!( "Falling back from IXFR to AXFR because no diff is available for zone '{}' from serial {}", zone.handle.name, client_soa.serial, From 26909f691e8c74f28d189710463f4c14e7e3dd68 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:00:18 +0200 Subject: [PATCH 06/85] Clippy. --- src/persistence/persist.rs | 5 ++--- src/persistence/restore.rs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index caeae288a..2471bcbc4 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -110,9 +110,8 @@ pub fn persist_signed( if !persister.signed_diff().is_empty() { let loaded_diff = persister.loaded_diff(); let signed_diff = persister.signed_diff(); - let loaded_serial = loaded_diff - .map(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)) - .flatten(); + let loaded_serial = + loaded_diff.and_then(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)); // Determine the path to write to and update the record of written // paths here as we don't want to give responsibility for working with diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 3a96b8a8e..386d81793 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -164,7 +164,7 @@ pub fn restore_signed( drop(state); // Process the initial "signed" AXFR wire format dump. - let (soa, records) = load_axfr_wire_dump(&signed_source, &mut buf).map_err(|err| { + let (soa, records) = load_axfr_wire_dump(signed_source, &mut buf).map_err(|err| { io::Error::other(format!( "Loading snapshot from '{}' failed: {err}", signed_source.display() From f7575305f0a969e667e83b491e54a72da7abbdc7 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:10:18 +0200 Subject: [PATCH 07/85] Remove commented out code. --- src/persistence/restore.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 386d81793..fa3ff14a7 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -152,10 +152,6 @@ pub fn restore_signed( // unsigned integer number and loads that file plus N more, where the // final number in the path is replaced by the previous number plus one // each time. - // let signed_source = center - // .config - // .zone_state_dir - // .join(format!("{}.signed.0", zone.name)); let paths = state.persistence.signed_diff_paths.clone(); trace!("Restoring from paths: {paths:?}"); let signed_source = paths[0].0.as_path(); From a2fbe681daad5eaf60b4cbb1eee16157e576146e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:30:07 +0200 Subject: [PATCH 08/85] More cleanup and consistency between loaded and signed restoration. --- src/persistence/restore.rs | 181 ++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 71 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index fa3ff14a7..c99027f44 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -8,8 +8,8 @@ use std::{ }; use cascade_zonedata::{ - LoadedZonePatcher, LoadedZoneRestorer, RegularRecord, SignedZonePatcher, SignedZoneRestorer, - SoaRecord, + DiffData, LoadedZonePatcher, LoadedZoneRestorer, RegularRecord, SignedZonePatcher, + SignedZoneRestorer, SoaRecord, }; use domain::{ new::{ @@ -40,32 +40,38 @@ pub fn restore_loaded( center: &Arc
, restorer: &mut LoadedZoneRestorer, ) -> io::Result { - let mut state = zone.write(center); - if state.persistence.loaded_diff_paths.is_empty() { - return io::Result::Ok(false); + // Remove any existing diffs. + { + let mut state = zone.write(center); + state.storage.diffs.clear(); } - info!("Restoring loaded zone from persisted data"); - state.storage.diffs.clear(); + let state = zone.read(); + let mut paths_iter = state.persistence.loaded_diff_paths.iter(); + let Some(snapshot_path) = paths_iter.next() else { + return io::Result::Ok(false); + }; + + info!("Restoring persisted loaded data for zone '{}'", zone.name); + trace!( + "Restoring from paths: {:?}", + state.persistence.loaded_diff_paths + ); // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in an // unsigned integer number and loads that file plus N more, where the // final number in the path is replaced by the previous number plus one // each time. - let loaded_source = center - .config - .zone_state_dir - .join(format!("{}.loaded.0", zone.name)); - let count = state.persistence.loaded_diff_paths.len(); let mut buf = Vec::::new(); - drop(state); // Process the initial "loaded" AXFR wire format dump. - let (soa, records) = - load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).map_err(|err| { - io::Error::other(format!("Loading snapshot '{loaded_source}' failed: {err}")) - })?; + let (soa, records) = load_axfr_wire_dump(snapshot_path, &mut buf).map_err(|err| { + io::Error::other(format!( + "Loading snapshot '{}' failed: {err}", + snapshot_path.display() + )) + })?; let mut loaded_replacer = restorer.fill().ok_or(io::Error::other( "Internal error: Could not acquire replacer".to_string(), ))?; @@ -73,28 +79,33 @@ pub fn restore_loaded( loaded_replacer.set_records(records).unwrap(); loaded_replacer.apply().unwrap(); trace!( - "Restored loaded snapshot for SOA serial {} for zone '{}' from file '{loaded_source}'", - soa.rdata.serial, zone.name + "Restored loaded snapshot for SOA serial {} for zone '{}' from file '{}'", + soa.rdata.serial, + zone.name, + snapshot_path.display() ); - if count == 1 { - return io::Result::Ok(true); - } - - let mut source = loaded_source.to_path_buf(); let mut all_serials = vec![]; + let mut diffs_to_store: Vec> = vec![]; - for i in 1..count { + for diff_path in paths_iter { + trace!( + "Loading and applying loaded diff form '{}'", + diff_path.display() + ); let mut loaded_patcher = restorer .patch() .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - source.set_extension(i.to_string()); - let (start_serial, end_serial) = - load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { - apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); - }) - .map_err(|err| io::Error::other(format!("Loading diff '{source}' failed: {err}",)))?; + let (start_serial, end_serial) = load_ixfr_wire_dump(diff_path, &mut buf, |event| { + apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!( + "Loading diff '{}' failed: {err}", + diff_path.display() + )) + })?; loaded_patcher.next_patchset().map_err(|err| { io::Error::other(format!("Internal error: Next patchset failed: {err}")) @@ -105,13 +116,11 @@ pub fn restore_loaded( .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; if let Some(diff) = restorer.take_diff() { - // Store the loaded diff to be used as part of serving an IXFR. - let mut state = zone.write(center); - state.storage.diffs.store_loaded_diff(diff.into()); - + diffs_to_store.push(diff.into()); trace!( - "Stored IXFR loaded diff for SOA serial {} from file '{loaded_source}': serial {start_serial} -> {end_serial}", + "Extracted IXFR loaded diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", soa.rdata.serial, + diff_path.display() ); } @@ -119,12 +128,24 @@ pub fn restore_loaded( let end_serial: u32 = end_serial.into(); all_serials.push((start_serial, end_serial)); } + drop(state); + + let num_diffs_to_restore = diffs_to_store.len(); trace!( - "Restored loaded diff for SOA serial {} for zone '{}' from file '{loaded_source}' with diff serials: {all_serials:?}", - soa.rdata.serial, zone.name + "Restoring {} loaded diffs for zone {} with serials: {all_serials:?}", + num_diffs_to_restore, zone.name ); - info!("Restored loaded zone snapshot and {} diffs", count - 1); + let mut state = zone.write(center); + for diff in diffs_to_store { + // Store the loaded diff to be used as part of serving an IXFR. + state.storage.diffs.store_loaded_diff(diff.into()); + } + + info!( + "Restored loaded zone snapshot and {num_diffs_to_restore} diffs for zone '{}'", + zone.name + ); io::Result::Ok(true) } @@ -143,27 +164,29 @@ pub fn restore_signed( restorer: &mut SignedZoneRestorer, ) -> io::Result { let state = zone.read(); - if state.persistence.signed_diff_paths.is_empty() { + let mut path_infos_iter = state.persistence.signed_diff_paths.iter(); + let Some((snapshot_path, _serial)) = path_infos_iter.next() else { return io::Result::Ok(false); - } + }; + + info!("Restoring persisted signed data for zone '{}'", zone.name); + trace!( + "Restoring from paths: {:?}", + state.persistence.signed_diff_paths + ); // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in an // unsigned integer number and loads that file plus N more, where the // final number in the path is replaced by the previous number plus one // each time. - let paths = state.persistence.signed_diff_paths.clone(); - trace!("Restoring from paths: {paths:?}"); - let signed_source = paths[0].0.as_path(); - let count = paths.len(); let mut buf = Vec::::new(); - drop(state); // Process the initial "signed" AXFR wire format dump. - let (soa, records) = load_axfr_wire_dump(signed_source, &mut buf).map_err(|err| { + let (soa, records) = load_axfr_wire_dump(snapshot_path, &mut buf).map_err(|err| { io::Error::other(format!( "Loading snapshot from '{}' failed: {err}", - signed_source.display() + snapshot_path.display() )) })?; let mut signed_replacer = restorer.fill().ok_or(io::Error::other( @@ -176,15 +199,11 @@ pub fn restore_signed( "Restored signed snapshot for SOA serial {} for zone '{}' from file '{}'", soa.rdata.serial, zone.name, - signed_source.display() + snapshot_path.display() ); - if count == 1 { - return io::Result::Ok(true); - } - - // Process zero or more "signed" IXFR wire format dumps. let mut all_serials = vec![]; + let mut diffs_to_store: Vec<(Option, Arc)> = vec![]; // Load each diff and apply it to the zone, retrieving a single DiffData // per signed diff. Store each signed DiffData alongside the corresponding @@ -192,47 +211,67 @@ pub fn restore_signed( // DiffData's will be used to respond to IXFR requests, while at the same // time also building up the entire signed zone that should be served for // AXFR requests. - for (i, (p, loaded_serial)) in paths[1..].iter().enumerate() { - let source = p.display(); + for (diff_path, loaded_serial) in path_infos_iter { + trace!( + "Loading and applying signed diff from '{}' for loaded serial {loaded_serial:?}", + diff_path.display() + ); let mut signed_patcher = restorer .patch() .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - let (start_serial, end_serial) = load_ixfr_wire_dump(p.as_path(), &mut buf, |event| { + let (start_serial, end_serial) = load_ixfr_wire_dump(diff_path, &mut buf, |event| { apply_ixfr_event_to_signed_data(&mut signed_patcher, event); }) - .map_err(|err| io::Error::other(format!("Loading diff '{source}' failed: {err}")))?; + .map_err(|err| { + io::Error::other(format!( + "Loading diff '{}' failed: {err}", + diff_path.display() + )) + })?; signed_patcher.next_patchset().map_err(|err| { io::Error::other(format!("Internal error: Next patchset failed: {err}")) })?; - trace!("Loaded serial for restored diff #{i} = {loaded_serial:?}"); - signed_patcher .apply() .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; - if let Some(signed_diff) = restorer.take_diff() { - let mut state = zone.write(center); - state - .storage - .diffs - .store_signed_diff(*loaded_serial, signed_diff.into()); + if let Some(diff) = restorer.take_diff() { + diffs_to_store.push((loaded_serial.clone(), diff.into())); + trace!( + "Extracted IXFR signed diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, + diff_path.display() + ); } let start_serial: u32 = start_serial.into(); let end_serial: u32 = end_serial.into(); all_serials.push((start_serial, end_serial)); } + drop(state); + + let num_diffs_to_restore = diffs_to_store.len(); trace!( - "Restored signed diff for SOA serial {} for zone '{}' from file '{}' with diff serials: {all_serials:?}", - soa.rdata.serial, - zone.name, - signed_source.display() + "Restoring {} signed diffs for zone {} with serials: {all_serials:?}", + num_diffs_to_restore, zone.name ); - info!("Restored signed zone snapshot and {} diffs", count - 1); + let mut state = zone.write(center); + for (loaded_serial, diff) in diffs_to_store { + // Store the signed diff to be used as part of serving an IXFR. + state + .storage + .diffs + .store_signed_diff(loaded_serial, diff.into()); + } + + info!( + "Restored signed zone snapshot and {num_diffs_to_restore} diffs for zone '{}'", + zone.name + ); io::Result::Ok(true) } From a59d661ca2fbec0e2b25a4d754d4b620787b4453 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:32:07 +0200 Subject: [PATCH 09/85] Clippy. --- src/persistence/restore.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index c99027f44..b96d12a52 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -139,7 +139,7 @@ pub fn restore_loaded( let mut state = zone.write(center); for diff in diffs_to_store { // Store the loaded diff to be used as part of serving an IXFR. - state.storage.diffs.store_loaded_diff(diff.into()); + state.storage.diffs.store_loaded_diff(diff); } info!( @@ -239,7 +239,7 @@ pub fn restore_signed( .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; if let Some(diff) = restorer.take_diff() { - diffs_to_store.push((loaded_serial.clone(), diff.into())); + diffs_to_store.push((*loaded_serial, diff.into())); trace!( "Extracted IXFR signed diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", soa.rdata.serial, @@ -265,7 +265,7 @@ pub fn restore_signed( state .storage .diffs - .store_signed_diff(loaded_serial, diff.into()); + .store_signed_diff(loaded_serial, diff); } info!( From a50a3c19d5a1d47cdc2ad746ceb37f195e6384c1 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:37:21 +0200 Subject: [PATCH 10/85] Cargo fmt. --- src/persistence/restore.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index b96d12a52..1ca2f6dc2 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -262,10 +262,7 @@ pub fn restore_signed( let mut state = zone.write(center); for (loaded_serial, diff) in diffs_to_store { // Store the signed diff to be used as part of serving an IXFR. - state - .storage - .diffs - .store_signed_diff(loaded_serial, diff); + state.storage.diffs.store_signed_diff(loaded_serial, diff); } info!( From e8a67b7cab003ae5f727a1621384b3b260f6e974 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:54:51 +0200 Subject: [PATCH 11/85] Use dedicated types. --- src/persistence/persist.rs | 30 ++---- src/persistence/restore.rs | 41 +++---- src/persistence/zone.rs | 213 +++++++++++++++++++++++++++++++++---- src/zone/state/mod.rs | 7 +- src/zone/state/v1.rs | 104 ++++++++++++++++-- 5 files changed, 321 insertions(+), 74 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 2471bcbc4..d0a0f7c3c 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -41,19 +41,12 @@ pub fn persist_loaded( // TODO: Compact diffs when idle? let destination = { let mut handle = zone.write_handle(center); - let next_idx = handle.state.persistence.loaded_diff_paths.len(); - let destination = center - .config - .zone_state_dir - .join(format!("{}.loaded.{next_idx}", zone.name)); - + let loaded_serial = loaded_diff.removed_soa.as_ref().map(|s| s.rdata.serial); handle .state .persistence - .loaded_diff_paths - .push(destination.clone().into()); - - destination + .loaded_diffs + .push(zone, center, loaded_serial, None) }; // Update the set of persisted zone data file paths BEFORE writing @@ -81,7 +74,7 @@ pub fn persist_loaded( // the Option is Some the referred to path can just be deleted. save_state_now(center, zone); - persist_to_file(destination.as_std_path(), loaded_diff.clone()); + persist_to_file(&destination, loaded_diff.clone()); if loaded_diff.removed_soa.is_some() && loaded_diff.removed_soa != loaded_diff.added_soa { let mut handle = zone.write_handle(center); @@ -122,19 +115,12 @@ pub fn persist_signed( // TODO: Compact diffs when idle? let destination = { let mut handle = zone.write_handle(center); - let next_idx = handle.state.persistence.signed_diff_paths.len(); - let destination = center - .config - .zone_state_dir - .join(format!("{}.signed.{next_idx}", zone.name)); - + let signed_serial = signed_diff.removed_soa.as_ref().map(|s| s.rdata.serial); handle .state .persistence - .signed_diff_paths - .push((destination.clone().into(), loaded_serial)); - - destination + .signed_diffs + .push(zone, center, loaded_serial, signed_serial) }; // Update the set of persisted zone data file paths BEFORE writing @@ -164,7 +150,7 @@ pub fn persist_signed( // Write the diff to disk as a binary AXFR snapshot or binary IXFR // diff. - persist_to_file(destination.as_std_path(), signed_diff.clone()); + persist_to_file(&destination, signed_diff.clone()); // Store the diffs in-memory for serving IXFR out. // diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 1ca2f6dc2..a48937c8a 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -47,15 +47,15 @@ pub fn restore_loaded( } let state = zone.read(); - let mut paths_iter = state.persistence.loaded_diff_paths.iter(); - let Some(snapshot_path) = paths_iter.next() else { + let mut diff_infos = state.persistence.loaded_diffs.diffs().iter(); + let Some(snapshot_path) = diff_infos.next().map(|d| d.path()) else { return io::Result::Ok(false); }; info!("Restoring persisted loaded data for zone '{}'", zone.name); trace!( - "Restoring from paths: {:?}", - state.persistence.loaded_diff_paths + "Restoring from loaded diffs: {:?}", + state.persistence.loaded_diffs ); // Determine the paths to read from. Each zone is persisted as an AXFR @@ -88,22 +88,22 @@ pub fn restore_loaded( let mut all_serials = vec![]; let mut diffs_to_store: Vec> = vec![]; - for diff_path in paths_iter { + for diff_info in diff_infos { trace!( "Loading and applying loaded diff form '{}'", - diff_path.display() + diff_info.path().display() ); let mut loaded_patcher = restorer .patch() .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - let (start_serial, end_serial) = load_ixfr_wire_dump(diff_path, &mut buf, |event| { + let (start_serial, end_serial) = load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); }) .map_err(|err| { io::Error::other(format!( "Loading diff '{}' failed: {err}", - diff_path.display() + diff_info.path().display() )) })?; @@ -120,7 +120,7 @@ pub fn restore_loaded( trace!( "Extracted IXFR loaded diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", soa.rdata.serial, - diff_path.display() + diff_info.path().display() ); } @@ -164,15 +164,15 @@ pub fn restore_signed( restorer: &mut SignedZoneRestorer, ) -> io::Result { let state = zone.read(); - let mut path_infos_iter = state.persistence.signed_diff_paths.iter(); - let Some((snapshot_path, _serial)) = path_infos_iter.next() else { + let mut diff_infos = state.persistence.signed_diffs.diffs().iter(); + let Some(snapshot_path) = diff_infos.next().map(|d| d.path()) else { return io::Result::Ok(false); }; info!("Restoring persisted signed data for zone '{}'", zone.name); trace!( - "Restoring from paths: {:?}", - state.persistence.signed_diff_paths + "Restoring from signed paths: {:?}", + state.persistence.signed_diffs ); // Determine the paths to read from. Each zone is persisted as an AXFR @@ -211,22 +211,23 @@ pub fn restore_signed( // DiffData's will be used to respond to IXFR requests, while at the same // time also building up the entire signed zone that should be served for // AXFR requests. - for (diff_path, loaded_serial) in path_infos_iter { + for diff_info in diff_infos { trace!( - "Loading and applying signed diff from '{}' for loaded serial {loaded_serial:?}", - diff_path.display() + "Loading and applying signed diff from '{}' for loaded serial {:?}", + diff_info.path().display(), + diff_info.loaded_serial() ); let mut signed_patcher = restorer .patch() .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - let (start_serial, end_serial) = load_ixfr_wire_dump(diff_path, &mut buf, |event| { + let (start_serial, end_serial) = load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { apply_ixfr_event_to_signed_data(&mut signed_patcher, event); }) .map_err(|err| { io::Error::other(format!( "Loading diff '{}' failed: {err}", - diff_path.display() + diff_info.path().display() )) })?; @@ -239,11 +240,11 @@ pub fn restore_signed( .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; if let Some(diff) = restorer.take_diff() { - diffs_to_store.push((*loaded_serial, diff.into())); + diffs_to_store.push((diff_info.loaded_serial(), diff.into())); trace!( "Extracted IXFR signed diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", soa.rdata.serial, - diff_path.display() + diff_info.path().display() ); } diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 8195839a2..c4e906c37 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -1,6 +1,10 @@ //! Zone-specific persistence management. -use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, + sync::Arc, +}; use cascade_zonedata::{ DiffData, LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister, SignedZoneRestorer, @@ -108,7 +112,7 @@ impl ZonePersistenceHandle<'_> { let mut handle = zone.write_handle(¢er); trace!( "Restored diffs: {:?}", - handle.state.persistence.loaded_diff_paths + handle.state.persistence.loaded_diffs ); handle.storage().finish_signed_restoration(restored); handle.state.persistence.ongoing.finish(); @@ -226,35 +230,39 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { // We can't use the persisted data so remove the paths from state, remove // the corresponding files on disk and remove any diffs that we loaded // into memory. - for p in state.persistence.loaded_diff_paths.iter().chain( - state - .persistence - .signed_diff_paths - .iter() - .map(|(p, _serial)| p), - ) { - if p.exists() && p.starts_with(center.config.zone_state_dir.as_std_path()) { + for file_info in state + .persistence + .loaded_diffs + .diff_infos + .iter() + .chain(state.persistence.signed_diffs.diff_infos.iter()) + { + if file_info.path.exists() + && file_info + .path + .starts_with(center.config.zone_state_dir.as_std_path()) + { info!( "Removing unusable persisted zone data file '{}'", - p.display() + file_info.path.display() ); - if let Err(err) = std::fs::remove_file(p) { + if let Err(err) = std::fs::remove_file(&file_info.path) { warn!( "Failed to remove unusable persisted zone data file '{}': {err}", - p.display() + file_info.path.display() ); } } } - state.persistence.loaded_diff_paths.clear(); - state.persistence.signed_diff_paths.clear(); + state.persistence.loaded_diffs.clear(); + state.persistence.signed_diffs.clear(); state.storage.diffs.clear(); } //----------- PersistenceState ----------------------------------------------- /// State related to data persistence for a zone. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct PersistenceState { /// Ongoing persist/restore operations. pub ongoing: BackgroundTasks, @@ -262,7 +270,7 @@ pub struct PersistenceState { /// Locations of persisted unsigned zone diffs to enable IXFR from /// the upstream to resume on restart, and to enable a complete latest /// unsigned version of the zone to be reconstituted. - pub loaded_diff_paths: Vec, + pub loaded_diffs: PersistedDiffManager, /// Locations of persisted signed zone diffs to ensure IXFR out toward /// downstreams is still possible after restart, and to enable a complete @@ -271,7 +279,176 @@ pub struct PersistenceState { /// track of which loaded serial the signed diff relates to. Only signed /// diffs triggered by a change in the loaded zone actually has an /// associated loaded diff serial. - pub signed_diff_paths: Vec<(PathBuf, Option)>, + pub signed_diffs: PersistedDiffManager, +} + +impl Default for PersistenceState { + fn default() -> Self { + Self { + loaded_diffs: PersistedDiffManager::new(PersistedDiffRecordSource::Loaded), + signed_diffs: PersistedDiffManager::new(PersistedDiffRecordSource::Signed), + ongoing: Default::default(), + } + } +} + +//----------- PersistedDiffRecordSource -------------------------------------- + +/// The source of the persisted diff records. +#[derive(Clone, Copy, Debug)] +pub enum PersistedDiffRecordSource { + Loaded, + Signed, +} + +//----------- PersistedDiffManager ------------------------------------------- + +/// Metadata about a related collection of persisted zone data files. +#[derive(Clone, Debug)] +pub struct PersistedDiffManager { + /// Which kind of data are we storing, loaded or signed? + record_source: PersistedDiffRecordSource, + + /// The index value to use when constructing the next file name to write + /// to. + next_idx: usize, + + /// The collection of persisted data file paths in this set. + diff_infos: BTreeSet, +} + +impl PersistedDiffManager { + pub fn new(record_source: PersistedDiffRecordSource) -> Self { + Self::from_parts(record_source, 0, Default::default()) + } + + pub fn from_parts( + record_source: PersistedDiffRecordSource, + next_idx: usize, + diff_infos: BTreeSet, + ) -> Self { + Self { + record_source, + next_idx, + diff_infos, + } + } + + pub fn push( + &mut self, + zone: &Arc, + center: &Arc
, + loaded_serial: Option, + signed_serial: Option, + ) -> PathBuf { + let zone_name = &zone.name; + let data_file_type = match self.record_source { + PersistedDiffRecordSource::Loaded => "loaded", + PersistedDiffRecordSource::Signed => "signed", + }; + + let path = center + .config + .zone_state_dir + .join(format!("{zone_name}.{data_file_type}.{}", self.next_idx)) + .into_std_path_buf(); + let file_info = PersistedDiffFileInfo { + path: path.clone(), + loaded_serial, + signed_serial, + }; + + assert!(self.diff_infos.insert(file_info)); + + // replace with strict_add() once our MSRV reaches 1.91.0. + assert_ne!(self.next_idx, usize::MAX); + self.next_idx += 1; + + path + } + + pub fn is_empty(&self) -> bool { + self.diff_infos.is_empty() + } + + pub fn clear(&mut self) { + self.diff_infos.clear(); + self.next_idx = 0; + } + + pub fn next_idx(&self) -> usize { + self.next_idx + } + + pub fn diffs(&self) -> &BTreeSet { + &self.diff_infos + } +} + +//----------- PersistedZoneDataFileInfo -------------------------------------- + +/// Information about a single persisted zone data file. +#[derive(Clone, Debug)] +pub struct PersistedDiffFileInfo { + /// The location on disk where the zone data file exists. + path: PathBuf, + + /// The loaded serial number that the data file relates to. + /// + /// This can be None for a signed diff resulting from changes only to the + /// signed zone. + loaded_serial: Option, + + /// The signed serial number that the data file relates to. + /// + /// This can be none for a loaded diff. + signed_serial: Option, +} + +impl PersistedDiffFileInfo { + pub fn new( + path: PathBuf, + loaded_serial: Option, + signed_serial: Option, + ) -> Self { + Self { + path, + loaded_serial, + signed_serial, + } + } + + pub fn path(&self) -> &PathBuf { + &self.path + } + + pub fn loaded_serial(&self) -> Option { + self.loaded_serial + } + + pub fn signed_serial(&self) -> Option { + self.signed_serial + } +} + +impl PartialEq for PersistedDiffFileInfo { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + +impl Eq for PersistedDiffFileInfo {} + +impl PartialOrd for PersistedDiffFileInfo { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PersistedDiffFileInfo { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.path.cmp(&other.path) + } } //----------- IxfrZoneDiffs -------------------------------------------------- diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index 077b98e65..956558133 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -116,11 +116,8 @@ impl Spec { let policy = policy.map(|p| p.latest.clone()); let persistence = PersistenceState { - loaded_diff_paths: persisted_loaded_diffs, - signed_diff_paths: persisted_signed_diffs - .into_iter() - .map(|(d, s)| (d, s.map(|s| domain::new::base::Serial::from(s.0)))) - .collect(), + loaded_diffs: persisted_loaded_diffs.parse(), + signed_diffs: persisted_signed_diffs.parse(), ..Default::default() }; diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index e8af191d1..ea1bfb98c 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -13,6 +13,9 @@ use domain::{base::Name, rdata::dnssec::Timestamp}; use serde::{Deserialize, Serialize}; use crate::loader::Source; +use crate::persistence::zone::{ + PersistedDiffFileInfo, PersistedDiffManager, PersistedDiffRecordSource, +}; use crate::policy::file::v1::{NameserverCommsSpec, OutboundSpec}; use crate::policy::{AutoConfig, DsAlgorithm, KeyParameters}; use crate::tsig::TsigStore; @@ -99,12 +102,12 @@ pub struct Spec { /// Locations of persisted unsigned zone diffs to enable IXFR from /// the upstream to resume on restart, and to enable a complete latest /// unsigned version of the zone to be reconstituted. - pub persisted_loaded_diffs: Vec, + pub persisted_loaded_diffs: PersistedDiffsSpec, /// Locations of persisted signed zone diffs to ensure IXFR out toward /// downstreams is still possible after restart, and to enable a complete /// latest signed version of the zone to be reconsituted. - pub persisted_signed_diffs: Vec<(PathBuf, Option)>, + pub persisted_signed_diffs: PersistedDiffsSpec, } //--- Conversion @@ -125,13 +128,12 @@ impl Spec { last_signature_refresh: zone.last_signature_refresh.clone(), previous_serial: zone.previous_serial, history: zone.history.clone(), - persisted_loaded_diffs: zone.persistence.loaded_diff_paths.clone(), - persisted_signed_diffs: zone - .persistence - .signed_diff_paths - .iter() - .map(|(p, s)| (p.clone(), s.map(|s| domain::base::Serial(u32::from(s))))) - .collect(), + persisted_loaded_diffs: PersistedDiffsSpec::build_loaded( + &zone.persistence.loaded_diffs, + ), + persisted_signed_diffs: PersistedDiffsSpec::build_signed( + &zone.persistence.signed_diffs, + ), } } } @@ -645,3 +647,87 @@ impl ZoneLoadSourceSpec { } } } + +//------------ PersistedDiffsSpec -------------------------------------------- + +/// Information about a collection of persisted diffs. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct PersistedDiffsSpec { + pub is_signed: bool, + pub next_idx: usize, + pub diff_infos: Vec, +} + +impl PersistedDiffsSpec { + /// Parse from this specification. + pub fn parse(self) -> PersistedDiffManager { + let diff_infos = self + .diff_infos + .into_iter() + .map(PersistedDiffFileInfoSpec::parse) + .collect(); + let is_signed = match self.is_signed { + true => PersistedDiffRecordSource::Signed, + false => PersistedDiffRecordSource::Loaded, + }; + PersistedDiffManager::from_parts(is_signed, self.next_idx, diff_infos) + } + + /// Build into this specification. + fn build_loaded(loaded_diffs: &PersistedDiffManager) -> Self { + Self { + is_signed: false, + next_idx: loaded_diffs.next_idx(), + diff_infos: loaded_diffs + .diffs() + .iter() + .map(PersistedDiffFileInfoSpec::build) + .collect(), + } + } + + /// Build into this specification. + fn build_signed(signed_diffs: &PersistedDiffManager) -> Self { + Self { + is_signed: true, + next_idx: signed_diffs.next_idx(), + diff_infos: signed_diffs + .diffs() + .iter() + .map(PersistedDiffFileInfoSpec::build) + .collect(), + } + } +} + +/// Information a single persisted diff. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct PersistedDiffFileInfoSpec { + path: PathBuf, + loaded_serial: Option, + signed_serial: Option, +} + +impl PersistedDiffFileInfoSpec { + /// Parse from this specification. + fn parse(self) -> PersistedDiffFileInfo { + PersistedDiffFileInfo::new( + self.path, + self.loaded_serial + .map(|s| domain::new::base::Serial::from(s.0)), + self.signed_serial + .map(|s| domain::new::base::Serial::from(s.0)), + ) + } + + /// Build into this specification. + fn build(info: &PersistedDiffFileInfo) -> Self { + Self { + path: info.path().to_path_buf(), + loaded_serial: info.loaded_serial().map(|s| Serial(s.into())), + signed_serial: info.signed_serial().map(|s| Serial(s.into())), + } + } +} From 1b1dc022d1c17fb0a2c9bfeec4dca6268b59024e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:44:07 +0200 Subject: [PATCH 12/85] Add purging of diffs. - Added policy setting server.outbound.max-diffs, default 5 like NSD. - Added output of new policy setting to cascade policy show. - Documented the new setting in the policy template. - Extended the persist-zone integration test to exercise diff purging. - Numbered some step names in the integration test to ease diagnostics. - Used dig +qid in some integration step tests to ease diagnostics. - Added a new Compacter that compacts persisted diffs in the background. - Added purging of in-memory diffs prior to storing a new signed diff. - Extracted persist_to_file() to persist_to_file_from_parts() for use by compaction. - Updated persist_to_file_from_parts() to do atomic writes like util::write_file(). - Added field restore_base_idx and logic to use it when applying new diffs after compaction. - Added logic to purge excess diffs on policy reload in case max-diffs was reduced. --- crates/api/src/lib.rs | 1 + crates/cli/src/commands/policy.rs | 3 + etc/policy.template.toml | 27 + .../persist-zone/policies/minimal-diffs.toml | 502 ++++++++++++++++++ .../tests/persist-zone/action.yml | 182 ++++++- src/center.rs | 5 +- src/main.rs | 3 +- src/manager.rs | 6 +- src/persistence/mod.rs | 64 ++- src/persistence/persist.rs | 166 +++--- src/persistence/restore.rs | 194 ++++--- src/persistence/zone.rs | 270 +++++++++- src/policy/file/v1.rs | 18 + src/policy/mod.rs | 5 + src/server/mod.rs | 8 + src/server/service.rs | 10 + src/units/http_server.rs | 12 +- src/zone/state/v1.rs | 10 +- 18 files changed, 1315 insertions(+), 171 deletions(-) create mode 100644 integration-tests/persist-zone/policies/minimal-diffs.toml diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 42b8e35bc..e107e8783 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -902,6 +902,7 @@ pub struct ServerPolicyInfo { pub struct OutboundPolicyInfo { pub provide_xfr_to: Vec, pub send_notify_to: Vec, + pub max_diffs: usize, } #[derive(Deserialize, Serialize, Debug, Clone)] diff --git a/crates/cli/src/commands/policy.rs b/crates/cli/src/commands/policy.rs index 2d9cc6868..e55adba1f 100644 --- a/crates/cli/src/commands/policy.rs +++ b/crates/cli/src/commands/policy.rs @@ -131,6 +131,8 @@ fn print_policy(p: &PolicyInfo) { let hsm_server_id = p.key_manager.hsm_server_id.as_ref().unwrap_or(&none); + let max_diffs = p.server.outbound.max_diffs; + fn print_review(r: &ReviewPolicyInfo) { println!(" review:"); match &r.mode { @@ -167,4 +169,5 @@ fn print_policy(p: &PolicyInfo) { print_nameserver_comms_policy(&p.server.outbound.provide_xfr_to); println!(" send NOTIFY to:"); print_nameserver_comms_policy(&p.server.outbound.send_notify_to); + println!(" max diffs: {max_diffs}") } diff --git a/etc/policy.template.toml b/etc/policy.template.toml index 1b1752fd6..f5ea48caf 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -478,3 +478,30 @@ mode = "off" # # If not specified, zone transfers will be provided to any nameserver. #provide-xfr-to = ["127.0.0.1", "127.0.0.1^my-tsig-key"] + +# The maximum number of sequences of differential information (diffs) that +# the server may store per zone in order to respond to RFC 1995 Incremental +# Zone Transfer (IXFR) requests. +# +# Diffs are persisted so that they can be restored on restart of the Cascade +# daemon. +# +# Excess in-memory diffs are discarded eagerly when the limit for +# a zone is reached, or when this policy setting is changd and the policy +# reloaded causing some in-memory diffs that were previously under the limit +# to exceed the new limit. +# +# Persisted diffs are discarded lazily to reduce the impact of persisted diff +# compaction. The number of persisted diffs may therefore temporarily exceed +# the limit specified here until purging of persisted diffs next occurs. Lazy +# purging of persisted diffs will be skipped for zones that are in maintenance +# mode. +# +# Note that while RFC 1955 section 5 suggests that diffs be purged based on +# total IXFR response size and/or if older than the SOA expire period, Cascade +# does not currently implement this behaviour. Cascade also currently has no +# support for RFC 1995 section 6 condensation of multiple versions. +# +# The default is to limits diffs to 5 per zone. If set to 0 storage of diffs +# will be disabled. +#max-diffs = 5 diff --git a/integration-tests/persist-zone/policies/minimal-diffs.toml b/integration-tests/persist-zone/policies/minimal-diffs.toml new file mode 100644 index 000000000..b087b0ed1 --- /dev/null +++ b/integration-tests/persist-zone/policies/minimal-diffs.toml @@ -0,0 +1,502 @@ +# Configuring a group of zones for Cascade +# ======================================== +# +# A policy is a collection of settings that apply to a group of zones known to +# Cascade. Policy controls how Cascade operates on those zones, e.g. how they +# are signed. This file is a template describing all possible settings and their +# defaults. You can copy this and customize it to your liking, or write a policy +# file from scratch using this as a reference. +# +# Policy files are managed by the user, and are stored at a configurable path +# (by default, '/etc/cascade/policies'). You can add, modify, and remove +# policy files, then update Cascade with 'cascade policy reload'. Note that: +# +# - Cascade maintains an internal copy of all policies, and will use this until +# 'cascade policy reload' is used. If reloading fails, Cascade will continue +# to use its existing internal copy. It won't reload policies if it restarts. +# +# - Policies cannot be removed if they are attached to zones; those zones need +# to be deleted or shifted to a different policy first. If you remove a used +# policy and reload policies in Cascade, it will fail and continue to use its +# internal copy of the policy. + +# The policy file version. +# +# This is the only required option. All other settings, and their defaults, are +# associated with this version number. More versions may be added in the future +# and Cascade may drop support for older versions over time. +# +# - 'v1': This format. +version = "v1" + + +# How zones are loaded. +[loader] + +# How loaded zones are reviewed. +# +# Review offers an opportunity to perform external checks on the zone contents +# loaded by Cascade. +[loader.review] + +# The mode for loader review. +# +# This can be one of the following values: +# +# - "off" will disable review +# - "manual" will enable manual review via the CLI. +# - "script" will enable automatic review via a hook. The `hook` field must be +# specified in this case. +# +# The default value is "off" +mode = "off" + +# A hook for reviewing a loaded zone. +# +# This command string will be executed in the user's shell when a new version of +# a zone is loaded. +# +# It will receive the following information via environment variables: +# +# - 'CASCADE_ZONE': The name of the zone, formatted without a trailing dot. +# - 'CASCADE_SERIAL': The serial number of the zone (decimal integer). +# - 'CASCADE_SERVER': The combined address and port where Cascade is serving +# the zone for review, formatted as ':'. +# - 'CASCADE_SERVER_IP': Just the address of the above server. +# - 'CASCADE_SERVER_PORT': Just the port of the above server. +# +# The command will be called from an unspecified directory, and it must be +# accessible to Cascade (i.e. after it has dropped privileges). Its exit code +# will determine whether the zone is approved or not. +#hook = "review-unsigned-zone.sh" + +# What to do when a zone is rejected by review. +# +# This field can only be specified if 'mode' is either "manual" or "script" and +# can have one of the following values: +# +# - "discard" will discard the rejected zone and go back to an idle state +# - "halt" will halt the pipeline until an operator either resets the pipeline +# or overrides the rejection. +# +# The default value is "discard". +#on-reject = "discard" + +# DNSSEC key management. +[key-manager] + +# How long keys are considered valid for. +# +# If a key has been used for longer than this time, it is considered expired, +# and (if enabled) it will automatically be rolled over to a new key. Even +# if automatic rollovers are not enabled, the key will be reported as expired. +# This is a soft condition -- DNSSEC does not have a concept of key expiry, +# and it will not break DNSSEC validation, but it is usually important to the +# security of the zone. +# +# Independent validity times are set for KSKs, ZSKs, and CSKs. An integer +# value will be interpreted as seconds, 'forever' means keys never expire, and +# a time string such as "365d" will be interpreted as 365 days. Supported +# suffixes include "s", "m", "h", "d" and "w". +ksk.validity = "365d" +zsk.validity = "30d" +csk.validity = "365d" + +# Whether to automatically start key rollovers. +# +# If this is enabled, Cascade will automatically start rolling over keys when +# they expire (as per 'validity'). When using this setting, 'validity' must not +# be set to 'forever'. +# +# The first step in a rollover will be to generate new keys to replace old ones. +# By disabling this setting, the user can manually control how new keys are +# generated, and when rollovers happen. +# +ksk.auto-start = true +zsk.auto-start = true +csk.auto-start = true +algorithm.auto-start = true + +# Whether to automatically check for record propagation. +# +# If this is enabled, Cascade will automatically contact public DNS servers to +# detect when new records (e.g. DNSKEY) are visible globally. It is necessary +# to wait until some records are visible globally to progress key rollovers. If +# this is disabled, the user will have to inform Cascade when these conditions +# are reached manually. +# +# For this setting to work, Cascade must have network access to the zone's +# public nameservers and the parent zone's public nameservers. +ksk.auto-report = true +zsk.auto-report = true +csk.auto-report = true +algorithm.auto-report = true + +# Whether to automatically wait for cache expiry. +# +# If this is enabled, Cascade will automatically progress through key rollover +# steps that need to wait for downstream users' DNS caches to expire. It will +# estimate the right amount of time to wait based on record TTLs. +ksk.auto-expire = true +zsk.auto-expire = true +csk.auto-expire = true +algorithm.auto-expire = true + +# Whether to automatically check for rollover completion. +# +# Like 'auto-report', if this setting is enabled, Cascade will automatically +# contact public DNS servers to detect when new records are visible globally. +# 'auto-done' specifically affects automatic checks for the last step of key +# rollovers, and is independent from 'auto-report'. +# +# For this setting to work, Cascade must have network access to the zone's +# public nameservers and the parent zone's public nameservers. +ksk.auto-done = true +zsk.auto-done = true +csk.auto-done = true +algorithm.auto-done = true + +# The hash algorithm used by the parent zones' DS records. +# +# Supported options: +# - 'SHA-256' +# - 'SHA-384' +ds-algorithm = "SHA-256" + +# Whether to automatically remove expired keys. +# +# If this is set, expired keys will be removed automatically (by deleting the +# files for on-disk keys or removing it from the HSM). +auto-remove = true + +# Delay after which expired keys will be removed when auto-remove is true. +# +# An integer value is interpreted as seconds. A string is interpreted as +# time string with a number followed by a unit (i.e. "s", "m", "h", "d", +# or "w"). +auto-remove-delay = "7d" + +# The set of nameservers to use when checking for RRSIG propagation during a +# key roll. +# +# Each nameserver must be specified as a string in the form: +# +# `"[:][^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# If no nameservers are specified, the nameserver specified by the SOA MNAME +# field will be checked. +# +# publication-nameservers = [] + +# The management of DNS records by the key manager. +# +# The key manager generates and signs several records (DNSKEY and CDS). This +# section controls its behaviour towards them. +[key-manager.records] + +# The TTL for the generated records. +# +# TODO: Perhaps Cascade can automatically set this to the SOA minimum. The +# key manager would have to be sent that information somehow. +ttl = "1h" + +# The offset for generated signature inceptions. +# +# Record signatures have a fixed inception time, from when they are considered +# valid. An imprecise computer clock could cause signatures to be considered +# invalid, because their inception point appears to be some time in the future. +# To prevent such cases, this setting allows the inception time to be offset +# into the past. +# +# Independent offsets can be set for each type of record. An integer value is +# interpreted as seconds; A string is interpreted as time string with a number +# followed by a unit (i.e. "s", "m", "h", "d", or "w"). Inception times will be +# calculated as 'now - offset' at the time of signing. +dnskey.signature-inception-offset = "1d" +cds.signature-inception-offset = "1d" + +# The lifetime of generated signatures. +# +# Record signatures have a fixed lifetime, after which they are considered +# invalid. To keep the zone valid, the signatures should be regenerated before +# they expire; see 'signature-remain-time' to control regeneration time. +# +# Independent lifetimes can be set for each type of record. An integer value +# is interpreted as seconds. A string is interpreted as time string with a +# number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +dnskey.signature-lifetime = "2w" +cds.signature-lifetime = "2w" + +# The amount of time remaining before expiry when signatures will be +# regenerated. +# +# In order to prevent a zone's signatures from appearing invalid, they +# have to be regenerated before they expire. That hard limit is set by +# 'signature-lifetime' above. This setting controls how long before expiry +# signatures will be regenerated; it must be less than the 'signature-lifetime' +# setting. +# +# Independent waiting times can be set for each type of record. An integer +# value is interpreted as seconds. A string is interpreted as time string with +# a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +dnskey.signature-remain-time = "1w" +cds.signature-remain-time = "1w" + +# How keys are generated. +[key-manager.generation] + +# The HSM server to use. +# +# If this is set, the named HSM server (which must be configured via 'cascade +# hsm add') will be used for generating new DNSSEC keys. +# +# Information about using a HSM with Cascade is at +# https://cascade.docs.nlnetlabs.nl/en/latest/hsms.html +#hsm-server-id = "softhsm" + +# Whether to generate CSKs, instead of separate ZSKs and KSKs. +# +# A CSK (Combined Signing Key) takes the role of both ZSK and KSK for a zone, +# unlike the standard practice of using separate keys for ZSK and KSK. This +# setting does not affect how DNSSEC validation is performed, only procedures +# for key rollovers. +# +# If this is enabled, Cascade will generate CSKs for the associated zones. +# +# TODO: Does this affect anything more than generation? Does it affect how +# existing keys are rolled over? +use-csk = false + +# The cryptographic algorithm (and parameters) for generated keys. +# +# DNSSEC supports various cryptographic algorithms for signatures; one must be +# selected, and for some algorithms, additional parameters are also necessary. +# The same algorithm and parameters will be applied to the ZSK and KSK. +# +# - 'RSASHA256[:]', algorithm 8, with a public key size of +# '' (default 2048) bits. +# - 'RSASHA512[:]', algorithm 10, with a public key size of +# '' (default 2048) bits. +# - 'ECDSAP256SHA256', algorithm 13. +# - 'ECDSAP384SHA384', algorithm 14. +# - 'ED25519', algorithm 15. +# - 'ED448', algorithm 16. +# +# There are additional algorithms, but many are now considered insecure, and it +# is recommended or mandated to avoid them. In addition, RSA keys smaller than +# 2048 bits are not recommended. +# +# NOTE: At the moment, only RSASHA256 and ECDSAP256SHA256 work with HSMs. Other +# algorithms cannot be used with HSMs, and will cause generation failures. +algorithm = "ECDSAP256SHA256" +#algorithm = "RSASHA512:4096" + + +# How zones are signed. +# +# Note that certain records (e.g. DNSKEY and CDS records at the apex of the +# zone) are signed by the key manager, rather than the zone signer; see the +# `[key-manager.records]` section for configuring the signing of those records. +[signer] + +# How SOA serial numbers are generated for signed zones. +# +# Supported options: +# - 'keep': use the same serial number as the unsigned zone. +# - 'counter': increment the serial number every time. +# - 'unix-time': use the current Unix time, in seconds. +# - 'date-counter': format the number as '
' in decimal. +# '' is a simple counter to allow up to 100 versions per day. +serial-policy = "date-counter" + +# The offset for generated signature inceptions. +# +# Record signatures have a fixed inception time, from when they are considered +# valid. An imprecise computer clock could cause signatures to be considered +# invalid, because their inception point appears to be some time in the future. +# To prevent such cases, this setting allows the inception time to be offset +# into the past. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +# Inception times will be calculated as 'now - offset' at the time of signing. +signature-inception-offset = "1d" + +# The lifetime of generated signatures. +# +# Record signatures have a fixed lifetime, after which they are considered +# invalid. To keep the zone valid, the signatures should be regenerated before +# they expire; see 'signature-remain-time' to control regeneration time. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +signature-lifetime = "2w" + +# The amount of time remaining before expiry when signatures will be +# regenerated. +# +# In order to prevent a zone's signatures from appearing invalid, they +# have to be regenerated before they expire. That hard limit is set by +# 'signature-lifetime' above. This setting controls how long before expiry +# signatures will be regenerated; it must be less than the 'signature-lifetime' +# setting. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +signature-remain-time = "1w" + +# Refresh period to prevent signatures from expiring. Each period, Cascade +# will refresh some number of signatures. This way the work to refresh all +# signatures is spread out over time. The effective lifetime of a signature +# is signature-lifetime - signature-remain-time. Each period roughly a +# fraction of all signatures that is equal to signature-refresh-interval +# divided by the effective signature lifetime will be refreshed. +# +# signature-refresh-interval should be a lot smaller than +# signature-remain-time to make sure that signatures are refreshed in time. +# If this is not the case then in extreme cases, signatures could expire. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +signature-refresh-interval = "12h" + +# To avoid resigning the entire zone at once during a ZSK or CSK roll, +# generating signatures with the new key can be spread out over time. +# New signatures are generated at intervals controlled by +# signature-refresh-interval. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +key-roll-time = "24h" + +# How denial-of-existence records are generated. +[signer.denial] + +# The type of denial-of-existence records to generate. +# +# Supported options: +# - 'nsec': Use NSEC records (RFC 4034). +# - 'nsec3': Use NSEC3 records (RFC 5155). +type = "nsec" + +# Whether to skip NSEC3 records for unsigned delegations. +# +# This enables the NSEC3 Opt-Out flag, and skips delegations to unsigned zones +# when generating NSEC3 records. This affects the security of the zone, so be +# careful if you wish to enable it. +#opt-out = false + +# How signed zones are reviewed. +[signer.review] + +# The mode for loader review. +# +# This can be one of the following values: +# +# - "off" will disable review +# - "manual" will enable manual review via the CLI. +# - "script" will enable automatic review via a hook. The `hook` field must be +# specified in this case. +# +# The default value is "off". +mode = "off" + +# A hook for reviewing a signed zone. +# +# This command string will be executed in the user's shell when a new version of +# a zone is signed. +# +# It will receive the following information via environment variables: +# +# - 'CASCADE_ZONE': The name of the zone, formatted without a trailing dot. +# - 'CASCADE_SERIAL': The serial number of the signed zone (decimal integer). +# - 'CASCADE_SERVER': The combined address and port where Cascade is serving +# the zone for review, formatted ':'. +# - 'CASCADE_SERVER_IP': Just the address of the above server. +# - 'CASCADE_SERVER_PORT': Just the port of the above server. +# +# The command will be called from an unspecified directory, and it must be +# accessible to Cascade (i.e. after it has dropped privileges). Its exit code +# will determine whether the zone is approved or not. +#hook = "review-signed-zone.sh" + +# What to do when a zone is rejected by review. +# +# This field can only be specified if 'mode' is either "manual" or "script" and +# can have one of the following values: +# +# - "discard" will discard the rejected zone and go back to an idle state +# - "halt" will halt the pipeline until an operator either resets the pipeline +# or overrides the rejection. +# +# The default value is "discard". +#on-reject = "discard" + +# How published zones are served. +[server.outbound] + +# The set of nameservers to which NOTIFY messages should be sent. +# +# Each nameserver must be specified as a string in the form: +# +# `"[:][^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# PORT defaults to 53. +# +# If not specified, no NOTIFY messages will be sent. +#send-notify-to = ["127.0.0.1", "127.0.0.1:53", "127.0.0.1^my-tsig-key"] + +# The set of nameservers to provide zone transfers to. +# +# Each nameserver must be specified as a string in the form: +# +# `"[^]"` +# +# Nameservers may alternatively be specified as an inline table, in which case +# PORT is mandatory but not checked other than its value must lie between 0 +# and 65535: +# +# `provide-xfr-to = [ +# { addr = ":", tsig-key-name = "" }, +# ... +# ]` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# Zone transfers will be provided to any nameserver that initiates a transfer +# request from one of the specified IP addresses. +# +# If not specified, zone transfers will be provided to any nameserver. +#provide-xfr-to = ["127.0.0.1", "127.0.0.1^my-tsig-key"] + +# The maximum number of sequences of differential information (diffs) that +# the server may store per zone in order to respond to RFC 1995 Incremental +# Zone Transfer (IXFR) requests. Defaults to 5 diffs per zone. +# +# Diffs are persisted so that they can be restored on restart of the Cascade +# daemon. +# +# Excess in-memory diffs are discarded eagerly when the limit for +# a zone is reached, or when this policy setting is changd and the policy +# reloaded causing some in-memory diffs that were previously under the limit +# to exceed the new limit. +# +# Persisted diffs are discarded lazily to reduce the impact of persisted diff +# compaction. The number of persisted diffs may therefore temporarily exceed +# the limit specified here until purging of persisted diffs next occurs. +# +# Note that while RFC 1955 section 5 suggests that diffs be purged based on +# total IXFR response size and/or if older than the SOA expire period, Cascade +# does not currently implement this behaviour. Cascade also currently has no +# support for RFC 1995 section 6 condensation of multiple versions. +max-diffs = 0 diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 36cc80614..8b5ee153e 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -480,11 +480,11 @@ runs: # the same IXFRs as before. # ------------------------------------------------------------------------ - - name: Stop Cascade again + - name: 6 Stop Cascade again run: | pkill cascaded - - name: Wait for Cascade to exit again + - name: 6 Wait for Cascade to exit again run: | timeout=10 # seconds start=$(date +%s) @@ -497,12 +497,12 @@ runs: sleep 1 done - - name: Start Cascade a 4th time + - name: 6 Start Cascade a 4th time run: | CASCADE_DIR=${{ github.workspace }}/cascade-dir cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" - - name: Wait for Cascade to become healthy a 4th time + - name: 6 Wait for Cascade to become healthy a 4th time run: | timeout=10 # seconds start=$(date +%s) @@ -514,7 +514,7 @@ runs: sleep 1 done - - name: Check that Cascade still knows the zone + - name: 6 Check that Cascade still knows the zone run: | timeout=10 # seconds start=$(date +%s) @@ -527,7 +527,7 @@ runs: sleep 1 done - - name: Wait for Cascade to serve the re-signed zone with a newer serial number + - name: 6 Wait for Cascade to serve the re-signed zone with a newer serial number env: EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} run: | @@ -544,12 +544,12 @@ runs: sleep 1 done - - name: Check that Cascade did NOT re-sign the zone + - name: 6 Check that Cascade did NOT re-sign the zone run: | dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed5.log diff -u example.test.signed4.log example.test.signed5.log - - name: "Verify again that IXFR for 01 -> 03 is available" + - name: "6 Verify again that IXFR for 01 -> 03 is available" env: EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} run: | @@ -574,7 +574,7 @@ runs: sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in diff -u diff.in "${EXPECTED_OUT_FILE}" - - name: "Verify again that IXFR for 02 -> 03 is available" + - name: "6 Verify again that IXFR for 02 -> 03 is available" env: EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} run: | @@ -592,21 +592,163 @@ runs: example.test. 5 IN SOA ns1.example.test. mail.example.test. ${SER_3} 60 60 3600 5 EOF - dig +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" + dig +qid=6 +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" + sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in + diff -u diff.in "${EXPECTED_OUT_FILE}" + + # ------------------------------------------------------------------------ + # 7. Reduce the number of diffs that Cascade should keep and check that + # diffs get removed from memory and from disk. + # ------------------------------------------------------------------------ + + - name: 7 Reduce policy setting max-diffs to 1. + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + TEST_DIR="${PWD}/integration-tests/persist-zone" + cp "${TEST_DIR}/policies/minimal-diffs.toml" "${POLICY_DIR}/default.toml" + cascade policy reload + + - name: "7 Verify that IXFR for 02 -> 03 is available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 2 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + EXPECTED_OUT_FILE="expected.out" + + let SER_2=$(( EXPECTED_SERIAL + 2)) + let SER_3=$(( EXPECTED_SERIAL + 3)) + + cat >"${EXPECTED_OUT_FILE}" < "${OUT_FILE}" + sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in + diff -u diff.in "${EXPECTED_OUT_FILE}" + + - name: "7 Verify that IXFR for 01 -> 03 is NOT available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # Attempting to get the diff from 00 -> 01 should return an AXFR + # (denoted by a single leading SOA and a single trailing SOA) + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 1 )) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 3)) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + dig +qid=72 +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" + + NUM_SOA=$(grep -E 'example.test.\s+5\s+IN\s+SOA\s+ns1.example.test.\smail.example.test.\s'${EXPECTED_SERIAL}'\s60\s60\s3600\s5' "${OUT_FILE}" | wc -l) + if [[ ${NUM_SOA} -ne 2 ]]; then + echo "::error:: Expected 2 SOA RRs but got ${NUM_SOA} RRs." + exit 1 + fi + + # ------------------------------------------------------------------------ + # 8. Stop and restart Cascade and verify that after restoring diffs from + # disk it has the same behaviour as before it was shutdown. + # ------------------------------------------------------------------------ + + - name: 8 Stop Cascade again + run: | + pkill cascaded + + - name: 8 Wait for Cascade to exit again + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + - name: 8 Start Cascade again + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: 8 Wait for Cascade to become healthy again + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: 8 Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q example.test; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: "8 Verify that IXFR for 02 -> 03 is available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 2 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + EXPECTED_OUT_FILE="expected.out" + + let SER_2=$(( EXPECTED_SERIAL + 2)) + let SER_3=$(( EXPECTED_SERIAL + 3)) + + cat >"${EXPECTED_OUT_FILE}" < "${OUT_FILE}" sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in diff -u diff.in "${EXPECTED_OUT_FILE}" + - name: "8 Verify that IXFR for 01 -> 03 is NOT available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # Attempting to get the diff from 00 -> 01 should return an AXFR + # (denoted by a single leading SOA and a single trailing SOA) + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 1 )) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 3)) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + dig +qid=82 +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" + + NUM_SOA=$(grep -E 'example.test.\s+5\s+IN\s+SOA\s+ns1.example.test.\smail.example.test.\s'${EXPECTED_SERIAL}'\s60\s60\s3600\s5' "${OUT_FILE}" | wc -l) + if [[ ${NUM_SOA} -ne 2 ]]; then + echo "::error:: Expected 2 SOA RRs but got ${NUM_SOA} RRs." + exit 1 + fi + # ------------------------------------------------------------------------ - # 7. Delete all persisted signed zone data files to verify that Cascade + # 9. Delete all persisted signed zone data files to verify that Cascade # can handle partial restoration for a zone that was previously signed # at least once. # ------------------------------------------------------------------------ - - name: Stop Cascade again + - name: 9 Stop Cascade again run: | pkill cascaded - - name: Wait for Cascade to exit again + - name: 9 Wait for Cascade to exit again run: | timeout=10 # seconds start=$(date +%s) @@ -619,17 +761,17 @@ runs: sleep 1 done - - name: Delete the persisted signed zone data that Cascade wrote to disk + - name: 9 Delete the persisted signed zone data that Cascade wrote to disk run: | CASCADE_DIR=${{ github.workspace }}/cascade-dir rm "${CASCADE_DIR}"/zone-state/example.test.signed.* - - name: Start Cascade again + - name: 9 Start Cascade again run: | CASCADE_DIR=${{ github.workspace }}/cascade-dir cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" - - name: Wait for Cascade to become healthy again + - name: 9 Wait for Cascade to become healthy again run: | timeout=10 # seconds start=$(date +%s) @@ -641,7 +783,7 @@ runs: sleep 1 done - - name: Check that Cascade still knows the zone + - name: 9 Check that Cascade still knows the zone run: | timeout=10 # seconds start=$(date +%s) @@ -654,7 +796,7 @@ runs: sleep 1 done - - name: Wait for Cascade to serve the re-signed zone with a newer serial number + - name: 9 Wait for Cascade to serve the re-signed zone with a newer serial number env: EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} run: | @@ -671,11 +813,11 @@ runs: sleep 1 done - - name: Save the signed zone for later comparison + - name: 9 Save the signed zone for later comparison run: | dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed7.log - - name: Check that Cascade DID resign the zone, not just bump the serial number + - name: 9 Check that Cascade DID resign the zone, not just bump the serial number run: | if diff -u example.test.signed2.log example.test.signed7.log > unexpected-diff.log; then echo "::error:: Cascade should have re-signed the zone" diff --git a/src/center.rs b/src/center.rs index 213dbf5b2..11c15c240 100644 --- a/src/center.rs +++ b/src/center.rs @@ -17,7 +17,7 @@ use crate::api::KeyImport; use crate::config::RuntimeConfig; use crate::loader::Loader; use crate::loader::zone::LoaderZoneHandle; -use crate::persistence::{Persister, Restorer}; +use crate::persistence::{Compacter, Persister, Restorer}; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; use crate::state::PolicySpec; use crate::tsig::ImportError; @@ -62,6 +62,9 @@ pub struct Center { /// The zone data restorer. pub restorer: Restorer, + /// The zone data compacter. + pub compacter: Compacter, + /// The review server for loaded instances of zones. pub loaded_review_server: LoadedReviewServer, diff --git a/src/main.rs b/src/main.rs index ba7c0af8a..bda80d909 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use cascaded::{ daemon::{PreBindError, SocketProvider, daemonize}, loader::Loader, manager::Manager, - persistence::{Persister, Restorer}, + persistence::{Compacter, Persister, Restorer}, policy, server::{LoadedReviewServer, PublicationServer, SignedReviewServer}, units::{key_manager::KeyManager, zone_signer::ZoneSigner}, @@ -256,6 +256,7 @@ fn main() -> ExitCode { key_manager: KeyManager::new(), persister: Persister::new(), restorer: Restorer::new(), + compacter: Compacter::new(), loaded_review_server: LoadedReviewServer::new(), signed_review_server: SignedReviewServer::new(), publication_server: PublicationServer::new(), diff --git a/src/manager.rs b/src/manager.rs index 1f13be3c4..c22d624bd 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -6,7 +6,7 @@ use crate::center::Center; use crate::daemon::SocketProvider; use crate::loader::Loader; use crate::metrics::MetricsCollection; -use crate::persistence::Restorer; +use crate::persistence::{Compacter, Restorer}; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; use crate::units::http_server::HTTP_UNIT_NAME; use crate::units::http_server::HttpServer; @@ -52,6 +52,10 @@ impl Manager { debug!("Starting the zone data restorer"); handles.push(Restorer::run(center.clone())); + // Spawn the zone data compacter. + debug!("Starting the zone data compacter"); + handles.push(Compacter::run(center.clone())); + // Spawn the zone loader. debug!("Starting the zone loader"); handles.push(Loader::run(center.clone())); diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index edb6fd507..bb58ca203 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -92,12 +92,12 @@ //! signed review hook be able to query the loaded review server for the //! loaded diff? -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use crate::{center::Center, util::AbortOnDrop, zone::ZoneByName}; mod persist; -use persist::{persist_loaded, persist_signed}; +use persist::{persist_loaded, persist_signed, persist_to_file_from_parts}; mod restore; use restore::{restore_loaded, restore_signed}; @@ -128,6 +128,66 @@ impl Default for Persister { } } +//----------- Compacter -------------------------------------------------------- + +/// The zone data compacter. +/// +/// Compacts zone data on disk periodically, keeping the number of diffs within +/// the configured maximum per zone. +#[derive(Debug)] +pub struct Compacter {} + +impl Compacter { + /// Construct a new [`Compacter`]. + pub fn new() -> Self { + Self {} + } + + /// Drive this [`Compacter`]. + pub fn run(center: Arc
) -> AbortOnDrop { + AbortOnDrop::from(tokio::spawn(async move { + // TODO: Make compaction interval configurable? + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + + // Obtain a list of all zones. + let zones = { + let state = center.state.lock().unwrap(); + // TODO: To avoid invoking compaction unnecessarily we + // could store a flag with the zone to say that the diffs + // have been changed since last compaction and reset it on + // compaction, and filter unchanged zones out here. + state + .zones + .iter() + .filter(|ZoneByName(z)| !z.state.read().maintenance_mode) + .map(|ZoneByName(z)| z.clone()) + .collect::>() + }; + + // Compact each zone one at a time. + // TODO: Add a configuration setting to control the maximum + // number of zones to compact concurrently? + for zone in zones { + // Spawn the compaction task on a Tokio blocking task + // thread so as not to block any other async tasks on the + // same executor thread with a long running compaction. + let mut handle = zone.write_handle(¢er); + handle.persistence().start_compaction(); + } + } + })) + } +} + +impl Default for Compacter { + fn default() -> Self { + Self::new() + } +} + //----------- Restorer --------------------------------------------------------- /// The zone data restorer. diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index d0a0f7c3c..72103adfb 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -1,14 +1,14 @@ //! Persisting zone data. use std::{ - fs::File, - io::{BufWriter, ErrorKind, Write}, + io::{BufWriter, Write}, path::Path, sync::Arc, }; use cascade_zonedata::{ - DiffData, LoadedZonePersisted, LoadedZonePersister, SignedZonePersisted, SignedZonePersister, + DiffData, LoadedZonePersisted, LoadedZonePersister, RegularRecord, SignedZonePersisted, + SignedZonePersister, SoaRecord, }; use domain::new::base::wire::{BuildBytes, TruncationError}; @@ -37,8 +37,6 @@ pub fn persist_loaded( // ZoneState to the persistence crate. Accumulate a set of diffs per // unsigned and signed zone, each stored at a path one suffixed by an // index which rises by one when persisted. - // TODO: Don't keep an unlimited number of diffs. - // TODO: Compact diffs when idle? let destination = { let mut handle = zone.write_handle(center); let loaded_serial = loaded_diff.removed_soa.as_ref().map(|s| s.rdata.serial); @@ -106,13 +104,6 @@ pub fn persist_signed( let loaded_serial = loaded_diff.and_then(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)); - // Determine the path to write to and update the record of written - // paths here as we don't want to give responsibility for working with - // ZoneState to the persistence crate. Accumulate a set of diffs per - // unsigned and signed zone, each stored at a path one suffixed by an - // index which rises by one when persisted. - // TODO: Don't keep an unlimited number of diffs. - // TODO: Compact diffs when idle? let destination = { let mut handle = zone.write_handle(center); let signed_serial = signed_diff.removed_soa.as_ref().map(|s| s.rdata.serial); @@ -173,6 +164,20 @@ pub fn persist_signed( let mut handle = zone.write_handle(center); + // Purge in-memory diffs if needed before adding a new one. + if let Some(max_diffs) = handle + .state + .policy + .as_ref() + .map(|p| p.server.outbound.max_diffs) + { + trace!( + "Discarding in-memory diffs to max {max_diffs} for zone '{}'", + zone.name + ); + handle.state.storage.diffs.discard_excess_diffs(max_diffs); + } + // If we have a new signed diff to store because records in the // loaded part of the zone changed, e.g. due to changes in the // zone content or receipt of a changed DNSKEY set from the key @@ -204,39 +209,50 @@ pub fn persist_signed( //------------ persist_to_file() ---------------------------------------------- -fn persist_to_file(destination: &Path, diff: Arc) { - // Write the diff in AXFR / IXFR wire format to disk. - let f = match File::create_new(destination) { - Ok(f) => f, - Err(err) if err.kind() == ErrorKind::AlreadyExists => { - // This is not expected. When persisting the zone data to a file - // we save Cascade zone state "now" so that the persisted paths in - // use are known on next restart, so we should know this path was - // in use and be attempting to write to a different non-existing - // path. If for some reason zone state was not persisted after the - // persisted zone data file was created, e.g. a power outage in - // combination with a change to persistence logic compared to how - // it is at the time of writing so that zone state was not ensured - // to be persisted before proceeding, that could cause this. - warn!( - "Overwriting existing persisted zone data file at '{}'.", - destination.display() - ); - File::create(destination).unwrap_or_else(|err| { - panic!( - "Failed to persist zone data to '{}': {err}", - destination.display() - ); - }) - } - Err(err) => { +pub fn persist_to_file(destination: &Path, diff: Arc) { + persist_to_file_from_parts( + destination, + diff.removed_soa.clone(), + diff.added_soa.clone().unwrap(), + diff.removed_records.iter().cloned(), + diff.added_records.iter().cloned(), + ); +} + +// TODO: It would be nice to take the records by reference. +pub fn persist_to_file_from_parts< + I: Iterator, + J: Iterator, +>( + destination: &Path, + removed_soa: Option, + added_soa: SoaRecord, + removed_records: I, + added_records: J, +) { + // Atomic writing based on crate::util::write_file(). + let dir = destination + .parent() + .expect("'destination' must be a file, so it must have a parent"); + std::fs::create_dir_all(dir).unwrap_or_else(|err| { + panic!( + "Failed to persist zone data to '{}': {err}", + destination.display() + ); + }); + + // Obtain a temporary file in the same directory. + let tmp_file = tempfile::Builder::new() + .tempfile_in(dir) + .unwrap_or_else(|err| { panic!( "Failed to persist zone data to '{}': {err}", destination.display() ); - } - }; - let mut f = BufWriter::new(f); + }); + + // Write the diff in AXFR / IXFR wire format to disk. + let mut f = BufWriter::new(tmp_file); let mut buf = vec![0u8; 1024]; @@ -272,8 +288,6 @@ fn persist_to_file(destination: &Path, diff: Arc) { writer.write_all(&buf[0..num_bytes_to_write]).unwrap(); } - let added_soa = diff.added_soa.clone().unwrap(); - // IXFR format has the form: // - New SOA // - Old SOA @@ -289,43 +303,73 @@ fn persist_to_file(destination: &Path, diff: Arc) { // // Write AXFR if no records were deleted by the diff, else write IXFR. + let mut n_rrs_removed = 0; + let mut n_rr_added = 0; + + trace!( + "persist_to_file: Writing initial SOA: {}", + added_soa.rdata.serial + ); write_rr(&mut buf, &added_soa, &mut f); // Start deleted records block by writing the old SOA, if any. - if let Some(removed_soa) = &diff.removed_soa { + if let Some(removed_soa) = &removed_soa { + trace!( + "persist_to_file: Writing IXFR diff sequence start: removed SOA: {}", + removed_soa.rdata.serial + ); write_rr(&mut buf, removed_soa, &mut f); + n_rrs_removed += 1; // Write the deleted records. - for r in &diff.removed_records { - write_rr(&mut buf, r, &mut f); + for r in removed_records { + trace!( + "persist_to_file: Writing IXFR diff sequence RR: {:?}", + r.rtype + ); + write_rr(&mut buf, &r, &mut f); + n_rrs_removed += 1; } // Start added records block by writing the new SOA + trace!( + "persist_to_file: Writing IXFR diff sequence continuation: added SOA: {}", + added_soa.rdata.serial + ); write_rr(&mut buf, &added_soa, &mut f); } // Write the added records. - for r in &diff.added_records { - write_rr(&mut buf, r, &mut f); + for r in added_records { + trace!( + "persist_to_file: Writing IXFR diff sequence RR: {:?}", + r.rtype + ); + write_rr(&mut buf, &r, &mut f); + n_rr_added += 1; } // Finish the AXFR/IXFR by writing the new SOA again + trace!( + "persist_to_file: Writing final SOA: {}", + added_soa.rdata.serial + ); write_rr(&mut buf, &added_soa, &mut f); + n_rr_added += 1; + + // Replace the target path with the temporary file. + let tmp_file = f.into_inner().unwrap(); + let _ = tmp_file.persist(destination).unwrap_or_else(|err| { + panic!( + "Failed to persist zone data to '{}': {err}", + destination.display() + ); + }); trace!( - "Persisted zone to file '{}': SOA {:?} -> {:?}: {} records removed, {} records added", + "Persisted zone to file '{}': SOA {:?} -> {:?}: {n_rrs_removed} records removed, {n_rr_added} records added", destination.display(), - diff.removed_soa.as_ref().map(|v| v.rdata.serial), - diff.added_soa.as_ref().map(|v| v.rdata.serial), - if !diff.removed_records.is_empty() { - diff.removed_records.len() + 1 - } else { - 0 - }, - if !diff.added_records.is_empty() { - diff.added_records.len() + 1 - } else { - 0 - }, + removed_soa.as_ref().map(|v| v.rdata.serial), + added_soa.rdata.serial, ); } diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index a48937c8a..4bfd240d5 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -87,42 +87,65 @@ pub fn restore_loaded( let mut all_serials = vec![]; let mut diffs_to_store: Vec> = vec![]; + let restore_base_idx = state.persistence.loaded_diffs.restore_base_idx(); - for diff_info in diff_infos { - trace!( - "Loading and applying loaded diff form '{}'", - diff_info.path().display() - ); - let mut loaded_patcher = restorer - .patch() - .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - - let (start_serial, end_serial) = load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { - apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); - }) - .map_err(|err| { - io::Error::other(format!( - "Loading diff '{}' failed: {err}", + for (idx, diff_info) in diff_infos.enumerate() { + let (start_serial, end_serial) = if idx < restore_base_idx { + trace!( + "Building standalone IXFR diff #{idx} from '{}'", diff_info.path().display() - )) - })?; - - loaded_patcher.next_patchset().map_err(|err| { - io::Error::other(format!("Internal error: Next patchset failed: {err}")) - })?; - - loaded_patcher - .apply() - .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; - - if let Some(diff) = restorer.take_diff() { + ); + let mut diff = Box::new(DiffData::new()); + let (start_serial, end_serial) = + load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { + apply_ixfr_event_to_diff_data(&mut diff, event); + }) + .map_err(|err| { + io::Error::other(format!( + "Loading diff '{}' failed: {err}", + diff_info.path().display() + )) + })?; diffs_to_store.push(diff.into()); + (start_serial, end_serial) + } else { trace!( - "Extracted IXFR loaded diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", - soa.rdata.serial, + "Loading and applying loaded diff #{idx} from '{}'", diff_info.path().display() ); - } + let mut loaded_patcher = restorer + .patch() + .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; + + let (start_serial, end_serial) = + load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { + apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!( + "Loading diff '{}' failed: {err}", + diff_info.path().display() + )) + })?; + + loaded_patcher.next_patchset().map_err(|err| { + io::Error::other(format!("Internal error: Next patchset failed: {err}")) + })?; + + loaded_patcher + .apply() + .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; + + if let Some(diff) = restorer.take_diff() { + diffs_to_store.push(diff.into()); + trace!( + "Extracted IXFR loaded diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, + diff_info.path().display() + ); + } + (start_serial, end_serial) + }; let start_serial: u32 = start_serial.into(); let end_serial: u32 = end_serial.into(); @@ -204,49 +227,74 @@ pub fn restore_signed( let mut all_serials = vec![]; let mut diffs_to_store: Vec<(Option, Arc)> = vec![]; + let restore_base_idx = state.persistence.signed_diffs.restore_base_idx(); // Load each diff and apply it to the zone, retrieving a single DiffData // per signed diff. Store each signed DiffData alongside the corresponding // loaded DiffData that was restored earlier in restore_loaded(). These // DiffData's will be used to respond to IXFR requests, while at the same // time also building up the entire signed zone that should be served for - // AXFR requests. - for diff_info in diff_infos { - trace!( - "Loading and applying signed diff from '{}' for loaded serial {:?}", - diff_info.path().display(), - diff_info.loaded_serial() - ); - let mut signed_patcher = restorer - .patch() - .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - - let (start_serial, end_serial) = load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { - apply_ixfr_event_to_signed_data(&mut signed_patcher, event); - }) - .map_err(|err| { - io::Error::other(format!( - "Loading diff '{}' failed: {err}", + // AXFR requests. Skip diffs that were included in the snapshot during the + // last compaction event but still need to be loaded into memory to serve + // in IXFR responses. + for (idx, diff_info) in diff_infos.enumerate() { + let (start_serial, end_serial) = if idx < restore_base_idx { + trace!( + "Building standalone IXFR diff #{idx} from '{}'", diff_info.path().display() - )) - })?; - - signed_patcher.next_patchset().map_err(|err| { - io::Error::other(format!("Internal error: Next patchset failed: {err}")) - })?; - - signed_patcher - .apply() - .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; - - if let Some(diff) = restorer.take_diff() { + ); + let mut diff = Box::new(DiffData::new()); + let (start_serial, end_serial) = + load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { + apply_ixfr_event_to_diff_data(&mut diff, event); + }) + .map_err(|err| { + io::Error::other(format!( + "Loading diff '{}' failed: {err}", + diff_info.path().display() + )) + })?; diffs_to_store.push((diff_info.loaded_serial(), diff.into())); + (start_serial, end_serial) + } else { trace!( - "Extracted IXFR signed diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", - soa.rdata.serial, - diff_info.path().display() + "Loading and applying signed diff #{idx} from '{}' for loaded serial {:?}", + diff_info.path().display(), + diff_info.loaded_serial() ); - } + let mut signed_patcher = restorer + .patch() + .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; + + let (start_serial, end_serial) = + load_ixfr_wire_dump(diff_info.path(), &mut buf, |event| { + apply_ixfr_event_to_signed_data(&mut signed_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!( + "Loading diff '{}' failed: {err}", + diff_info.path().display() + )) + })?; + + signed_patcher.next_patchset().map_err(|err| { + io::Error::other(format!("Internal error: Next patchset failed: {err}")) + })?; + + signed_patcher + .apply() + .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; + + if let Some(diff) = restorer.take_diff() { + diffs_to_store.push((diff_info.loaded_serial(), diff.into())); + trace!( + "Extracted IXFR signed diff for SOA serial {} from file '{}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, + diff_info.path().display() + ); + } + (start_serial, end_serial) + }; let start_serial: u32 = start_serial.into(); let end_serial: u32 = end_serial.into(); @@ -260,12 +308,22 @@ pub fn restore_signed( num_diffs_to_restore, zone.name ); + let max_diffs = zone + .read() + .policy + .clone() + .map(|p| p.server.outbound.max_diffs); + let mut state = zone.write(center); for (loaded_serial, diff) in diffs_to_store { // Store the signed diff to be used as part of serving an IXFR. state.storage.diffs.store_signed_diff(loaded_serial, diff); } + if let Some(max_diffs) = max_diffs { + state.storage.diffs.discard_excess_diffs(max_diffs); + } + info!( "Restored signed zone snapshot and {num_diffs_to_restore} diffs for zone '{}'", zone.name @@ -500,3 +558,13 @@ fn apply_ixfr_event_to_signed_data(patcher: &mut SignedZonePatcher<'_>, event: I IxfrEvent::EndOfUpdate => patcher.next_patchset().unwrap(), } } + +fn apply_ixfr_event_to_diff_data(diff: &mut Box, event: IxfrEvent) { + match event { + IxfrEvent::Remove(r) if r.rtype == RType::SOA => diff.removed_soa = Some(r.into()), + IxfrEvent::Remove(r) => diff.removed_records.push(r), + IxfrEvent::Add(r) if r.rtype == RType::SOA => diff.added_soa = Some(r.into()), + IxfrEvent::Add(r) => diff.added_records.push(r), + IxfrEvent::EndOfUpdate => {} + } +} diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index c4e906c37..829df300c 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -4,6 +4,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, path::PathBuf, sync::Arc, + time::Instant, }; use cascade_zonedata::{ @@ -42,6 +43,26 @@ impl ZonePersistenceHandle<'_> { } } + /// Compact persisted data for the zone. + #[tracing::instrument( + level = "info", + skip_all, + fields(zone = %self.zone.name), + )] + pub fn start_compaction(&mut self) { + let zone = self.zone.clone(); + let center = self.center.clone(); + let span = trace_span!("compact"); + self.state + .persistence + .ongoing + .spawn_blocking(span, move || { + PersistenceState::compact(¢er, &zone); + let mut handle = zone.write_handle(¢er); + handle.state.persistence.ongoing.finish(); + }); + } + /// Begin restoring data for the zone. /// /// A background task will be spawned to restore the zone's data (for both @@ -280,14 +301,179 @@ pub struct PersistenceState { /// diffs triggered by a change in the loaded zone actually has an /// associated loaded diff serial. pub signed_diffs: PersistedDiffManager, + + /// The last time that the extra diffs were merged into the persisted + /// zone. + pub last_compacted_at: Option, +} + +impl PersistenceState { + pub fn compact(center: &Arc
, zone: &Arc) { + // Is the zone available at the publication server? We need to read + // from that view so that we can update the zone snapshot files on + // disk so we can't do anything while Cascade is still starting up + // and hasn't yet assigned the viewer or for a zone that hasn't been + // published yet. + let Some(viewer) = center.publication_server.viewer(zone) else { + trace!( + "Ignoring compaction request for zone '{}': no publication viewer available", + zone.name + ); + return; + }; + + // Is compaction needed? Compare the allowed number of diffs to the + // actual number of persisted diffs. For that we need a policy, + // which the zone _should_ have. If not, abort. + let state = zone.state.read(); + let Some(ref policy) = state.policy else { + trace!( + "Ignoring compaction request for zone '{}': no policy available", + zone.name + ); + return; + }; + + // Grab some values that we need then release the state lock. + let max_diffs = policy.server.outbound.max_diffs; + // The number of actual diffs is one less than the set of diff paths + // as the first path is to the snapshot, not to a diff. + let num_signed_diffs = state.persistence.signed_diffs.len().saturating_sub(1); + let loaded_snapshot_path = state.persistence.loaded_diffs.diff_infos.first().cloned(); + let signed_snapshot_path = state.persistence.signed_diffs.diff_infos.first().cloned(); + drop(state); + + // Check the number of persisted diffs vs the number allowed. + trace!( + "Checking if compaction is needed for zone '{}': {num_signed_diffs} > {max_diffs}", + zone.name + ); + if num_signed_diffs > max_diffs { + debug!( + "Compacting persisted diffs for zone '{}' with {} diffs > {} max diffs", + zone.name, num_signed_diffs, max_diffs + ); + let num_diffs_to_remove = num_signed_diffs.abs_diff(max_diffs); + let loaded_snapshot_path = &loaded_snapshot_path.unwrap().path; + let signed_snapshot_path = &signed_snapshot_path.unwrap().path; + + // Get access to the published records for the zone, so that we + // can write new loaded and signed snapshot files to disk. + if let Ok(viewer) = viewer.try_read() + && let Some(reader) = viewer.read() + { + debug!( + "Writing loaded zone snapshot to {}", + loaded_snapshot_path.display() + ); + crate::persistence::persist_to_file_from_parts( + loaded_snapshot_path, + None, + reader.soa().clone(), + [].iter().cloned(), + // TODO: It would be nice if we didn't have to clone + // the records here. + reader.loaded_records(), + ); + + debug!( + "Writing new signed zone snapshot to {}", + signed_snapshot_path.display() + ); + crate::persistence::persist_to_file_from_parts( + signed_snapshot_path, + None, + reader.soa().clone(), + [].iter().cloned(), + // TODO: It would be nice if we didn't have to clone + // the records here. + reader.generated_records().iter().cloned(), + ); + + // Now that we have re-written the snapshots using the latest + // published version of the zone we don't need any of the + // on-disk persisted diffs that were previously applied on top + // of the old snapshot to re-create the zone. + // + // We might still however need some of those on-disk diffs so + // that we can reload them on startup to be able to serve them + // as IXFR diffs to downstream nameservers. + // + // Check which ones we can delete and after deleting them + // update our record of the first on-disk diff file that + // should be applied on top of the updated snapshot. + let mut state = zone.write(center); + + // Remove the first N oldest signed diffs and their + // corresponding loaded diffs. Skip the first "diff" as it is + // the snapshot, not a diff. + let mut idx = 0; + let mut loaded_serials_to_remove = vec![]; + state + .persistence + .signed_diffs + .diff_infos + .retain(|diff_info| { + // Keep only the snapshot and diffs newer than the ones + // to remove. + let keep = idx == 0 || idx > num_diffs_to_remove; + trace!("Compaction for zone '{}': removing {num_diffs_to_remove} diffs: retain diff #{idx}: {keep}", zone.name); + idx += 1; + + if !keep { + // Remove the corresponding loaded diff. + if let Some(loaded_serial) = diff_info.loaded_serial { + loaded_serials_to_remove.push(loaded_serial); + } + } + keep + }); + + // Remove the corresponding loaded diffs. + for loaded_serial in loaded_serials_to_remove.into_iter() { + if let Some(found_item) = state + .persistence + .loaded_diffs + .diffs() + .iter() + .find(|item| item.loaded_serial == Some(loaded_serial)) + .cloned() + { + trace!( + "Compaction for zone '{}': removing loaded diff for loaded serial {loaded_serial}", + zone.name + ); + let _ = state + .persistence + .loaded_diffs + .diff_infos + .remove(&found_item); + } + } + + state.persistence.loaded_diffs.restore_base_idx = + state.persistence.loaded_diffs.len(); + state.persistence.signed_diffs.restore_base_idx = + state.persistence.signed_diffs.len(); + trace!( + "Compaction complete: next_idx: loaded={}, signed={}, restore_base_idx: loaded={}, signed={}", + state.persistence.loaded_diffs.next_idx, + state.persistence.signed_diffs.next_idx, + state.persistence.loaded_diffs.restore_base_idx, + state.persistence.signed_diffs.restore_base_idx + ); + } + } + } } impl Default for PersistenceState { fn default() -> Self { Self { + ongoing: Default::default(), loaded_diffs: PersistedDiffManager::new(PersistedDiffRecordSource::Loaded), signed_diffs: PersistedDiffManager::new(PersistedDiffRecordSource::Signed), - ongoing: Default::default(), + last_compacted_at: None, } } } @@ -313,23 +499,34 @@ pub struct PersistedDiffManager { /// to. next_idx: usize, + /// The index of the first diff_info to apply to the snapshot when restoring. + /// + /// After compaction the on-disk diffs that existed must no longer be applied + /// to the base snapshot as the new snapshot includes them, but we should + /// still track their paths so that we can load them for use in responding to + /// IXFR client requests. So we need to remember which index to start applying + /// diffs to the snapshot from. + restore_base_idx: usize, + /// The collection of persisted data file paths in this set. diff_infos: BTreeSet, } impl PersistedDiffManager { pub fn new(record_source: PersistedDiffRecordSource) -> Self { - Self::from_parts(record_source, 0, Default::default()) + Self::from_parts(record_source, 0, 0, Default::default()) } pub fn from_parts( record_source: PersistedDiffRecordSource, next_idx: usize, + restore_base_idx: usize, diff_infos: BTreeSet, ) -> Self { Self { record_source, next_idx, + restore_base_idx, diff_infos, } } @@ -383,6 +580,14 @@ impl PersistedDiffManager { pub fn diffs(&self) -> &BTreeSet { &self.diff_infos } + + fn len(&self) -> usize { + self.diff_infos.len() + } + + pub fn restore_base_idx(&self) -> usize { + self.restore_base_idx + } } //----------- PersistedZoneDataFileInfo -------------------------------------- @@ -514,24 +719,6 @@ pub struct IxfrZoneDiffs { signed_diffs: BTreeMap, } -struct RelatedSignedDiff { - /// The signed diff. - diff: Arc, - - /// The removed serial number of the loaded diff that this signed diff - /// relates to, if any. - related_loaded_serial: Option, -} - -impl RelatedSignedDiff { - fn new(diff: Arc, loaded_serial: Option) -> Self { - Self { - diff, - related_loaded_serial: loaded_serial.map(Into::into), - } - } -} - impl IxfrZoneDiffs { pub fn new() -> Self { Default::default() @@ -599,6 +786,29 @@ impl IxfrZoneDiffs { diffs } + + pub fn discard_excess_diffs(&mut self, max_diffs: usize) { + let num_signed_diffs = self.num_signed_diffs(); + debug!("Checking for diffs to discard: {num_signed_diffs} > {max_diffs}?"); + if num_signed_diffs > max_diffs { + // Prune the oldest diffs so that we end up storing no more than + // max_diffs signed diffs. + let num_diffs_to_prune = num_signed_diffs - max_diffs; + debug!("Discarding {num_diffs_to_prune} in-memory diffs"); + for _ in 0..num_diffs_to_prune { + if let Some(e) = self.signed_diffs.first_entry() { + trace!("Discarding in-memory signed diff for serial {}", e.key()); + let related_loaded_diff = e.remove(); + if let Some(loaded_serial) = related_loaded_diff.related_loaded_serial { + trace!( + "Discarding related in-memory loaded diff for serial {loaded_serial}" + ); + let _ = self.loaded_diffs.remove(&loaded_serial); + } + } + } + } + } } impl std::fmt::Display for IxfrZoneDiffs { @@ -632,6 +842,26 @@ impl std::fmt::Display for IxfrZoneDiffs { } } +//------------ RelatedSignedDiff --------------------------------------------- + +struct RelatedSignedDiff { + /// The signed diff. + diff: Arc, + + /// The removed serial number of the loaded diff that this signed diff + /// relates to, if any. + related_loaded_serial: Option, +} + +impl RelatedSignedDiff { + fn new(diff: Arc, loaded_serial: Option) -> Self { + Self { + diff, + related_loaded_serial: loaded_serial.map(Into::into), + } + } +} + fn log_stored_diff(r#type: &'static str, updating: bool, from: Serial, to: Serial) { if updating { trace!("Updating existing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); diff --git a/src/policy/file/v1.rs b/src/policy/file/v1.rs index ce09efb9d..326bd38d8 100644 --- a/src/policy/file/v1.rs +++ b/src/policy/file/v1.rs @@ -60,6 +60,12 @@ const KEY_ROLL_TIME: u32 = 24 * 3600; // When auto remove is enabled, remove old keys after one week. const AUTO_REMOVE_DELAY: u32 = 7 * 24 * 3600; +// Defaults for diff purging. +// +// The maximum number of diffs to keep per zone. +// Based on the NSD default of ixfr-number: 5. +const MAX_DIFFS: usize = 5; + //----------- Spec ------------------------------------------------------------- /// A policy file. @@ -945,12 +951,22 @@ pub struct OutboundSpec { /// TODO: support the RFC 1996 "Notify Set"? #[serde(default = "empty_list")] pub send_notify_to: Vec, + + /// The maximum number of IXFR diffs to keep. + /// + /// Excess diffs will be discarded. + #[serde(default = "default_max_diffs")] + pub max_diffs: usize, } fn empty_list() -> Vec { vec![] } +fn default_max_diffs() -> usize { + MAX_DIFFS +} + //--- Conversion impl OutboundSpec { @@ -959,6 +975,7 @@ impl OutboundSpec { OutboundPolicy { provide_xfr_to: self.provide_xfr_to.into_iter().map(|v| v.parse()).collect(), send_notify_to: self.send_notify_to.into_iter().map(|v| v.parse()).collect(), + max_diffs: self.max_diffs, } } @@ -975,6 +992,7 @@ impl OutboundSpec { .iter() .map(NameserverCommsSpec::build) .collect(), + max_diffs: policy.max_diffs, } } } diff --git a/src/policy/mod.rs b/src/policy/mod.rs index 730d8b9a5..bafecd0bd 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -562,6 +562,11 @@ pub struct OutboundPolicy { /// /// TODO: support the RFC 1996 "Notify Set"? pub send_notify_to: Vec, + + /// The maximum number of IXFR diffs to keep. + /// + /// Excess diffs will be discarded. + pub max_diffs: usize, } //----------- NameserverCommsPolicy ------------------------------------------- diff --git a/src/server/mod.rs b/src/server/mod.rs index 94c1e41d1..5d5b79528 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -257,6 +257,14 @@ impl PublicationServer { let handle = ¢er.publication_server.handle; handle.remove_zone(zone); } + + /// Get the viewer for this zone. + /// + /// If Cascade is still starting up there may not be a viewer for the zone + /// yet. + pub fn viewer(&self, zone: &Arc) -> Option>> { + self.handle.viewer(zone) + } } impl Default for PublicationServer { diff --git a/src/server/service.rs b/src/server/service.rs index aee88fc55..07361b5cd 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -826,6 +826,16 @@ impl ZoneServiceHandle { ); let _ = viewer; } + + /// Get a viewer for a zone. + /// + /// If Cascade is still starting up there may not be a viewer for the zone + /// yet. + pub fn viewer(&self, zone: &Arc) -> Option>> { + let state = self.state.read().unwrap(); + let name = RevNameBuf::parse_bytes(zone.name.as_slice()).unwrap(); + state.zones.get(&*name).map(|z| z.viewer.clone()) + } } //----------- ZoneServiceState ------------------------------------------------- diff --git a/src/units/http_server.rs b/src/units/http_server.rs index c80d8d8d0..89018fe98 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -944,7 +944,16 @@ impl HttpServer { .get(zone_name) .expect("zones and policies are consistent"); - zone.write(center).policy = Some(pol.latest.clone()); + // Discard excess IXFR diffs if the maximum number permitted + // by the policy has been reduced. + { + let mut state = zone.write(center); + state.policy = Some(pol.latest.clone()); + state + .storage + .diffs + .discard_excess_diffs(pol.latest.server.outbound.max_diffs); + } center .key_manager @@ -1030,6 +1039,7 @@ impl HttpServer { .iter() .map(|v| NameserverCommsPolicyInfo { addr: v.addr }) .collect(), + max_diffs: p_outbound.max_diffs, }, }; diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index ea1bfb98c..781128a8a 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -656,6 +656,7 @@ impl ZoneLoadSourceSpec { pub struct PersistedDiffsSpec { pub is_signed: bool, pub next_idx: usize, + pub restore_base_idx: usize, pub diff_infos: Vec, } @@ -671,7 +672,12 @@ impl PersistedDiffsSpec { true => PersistedDiffRecordSource::Signed, false => PersistedDiffRecordSource::Loaded, }; - PersistedDiffManager::from_parts(is_signed, self.next_idx, diff_infos) + PersistedDiffManager::from_parts( + is_signed, + self.next_idx, + self.restore_base_idx, + diff_infos, + ) } /// Build into this specification. @@ -679,6 +685,7 @@ impl PersistedDiffsSpec { Self { is_signed: false, next_idx: loaded_diffs.next_idx(), + restore_base_idx: loaded_diffs.restore_base_idx(), diff_infos: loaded_diffs .diffs() .iter() @@ -692,6 +699,7 @@ impl PersistedDiffsSpec { Self { is_signed: true, next_idx: signed_diffs.next_idx(), + restore_base_idx: signed_diffs.restore_base_idx(), diff_infos: signed_diffs .diffs() .iter() From 3d6e1879239bbd64eb932eecc7aacc5dc84e179c Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:37:23 +0200 Subject: [PATCH 13/85] FIX: Revert temporary local change that breaks the test. --- integration-tests/persist-zone/policies/minimal-diffs.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/persist-zone/policies/minimal-diffs.toml b/integration-tests/persist-zone/policies/minimal-diffs.toml index b087b0ed1..15f42a639 100644 --- a/integration-tests/persist-zone/policies/minimal-diffs.toml +++ b/integration-tests/persist-zone/policies/minimal-diffs.toml @@ -499,4 +499,4 @@ mode = "off" # total IXFR response size and/or if older than the SOA expire period, Cascade # does not currently implement this behaviour. Cascade also currently has no # support for RFC 1995 section 6 condensation of multiple versions. -max-diffs = 0 +max-diffs = 1 From b8306591022645ff526bb86b7a9886246b827358 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:38:02 +0200 Subject: [PATCH 14/85] Consistency. Other methods are pub, so make len() pub too. --- src/persistence/zone.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 829df300c..f3834ac23 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -581,7 +581,7 @@ impl PersistedDiffManager { &self.diff_infos } - fn len(&self) -> usize { + pub fn len(&self) -> usize { self.diff_infos.len() } From 4292fefdeb986d045fbe5b940ed1c57cd8320f33 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:38:32 +0200 Subject: [PATCH 15/85] Removed unused field. --- src/persistence/zone.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index f3834ac23..84ec58648 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -4,7 +4,6 @@ use std::{ collections::{BTreeMap, BTreeSet}, path::PathBuf, sync::Arc, - time::Instant, }; use cascade_zonedata::{ @@ -301,10 +300,6 @@ pub struct PersistenceState { /// diffs triggered by a change in the loaded zone actually has an /// associated loaded diff serial. pub signed_diffs: PersistedDiffManager, - - /// The last time that the extra diffs were merged into the persisted - /// zone. - pub last_compacted_at: Option, } impl PersistenceState { From cf5933709581dd7e49639168f1e1454c279ec24b Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:39:01 +0200 Subject: [PATCH 16/85] More remove unused field. --- src/persistence/zone.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 84ec58648..e329ae631 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -468,7 +468,6 @@ impl Default for PersistenceState { ongoing: Default::default(), loaded_diffs: PersistedDiffManager::new(PersistedDiffRecordSource::Loaded), signed_diffs: PersistedDiffManager::new(PersistedDiffRecordSource::Signed), - last_compacted_at: None, } } } From d4bfab73ac12a5c1847b14c2834ae3c0224e28c4 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:39:38 +0200 Subject: [PATCH 17/85] Ensure that compaction blocks changes elsewhere concurrently to the persistence related state of the zone. --- src/persistence/zone.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index e329ae631..198e8725e 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -317,10 +317,8 @@ impl PersistenceState { return; }; - // Is compaction needed? Compare the allowed number of diffs to the - // actual number of persisted diffs. For that we need a policy, - // which the zone _should_ have. If not, abort. - let state = zone.state.read(); + let mut state = zone.write(center); + let Some(ref policy) = state.policy else { trace!( "Ignoring compaction request for zone '{}': no policy available", @@ -336,7 +334,6 @@ impl PersistenceState { let num_signed_diffs = state.persistence.signed_diffs.len().saturating_sub(1); let loaded_snapshot_path = state.persistence.loaded_diffs.diff_infos.first().cloned(); let signed_snapshot_path = state.persistence.signed_diffs.diff_infos.first().cloned(); - drop(state); // Check the number of persisted diffs vs the number allowed. trace!( @@ -397,7 +394,6 @@ impl PersistenceState { // Check which ones we can delete and after deleting them // update our record of the first on-disk diff file that // should be applied on top of the updated snapshot. - let mut state = zone.write(center); // Remove the first N oldest signed diffs and their // corresponding loaded diffs. Skip the first "diff" as it is From b719a040320326bb87bd44186c8a6918522d9e04 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:39:54 +0200 Subject: [PATCH 18/85] Fix a comment. --- src/persistence/zone.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 198e8725e..47ca3da69 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -335,7 +335,9 @@ impl PersistenceState { let loaded_snapshot_path = state.persistence.loaded_diffs.diff_infos.first().cloned(); let signed_snapshot_path = state.persistence.signed_diffs.diff_infos.first().cloned(); - // Check the number of persisted diffs vs the number allowed. + // Is compaction needed? Compare the allowed number of diffs to the + // actual number of persisted diffs. For that we need a policy, which + // the zone _should_ have. If not, abort. trace!( "Checking if compaction is needed for zone '{}': {num_signed_diffs} > {max_diffs}", zone.name From 11181caae2c1bced0fde120273abe483aa66a2d6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:40:48 +0200 Subject: [PATCH 19/85] FIX: Revert persistence of loaded zone when loaded zone is discarded post review. (fixes #825) --- src/persistence/zone.rs | 55 +++++++++++++++++++++++++++++++++++++++-- src/zone/storage.rs | 30 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 47ca3da69..0bc3d9469 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -530,6 +530,17 @@ impl PersistedDiffManager { loaded_serial: Option, signed_serial: Option, ) -> PathBuf { + // Catch issues like https://github.com/NLnetLabs/cascade/issues/825: + // If both serials are None the diff represents a snapshot which we + // should only receive if we have no stored diff paths already. We + // could delete the existing diff_infos entries at this point but that + // would leave behind any actual diffs at those paths on disk, and + // if we are wrong we will interfere with normal Cascade operation by + // discarding diff paths that we should not be discarding. So we can't + // handle this heere and should never get into this state so just + // abort as something is seriously wrong. + assert!(self.diff_infos.is_empty() || loaded_serial.is_some() || signed_serial.is_some()); + let zone_name = &zone.name; let data_file_type = match self.record_source { PersistedDiffRecordSource::Loaded => "loaded", @@ -556,13 +567,53 @@ impl PersistedDiffManager { path } - pub fn is_empty(&self) -> bool { - self.diff_infos.is_empty() + pub fn cleanup(&mut self, serial: Option) { + // If no serial number is provided we can only cleanup the initial + // snapshot, and we should only do that if we have only a snapshot + // and no diffs. + assert!(!self.is_empty()); + assert!(self.diff_infos.len() == 1 || serial.is_some()); + + // We can't just remove a diff out of the middle of a sequence, + // we can only cleanup the last diff. If it's a snapshot we are + // cleaning up that should be the last entry, we can't orphan diffs + // by removing the snapshot they apply to. + let last = self.diff_infos.pop_last().unwrap(); + if let Some(serial) = serial { + // When removing a diff the specified serial must match that of + // the last diff that we have. + assert_eq!(last.loaded_serial, Some(serial)); + } else { + // In the case of removing a snapshot, set next_idx back to 0 + // so that the snapshot is always numbered 0. Nothing should + // depend on this but it just feels a bit nicer to see 0 in the + // filename of the snapshot and know that that should be the + // snapshot. + // TODO: Maybe we should separate out snapshot files from diff + // files. + self.next_idx = 0; + } + + trace!( + "Removing persisted zone data file '{}' for cleaned serial {serial:?}", + last.path.display() + ); + if let Err(err) = std::fs::remove_file(&last.path) { + warn!( + "Unable to cleanup persisted data for serial {serial:?} by deleting '{}': {err}", + last.path.display() + ); + } } pub fn clear(&mut self) { self.diff_infos.clear(); self.next_idx = 0; + self.restore_base_idx = 0; + } + + pub fn is_empty(&self) -> bool { + self.diff_infos.is_empty() } pub fn next_idx(&self) -> usize { diff --git a/src/zone/storage.rs b/src/zone/storage.rs index 173d0a3b8..8bde743df 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -542,6 +542,9 @@ impl StorageZoneHandle<'_> { handle.storage().start_cleanup(cleaner); + // Notify the rest of Cascade that cleanup has been done. + handle.storage().on_loaded_cleanup(); + handle.state.storage.background_tasks.finish(); }); } @@ -842,6 +845,10 @@ impl StorageZoneHandle<'_> { // Initiate cleanup of the abandoned instance. handle.storage().start_cleanup(cleaner); + // Notify the rest of Cascade that any additional cleanup + // of the loaded zone is needed.. + handle.storage().on_loaded_cleanup(); + handle.state.storage.background_tasks.finish(); }); } @@ -871,6 +878,29 @@ impl StorageZoneHandle<'_> { // return; } } + + /// Do any non-state machine related loaded zone cleanup. + /// + /// This method triggers an update of persistence state to reflect that + /// the persisted loaded zone did not progress successfully through + /// signing and instead has been discarded and so persistence should also + /// cleanup the persisted copy of the loaded zone. + #[tracing::instrument( + level = "trace", + skip_all, + fields(zone = %self.zone.name), + )] + pub(crate) fn on_loaded_cleanup(&mut self) { + // TODO: is it safe to access the loaded review SOA after the review + // states have been left? + let loaded_serial = self + .state + .storage + .loaded_review_soa + .as_ref() + .map(|soa| soa.rdata.serial); + self.state.persistence.loaded_diffs.cleanup(loaded_serial); + } } //----------- StorageState ----------------------------------------------------- From 38d584834c5d71bd299c59649163c14a998edfb6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:40:56 +0200 Subject: [PATCH 20/85] Don't leave nulls in the state file. --- src/zone/state/v1.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 781128a8a..6909d5244 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -714,7 +714,9 @@ impl PersistedDiffsSpec { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct PersistedDiffFileInfoSpec { path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] loaded_serial: Option, + #[serde(skip_serializing_if = "Option::is_none")] signed_serial: Option, } From e95037dc0f0912f426e5bf42cab693cf00427742 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:41:07 +0200 Subject: [PATCH 21/85] Remove outdated comment. --- src/persistence/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index bb58ca203..a44763fc7 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -111,9 +111,7 @@ pub mod zone; /// This component is responsible for persisting zone data, so it can be /// restored (and Cascade can resume operation) after a crash / restart. #[derive(Debug)] -pub struct Persister { - // TODO: Do we need any global state for persistence? -} +pub struct Persister {} impl Persister { /// Construct a new [`Persister`]. From 0b26f4c73710aed358c41a1a9fad330555ff15a6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:41:27 +0200 Subject: [PATCH 22/85] cargo fmt. --- src/persistence/persist.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 72103adfb..c3e4681d0 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -107,11 +107,12 @@ pub fn persist_signed( let destination = { let mut handle = zone.write_handle(center); let signed_serial = signed_diff.removed_soa.as_ref().map(|s| s.rdata.serial); - handle - .state - .persistence - .signed_diffs - .push(zone, center, loaded_serial, signed_serial) + handle.state.persistence.signed_diffs.push( + zone, + center, + loaded_serial, + signed_serial, + ) }; // Update the set of persisted zone data file paths BEFORE writing From 3afd8a089d7b1384e964abeded82acca1aaf9ccd Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:41:38 +0200 Subject: [PATCH 23/85] Add missing state transition to zone storage diagram, --- crates/zonedata/src/storage/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/zonedata/src/storage/mod.rs b/crates/zonedata/src/storage/mod.rs index 8cf62f863..c0021d521 100644 --- a/crates/zonedata/src/storage/mod.rs +++ b/crates/zonedata/src/storage/mod.rs @@ -47,9 +47,9 @@ mod transitions; /// ║ │ │ stop_review ║ /// ║ │ ┌─────────────────────┐ stop_review ┌────────────────────┐ give_up ║ /// load ║ │ give_up │ CleanLoadedPending <──────────────────────────│ CleanWholePending <─────────────────────────────────────────────────────┐ ║ -/// ║ │ └────▲────────────────┘ └────────────────────┘ │ ║ -/// ▼ │ │ give_up │ ║ -/// ╔═════════╧═╗ finish ╔══════════════════════════╗ start ╔═══════════════════╗ approve ╔════════════════════╗ complete ╔══▼═════════════╗ finish ╔═══════════════════════╗ start ╔══════════════════╗ +/// ║ │ └────▲─────────────▲──┘ └────────────────────┘ │ ║ +/// ▼ │ │ give_up └───────────────────────────────────────────────┐ give_up │ ║ +/// ╔═════════╧═╗ finish ╔══════════════════════════╗ start ╔═══════════════════╗ approve ╔════════════════════╗ complete ╔═══│════════════╗ finish ╔═══════════════════════╗ start ╔══════════════════╗ /// ║ Loading ╠════════▶ ReviewingLoadedPending ╠═══════▶ ReviewingLoaded ╠═════════▶ PersistingLoaded ╠══════════▶ Signing ╠════════▶ ReviewSignedPending ╠═══════▶ ReviewingSigned ║ /// ╚═══════════╝ ╚══════════════════════════╝ ╚═══════════════════╝ ╚════════════════════╝ ╚══╤═════════▲═══╝ ╚═══════════════════════╝ ╚════════╤═════════╝ /// retry │ │ complete retry │ From 2ae201040c95f536eb79f0d235d6b4830b12c590 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:42:14 +0200 Subject: [PATCH 24/85] Cargo fmt. --- src/persistence/persist.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index c3e4681d0..72103adfb 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -107,12 +107,11 @@ pub fn persist_signed( let destination = { let mut handle = zone.write_handle(center); let signed_serial = signed_diff.removed_soa.as_ref().map(|s| s.rdata.serial); - handle.state.persistence.signed_diffs.push( - zone, - center, - loaded_serial, - signed_serial, - ) + handle + .state + .persistence + .signed_diffs + .push(zone, center, loaded_serial, signed_serial) }; // Update the set of persisted zone data file paths BEFORE writing From aba4ab139e0c1cef0a20c30330689fb58737cbd5 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:49:01 +0200 Subject: [PATCH 25/85] Oops, the loaded zone wasn't persisted yet. --- src/zone/storage.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/zone/storage.rs b/src/zone/storage.rs index 8bde743df..f0b74ffdf 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -845,10 +845,6 @@ impl StorageZoneHandle<'_> { // Initiate cleanup of the abandoned instance. handle.storage().start_cleanup(cleaner); - // Notify the rest of Cascade that any additional cleanup - // of the loaded zone is needed.. - handle.storage().on_loaded_cleanup(); - handle.state.storage.background_tasks.finish(); }); } From 3235690610b5e5e82ea4adc5011ea7151f20657f Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:04:47 +0200 Subject: [PATCH 26/85] Add a test of restore following signed reject. --- .../policies/manual-signed-review.toml | 502 ++++++++++++++++++ integration-tests/system-tests.yml | 16 + .../tests/persist-then-reject/action.yml | 202 +++++++ 3 files changed, 720 insertions(+) create mode 100644 integration-tests/persist-then-reject/policies/manual-signed-review.toml create mode 100644 integration-tests/tests/persist-then-reject/action.yml diff --git a/integration-tests/persist-then-reject/policies/manual-signed-review.toml b/integration-tests/persist-then-reject/policies/manual-signed-review.toml new file mode 100644 index 000000000..21bc14aa2 --- /dev/null +++ b/integration-tests/persist-then-reject/policies/manual-signed-review.toml @@ -0,0 +1,502 @@ +# Configuring a group of zones for Cascade +# ======================================== +# +# A policy is a collection of settings that apply to a group of zones known to +# Cascade. Policy controls how Cascade operates on those zones, e.g. how they +# are signed. This file is a template describing all possible settings and their +# defaults. You can copy this and customize it to your liking, or write a policy +# file from scratch using this as a reference. +# +# Policy files are managed by the user, and are stored at a configurable path +# (by default, '/etc/cascade/policies'). You can add, modify, and remove +# policy files, then update Cascade with 'cascade policy reload'. Note that: +# +# - Cascade maintains an internal copy of all policies, and will use this until +# 'cascade policy reload' is used. If reloading fails, Cascade will continue +# to use its existing internal copy. It won't reload policies if it restarts. +# +# - Policies cannot be removed if they are attached to zones; those zones need +# to be deleted or shifted to a different policy first. If you remove a used +# policy and reload policies in Cascade, it will fail and continue to use its +# internal copy of the policy. + +# The policy file version. +# +# This is the only required option. All other settings, and their defaults, are +# associated with this version number. More versions may be added in the future +# and Cascade may drop support for older versions over time. +# +# - 'v1': This format. +version = "v1" + + +# How zones are loaded. +[loader] + +# How loaded zones are reviewed. +# +# Review offers an opportunity to perform external checks on the zone contents +# loaded by Cascade. +[loader.review] + +# The mode for loader review. +# +# This can be one of the following values: +# +# - "off" will disable review +# - "manual" will enable manual review via the CLI. +# - "script" will enable automatic review via a hook. The `hook` field must be +# specified in this case. +# +# The default value is "off" +mode = "off" + +# A hook for reviewing a loaded zone. +# +# This command string will be executed in the user's shell when a new version of +# a zone is loaded. +# +# It will receive the following information via environment variables: +# +# - 'CASCADE_ZONE': The name of the zone, formatted without a trailing dot. +# - 'CASCADE_SERIAL': The serial number of the zone (decimal integer). +# - 'CASCADE_SERVER': The combined address and port where Cascade is serving +# the zone for review, formatted as ':'. +# - 'CASCADE_SERVER_IP': Just the address of the above server. +# - 'CASCADE_SERVER_PORT': Just the port of the above server. +# +# The command will be called from an unspecified directory, and it must be +# accessible to Cascade (i.e. after it has dropped privileges). Its exit code +# will determine whether the zone is approved or not. +#hook = "review-unsigned-zone.sh" + +# What to do when a zone is rejected by review. +# +# This field can only be specified if 'mode' is either "manual" or "script" and +# can have one of the following values: +# +# - "discard" will discard the rejected zone and go back to an idle state +# - "halt" will halt the pipeline until an operator either resets the pipeline +# or overrides the rejection. +# +# The default value is "discard". +#on-reject = "discard" + +# DNSSEC key management. +[key-manager] + +# How long keys are considered valid for. +# +# If a key has been used for longer than this time, it is considered expired, +# and (if enabled) it will automatically be rolled over to a new key. Even +# if automatic rollovers are not enabled, the key will be reported as expired. +# This is a soft condition -- DNSSEC does not have a concept of key expiry, +# and it will not break DNSSEC validation, but it is usually important to the +# security of the zone. +# +# Independent validity times are set for KSKs, ZSKs, and CSKs. An integer +# value will be interpreted as seconds, 'forever' means keys never expire, and +# a time string such as "365d" will be interpreted as 365 days. Supported +# suffixes include "s", "m", "h", "d" and "w". +ksk.validity = "365d" +zsk.validity = "30d" +csk.validity = "365d" + +# Whether to automatically start key rollovers. +# +# If this is enabled, Cascade will automatically start rolling over keys when +# they expire (as per 'validity'). When using this setting, 'validity' must not +# be set to 'forever'. +# +# The first step in a rollover will be to generate new keys to replace old ones. +# By disabling this setting, the user can manually control how new keys are +# generated, and when rollovers happen. +# +ksk.auto-start = true +zsk.auto-start = true +csk.auto-start = true +algorithm.auto-start = true + +# Whether to automatically check for record propagation. +# +# If this is enabled, Cascade will automatically contact public DNS servers to +# detect when new records (e.g. DNSKEY) are visible globally. It is necessary +# to wait until some records are visible globally to progress key rollovers. If +# this is disabled, the user will have to inform Cascade when these conditions +# are reached manually. +# +# For this setting to work, Cascade must have network access to the zone's +# public nameservers and the parent zone's public nameservers. +ksk.auto-report = true +zsk.auto-report = true +csk.auto-report = true +algorithm.auto-report = true + +# Whether to automatically wait for cache expiry. +# +# If this is enabled, Cascade will automatically progress through key rollover +# steps that need to wait for downstream users' DNS caches to expire. It will +# estimate the right amount of time to wait based on record TTLs. +ksk.auto-expire = true +zsk.auto-expire = true +csk.auto-expire = true +algorithm.auto-expire = true + +# Whether to automatically check for rollover completion. +# +# Like 'auto-report', if this setting is enabled, Cascade will automatically +# contact public DNS servers to detect when new records are visible globally. +# 'auto-done' specifically affects automatic checks for the last step of key +# rollovers, and is independent from 'auto-report'. +# +# For this setting to work, Cascade must have network access to the zone's +# public nameservers and the parent zone's public nameservers. +ksk.auto-done = true +zsk.auto-done = true +csk.auto-done = true +algorithm.auto-done = true + +# The hash algorithm used by the parent zones' DS records. +# +# Supported options: +# - 'SHA-256' +# - 'SHA-384' +ds-algorithm = "SHA-256" + +# Whether to automatically remove expired keys. +# +# If this is set, expired keys will be removed automatically (by deleting the +# files for on-disk keys or removing it from the HSM). +auto-remove = true + +# Delay after which expired keys will be removed when auto-remove is true. +# +# An integer value is interpreted as seconds. A string is interpreted as +# time string with a number followed by a unit (i.e. "s", "m", "h", "d", +# or "w"). +auto-remove-delay = "7d" + +# The set of nameservers to use when checking for RRSIG propagation during a +# key roll. +# +# Each nameserver must be specified as a string in the form: +# +# `"[:][^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# If no nameservers are specified, the nameserver specified by the SOA MNAME +# field will be checked. +# +# publication-nameservers = [] + +# The management of DNS records by the key manager. +# +# The key manager generates and signs several records (DNSKEY and CDS). This +# section controls its behaviour towards them. +[key-manager.records] + +# The TTL for the generated records. +# +# TODO: Perhaps Cascade can automatically set this to the SOA minimum. The +# key manager would have to be sent that information somehow. +ttl = "1h" + +# The offset for generated signature inceptions. +# +# Record signatures have a fixed inception time, from when they are considered +# valid. An imprecise computer clock could cause signatures to be considered +# invalid, because their inception point appears to be some time in the future. +# To prevent such cases, this setting allows the inception time to be offset +# into the past. +# +# Independent offsets can be set for each type of record. An integer value is +# interpreted as seconds; A string is interpreted as time string with a number +# followed by a unit (i.e. "s", "m", "h", "d", or "w"). Inception times will be +# calculated as 'now - offset' at the time of signing. +dnskey.signature-inception-offset = "1d" +cds.signature-inception-offset = "1d" + +# The lifetime of generated signatures. +# +# Record signatures have a fixed lifetime, after which they are considered +# invalid. To keep the zone valid, the signatures should be regenerated before +# they expire; see 'signature-remain-time' to control regeneration time. +# +# Independent lifetimes can be set for each type of record. An integer value +# is interpreted as seconds. A string is interpreted as time string with a +# number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +dnskey.signature-lifetime = "2w" +cds.signature-lifetime = "2w" + +# The amount of time remaining before expiry when signatures will be +# regenerated. +# +# In order to prevent a zone's signatures from appearing invalid, they +# have to be regenerated before they expire. That hard limit is set by +# 'signature-lifetime' above. This setting controls how long before expiry +# signatures will be regenerated; it must be less than the 'signature-lifetime' +# setting. +# +# Independent waiting times can be set for each type of record. An integer +# value is interpreted as seconds. A string is interpreted as time string with +# a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +dnskey.signature-remain-time = "1w" +cds.signature-remain-time = "1w" + +# How keys are generated. +[key-manager.generation] + +# The HSM server to use. +# +# If this is set, the named HSM server (which must be configured via 'cascade +# hsm add') will be used for generating new DNSSEC keys. +# +# Information about using a HSM with Cascade is at +# https://cascade.docs.nlnetlabs.nl/en/latest/hsms.html +#hsm-server-id = "softhsm" + +# Whether to generate CSKs, instead of separate ZSKs and KSKs. +# +# A CSK (Combined Signing Key) takes the role of both ZSK and KSK for a zone, +# unlike the standard practice of using separate keys for ZSK and KSK. This +# setting does not affect how DNSSEC validation is performed, only procedures +# for key rollovers. +# +# If this is enabled, Cascade will generate CSKs for the associated zones. +# +# TODO: Does this affect anything more than generation? Does it affect how +# existing keys are rolled over? +use-csk = false + +# The cryptographic algorithm (and parameters) for generated keys. +# +# DNSSEC supports various cryptographic algorithms for signatures; one must be +# selected, and for some algorithms, additional parameters are also necessary. +# The same algorithm and parameters will be applied to the ZSK and KSK. +# +# - 'RSASHA256[:]', algorithm 8, with a public key size of +# '' (default 2048) bits. +# - 'RSASHA512[:]', algorithm 10, with a public key size of +# '' (default 2048) bits. +# - 'ECDSAP256SHA256', algorithm 13. +# - 'ECDSAP384SHA384', algorithm 14. +# - 'ED25519', algorithm 15. +# - 'ED448', algorithm 16. +# +# There are additional algorithms, but many are now considered insecure, and it +# is recommended or mandated to avoid them. In addition, RSA keys smaller than +# 2048 bits are not recommended. +# +# NOTE: At the moment, only RSASHA256 and ECDSAP256SHA256 work with HSMs. Other +# algorithms cannot be used with HSMs, and will cause generation failures. +algorithm = "ECDSAP256SHA256" +#algorithm = "RSASHA512:4096" + + +# How zones are signed. +# +# Note that certain records (e.g. DNSKEY and CDS records at the apex of the +# zone) are signed by the key manager, rather than the zone signer; see the +# `[key-manager.records]` section for configuring the signing of those records. +[signer] + +# How SOA serial numbers are generated for signed zones. +# +# Supported options: +# - 'keep': use the same serial number as the unsigned zone. +# - 'counter': increment the serial number every time. +# - 'unix-time': use the current Unix time, in seconds. +# - 'date-counter': format the number as '
' in decimal. +# '' is a simple counter to allow up to 100 versions per day. +serial-policy = "date-counter" + +# The offset for generated signature inceptions. +# +# Record signatures have a fixed inception time, from when they are considered +# valid. An imprecise computer clock could cause signatures to be considered +# invalid, because their inception point appears to be some time in the future. +# To prevent such cases, this setting allows the inception time to be offset +# into the past. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +# Inception times will be calculated as 'now - offset' at the time of signing. +signature-inception-offset = "1d" + +# The lifetime of generated signatures. +# +# Record signatures have a fixed lifetime, after which they are considered +# invalid. To keep the zone valid, the signatures should be regenerated before +# they expire; see 'signature-remain-time' to control regeneration time. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +signature-lifetime = "2w" + +# The amount of time remaining before expiry when signatures will be +# regenerated. +# +# In order to prevent a zone's signatures from appearing invalid, they +# have to be regenerated before they expire. That hard limit is set by +# 'signature-lifetime' above. This setting controls how long before expiry +# signatures will be regenerated; it must be less than the 'signature-lifetime' +# setting. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +signature-remain-time = "1w" + +# Refresh period to prevent signatures from expiring. Each period, Cascade +# will refresh some number of signatures. This way the work to refresh all +# signatures is spread out over time. The effective lifetime of a signature +# is signature-lifetime - signature-remain-time. Each period roughly a +# fraction of all signatures that is equal to signature-refresh-interval +# divided by the effective signature lifetime will be refreshed. +# +# signature-refresh-interval should be a lot smaller than +# signature-remain-time to make sure that signatures are refreshed in time. +# If this is not the case then in extreme cases, signatures could expire. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +signature-refresh-interval = "12h" + +# To avoid resigning the entire zone at once during a ZSK or CSK roll, +# generating signatures with the new key can be spread out over time. +# New signatures are generated at intervals controlled by +# signature-refresh-interval. +# +# An integer value is interpreted as seconds. A string is interpreted as time +# string with a number followed by a unit (i.e. "s", "m", "h", "d", or "w"). +key-roll-time = "24h" + +# How denial-of-existence records are generated. +[signer.denial] + +# The type of denial-of-existence records to generate. +# +# Supported options: +# - 'nsec': Use NSEC records (RFC 4034). +# - 'nsec3': Use NSEC3 records (RFC 5155). +type = "nsec" + +# Whether to skip NSEC3 records for unsigned delegations. +# +# This enables the NSEC3 Opt-Out flag, and skips delegations to unsigned zones +# when generating NSEC3 records. This affects the security of the zone, so be +# careful if you wish to enable it. +#opt-out = false + +# How signed zones are reviewed. +[signer.review] + +# The mode for loader review. +# +# This can be one of the following values: +# +# - "off" will disable review +# - "manual" will enable manual review via the CLI. +# - "script" will enable automatic review via a hook. The `hook` field must be +# specified in this case. +# +# The default value is "off". +mode = "manual" + +# A hook for reviewing a signed zone. +# +# This command string will be executed in the user's shell when a new version of +# a zone is signed. +# +# It will receive the following information via environment variables: +# +# - 'CASCADE_ZONE': The name of the zone, formatted without a trailing dot. +# - 'CASCADE_SERIAL': The serial number of the signed zone (decimal integer). +# - 'CASCADE_SERVER': The combined address and port where Cascade is serving +# the zone for review, formatted ':'. +# - 'CASCADE_SERVER_IP': Just the address of the above server. +# - 'CASCADE_SERVER_PORT': Just the port of the above server. +# +# The command will be called from an unspecified directory, and it must be +# accessible to Cascade (i.e. after it has dropped privileges). Its exit code +# will determine whether the zone is approved or not. +#hook = "review-signed-zone.sh" + +# What to do when a zone is rejected by review. +# +# This field can only be specified if 'mode' is either "manual" or "script" and +# can have one of the following values: +# +# - "discard" will discard the rejected zone and go back to an idle state +# - "halt" will halt the pipeline until an operator either resets the pipeline +# or overrides the rejection. +# +# The default value is "discard". +#on-reject = "discard" + +# How published zones are served. +[server.outbound] + +# The set of nameservers to which NOTIFY messages should be sent. +# +# Each nameserver must be specified as a string in the form: +# +# `"[:][^]"` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# PORT defaults to 53. +# +# If not specified, no NOTIFY messages will be sent. +#send-notify-to = ["127.0.0.1", "127.0.0.1:53", "127.0.0.1^my-tsig-key"] + +# The set of nameservers to provide zone transfers to. +# +# Each nameserver must be specified as a string in the form: +# +# `"[^]"` +# +# Nameservers may alternatively be specified as an inline table, in which case +# PORT is mandatory but not checked other than its value must lie between 0 +# and 65535: +# +# `provide-xfr-to = [ +# { addr = ":", tsig-key-name = "" }, +# ... +# ]` +# +# If a TSIG key name is specified, a key by that name must exist in the +# Cascade TSIG key store and will be used to authenticate communication with +# the nameserver. +# +# Zone transfers will be provided to any nameserver that initiates a transfer +# request from one of the specified IP addresses. +# +# If not specified, zone transfers will be provided to any nameserver. +#provide-xfr-to = ["127.0.0.1", "127.0.0.1^my-tsig-key"] + +# The maximum number of sequences of differential information (diffs) that +# the server may store per zone in order to respond to RFC 1995 Incremental +# Zone Transfer (IXFR) requests. Defaults to 5 diffs per zone. +# +# Diffs are persisted so that they can be restored on restart of the Cascade +# daemon. +# +# Excess in-memory diffs are discarded eagerly when the limit for +# a zone is reached, or when this policy setting is changd and the policy +# reloaded causing some in-memory diffs that were previously under the limit +# to exceed the new limit. +# +# Persisted diffs are discarded lazily to reduce the impact of persisted diff +# compaction. The number of persisted diffs may therefore temporarily exceed +# the limit specified here until purging of persisted diffs next occurs. +# +# Note that while RFC 1955 section 5 suggests that diffs be purged based on +# total IXFR response size and/or if older than the SOA expire period, Cascade +# does not currently implement this behaviour. Cascade also currently has no +# support for RFC 1995 section 6 condensation of multiple versions. +#max-diffs = 5 diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index b6ccd6847..087b09180 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -331,3 +331,19 @@ jobs: - uses: ./integration-tests/tests/persist-zone with: log-level: ${{ inputs.log-level }} + + # Added for https://github.com/NLnetLabs/cascade/pull/825 + persist-then-reject: + name: Restore after discard of the loaded zone should still work. + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/persist-then-reject + with: + log-level: ${{ inputs.log-level }} diff --git a/integration-tests/tests/persist-then-reject/action.yml b/integration-tests/tests/persist-then-reject/action.yml new file mode 100644 index 000000000..e6e6775e0 --- /dev/null +++ b/integration-tests/tests/persist-then-reject/action.yml @@ -0,0 +1,202 @@ +# Making reusable composite actions documented at +# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository +name: 'Restore after discard of the loaded zone should still work.' +description: 'Restore after discard of the loaded zone should still work.' +defaults: + # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell + run: + shell: bash --noprofile --norc -eo pipefail -x {0} +inputs: + log-level: + description: The level of logging that Cascade should output. + required: false + default: debug + type: choice + options: + - error + - warning + - info + - debug + - trace +runs: + using: "composite" + steps: + # In https://github.com/NLnetLabs/cascade/issues/825 we discovered that + # rejecting the signed zone leaves the persisted loaded zone behind even + # though Cascade internally deletes the in-memory loaded zone. When a + # loaded zone is again approved a second loaded zone snapshot will be + # persisted which will cause restore on restart to fail as it expects to + # find a snapshot + diffs, not a snapshot + snapshot. The failed restore + # then means that the zone is no longer served from the publication + # server. This test exercises that flow and verifies that the problem no + # longer exists. + - uses: ./.github/actions/prepare-systest-env + + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + # By default Casade policy uses the 'date-counter' serial policy which + # makes it possible to predict the serial number it will generate. + - name: Predict the SOA SERIAL that Cascade will generate for the signed zone + id: expected-serial + run: | + EXPECTED_SERIAL=$(date +'%Y%m%d00') + echo "serial=${EXPECTED_SERIAL}" >> "$GITHUB_OUTPUT" + + - name: Create a manual signed review policy. + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + TEST_DIR="${PWD}/integration-tests/persist-then-reject" + cp "${TEST_DIR}/policies/manual-signed-review.toml" "${POLICY_DIR}/" + cascade policy reload + + - name: Add a zone served by the NSD primary + run: | + cascade zone add --policy manual-signed-review --source 127.0.0.1:1055 example.test + + - name: Wait for Cascade to serve the signed zone at the signed review server + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +noall +answer @127.0.0.1 -p 4541 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed review zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4541 example.test AXFR + cascade zone status example.test + exit 1 + fi + sleep 1 + done + + - name: Reject the signed review + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + cascade zone reject --signed example.test ${EXPECTED_SERIAL} + + - name: Reload the zone + run: + cascade zone reload example.test + + - name: Wait for Cascade to serve the signed zone at the signed review server + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # The previous serial number was used already by Cascade (even though + # it was not published) so expect the next serial number to be used + # now. + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) + timeout=10 # seconds + start=$(date +%s) + until dig +noall +answer @127.0.0.1 -p 4541 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed review zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4541 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Approve the signed review + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # The previous serial number was used already by Cascade (even though + # it was not published) so expect the next serial number to be used + # now. + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) + cascade zone approve --signed example.test ${EXPECTED_SERIAL} + + - name: Wait for Cascade to serve the published signed zone with the newer serial number + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # The previous serial number was used already by Cascade (even though + # it was not published) so expect the next serial number to be used + # now. + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) + timeout=10 # seconds + start=$(date +%s) + + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Stop Cascade + run: | + pkill cascaded + + - name: Wait for Cascade to exit again + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + - name: Restart Cascade + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: Wait for Cascade to become healthy + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q example.test; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the signed zone + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # The previous serial number was used already by Cascade (even though + # it was not published) so expect the next serial number to be used + # now. + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) + timeout=10 # seconds + start=$(date +%s) + + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() From 70b714ad492b1dfedd7f33c1b6a6492cefe58577 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:05:29 +0200 Subject: [PATCH 27/85] FIX: Set last published SOA fields in storage on restore. --- src/persistence/restore.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 4bfd240d5..b3ee7972a 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -315,13 +315,29 @@ pub fn restore_signed( .map(|p| p.server.outbound.max_diffs); let mut state = zone.write(center); - for (loaded_serial, diff) in diffs_to_store { - // Store the signed diff to be used as part of serving an IXFR. - state.storage.diffs.store_signed_diff(loaded_serial, diff); - } + let last_diff = diffs_to_store.last(); + if let Some((last_loaded_serial, last_diff)) = last_diff { + state.storage.published_soa = last_diff.added_soa.clone(); + state.storage.published_loaded_soa = Some( + state + .storage + .published_soa + .clone() + .map(|mut soa| { + soa.rdata.serial = last_loaded_serial.unwrap(); + soa + }) + .unwrap(), + ); - if let Some(max_diffs) = max_diffs { - state.storage.diffs.discard_excess_diffs(max_diffs); + for (loaded_serial, diff) in diffs_to_store { + // Store the signed diff to be used as part of serving an IXFR. + state.storage.diffs.store_signed_diff(loaded_serial, diff); + } + + if let Some(max_diffs) = max_diffs { + state.storage.diffs.discard_excess_diffs(max_diffs); + } } info!( From 936385773ef5a585fa36d0e63570c14c9540c6ea Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:05:58 +0200 Subject: [PATCH 28/85] On signed rejection, discard the loaded in-memory diff that was added. --- src/persistence/zone.rs | 27 +++++++++++++++++++++++++++ src/zone/storage.rs | 17 +++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 0bc3d9469..cc25473e0 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -852,6 +852,33 @@ impl IxfrZoneDiffs { } } } + + pub fn discard_last_matching_diffs( + &mut self, + loaded_serial: Option, + signed_serial: Option, + ) { + assert!(loaded_serial.is_some() || signed_serial.is_some()); + trace!("discard_last_matching_diff: {loaded_serial:?}"); + + if let Some(loaded_serial) = loaded_serial { + if let Some(e) = self.loaded_diffs.last_entry() { + if *e.key() == u32::from(loaded_serial) { + trace!("Discarding loaded diff for serial {loaded_serial}"); + self.loaded_diffs.pop_last(); + } + } + } + + if let Some(signed_serial) = signed_serial { + if let Some(e) = self.signed_diffs.last_entry() { + if *e.key() == u32::from(signed_serial) { + trace!("Discarding signed diff for serial {signed_serial}"); + self.signed_diffs.pop_last(); + } + } + } + } } impl std::fmt::Display for IxfrZoneDiffs { diff --git a/src/zone/storage.rs b/src/zone/storage.rs index f0b74ffdf..8b8027556 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -895,7 +895,24 @@ impl StorageZoneHandle<'_> { .loaded_review_soa .as_ref() .map(|soa| soa.rdata.serial); + + // Remove the persisted copy of the loaded zone. self.state.persistence.loaded_diffs.cleanup(loaded_serial); + + // Remove the persisted in-memory diff. + let last_loaded_serial = self + .state + .storage + .published_loaded_soa + .as_ref() + .map(|soa| soa.rdata.serial); + trace!("Last loaded serial: {last_loaded_serial:?}"); + if last_loaded_serial.is_some() { + self.state + .storage + .diffs + .discard_last_matching_diffs(last_loaded_serial, None); + } } } From 69c0a2cc61bec1c29fda12237f0d2a975ce81718 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:07:50 +0200 Subject: [PATCH 29/85] Clippy. --- src/persistence/zone.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index cc25473e0..51f7ae4cf 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -861,23 +861,19 @@ impl IxfrZoneDiffs { assert!(loaded_serial.is_some() || signed_serial.is_some()); trace!("discard_last_matching_diff: {loaded_serial:?}"); - if let Some(loaded_serial) = loaded_serial { - if let Some(e) = self.loaded_diffs.last_entry() { - if *e.key() == u32::from(loaded_serial) { + if let Some(loaded_serial) = loaded_serial + && let Some(e) = self.loaded_diffs.last_entry() + && *e.key() == u32::from(loaded_serial) { trace!("Discarding loaded diff for serial {loaded_serial}"); self.loaded_diffs.pop_last(); } - } - } - if let Some(signed_serial) = signed_serial { - if let Some(e) = self.signed_diffs.last_entry() { - if *e.key() == u32::from(signed_serial) { + if let Some(signed_serial) = signed_serial + && let Some(e) = self.signed_diffs.last_entry() + && *e.key() == u32::from(signed_serial) { trace!("Discarding signed diff for serial {signed_serial}"); self.signed_diffs.pop_last(); } - } - } } } From 8a770b86790cb8e0dc7352849865e1d57e9b35e1 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:10:07 +0200 Subject: [PATCH 30/85] Cargo fmt. --- src/persistence/zone.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 51f7ae4cf..5f1572211 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -863,17 +863,19 @@ impl IxfrZoneDiffs { if let Some(loaded_serial) = loaded_serial && let Some(e) = self.loaded_diffs.last_entry() - && *e.key() == u32::from(loaded_serial) { - trace!("Discarding loaded diff for serial {loaded_serial}"); - self.loaded_diffs.pop_last(); - } + && *e.key() == u32::from(loaded_serial) + { + trace!("Discarding loaded diff for serial {loaded_serial}"); + self.loaded_diffs.pop_last(); + } if let Some(signed_serial) = signed_serial && let Some(e) = self.signed_diffs.last_entry() - && *e.key() == u32::from(signed_serial) { - trace!("Discarding signed diff for serial {signed_serial}"); - self.signed_diffs.pop_last(); - } + && *e.key() == u32::from(signed_serial) + { + trace!("Discarding signed diff for serial {signed_serial}"); + self.signed_diffs.pop_last(); + } } } From 80ee2011c35e43866ff97c6db318f6f28ebf63d6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:18:58 +0200 Subject: [PATCH 31/85] Add max-diffs-size policy setting. - Move adding the loaded diff to the in-memory store to signed persistence to avoid the need to "undo" addition of a loaded in-memory diff if the signing is aborted or signed review is rejected. - Remove no-longer needed discard_last_loaded() fn. --- crates/api/src/lib.rs | 1 + crates/cli/src/commands/policy.rs | 4 +- etc/policy.template.toml | 16 +- .../persist-zone/policies/minimal-diffs.toml | 25 ++- .../tests/persist-zone/action.yml | 14 ++ src/persistence/mod.rs | 4 +- src/persistence/persist.rs | 144 +++++++++--------- src/persistence/restore.rs | 27 ++-- src/persistence/zone.rs | 91 ++++++----- src/policy/file/v1.rs | 24 +++ src/policy/mod.rs | 8 + src/units/http_server.rs | 10 +- src/zone/storage.rs | 17 +-- 13 files changed, 240 insertions(+), 145 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index e107e8783..56cf86e66 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -903,6 +903,7 @@ pub struct OutboundPolicyInfo { pub provide_xfr_to: Vec, pub send_notify_to: Vec, pub max_diffs: usize, + pub max_diffs_size: usize, } #[derive(Deserialize, Serialize, Debug, Clone)] diff --git a/crates/cli/src/commands/policy.rs b/crates/cli/src/commands/policy.rs index e55adba1f..411806f0e 100644 --- a/crates/cli/src/commands/policy.rs +++ b/crates/cli/src/commands/policy.rs @@ -130,8 +130,8 @@ fn print_policy(p: &PolicyInfo) { }; let hsm_server_id = p.key_manager.hsm_server_id.as_ref().unwrap_or(&none); - let max_diffs = p.server.outbound.max_diffs; + let max_diffs_size = p.server.outbound.max_diffs_size; fn print_review(r: &ReviewPolicyInfo) { println!(" review:"); @@ -169,5 +169,5 @@ fn print_policy(p: &PolicyInfo) { print_nameserver_comms_policy(&p.server.outbound.provide_xfr_to); println!(" send NOTIFY to:"); print_nameserver_comms_policy(&p.server.outbound.send_notify_to); - println!(" max diffs: {max_diffs}") + println!(" max diffs: {max_diffs} ({max_diffs_size}%)"); } diff --git a/etc/policy.template.toml b/etc/policy.template.toml index f5ea48caf..6aef797f7 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -502,6 +502,20 @@ mode = "off" # does not currently implement this behaviour. Cascade also currently has no # support for RFC 1995 section 6 condensation of multiple versions. # -# The default is to limits diffs to 5 per zone. If set to 0 storage of diffs +# The default is to limits diffs to 5 per zone. If set to 0, storage of diffs # will be disabled. #max-diffs = 5 + +# The maximum size that the collection of IXFR diffs for a zone can grow +# to in-memory after which diffs are discarded, oldest first, one at a time +# until the remaining diffs combined are smaller than this percentage of the +# published zone. +# +# This setting has no impact on the number of diffs persisted to disk. See +# max-diffs to control that. +# +# The default is 20%. If set to 0, or a value low enough for the size of zone +# and the rate and size of change means that the collection of diffs always +# exceeds this percentage, then no diffs will be stored in memory and IXFR +# requests will be responded to with an AXFR instead. +#max-diffs-size = 20 diff --git a/integration-tests/persist-zone/policies/minimal-diffs.toml b/integration-tests/persist-zone/policies/minimal-diffs.toml index 15f42a639..77df1e858 100644 --- a/integration-tests/persist-zone/policies/minimal-diffs.toml +++ b/integration-tests/persist-zone/policies/minimal-diffs.toml @@ -481,7 +481,7 @@ mode = "off" # The maximum number of sequences of differential information (diffs) that # the server may store per zone in order to respond to RFC 1995 Incremental -# Zone Transfer (IXFR) requests. Defaults to 5 diffs per zone. +# Zone Transfer (IXFR) requests. # # Diffs are persisted so that they can be restored on restart of the Cascade # daemon. @@ -493,10 +493,31 @@ mode = "off" # # Persisted diffs are discarded lazily to reduce the impact of persisted diff # compaction. The number of persisted diffs may therefore temporarily exceed -# the limit specified here until purging of persisted diffs next occurs. +# the limit specified here until purging of persisted diffs next occurs. Lazy +# purging of persisted diffs will be skipped for zones that are in maintenance +# mode. # # Note that while RFC 1955 section 5 suggests that diffs be purged based on # total IXFR response size and/or if older than the SOA expire period, Cascade # does not currently implement this behaviour. Cascade also currently has no # support for RFC 1995 section 6 condensation of multiple versions. +# +# The default is to limits diffs to 5 per zone. If set to 0, storage of diffs +# will be disabled. +#max-diffs = 5 max-diffs = 1 + +# The maximum size that the collection of IXFR diffs for a zone can grow +# to in-memory after which diffs are discarded, oldest first, one at a time +# until the remaining diffs combined are smaller than this percentage of the +# published zone. +# +# This setting has no impact on the number of diffs persisted to disk. See +# max-diffs to control that. +# +# The default is 20%. If set to 0, or a value low enough for the size of zone +# and the rate and size of change means that the collection of diffs always +# exceeds this percentage, then no diffs will be stored in memory and IXFR +# requests will be responded to with an AXFR instead. +#max-diffs = 20 +max-diffs-size = 100 diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 8b5ee153e..18e63344f 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -27,6 +27,20 @@ runs: with: log-level: ${{ inputs.log-level }} + # ------------------------------------------------------------------------ + # Increase the server.outbound.max-diffs-size policy setting to prevent it + # interfering in our test which happens because this test uses very small + # zones, and so the default of 20% causes diffs to be discarded as 20% of + # a small number of records is a very small number and this the limit is + # easily reached. + # ------------------------------------------------------------------------ + + - name: Increase policy setting max-diffs-size to 100%. + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + sed -i -e 's|#max-diffs-size.\+|max-diffs-size = 100|' "${POLICY_DIR}/default.toml" + cascade policy reload + # ------------------------------------------------------------------------ # 0. Add a zone that cannot be loaded but does not fail `zone add` # immediately so that Cascade has the zone but no content for it, diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index a44763fc7..6429477cd 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -97,7 +97,9 @@ use std::{sync::Arc, time::Duration}; use crate::{center::Center, util::AbortOnDrop, zone::ZoneByName}; mod persist; -use persist::{persist_loaded, persist_signed, persist_to_file_from_parts}; +pub use persist::{ + discard_excess_diffs, persist_loaded, persist_signed, persist_to_file_from_parts, +}; mod restore; use restore::{restore_loaded, restore_signed}; diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 72103adfb..59eeeffd2 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -12,11 +12,11 @@ use cascade_zonedata::{ }; use domain::new::base::wire::{BuildBytes, TruncationError}; -use tracing::{trace, warn}; +use tracing::trace; use crate::{ center::Center, - zone::{Zone, save_state_now}, + zone::{OwnedZoneHandle, Zone, save_state_now}, }; /// Persist the data for a loaded instance of a zone. @@ -74,14 +74,11 @@ pub fn persist_loaded( persist_to_file(&destination, loaded_diff.clone()); - if loaded_diff.removed_soa.is_some() && loaded_diff.removed_soa != loaded_diff.added_soa { - let mut handle = zone.write_handle(center); - handle - .state - .storage - .diffs - .store_loaded_diff(loaded_diff.clone()); - } + // We don't add the loaded diff to the in-memory store used for + // serving IXFR responses, that is done later in persist_signed() as + // the store is only used for answering requests to the publication + // server, and because if we add it here then abandon signing for some + // reason we would then have to remove the loaded diff that we added. } persister.mark_complete() @@ -101,11 +98,11 @@ pub fn persist_signed( if !persister.signed_diff().is_empty() { let loaded_diff = persister.loaded_diff(); let signed_diff = persister.signed_diff(); - let loaded_serial = - loaded_diff.and_then(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)); let destination = { let mut handle = zone.write_handle(center); + let loaded_serial = + loaded_diff.and_then(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)); let signed_serial = signed_diff.removed_soa.as_ref().map(|s| s.rdata.serial); handle .state @@ -144,64 +141,7 @@ pub fn persist_signed( persist_to_file(&destination, signed_diff.clone()); // Store the diffs in-memory for serving IXFR out. - // - // Only store a diff if the SOA from the previous version of the - // signed zone was removed and a new one added, otherwise this is not - // a diff to a previous version of the zone but actually a snapshot of - // the zone after having been signed for the first time. - if signed_diff.removed_soa.is_some() && signed_diff.removed_soa != signed_diff.added_soa { - // Store anything that changed when the zone was re-loaded, i.e. - // unsigned zone content changes. Note that the SOA SERIAL is not - // required to change unless using 'keep' policy and so we should - // not require the SOA to have been removed and a new one added. - - // Store anything that changed when the zone was re-signed, i.e. - // changes DNSSEC RRs that can be caused by unsigned content - // changes or changing from NSEC <-> NSEC3 or using a new key - // to sign with or just regenerating signatures to avoid them - // expiring. Signed zones MUST always have a new SOA SERIAL - // compared to the previous version of the signed zone. - - let mut handle = zone.write_handle(center); - - // Purge in-memory diffs if needed before adding a new one. - if let Some(max_diffs) = handle - .state - .policy - .as_ref() - .map(|p| p.server.outbound.max_diffs) - { - trace!( - "Discarding in-memory diffs to max {max_diffs} for zone '{}'", - zone.name - ); - handle.state.storage.diffs.discard_excess_diffs(max_diffs); - } - - // If we have a new signed diff to store because records in the - // loaded part of the zone changed, e.g. due to changes in the - // zone content or receipt of a changed DNSKEY set from the key - // manager, then the loaded diff will have been stored in-memory - // by loaded zone persistence, but the corresponding signed diff - // will not yet have been stored in-memory, we have to do that - // now. In this case we have to update the last stored in-memory - // diff. We drop the partial diff and push a replacement full - // diff instead. - // - // Alternatively if we have a new signed diff to store because - // records in the signed part of the zone changed, e.g. due to - // signature re-generation to ensure that existing signatures - // don't expire, then there will be no corresponding loaded diff - // yet in-memory. In this case we have to push an entirely new - // diff to the in-memory collection without dropping an existing - // diff first. - - handle - .state - .storage - .diffs - .store_signed_diff(loaded_serial, signed_diff.clone()); - } + store_for_ixfr_out(center, zone, loaded_diff, signed_diff); } persister.mark_complete() @@ -373,3 +313,67 @@ pub fn persist_to_file_from_parts< added_soa.rdata.serial, ); } + +//------------ store_for_ixfr_out() ------------------------------------------ + +fn store_for_ixfr_out( + center: &Arc
, + zone: &Arc, + loaded_diff: Option<&Arc>, + signed_diff: &Arc, +) { + // Only store a diff if the SOA from the previous version of the + // signed zone was removed and a new one added, otherwise this is not + // a diff to a previous version of the zone but actually a snapshot of + // the zone after having been signed for the first time. + // Ignore the diff if it is not acceptable, e.g. if it changes more than + // X% of the records in the zone or crosses some other threshold. + if signed_diff.removed_soa.is_some() && signed_diff.added_soa.is_some() { + let mut handle = zone.write_handle(center); + discard_excess_diffs(&mut handle); + store_diff(&mut handle, loaded_diff, signed_diff); + } +} + +fn store_diff( + handle: &mut OwnedZoneHandle<'_>, + loaded_diff: Option<&Arc>, + signed_diff: &Arc, +) { + let loaded_serial = loaded_diff.and_then(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)); + let diffs = &mut handle.state.storage.diffs; + if let Some(loaded_diff) = loaded_diff { + diffs.store_loaded_diff(loaded_diff.clone()); + } + diffs.store_signed_diff(loaded_serial, signed_diff.clone()); +} + +//------------ discard_excess_diffs() ---------------------------------------- + +pub fn discard_excess_diffs(handle: &mut OwnedZoneHandle) { + // Purge in-memory diffs if needed before adding a new one. + if let Some(policy) = handle.state.policy.as_ref() { + if let Some(last_published) = handle.state.last_published.as_ref() { + // Fetch diff purging settings from policy. + let max_diffs = policy.server.outbound.max_diffs; + let max_size_percentage = policy.server.outbound.max_diffs_size; + + // Calculate the maximum number of records that a set of diffs can + // be based on the policy settings. IxfrZoneDiffs can't do this + // for us as it has no access to `last_published`. + let current_size = last_published.num_records as f64; + let max_size = if max_size_percentage == 0 { + 0 + } else { + let percentage = max_size_percentage as f64 / 100.0; + (current_size * percentage) as usize + }; + + trace!( + "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={current_size}, max_size={max_size_percentage}% ({max_size} RRs)", + handle.zone.name + ); + handle.state.storage.diffs.trim(max_diffs, max_size); + } + } +} diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index b3ee7972a..3a144aecd 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -24,7 +24,7 @@ use domain::{ }; use tracing::{info, trace}; -use crate::{center::Center, zone::Zone}; +use crate::{center::Center, persistence::discard_excess_diffs, zone::Zone}; /// Restore the loaded instance data of a zone. /// @@ -308,18 +308,13 @@ pub fn restore_signed( num_diffs_to_restore, zone.name ); - let max_diffs = zone - .read() - .policy - .clone() - .map(|p| p.server.outbound.max_diffs); - - let mut state = zone.write(center); + let mut handle = zone.write_handle(center); let last_diff = diffs_to_store.last(); if let Some((last_loaded_serial, last_diff)) = last_diff { - state.storage.published_soa = last_diff.added_soa.clone(); - state.storage.published_loaded_soa = Some( - state + handle.state.storage.published_soa = last_diff.added_soa.clone(); + handle.state.storage.published_loaded_soa = Some( + handle + .state .storage .published_soa .clone() @@ -332,12 +327,14 @@ pub fn restore_signed( for (loaded_serial, diff) in diffs_to_store { // Store the signed diff to be used as part of serving an IXFR. - state.storage.diffs.store_signed_diff(loaded_serial, diff); + handle + .state + .storage + .diffs + .store_signed_diff(loaded_serial, diff); } - if let Some(max_diffs) = max_diffs { - state.storage.diffs.discard_excess_diffs(max_diffs); - } + discard_excess_diffs(&mut handle); } info!( diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 5f1572211..5785df55b 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -830,52 +830,75 @@ impl IxfrZoneDiffs { diffs } - pub fn discard_excess_diffs(&mut self, max_diffs: usize) { + pub fn trim(&mut self, max_diffs: usize, max_size: usize) { + // First check and trim excess diffs. let num_signed_diffs = self.num_signed_diffs(); - debug!("Checking for diffs to discard: {num_signed_diffs} > {max_diffs}?"); + debug!( + "Checking for diffs to discard: {num_signed_diffs} signed diffs > max_diffs ({max_diffs})?" + ); if num_signed_diffs > max_diffs { // Prune the oldest diffs so that we end up storing no more than // max_diffs signed diffs. let num_diffs_to_prune = num_signed_diffs - max_diffs; debug!("Discarding {num_diffs_to_prune} in-memory diffs"); for _ in 0..num_diffs_to_prune { - if let Some(e) = self.signed_diffs.first_entry() { - trace!("Discarding in-memory signed diff for serial {}", e.key()); - let related_loaded_diff = e.remove(); - if let Some(loaded_serial) = related_loaded_diff.related_loaded_serial { - trace!( - "Discarding related in-memory loaded diff for serial {loaded_serial}" - ); - let _ = self.loaded_diffs.remove(&loaded_serial); - } + let _ = self.discard_first_diff_pair(); + } + } + + // Next trim enough diffs to bring the total number of RRs stored + // under the specified limit. + let loaded_diff_sizes = self + .loaded_diffs + .iter() + .map(|(_, d)| Self::calc_diff_size(d)) + .collect::>(); + let signed_diff_sizes = self + .signed_diffs + .iter() + .map(|(_, rd)| Self::calc_diff_size(&rd.diff)) + .collect::>(); + let mut total_rr_count = + loaded_diff_sizes.iter().sum::() + signed_diff_sizes.iter().sum::(); + + debug!("Checking for diffs to discard: {total_rr_count} RRs > max_size ({max_size}) RRs??"); + while total_rr_count > max_size { + if let Some((loaded_diff, signed_diff)) = self.discard_first_diff_pair() { + total_rr_count -= Self::calc_diff_size(&signed_diff); + if let Some(loaded_diff) = loaded_diff { + total_rr_count -= Self::calc_diff_size(&loaded_diff); } + debug!("Discarded in-memory diff: updated total RR count = {total_rr_count}"); + } else { + break; } } } - pub fn discard_last_matching_diffs( - &mut self, - loaded_serial: Option, - signed_serial: Option, - ) { - assert!(loaded_serial.is_some() || signed_serial.is_some()); - trace!("discard_last_matching_diff: {loaded_serial:?}"); - - if let Some(loaded_serial) = loaded_serial - && let Some(e) = self.loaded_diffs.last_entry() - && *e.key() == u32::from(loaded_serial) - { - trace!("Discarding loaded diff for serial {loaded_serial}"); - self.loaded_diffs.pop_last(); + fn discard_first_diff_pair(&mut self) -> Option<(Option>, Arc)> { + if let Some(e) = self.signed_diffs.first_entry() { + trace!("Discarding in-memory signed diff for serial {}", e.key()); + let RelatedSignedDiff { + diff, + related_loaded_serial, + } = e.remove(); + if let Some(loaded_serial) = related_loaded_serial { + trace!("Discarding related in-memory loaded diff for serial {loaded_serial}"); + let loaded_diff = self.loaded_diffs.remove(&loaded_serial); + Some((loaded_diff, diff)) + } else { + Some((None, diff)) + } + } else { + None } + } - if let Some(signed_serial) = signed_serial - && let Some(e) = self.signed_diffs.last_entry() - && *e.key() == u32::from(signed_serial) - { - trace!("Discarding signed diff for serial {signed_serial}"); - self.signed_diffs.pop_last(); - } + fn calc_diff_size(diff: &Arc) -> usize { + diff.removed_soa.as_ref().map(|_| 1).unwrap_or(0) + + diff.added_soa.as_ref().map(|_| 1).unwrap_or(0) + + diff.removed_records.len() + + diff.added_records.len() } } @@ -914,11 +937,11 @@ impl std::fmt::Display for IxfrZoneDiffs { struct RelatedSignedDiff { /// The signed diff. - diff: Arc, + pub diff: Arc, /// The removed serial number of the loaded diff that this signed diff /// relates to, if any. - related_loaded_serial: Option, + pub related_loaded_serial: Option, } impl RelatedSignedDiff { diff --git a/src/policy/file/v1.rs b/src/policy/file/v1.rs index 326bd38d8..526c04432 100644 --- a/src/policy/file/v1.rs +++ b/src/policy/file/v1.rs @@ -66,6 +66,16 @@ const AUTO_REMOVE_DELAY: u32 = 7 * 24 * 3600; // Based on the NSD default of ixfr-number: 5. const MAX_DIFFS: usize = 5; +// The maximum size that in-memory diffs may reach as a percentage of the +// published zone. +// +// IXFR diffs that describe larger changes (compared to the last published +// version of the zone) than this limit will be kept in-memory to to serve to +// IXFR clients. +// +// Based on https://github.com/NLnetLabs/cascade/issues/830#issuecomment-4752275415 +const MAX_DIFFS_SIZE: usize = 20; + //----------- Spec ------------------------------------------------------------- /// A policy file. @@ -957,6 +967,14 @@ pub struct OutboundSpec { /// Excess diffs will be discarded. #[serde(default = "default_max_diffs")] pub max_diffs: usize, + + /// The maximum percentage of change allowed for a single IXFR diff. + /// + /// Only diffs that desribe smaller changes (compared to the last + /// published version of the zone) than this limit will be stored and + /// served to clients. + #[serde(default = "default_max_diffs_size")] + max_diffs_size: usize, } fn empty_list() -> Vec { @@ -967,6 +985,10 @@ fn default_max_diffs() -> usize { MAX_DIFFS } +fn default_max_diffs_size() -> usize { + MAX_DIFFS_SIZE +} + //--- Conversion impl OutboundSpec { @@ -976,6 +998,7 @@ impl OutboundSpec { provide_xfr_to: self.provide_xfr_to.into_iter().map(|v| v.parse()).collect(), send_notify_to: self.send_notify_to.into_iter().map(|v| v.parse()).collect(), max_diffs: self.max_diffs, + max_diffs_size: self.max_diffs_size, } } @@ -993,6 +1016,7 @@ impl OutboundSpec { .map(NameserverCommsSpec::build) .collect(), max_diffs: policy.max_diffs, + max_diffs_size: policy.max_diffs_size, } } } diff --git a/src/policy/mod.rs b/src/policy/mod.rs index bafecd0bd..9bd83df10 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -567,6 +567,14 @@ pub struct OutboundPolicy { /// /// Excess diffs will be discarded. pub max_diffs: usize, + + /// The maximum size that in-memory diffs may reach as a percentage + /// of the published zone. + /// + /// IXFR diffs that describe larger changes (compared to the last + /// published version of the zone) than this limit will be kept in-memory + /// to to serve to IXFR clients. + pub max_diffs_size: usize, } //----------- NameserverCommsPolicy ------------------------------------------- diff --git a/src/units/http_server.rs b/src/units/http_server.rs index 89018fe98..b88830dfe 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -947,12 +947,9 @@ impl HttpServer { // Discard excess IXFR diffs if the maximum number permitted // by the policy has been reduced. { - let mut state = zone.write(center); - state.policy = Some(pol.latest.clone()); - state - .storage - .diffs - .discard_excess_diffs(pol.latest.server.outbound.max_diffs); + let mut handle = zone.write_handle(center); + + crate::persistence::discard_excess_diffs(&mut handle); } center @@ -1040,6 +1037,7 @@ impl HttpServer { .map(|v| NameserverCommsPolicyInfo { addr: v.addr }) .collect(), max_diffs: p_outbound.max_diffs, + max_diffs_size: p_outbound.max_diffs_size, }, }; diff --git a/src/zone/storage.rs b/src/zone/storage.rs index 8b8027556..2fa16f10f 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -899,20 +899,9 @@ impl StorageZoneHandle<'_> { // Remove the persisted copy of the loaded zone. self.state.persistence.loaded_diffs.cleanup(loaded_serial); - // Remove the persisted in-memory diff. - let last_loaded_serial = self - .state - .storage - .published_loaded_soa - .as_ref() - .map(|soa| soa.rdata.serial); - trace!("Last loaded serial: {last_loaded_serial:?}"); - if last_loaded_serial.is_some() { - self.state - .storage - .diffs - .discard_last_matching_diffs(last_loaded_serial, None); - } + // We don't need to remove the loaded diff from the in-memory IXFR + // store as it only gets added along with the signed zone when the + // signed zone is persisted. } } From 0ec472a5fc84329f26df7c355fb8f180033ce9e7 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:19:16 +0200 Subject: [PATCH 32/85] FIX: Set new policy in zone after reload. --- src/units/http_server.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/units/http_server.rs b/src/units/http_server.rs index b88830dfe..974740331 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -949,6 +949,10 @@ impl HttpServer { { let mut handle = zone.write_handle(center); + // Update the policy version used by the zone to be the + // new version that resulted from reloading policy. + handle.state.policy = Some(pol.latest.clone()); + crate::persistence::discard_excess_diffs(&mut handle); } From 61c40743c748d11c59219db94ce4d5ce3cf7a8fd Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:26:27 +0200 Subject: [PATCH 33/85] Clippy. --- src/persistence/persist.rs | 5 ++--- src/persistence/zone.rs | 8 ++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 59eeeffd2..4ef6cb6c6 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -352,8 +352,8 @@ fn store_diff( pub fn discard_excess_diffs(handle: &mut OwnedZoneHandle) { // Purge in-memory diffs if needed before adding a new one. - if let Some(policy) = handle.state.policy.as_ref() { - if let Some(last_published) = handle.state.last_published.as_ref() { + if let Some(policy) = handle.state.policy.as_ref() + && let Some(last_published) = handle.state.last_published.as_ref() { // Fetch diff purging settings from policy. let max_diffs = policy.server.outbound.max_diffs; let max_size_percentage = policy.server.outbound.max_diffs_size; @@ -375,5 +375,4 @@ pub fn discard_excess_diffs(handle: &mut OwnedZoneHandle) { ); handle.state.storage.diffs.trim(max_diffs, max_size); } - } } diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 5785df55b..7a064496f 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -849,14 +849,10 @@ impl IxfrZoneDiffs { // Next trim enough diffs to bring the total number of RRs stored // under the specified limit. let loaded_diff_sizes = self - .loaded_diffs - .iter() - .map(|(_, d)| Self::calc_diff_size(d)) + .loaded_diffs.values().map(Self::calc_diff_size) .collect::>(); let signed_diff_sizes = self - .signed_diffs - .iter() - .map(|(_, rd)| Self::calc_diff_size(&rd.diff)) + .signed_diffs.values().map(|rd| Self::calc_diff_size(&rd.diff)) .collect::>(); let mut total_rr_count = loaded_diff_sizes.iter().sum::() + signed_diff_sizes.iter().sum::(); From acc21021767a8a8dd16f39f1439d0d2d3036b4ee Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:32:50 +0200 Subject: [PATCH 34/85] Cargo fmt. --- src/persistence/persist.rs | 43 +++++++++++++++++++------------------- src/persistence/zone.rs | 8 +++++-- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 4ef6cb6c6..a01e29dba 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -353,26 +353,27 @@ fn store_diff( pub fn discard_excess_diffs(handle: &mut OwnedZoneHandle) { // Purge in-memory diffs if needed before adding a new one. if let Some(policy) = handle.state.policy.as_ref() - && let Some(last_published) = handle.state.last_published.as_ref() { - // Fetch diff purging settings from policy. - let max_diffs = policy.server.outbound.max_diffs; - let max_size_percentage = policy.server.outbound.max_diffs_size; - - // Calculate the maximum number of records that a set of diffs can - // be based on the policy settings. IxfrZoneDiffs can't do this - // for us as it has no access to `last_published`. - let current_size = last_published.num_records as f64; - let max_size = if max_size_percentage == 0 { - 0 - } else { - let percentage = max_size_percentage as f64 / 100.0; - (current_size * percentage) as usize - }; + && let Some(last_published) = handle.state.last_published.as_ref() + { + // Fetch diff purging settings from policy. + let max_diffs = policy.server.outbound.max_diffs; + let max_size_percentage = policy.server.outbound.max_diffs_size; + + // Calculate the maximum number of records that a set of diffs can + // be based on the policy settings. IxfrZoneDiffs can't do this + // for us as it has no access to `last_published`. + let current_size = last_published.num_records as f64; + let max_size = if max_size_percentage == 0 { + 0 + } else { + let percentage = max_size_percentage as f64 / 100.0; + (current_size * percentage) as usize + }; - trace!( - "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={current_size}, max_size={max_size_percentage}% ({max_size} RRs)", - handle.zone.name - ); - handle.state.storage.diffs.trim(max_diffs, max_size); - } + trace!( + "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={current_size}, max_size={max_size_percentage}% ({max_size} RRs)", + handle.zone.name + ); + handle.state.storage.diffs.trim(max_diffs, max_size); + } } diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 7a064496f..586936956 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -849,10 +849,14 @@ impl IxfrZoneDiffs { // Next trim enough diffs to bring the total number of RRs stored // under the specified limit. let loaded_diff_sizes = self - .loaded_diffs.values().map(Self::calc_diff_size) + .loaded_diffs + .values() + .map(Self::calc_diff_size) .collect::>(); let signed_diff_sizes = self - .signed_diffs.values().map(|rd| Self::calc_diff_size(&rd.diff)) + .signed_diffs + .values() + .map(|rd| Self::calc_diff_size(&rd.diff)) .collect::>(); let mut total_rr_count = loaded_diff_sizes.iter().sum::() + signed_diff_sizes.iter().sum::(); From adcefbc17155d494c8c9b0bdc11286a7ba7852f1 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:22:02 +0200 Subject: [PATCH 35/85] Consistency: refactor into `on_zone_policy_changed()` style. --- src/persistence/mod.rs | 27 ++++++++++++++++++- src/persistence/persist.rs | 24 ++++++++--------- src/persistence/restore.rs | 53 +++++++++++++++++++++----------------- src/units/http_server.rs | 14 +++------- 4 files changed, 72 insertions(+), 46 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 6429477cd..a391c8007 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -94,7 +94,12 @@ use std::{sync::Arc, time::Duration}; -use crate::{center::Center, util::AbortOnDrop, zone::ZoneByName}; +use crate::{ + center::Center, + policy::PolicyVersion, + util::AbortOnDrop, + zone::{Zone, ZoneByName}, +}; mod persist; pub use persist::{ @@ -120,6 +125,26 @@ impl Persister { pub fn new() -> Self { Self {} } + + pub fn on_zone_policy_changed( + &self, + center: &Arc
, + zone: &Arc, + old: Option>, + new: Arc, + ) { + if let Some(old) = old + && old.server.outbound.max_diffs <= new.server.outbound.max_diffs + && old.server.outbound.max_diffs_size <= new.server.outbound.max_diffs_size + { + // Nothing changed, at least not in a way that affects us. + // Increased diff limits doesn't require action, only a reduction + // in limits requires us to act. + return; + } + + discard_excess_diffs(center, zone); + } } impl Default for Persister { diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 386aef0be..e67ea2f88 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -16,7 +16,7 @@ use tracing::trace; use crate::{ center::Center, - zone::{OwnedZoneHandle, Zone, save_state_now}, + zone::{Zone, save_state_now}, }; /// Persist the data for a loaded instance of a zone. @@ -336,19 +336,19 @@ fn store_for_ixfr_out( // Ignore the diff if it is not acceptable, e.g. if it changes more than // X% of the records in the zone or crosses some other threshold. if signed_diff.removed_soa.is_some() && signed_diff.added_soa.is_some() { - let mut handle = zone.write_handle(center); - discard_excess_diffs(&mut handle); - store_diff(&mut handle, loaded_diff, signed_diff); + discard_excess_diffs(center, zone); + store_diff(center, zone, loaded_diff, signed_diff); } } fn store_diff( - handle: &mut OwnedZoneHandle<'_>, + center: &Arc
, + zone: &Arc, loaded_diff: Option<&Arc>, signed_diff: &Arc, ) { let loaded_serial = loaded_diff.and_then(|d| d.removed_soa.as_ref().map(|s| s.rdata.serial)); - let diffs = &mut handle.state.storage.diffs; + let diffs = &mut zone.write(center).storage.diffs; if let Some(loaded_diff) = loaded_diff { diffs.store_loaded_diff(loaded_diff.clone()); } @@ -357,10 +357,10 @@ fn store_diff( //------------ discard_excess_diffs() ---------------------------------------- -pub fn discard_excess_diffs(handle: &mut OwnedZoneHandle) { - // Purge in-memory diffs if needed before adding a new one. - if let Some(policy) = handle.state.policy.as_ref() - && let Some(last_published) = handle.state.last_published.as_ref() +pub fn discard_excess_diffs(center: &Arc
, zone: &Arc) { + let mut state = zone.write(center); + if let Some(policy) = state.policy.as_ref() + && let Some(last_published) = state.last_published.as_ref() { // Fetch diff purging settings from policy. let max_diffs = policy.server.outbound.max_diffs; @@ -379,8 +379,8 @@ pub fn discard_excess_diffs(handle: &mut OwnedZoneHandle) { trace!( "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={current_size}, max_size={max_size_percentage}% ({max_size} RRs)", - handle.zone.name + zone.name ); - handle.state.storage.diffs.trim(max_diffs, max_size); + state.storage.diffs.trim(max_diffs, max_size); } } diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index d7838e650..38696af8a 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -310,33 +310,40 @@ pub fn restore_signed( num_diffs_to_restore, zone.name ); - let mut handle = zone.write_handle(center); let last_diff = diffs_to_store.last(); if let Some((last_loaded_serial, last_diff)) = last_diff { - handle.state.storage.published_soa = last_diff.added_soa.clone(); - handle.state.storage.published_loaded_soa = Some( - handle - .state - .storage - .published_soa - .clone() - .map(|mut soa| { - soa.rdata.serial = last_loaded_serial.unwrap(); - soa - }) - .unwrap(), - ); - - for (loaded_serial, diff) in diffs_to_store { - // Store the signed diff to be used as part of serving an IXFR. - handle - .state - .storage - .diffs - .store_signed_diff(loaded_serial, diff); + { + let mut state = zone.write(center); + + // Build a new loaded SOA RR from the last published SOA RR with + // the serial number replaced with that of the last loaded serial. + // We don't have access to the actual last loaded SOA here, only + // it's serial number as that is all that we stored in the state + // that we restored. We can't set `published_loaded_soa` in + // restore_loaded() as we don't know at that point if the last + // loaded diff was later published. + // + // TODO: Could fields in the SOA other than the serial differ + // between the loaded SOA and the published SOA, and thus is it + // then insufficient to use the last published SOA RR as the basis + // for constructing a last published loaded SOA RR? + let mut template_soa = + last_diff.added_soa.clone().expect( + "To be restoring a signed zone we must have had a diff which must have had an added SOA", + ); + template_soa.rdata.serial = last_loaded_serial + .expect("A restored signed diff must be related to a loaded diff"); + + state.storage.published_soa = last_diff.added_soa.clone(); + state.storage.published_loaded_soa = Some(template_soa); + + for (loaded_serial, diff) in diffs_to_store { + // Store the signed diff to be used as part of serving an IXFR. + state.storage.diffs.store_signed_diff(loaded_serial, diff); + } } - discard_excess_diffs(&mut handle); + discard_excess_diffs(center, zone); } info!( diff --git a/src/units/http_server.rs b/src/units/http_server.rs index 2e37170ab..23073ecea 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -953,17 +953,11 @@ impl HttpServer { .get(zone_name) .expect("zones and policies are consistent"); - // Discard excess IXFR diffs if the maximum number permitted - // by the policy has been reduced. - { - let mut handle = zone.write_handle(center); - - // Update the policy version used by the zone to be the - // new version that resulted from reloading policy. - handle.state.policy = Some(pol.latest.clone()); + zone.write(center).policy = Some(pol.latest.clone()); - crate::persistence::discard_excess_diffs(&mut handle); - } + center + .persister + .on_zone_policy_changed(center, zone, old.clone(), new.clone()); center .key_manager From cb134f0d8d48420be69aca6a598bb02e62f89460 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:06:53 +0200 Subject: [PATCH 36/85] Update purging policy setting descriptions. --- doc/manual/build/man/cascaded-policy.toml.5 | 48 ++++++++++++++++- .../source/man/cascaded-policy.toml.rst | 44 +++++++++++++++ etc/policy.template.toml | 54 +++++++++---------- 3 files changed, 117 insertions(+), 29 deletions(-) diff --git a/doc/manual/build/man/cascaded-policy.toml.5 b/doc/manual/build/man/cascaded-policy.toml.5 index 0e442e542..2438ec70b 100644 --- a/doc/manual/build/man/cascaded-policy.toml.5 +++ b/doc/manual/build/man/cascaded-policy.toml.5 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADED-POLICY.TOML" "5" "Jan 01, 2026" "0.1.0-beta3" "Cascade" +.TH "CASCADED-POLICY.TOML" "5" "Jan 01, 2026" "0.1.0-beta4-dev" "Cascade" .SH NAME cascaded-policy.toml \- Cascade policy file format .sp @@ -739,6 +739,52 @@ Each nameserver must be specified as a string in the form: .sp \fI\(dq[^]\(dq\fP .UNINDENT +.INDENT 0.0 +.TP +.B max\-diffs = 5 +The maximum number of \(dqsequences of differential information\(dq (diffs) that +the server may store per zone in order to respond to RFC 1995 Incremental +Zone Transfer (IXFR) requests. +.sp +A diff is created when changes to the input zone are received and when +re\-signing alters the signed records generated when signing last occurred. +.sp +Diffs are persisted to disk once approved and will be restored on next +start of the Cascade daemon. +.sp +Excess in\-memory diffs are discarded as soon as possible after approval of +a new diff, or on policy reload if the diff maximums were reduced compared +to previous version of the policy. +.sp +Persisted diffs are discarded periodically to reduce the impact of +persisted diff compaction. The number of persisted diffs may therefore +temporarily exceed the limit specified here until purging of persisted +diffs next occurs. Discarding of persisted diffs will be skipped for +zones that are in maintenance mode. +.sp +Note that while RFC 1955 section 5 suggests that diffs be purged based on +total IXFR response size and/or if older than the SOA expire period, Cascade +does not currently implement this behaviour. Cascade also currently has no +support for RFC 1995 section 6 condensation of multiple versions. +.sp +The default is to limits diffs to 5 per zone. If set to 0, storage of diffs +will be disabled. +.UNINDENT +.INDENT 0.0 +.TP +.B max\-diffs\-size = 20 +The maximum size allowed for in\-memory diffs for a single zone, defined as +a parcentage of the size of the last published version of the zone. If +storing a new diff would exceed the limit, diffs will be discarded to make +space starting with the oldest diffs first. +.sp +This setting has no impact on the number of diffs persisted to disk. See +max\-diffs to control that. +.sp +The default is 20%. If set to 0, or a value low enough such that the set of +diffs for a zone always exceed the limit, then no diffs will be stored in +memory and IXFR requests will be responded to with an AXFR instead. +.UNINDENT .SH FILES .INDENT 0.0 .TP diff --git a/doc/manual/source/man/cascaded-policy.toml.rst b/doc/manual/source/man/cascaded-policy.toml.rst index a04370ac5..f4c561789 100644 --- a/doc/manual/source/man/cascaded-policy.toml.rst +++ b/doc/manual/source/man/cascaded-policy.toml.rst @@ -597,6 +597,50 @@ The ``[server.outbound]`` section. `"[^]"` +.. option:: max-diffs = 5 + + The maximum number of "sequences of differential information" (diffs) that + the server may store per zone in order to respond to RFC 1995 Incremental + Zone Transfer (IXFR) requests. + + A diff is created when changes to the input zone are received and when + re-signing alters the signed records generated when signing last occurred. + + Diffs are persisted to disk once approved and will be restored on next + start of the Cascade daemon. + + Excess in-memory diffs are discarded as soon as possible after approval of + a new diff, or on policy reload if the diff maximums were reduced compared + to previous version of the policy. + + Persisted diffs are discarded periodically to reduce the impact of + persisted diff compaction. The number of persisted diffs may therefore + temporarily exceed the limit specified here until purging of persisted + diffs next occurs. Discarding of persisted diffs will be skipped for + zones that are in maintenance mode. + + Note that while RFC 1955 section 5 suggests that diffs be purged based on + total IXFR response size and/or if older than the SOA expire period, Cascade + does not currently implement this behaviour. Cascade also currently has no + support for RFC 1995 section 6 condensation of multiple versions. + + The default is to limits diffs to 5 per zone. If set to 0, storage of diffs + will be disabled. + +.. option:: max-diffs-size = 20 + + The maximum size allowed for in-memory diffs for a single zone, defined as + a parcentage of the size of the last published version of the zone. If + storing a new diff would exceed the limit, diffs will be discarded to make + space starting with the oldest diffs first. + + This setting has no impact on the number of diffs persisted to disk. See + max-diffs to control that. + + The default is 20%. If set to 0, or a value low enough such that the set of + diffs for a zone always exceed the limit, then no diffs will be stored in + memory and IXFR requests will be responded to with an AXFR instead. + Files ----- diff --git a/etc/policy.template.toml b/etc/policy.template.toml index 6aef797f7..90189863a 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -479,43 +479,41 @@ mode = "off" # If not specified, zone transfers will be provided to any nameserver. #provide-xfr-to = ["127.0.0.1", "127.0.0.1^my-tsig-key"] -# The maximum number of sequences of differential information (diffs) that +# The maximum number of "sequences of differential information" (diffs) that # the server may store per zone in order to respond to RFC 1995 Incremental # Zone Transfer (IXFR) requests. -# -# Diffs are persisted so that they can be restored on restart of the Cascade -# daemon. -# -# Excess in-memory diffs are discarded eagerly when the limit for -# a zone is reached, or when this policy setting is changd and the policy -# reloaded causing some in-memory diffs that were previously under the limit -# to exceed the new limit. -# -# Persisted diffs are discarded lazily to reduce the impact of persisted diff -# compaction. The number of persisted diffs may therefore temporarily exceed -# the limit specified here until purging of persisted diffs next occurs. Lazy -# purging of persisted diffs will be skipped for zones that are in maintenance -# mode. -# +# +# A diff is created when changes to the input zone are received and when +# re-signing alters the signed records generated when signing last occurred. +# +# Diffs are persisted to disk once approved and will be restored on next start +# of the Cascade daemon. +# +# Excess in-memory diffs are discarded as soon as possible after approval of a +# new diff, or on policy reload if the diff maximums were reduced compared to +# previous version of the policy. +# +# Persisted diffs are discarded periodically to reduce the impact of persisted +# diff compaction. The number of persisted diffs may therefore temporarily +# exceed the limit specified here until purging of persisted diffs next +# occurs. Discarding of persisted diffs will be skipped for zones that are in +# maintenance mode. +# # Note that while RFC 1955 section 5 suggests that diffs be purged based on # total IXFR response size and/or if older than the SOA expire period, Cascade # does not currently implement this behaviour. Cascade also currently has no # support for RFC 1995 section 6 condensation of multiple versions. -# -# The default is to limits diffs to 5 per zone. If set to 0, storage of diffs -# will be disabled. #max-diffs = 5 -# The maximum size that the collection of IXFR diffs for a zone can grow -# to in-memory after which diffs are discarded, oldest first, one at a time -# until the remaining diffs combined are smaller than this percentage of the -# published zone. +# The maximum size allowed for in-memory diffs for a single zone, defined as a +# parcentage of the size of the last published version of the zone. If storing +# a new diff would exceed the limit, diffs will be discarded to make space +# starting with the oldest diffs first. # # This setting has no impact on the number of diffs persisted to disk. See # max-diffs to control that. -# -# The default is 20%. If set to 0, or a value low enough for the size of zone -# and the rate and size of change means that the collection of diffs always -# exceeds this percentage, then no diffs will be stored in memory and IXFR -# requests will be responded to with an AXFR instead. +# +# The default is 20%. If set to 0, or a value low enough such that the set of +# diffs for a zone always exceed the limit, then no diffs will be stored in +# memory and IXFR requests will be responded to with an AXFR instead. #max-diffs-size = 20 From 00e293dcce7fd23c6f3193d496aaf8a58c270670 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:48:42 +0200 Subject: [PATCH 37/85] Update persistence developer notes to include purging. --- src/persistence/mod.rs | 65 +++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index a391c8007..a005bab96 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -1,13 +1,25 @@ //! Persisting zone data to and restoring from disk. //! -//! The zone persister saves the data for loaded and signed zones to disk, so -//! that Cascade can seamlessly resume operation after a crash / restart. At -//! startup it tries to restore data for all known zones. +//! # Summary //! -//! When re-starting Cascade in-memory zone and IXFR diff data will be lost -//! unless persisted and restored. This module implements persistence -//! and restoration using files on disk stored in the zone-state directory -//! alongside the JSON '.db' zone state files. +//! On approval of loaded or signed diffs the persister: +//! - Writes diffs to disk, so that Cascade can seamlessly resume operation +//! after a crash restart. Separate files are stored for loaded vs signed +//! data. Persistence files are stored alongside other state files for a +//! zone in the zone-state configuration path, with the set of currently +//! in-use persistence paths being stored in Cascade zone state. +//! - Stores diffs in memory, so that RFC 1995 IXFR requests can be +//! responded to with the set of diffs needed by the client. +//! +//! When re-starting Cascade, lost in-memory zone and IXFR diff data will be +//! restored from the disk files written by the persister. +//! +//! In-memory diffs are discarded, oldest first, when configured limits are +//! exceeded. +//! +//! Persisted disk files are also discarded oldest first but after a delay +//! to spread out the cost of "compacting" the zone (replacing the snapshot +//! with a new one that contains the current set of published zone records). //! //! # Data format //! @@ -86,12 +98,39 @@ //! to still be able to query the review server for an IXFR diff after //! Cascsade restarts. //! -//! TODO: What happens if loaded data is approved and persisted, but -//! Cascade is terminated before signing occurs. In such a case if restore -//! is done as described above signing can occur as usual, but will a -//! signed review hook be able to query the loaded review server for the -//! loaded diff? - +//! # Purging +//! +//! To avoid excess disk and memory usage, diffs in excess of configured +//! limits are discarded. +//! +//! # Architecture +//! +//! - The zone storage state machine has states relating to persistence +//! and restoration and invokes code in this module to actually implement +//! those responsibilities. +//! - IXFR diffs for use by the publication server are stored in zone +//! storage. IXFR diffs for use by the preview servers are accessed from +//! the in-memory temporary diffs held in review related storage machine +//! states. +//! - Three "units" defined in this module are stored in `Center` and run +//! by `Manager`: `Persister`, `Restorer` and `Compacter`. Restorer runs +//! on startup. Compacter runs in the background continuously. Persister +//! does not "run" but instead provides callback `on_zone_policy_changed`. +//! - The relationship between a signed diff and the loaded diff it +//! corresponds to is tracked both in persistence and in-memory diff state. +//! - Persistence is done atomically, writing first to a temporary file and +//! then replacing any previous file with an atomic rename. +//! - Diffs are stored and accessed using the same data type as already used +//! by Cascade to transport diffs between pipeline stages when needed, +//! namely `DiffData`. +//! - `PersistenceState` per zone uses two instances of `PersistedDiffManager` +//! to keep track of persisted zone data files and implemements compaction +//! of a single zone. Compaction requires access to the latest published +//! version of a zone in order to replace the existing persisted snapshot +//! with an up-to-date version. Access to the published zone is done via the +//! viewer for the zone. +//! - `IxfrZoneDiffs` stores diffs used when responding to an RFC 1995 IXFR +//! request, and offers lookup and trim operations. use std::{sync::Arc, time::Duration}; use crate::{ From ee09f624486f231ac93fc72756a9b09bab68137e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:52:03 +0200 Subject: [PATCH 38/85] Tweaks to IxfrZoneDiffs RustDocs. --- src/persistence/zone.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index b4fc92390..d5c167bc7 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -700,10 +700,9 @@ impl Ord for PersistedDiffFileInfo { /// The set of diffs for a single zone, to be used to serve IXFR responses /// from the publication server to clients. /// -/// Note: These diffs are not currently used to serve IXFR responses from -/// review servers to clients as during review the current diff is already -/// available to the review server via the current state of the zone storage -/// state machine. +/// Note: These diffs are not used to serve IXFR responses from review servers +/// to clients as during review the current diff is already available to the +/// review server via the current state of the zone storage state machine. /// /// A new diff is added to this set once the loaded or signed change to the /// zone is approved at a pipeline review stage. @@ -743,6 +742,10 @@ impl Ord for PersistedDiffFileInfo { /// at different moments in the zone pipeline lifecycle we need to keep track /// when receiving a signed diff of which loaded SOA serial the signed diff /// relates to, so that we can later serve them together. +/// +/// To ensure memory usage can be controlled diffs can be "trimmed" discarding +/// older diffs if the total number or size of the diffs exceeds configured +/// bounds. #[derive(Default)] pub struct IxfrZoneDiffs { /// Diffs in the loaded part of the zone from one serial number to From a657b3ba8a23106abb4a6b365ecb08eda72091b6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:10:09 +0200 Subject: [PATCH 39/85] Remove excessive trace logging. --- src/persistence/persist.rs | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index e67ea2f88..40ea41866 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -247,18 +247,10 @@ pub fn persist_to_file_from_parts< let mut n_rrs_removed = 0; let mut n_rr_added = 0; - trace!( - "persist_to_file: Writing initial SOA: {}", - added_soa.rdata.serial - ); write_rr(&mut buf, &added_soa, &mut f); // Start deleted records block by writing the old SOA, if any. if let Some(removed_soa) = &removed_soa { - trace!( - "persist_to_file: Writing IXFR diff sequence start: removed SOA: {}", - removed_soa.rdata.serial - ); write_rr(&mut buf, removed_soa, &mut f); n_rrs_removed += 1; @@ -267,19 +259,11 @@ pub fn persist_to_file_from_parts< if r.rname == removed_soa.rname && r.rtype == removed_soa.rtype { continue; } - trace!( - "persist_to_file: Writing IXFR diff sequence RR: {:?}", - r.rtype - ); write_rr(&mut buf, r, &mut f); n_rrs_removed += 1; } // Start added records block by writing the new SOA - trace!( - "persist_to_file: Writing IXFR diff sequence continuation: added SOA: {}", - added_soa.rdata.serial - ); write_rr(&mut buf, &added_soa, &mut f); } @@ -288,19 +272,11 @@ pub fn persist_to_file_from_parts< if r.rname == added_soa.rname && r.rtype == added_soa.rtype { continue; } - trace!( - "persist_to_file: Writing IXFR diff sequence RR: {:?}", - r.rtype - ); write_rr(&mut buf, r, &mut f); n_rr_added += 1; } // Finish the AXFR/IXFR by writing the new SOA again - trace!( - "persist_to_file: Writing final SOA: {}", - added_soa.rdata.serial - ); write_rr(&mut buf, &added_soa, &mut f); n_rr_added += 1; From 355ddabdd14b15eaaa79b6bc07d444200dcce1f8 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 02:42:43 +0200 Subject: [PATCH 40/85] Refactor free fn as associated fn. --- src/persistence/zone.rs | 72 +++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index d5c167bc7..a6182620c 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -225,9 +225,9 @@ fn abandon_signed_restoration( } fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) { + PersistenceState::clear(center, zone); { let mut state = zone.write(center); - clear_persisted_zone_data(center, &mut state); // In case this zone was signed in the past we have to make sure that // any attempt to enqueue a re-signing operation will be skipped as @@ -246,39 +246,6 @@ fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) save_state_now(center, zone); } -fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { - // We can't use the persisted data so remove the paths from state, remove - // the corresponding files on disk and remove any diffs that we loaded - // into memory. - for file_info in state - .persistence - .loaded_diffs - .diff_infos - .iter() - .chain(state.persistence.signed_diffs.diff_infos.iter()) - { - if file_info.path.exists() - && file_info - .path - .starts_with(center.config.zone_state_dir.as_std_path()) - { - info!( - "Removing unusable persisted zone data file '{}'", - file_info.path.display() - ); - if let Err(err) = std::fs::remove_file(&file_info.path) { - warn!( - "Failed to remove unusable persisted zone data file '{}': {err}", - file_info.path.display() - ); - } - } - } - state.persistence.loaded_diffs.clear(); - state.persistence.signed_diffs.clear(); - state.storage.diffs.clear(); -} - //----------- PersistenceState ----------------------------------------------- /// State related to data persistence for a zone. @@ -454,6 +421,37 @@ impl PersistenceState { } } } + + pub fn clear(center: &Arc
, zone: &Arc) { + let mut state = zone.write(center); + for file_info in state + .persistence + .loaded_diffs + .diff_infos + .iter() + .chain(state.persistence.signed_diffs.diff_infos.iter()) + { + if file_info.path.exists() + && file_info + .path + .starts_with(center.config.zone_state_dir.as_std_path()) + { + info!( + "Removing persisted zone data file '{}'", + file_info.path.display() + ); + if let Err(err) = std::fs::remove_file(&file_info.path) { + warn!( + "Failed to remove unusable persisted zone data file '{}': {err}", + file_info.path.display() + ); + } + } + } + state.persistence.loaded_diffs.clear(); + state.persistence.signed_diffs.clear(); + state.storage.diffs.clear(); + } } impl Default for PersistenceState { @@ -548,11 +546,7 @@ impl PersistedDiffManager { .zone_state_dir .join(format!("{zone_name}.{data_file_type}.{}", self.next_idx)) .into_std_path_buf(); - let file_info = PersistedDiffFileInfo { - path: path.clone(), - loaded_serial, - signed_serial, - }; + let file_info = PersistedDiffFileInfo::new(path.clone(), loaded_serial, signed_serial); assert!(self.diff_infos.insert(file_info)); From 396ae49c15a25dec75d78ca5cbb9527c27bb8a17 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:22:10 +0200 Subject: [PATCH 41/85] FIX: Restore last published SOAs from existing persisted state. --- src/persistence/restore.rs | 71 +++++++++++++++++++++----------------- src/persistence/zone.rs | 14 +++++++- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 38696af8a..6454681b0 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -310,42 +310,49 @@ pub fn restore_signed( num_diffs_to_restore, zone.name ); - let last_diff = diffs_to_store.last(); - if let Some((last_loaded_serial, last_diff)) = last_diff { - { - let mut state = zone.write(center); - - // Build a new loaded SOA RR from the last published SOA RR with - // the serial number replaced with that of the last loaded serial. - // We don't have access to the actual last loaded SOA here, only - // it's serial number as that is all that we stored in the state - // that we restored. We can't set `published_loaded_soa` in - // restore_loaded() as we don't know at that point if the last - // loaded diff was later published. - // - // TODO: Could fields in the SOA other than the serial differ - // between the loaded SOA and the published SOA, and thus is it - // then insufficient to use the last published SOA RR as the basis - // for constructing a last published loaded SOA RR? - let mut template_soa = - last_diff.added_soa.clone().expect( - "To be restoring a signed zone we must have had a diff which must have had an added SOA", - ); - template_soa.rdata.serial = last_loaded_serial - .expect("A restored signed diff must be related to a loaded diff"); - - state.storage.published_soa = last_diff.added_soa.clone(); - state.storage.published_loaded_soa = Some(template_soa); + if let Some((_, last_diff)) = diffs_to_store.last() { + let mut state = zone.write(center); - for (loaded_serial, diff) in diffs_to_store { - // Store the signed diff to be used as part of serving an IXFR. - state.storage.diffs.store_signed_diff(loaded_serial, diff); - } + // Build a new loaded SOA RR from the last published SOA RR with + // the serial number replaced with that of the last loaded serial. + // We don't have access to the actual last loaded SOA here, only + // it's serial number as that is all that we stored in the state + // that we restored. We can't set `published_loaded_soa` in + // restore_loaded() as we don't know at that point if the last + // loaded diff was later published. + // + // TODO: Could fields in the SOA other than the serial differ + // between the loaded SOA and the published SOA, and thus is it + // then insufficient to use the last published SOA RR as the basis + // for constructing a last published loaded SOA RR? + let mut template_soa = last_diff.added_soa.clone().expect( + "To be restoring a signed zone we must have had a diff which must have added a SOA", + ); + + let last_pub_loaded_serial = state + .last_published + .as_ref() + .map(|p| p.loaded_serial.0) + .unwrap(); + let last_pub_signed_serial = state + .last_published + .as_ref() + .map(|p| p.signed_serial.0) + .unwrap(); + + template_soa.rdata.serial = last_pub_loaded_serial.into(); + state.storage.published_loaded_soa = Some(template_soa.clone()); + template_soa.rdata.serial = last_pub_signed_serial.into(); + state.storage.published_soa = Some(template_soa); + + for (loaded_serial, diff) in diffs_to_store { + // Store the signed diff to be used as part of serving an IXFR. + state.storage.diffs.store_signed_diff(loaded_serial, diff); } - - discard_excess_diffs(center, zone); } + discard_excess_diffs(center, zone); + info!( "Restored signed zone snapshot and {num_diffs_to_restore} diffs for zone '{}'", zone.name diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index a6182620c..b5ba4db6a 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -381,6 +381,12 @@ impl PersistenceState { if let Some(loaded_serial) = diff_info.loaded_serial { loaded_serials_to_remove.push(loaded_serial); } + if let Err(err) = std::fs::remove_file(&diff_info.path) { + warn!( + "Failed to remove unusable persisted zone data file '{}': {err}", + diff_info.path.display() + ); + } } keep }); @@ -404,6 +410,12 @@ impl PersistenceState { .loaded_diffs .diff_infos .remove(&found_item); + if let Err(err) = std::fs::remove_file(&found_item.path) { + warn!( + "Failed to remove unusable persisted zone data file '{}': {err}", + found_item.path.display() + ); + } } } @@ -442,7 +454,7 @@ impl PersistenceState { ); if let Err(err) = std::fs::remove_file(&file_info.path) { warn!( - "Failed to remove unusable persisted zone data file '{}': {err}", + "Failed to remove persisted zone data file '{}': {err}", file_info.path.display() ); } From 730552b3d378e78d7f50cf263e894c3bf375f478 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:13:33 +0200 Subject: [PATCH 42/85] Does this change make subcmd mentions into links? --- doc/manual/source/zone-transfers.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 06e033dda..6b27c4944 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -54,9 +54,9 @@ policy setting ``server.outbound.send-notify-to``, optionally specifying an :RFC:8945` TSIG key to use to authenticate communication. .. tip:: Remember to reload the policy file after changing it. See - :program:`cascade` :subcmd:`policy reload`. + :program:`cascade` policy :subcmd:`reload` -.. tip:: Use :program:`cascade` :subcmd:`tsig add` to add a TSIG key to +.. tip:: Use :program:`cascade` tsig :subcmd:`add` to add a TSIG key to Cascade _before_ reloading policy file changes. Controlling automatic key rollover zone transfer settings From 28dc62aef9aed4f5b6bdc663050e805bd73959bb Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:15:36 +0200 Subject: [PATCH 43/85] Revert "Does this change make subcmd mentions into links?" This reverts commit 730552b3d378e78d7f50cf263e894c3bf375f478. --- doc/manual/source/zone-transfers.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 6b27c4944..06e033dda 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -54,9 +54,9 @@ policy setting ``server.outbound.send-notify-to``, optionally specifying an :RFC:8945` TSIG key to use to authenticate communication. .. tip:: Remember to reload the policy file after changing it. See - :program:`cascade` policy :subcmd:`reload` + :program:`cascade` :subcmd:`policy reload`. -.. tip:: Use :program:`cascade` tsig :subcmd:`add` to add a TSIG key to +.. tip:: Use :program:`cascade` :subcmd:`tsig add` to add a TSIG key to Cascade _before_ reloading policy file changes. Controlling automatic key rollover zone transfer settings From 3e2f98d3e53cd0d6f8967096a4e7ca2a9b40ae3a Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:19:57 +0200 Subject: [PATCH 44/85] RST syntax fix. --- cascade.conf | 231 +++++++++++++++++++++++++++ doc/manual/source/zone-transfers.rst | 2 +- 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 cascade.conf diff --git a/cascade.conf b/cascade.conf new file mode 100644 index 000000000..b1ffd3ada --- /dev/null +++ b/cascade.conf @@ -0,0 +1,231 @@ +# Configuring Cascade +# =================== +# +# This is a template file. Uncommented lines demonstrate the default settings. +# You can copy this and customize it to your liking, or write a configuration +# file from scratch using this as a reference. + +# The configuration file version. +# +# This is the only required option. All other settings, and their defaults, are +# associated with this version number. More versions may be added in the future +# and Cascade may drop support for older versions over time. +# +# - 'v1': This format. +version = "v1" + + +# The directory storing zone policies. +# +# Zone policies are user-managed files configuring groups of zones. You can +# modify them as you like, then ask Cascade to reload them with 'cascade policy +# reload'. +policy-dir = "/tmp/cascade/policies" + +# The directory storing per-zone state files. +# +# Cascade maintains an internal state file for every known zone here. These +# files should not be modified manually, but they can be backed up and restored +# in the event of filesystem corruption. +zone-state-dir = "/tmp/cascade/zone-state" + +# The file storing TSIG key secrets. +# +# This is an internal state file containing sensitive cryptographic material. +# It should not be modified manually, but it can be backed up and restored in +# the event of filesystem corruption. Carefully consider its security. +tsig-store-path = "/tmp/cascade/tsig-keys.db" + +# The file storing KMIP credentials. +# +# This is an internal state file containing sensitive cryptographic material. +# It should not be modified manually, but it can be backed up and restored in +# the event of filesystem corruption. Carefully consider its security. +kmip-credentials-store-path = "/tmp/cascade/kmip/credentials.db" + +# The directory storing rollover states and on-disk DNSSEC keys. +# +# For every zone, the state of its DNSSEC keys (which keys are used, ongoing +# rollovers, etc.) are stored here. If on-disk keys are used to sign zones, +# they are stored also here. +# +# The organization of this directory (file names and file formats) constitutes +# internal implementation details. It should not be modified manually, but +# it can be backed up and restored in the event of filesystem corruption. +# Carefully consider its security. +# +# TODO: Move rollover state files to a separate directory? +keys-dir = "/tmp/cascade/keys" + +# The directory containing KMIP server state. +# +# Information about known KMIP servers is stored in this directory. +# +# The organization of this directory (file names and file formats) constitutes +# internal implementation details. It should not be modified manually, but +# it can be backed up and restored in the event of filesystem corruption. +kmip-server-state-dir = "/tmp/cascade/kmip" + +# The path to the dnst binary Cascade should use. +# +# Cascade relies on the 'dnst' program () in +# order to perform DNSSEC key rollovers. You can specify an absolute path here, +# or just 'dnst' if it is in $PATH. +dnst-binary-path = "/tmp/cascade-dnst" + + +# Settings relevant to any daemon program. +[daemon] + +# The minimum severity of the messages logged by the daemon. +# +# Messages at or above the specified severity level will be logged. The +# following levels are defined: +# - 'trace': A function or variable was interacted with, for debugging. +# - 'debug': Something occurred that may be relevant to debugging. +# - 'info': Things are proceeding as expected. +# - 'warning': Something does not appear to be correct. +# - 'error': Something went wrong (but Cascade can recover). +# - 'critical': Something went wrong and Cascade can't function at all. +log-level = "trace" + +# The location the daemon writes logs to. +# +# - type 'file': Logs are appended line-by-line to the specified file path. +# +# If it is a terminal, ANSI escape codes may be used to style the output. +# +# - type 'stdout': Logs are written to stdout. (The default) +# +# If it is a terminal, ANSI escape codes may be used to style the output. +# +# - type 'stderr': Logs are written to stderr. +# +# If it is a terminal, ANSI escape codes may be used to style the output. +# +# - type 'syslog': Logs are written to the UNIX syslog. +# +# This option is only supported on UNIX systems. +# +# NOTE: When using systemd, 'syslog' and 'stdout' are the most reliable options. +# systemd environments are often heavily isolated, making file-based logging +# difficult. +#log-target = { type = "stdout" } +#log-target = { type = "syslog" } +log-target = { type = "file", path = "/tmp/cascade.log" } + +# Whether to apply internal daemonization. +# +# 'Daemonization' involves several steps: +# - Forking the process to disconnect it from the terminal +# - Tracking the new process' PID (by storing it in a file) +# - Binding privileged ports (below 1024) as configured +# - Dropping administrator privileges +# +# These features may be provided by an external system service manager, such as +# systemd. If no such service manager is being used, Cascade can provide such +# features itself, by setting this option to 'true'. This will also enable the +# 'pid-file' and 'identity' settings (although they remain optional). +# +# TODO: Link to a dedicated systemd / daemonization guide for Cascade. +daemonize = false + +# The path to a PID file to maintain, if any. +# +# If specified, Cascade will maintain a PID file at this location; it will be a +# simple plain-text file containing the PID number of the daemon process. This +# option is only supported if 'daemonize' is true. +pid-file = "/tmp/cascade/cascade.pid" + +# An identity (user and group) to assume after startup. +# +# Cascade will assume the specified identity after initialization. Note that +# this will fail if Cascade is started without administrator privileges. This +# option is only supported if 'daemonize' is 'true'. +# +# The identity can be specified as ':' or just ''; in the +# latter case, the identically named group will be used. Numeric IDs are not +# supported; only names can be used. +# +# NOTE: When using systemd, you should rely on its 'User=' and 'Group=' options +# instead. See . +#identity = "cascade:cascade" + + +# How Cascade is controlled. +[remote-control] +# Where to serve Cascade's HTTP API. +# +# The HTTP API can be used to monitor and control Cascade. The addresses refer +# to TCP sockets that will be listened on for HTTP requests. At the moment, +# security mechanisms like TLS are not supported. +# +# These sockets may be bound by systemd and passed into Cascade. If systemd +# does not provide them, Cascade will bind them itself (and will do so before +# dropping privileges, if that is enabled). +servers = ["127.0.0.1:2006"] + + +# How zones are loaded. +[loader] + +# How loaded zones are reviewed. +[loader.review] +# Where to serve loaded zones for review. +# +# A DNS server will be bound to these addresses, and will serve the contents of +# all loaded zones. This can be used to verify the consistency of these zones. +# +# Unless explicitly specified (e.g. 'udp://localhost:4541'), each address will +# be served over UDP and TCP. An empty array will disable serving entirely. +# +# These sockets may be bound by systemd and passed into Cascade. If systemd +# does not provide them, Cascade will bind them itself (and will do so before +# dropping privileges, if that is enabled). +# +# TODO: Note that this MUST be different to load.notify-listeners and signer.review.servers. +servers = ["127.0.0.1:2007"] + + +# How zones are signed. +[signer] + +# How signed zones are reviewed. +[signer.review] +# Where to serve signed zones for review. +# +# A DNS server will be bound to these addresses, and will serve the contents of +# all signed (but not necessarily published) zones. This can be used to check +# the correctness of the signer. +# +# Unless explicitly specified (e.g. 'udp://localhost:4542'), each address will +# be served over UDP and TCP. An empty array will disable serving entirely. +# +# These sockets may be bound by systemd and passed into Cascade. If systemd +# does not provide them, Cascade will bind them itself (and will do so before +# dropping privileges, if that is enabled). +# +# TODO: Note that this MUST be different to load.notify-listeners and loader.review.servers. +servers = ["127.0.0.1:2008"] + + +# DNSSEC key management. +[key-manager] + + +# How zones are published. +[server] +# Where to serve published zones. +# +# A DNS server will be bound to these addresses, and will serve the contents of +# all published zones. This is the final output from Cascade. +# +# Unless explicitly specified (e.g. 'udp://localhost:4543'), each address will +# be served over UDP and TCP. At least one address must be specified. +# +# These sockets may be bound by systemd and passed into Cascade. If systemd +# does not provide them, Cascade will bind them itself (and will do so before +# dropping privileges, if that is enabled). +# +# TODO: Note that this can be the same as loader.notify-listeners +servers = ["127.0.0.1:2005"] diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 06e033dda..53aa47c25 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -51,7 +51,7 @@ zone transfer, no configuration is needed. To ensure timely update by secondaries, Cascade can be configured to send :RFC:`1996` NOTIFY messages to specified secondaries. This is done via the policy setting ``server.outbound.send-notify-to``, optionally specifying an -:RFC:8945` TSIG key to use to authenticate communication. +:RFC:`8945` TSIG key to use to authenticate communication. .. tip:: Remember to reload the policy file after changing it. See :program:`cascade` :subcmd:`policy reload`. From bfbe48093711bc2d16506feed788fdcb37aac7fb Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:22:19 +0200 Subject: [PATCH 45/85] Mention provide-xfr-to policy setting in zone transfer core topic. --- doc/manual/source/zone-transfers.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 53aa47c25..7f7b5d325 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -48,6 +48,9 @@ Providing zone transfers to a downstream server By default, Cascade allows downstream servers to access published zones by zone transfer, no configuration is needed. +To restrict the downstream nameservers which may request transfer of the +zone use the ``server.outbound.provide-xfr-to`` policy setting.` + To ensure timely update by secondaries, Cascade can be configured to send :RFC:`1996` NOTIFY messages to specified secondaries. This is done via the policy setting ``server.outbound.send-notify-to``, optionally specifying an @@ -59,8 +62,8 @@ policy setting ``server.outbound.send-notify-to``, optionally specifying an .. tip:: Use :program:`cascade` :subcmd:`tsig add` to add a TSIG key to Cascade _before_ reloading policy file changes. -Controlling automatic key rollover zone transfer settings -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Zone transfers when using automated key rollover +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When using automatic key rollover (the default) Cascade will attempt to verify that certain key properties of the signed zone being served to consumers are From c8a5bc4ed0e8ba860b242fe7e4a4278c6af0be30 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:32:04 +0200 Subject: [PATCH 46/85] Say something in the docs about removal of excess diffs. --- doc/manual/source/zone-transfers.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 7f7b5d325..1421285bf 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -62,6 +62,29 @@ policy setting ``server.outbound.send-notify-to``, optionally specifying an .. tip:: Use :program:`cascade` :subcmd:`tsig add` to add a TSIG key to Cascade _before_ reloading policy file changes. +Managing IXFR diff memory and disk usage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Cascade has two policy settings which control how much disk space and memory +will be used for storing IXFR diffs: + +- ``server.outbound.max_diffs`` +- ``server.outbound.max_diffs_size`` + +``max_diffs`` specifies the maximum numer of IXFR diffs per zone that Cascade +may keep in memory and on disk. + +``max_diffs_size`` specifies the maximum size of all diffs combined that may +be stored in memory per zone and is defined in relation to the size (number of +records) of the published zone. + +Note that diffs on disk are (a) lazily removed, and so may persist longer than +expected, and (b) may also be needed to restore the published zone on restart +of Cascade and will then be removed once the persisted zone record data has +been compacted at which point it is safe to delete diffs. + +``max_diffs_size`` ` + Zone transfers when using automated key rollover ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 1281755395d3570ad1f7a90154dcae81575b5437 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:35:21 +0200 Subject: [PATCH 47/85] Say something about special statuses that zone status can report. --- doc/manual/build/man/cascade-zone.1 | 5 ++++- doc/manual/source/man/cascade-zone.rst | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/manual/build/man/cascade-zone.1 b/doc/manual/build/man/cascade-zone.1 index cec903dfe..02b5687e1 100644 --- a/doc/manual/build/man/cascade-zone.1 +++ b/doc/manual/build/man/cascade-zone.1 @@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "CASCADE-ZONE" "1" "Jan 01, 2026" "0.1.0-beta3" "Cascade" +.TH "CASCADE-ZONE" "1" "Jan 01, 2026" "0.1.0-beta4-dev" "Cascade" .SH NAME cascade-zone \- Manage zones .SH SYNOPSIS @@ -110,6 +110,9 @@ Override a previous rejection of a zone review. .TP .B status Get the status of a single zone. +.sp +Also reports any issues that occured with recent operations on the zone +and whether the zone is being restored from disk. .UNINDENT .INDENT 0.0 .TP diff --git a/doc/manual/source/man/cascade-zone.rst b/doc/manual/source/man/cascade-zone.rst index d49e12a48..78adcc01f 100644 --- a/doc/manual/source/man/cascade-zone.rst +++ b/doc/manual/source/man/cascade-zone.rst @@ -77,6 +77,9 @@ Commands Get the status of a single zone. + Also reports any issues that occured with recent operations on the zone + and whether the zone is being restored from disk. + .. subcmd:: reset Reset the pipeline for a zone to get it out of a halted state. From e5e56ec01a8528de368966a15b3ae7cf05b5a248 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:37:39 +0200 Subject: [PATCH 48/85] Remove errant text. --- doc/manual/source/zone-transfers.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 1421285bf..2cf0ef7fe 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -49,7 +49,7 @@ By default, Cascade allows downstream servers to access published zones by zone transfer, no configuration is needed. To restrict the downstream nameservers which may request transfer of the -zone use the ``server.outbound.provide-xfr-to`` policy setting.` +zone use the ``server.outbound.provide-xfr-to`` policy setting. To ensure timely update by secondaries, Cascade can be configured to send :RFC:`1996` NOTIFY messages to specified secondaries. This is done via the @@ -83,8 +83,6 @@ expected, and (b) may also be needed to restore the published zone on restart of Cascade and will then be removed once the persisted zone record data has been compacted at which point it is safe to delete diffs. -``max_diffs_size`` ` - Zone transfers when using automated key rollover ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From a55ba83b2a7d8059ce7b42f9f05864d1f3a59d66 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:31:07 +0200 Subject: [PATCH 49/85] More zone transfer page updates. --- doc/manual/source/zone-transfers.rst | 52 ++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 2cf0ef7fe..6f8861dd8 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -27,8 +27,8 @@ based on the zone's SOA timers. signed zone review hook could be used to AXFR the signed zone to a file on disk to achieve this. -Using zone transfers with an upstream nameserver -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Requesting zone transfers from upstream nameservers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To instruct Cascade to transfer a zone via the network instead of loading it from a file you must supply an upstream nameserver IP address when @@ -49,12 +49,14 @@ By default, Cascade allows downstream servers to access published zones by zone transfer, no configuration is needed. To restrict the downstream nameservers which may request transfer of the -zone use the ``server.outbound.provide-xfr-to`` policy setting. +zone use the ``server.outbound.provide-xfr-to`` policy setting. As with +upstream transfers, TSIG can also be used to authentication communication with +downstream nameservers. To ensure timely update by secondaries, Cascade can be configured to send :RFC:`1996` NOTIFY messages to specified secondaries. This is done via the -policy setting ``server.outbound.send-notify-to``, optionally specifying an -:RFC:`8945` TSIG key to use to authenticate communication. +policy setting ``server.outbound.send-notify-to``, optionally specifying a +TSIG key to use to authenticate communication. .. tip:: Remember to reload the policy file after changing it. See :program:`cascade` :subcmd:`policy reload`. @@ -62,11 +64,26 @@ policy setting ``server.outbound.send-notify-to``, optionally specifying an .. tip:: Use :program:`cascade` :subcmd:`tsig add` to add a TSIG key to Cascade _before_ reloading policy file changes. -Managing IXFR diff memory and disk usage -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Incremental zone transfers +~~~~~~~~~~~~~~~~~~~~~~~~~~ -Cascade has two policy settings which control how much disk space and memory -will be used for storing IXFR diffs: +Cascade will automatically attempt to use IXFR with upstream nameservers, +and accumulates :RFC:`1995` "sequences of differential information" (diffs) +in memory in order to use them to respond to IXFR requests from downstream +nameservers. + +.. tip:: Incremental diffs are also available from the Cascade review + nameservers, but only the difference between the current and previous + version of the zone. IXFR capable :doc:`review-hooks` could use this + to avoid requesting and processing the entire zone using AXFR and + instead review only the changes in the zone. + +Diffs are persisted to disk so that if Cascade is restarted that it is still +able to respond to IXFR requests from downstream nameservers rather than +forcing them to costly fallback to AXFR transfers. + +Cascade has two policy settings which limit the amount of memory and disk +space used per zone to store diffs: - ``server.outbound.max_diffs`` - ``server.outbound.max_diffs_size`` @@ -78,13 +95,17 @@ may keep in memory and on disk. be stored in memory per zone and is defined in relation to the size (number of records) of the published zone. -Note that diffs on disk are (a) lazily removed, and so may persist longer than -expected, and (b) may also be needed to restore the published zone on restart -of Cascade and will then be removed once the persisted zone record data has -been compacted at which point it is safe to delete diffs. +Note that diffs on-disk are (a) lazily removed, and so may persist longer than +expected, and (b) on-disk diffs may be needed to restore the published zone +on restart of Cascade and will then be removed once the persisted zone record +data has been compacted at which point it is safe to delete diffs. -Zone transfers when using automated key rollover -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. caution:: Do not manually remove on-disk diff files as doing so may prevent + Cascade restoring the last published version of the zone if the + daemon process is restarted. + +Configuring the nameservers used by automated key rollover +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When using automatic key rollover (the default) Cascade will attempt to verify that certain key properties of the signed zone being served to consumers are @@ -99,3 +120,4 @@ nameserver, or if a specific port number or TSIG key should be used to request the transfer, you will also need to configure the Cascade key manager to fetch the zone correctly. This can be done via the ``key-manager.publication-nameservers`` policy setting. + From 0f43c9479c1f51a9d7c9585d168834cd07e022dd Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:33:24 +0200 Subject: [PATCH 50/85] Consistency. --- doc/manual/source/zone-transfers.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 6f8861dd8..48b3790fa 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -42,8 +42,8 @@ Cascade can be instructed to authenticate the upstream nameserver by use of a TSIG key. The TSIG key to use must be provided to Cascade _before_ adding the zone. See :program:`cascade` :subcmd:`tsig add`. -Providing zone transfers to a downstream server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Providing zone transfers to downstream nameservers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default, Cascade allows downstream servers to access published zones by zone transfer, no configuration is needed. From 28bc2f4cd0c591ae61a8510c360d327e14a1ec24 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:34:40 +0200 Subject: [PATCH 51/85] Shorter better heading. --- doc/manual/source/zone-transfers.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 48b3790fa..1e3313847 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -104,8 +104,8 @@ data has been compacted at which point it is safe to delete diffs. Cascade restoring the last published version of the zone if the daemon process is restarted. -Configuring the nameservers used by automated key rollover -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Zone transfers and automated key rollover +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When using automatic key rollover (the default) Cascade will attempt to verify that certain key properties of the signed zone being served to consumers are From 95c6fc7ce5fcb9324d24359f14c0ba847c2e5e93 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:56:22 +0200 Subject: [PATCH 52/85] Unit test a tiny piece of logic. --- src/persistence/persist.rs | 69 +++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 40ea41866..2d47b8aac 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -16,7 +16,7 @@ use tracing::trace; use crate::{ center::Center, - zone::{Zone, save_state_now}, + zone::{LastPublished, Zone, save_state_now}, }; /// Persist the data for a loaded instance of a zone. @@ -342,21 +342,64 @@ pub fn discard_excess_diffs(center: &Arc
, zone: &Arc) { let max_diffs = policy.server.outbound.max_diffs; let max_size_percentage = policy.server.outbound.max_diffs_size; - // Calculate the maximum number of records that a set of diffs can - // be based on the policy settings. IxfrZoneDiffs can't do this - // for us as it has no access to `last_published`. - let current_size = last_published.num_records as f64; - let max_size = if max_size_percentage == 0 { - 0 - } else { - let percentage = max_size_percentage as f64 / 100.0; - (current_size * percentage) as usize - }; + // Calculate the maximum number of records that a set of diffs can be based on + // the policy settings. IxfrZoneDiffs can't do this for us as it has + // no access to `last_published`. + let max_size = calc_max_diff_size(max_size_percentage, last_published); trace!( - "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={current_size}, max_size={max_size_percentage}% ({max_size} RRs)", - zone.name + "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={}, max_size={max_size_percentage}% ({max_size} RRs)", + zone.name, last_published.num_records ); state.storage.diffs.trim(max_diffs, max_size); } } + +/// Calculate the maximum size a diff can be as a percentage of the last +/// published zone. +fn calc_max_diff_size(max_size_percentage: usize, last_published: &LastPublished) -> usize { + let current_size = last_published.num_records as f64; + let percentage = max_size_percentage as f64 / 100.0; + (current_size * percentage) as usize +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use domain::base::Serial; + + use crate::zone::LastPublished; + + use super::calc_max_diff_size; + + #[test] + pub fn test_calc_max_diff_size() { + let empty_zone = last_published(0); + assert_eq!(calc_max_diff_size(0, &empty_zone), 0); + assert_eq!(calc_max_diff_size(50, &empty_zone), 0); + assert_eq!(calc_max_diff_size(100, &empty_zone), 0); + assert_eq!(calc_max_diff_size(1000, &empty_zone), 0); + + let small_zone = last_published(5); + assert_eq!(calc_max_diff_size(0, &small_zone), 0); + assert_eq!(calc_max_diff_size(50, &small_zone), 2); + assert_eq!(calc_max_diff_size(100, &small_zone), 5); + assert_eq!(calc_max_diff_size(1000, &small_zone), 50); + + let large_zone = last_published(500000); + assert_eq!(calc_max_diff_size(0, &large_zone), 0); + assert_eq!(calc_max_diff_size(50, &large_zone), 250000); + assert_eq!(calc_max_diff_size(100, &large_zone), 500000); + assert_eq!(calc_max_diff_size(1000, &large_zone), 5000000); + } + + fn last_published(num_records: usize) -> LastPublished { + LastPublished { + loaded_serial: Serial::from(0), + signed_serial: Serial::from(0), + timestamp: SystemTime::now(), + num_records, + } + } +} From 2735dbeb014dbbe4dc73033547db78ff98234e18 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:03:24 +0200 Subject: [PATCH 53/85] Better log message. --- src/persistence/zone.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index b5ba4db6a..203de3141 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -383,8 +383,8 @@ impl PersistenceState { } if let Err(err) = std::fs::remove_file(&diff_info.path) { warn!( - "Failed to remove unusable persisted zone data file '{}': {err}", - diff_info.path.display() + "Failed to remove persisted zone data file '{}' while compacting zone '{}': {err}", + zone.name, diff_info.path.display() ); } } From c0112e500c1f54c0f3825d156b9edc992f2a01a2 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:04:24 +0200 Subject: [PATCH 54/85] Better log message. --- src/persistence/zone.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 203de3141..d3bfc09f1 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -412,7 +412,8 @@ impl PersistenceState { .remove(&found_item); if let Err(err) = std::fs::remove_file(&found_item.path) { warn!( - "Failed to remove unusable persisted zone data file '{}': {err}", + "Failed to remove persisted zone data file '{}' while compacting zone '{}': {err}", + zone.name, found_item.path.display() ); } From a3918a11dda936c0a3887bfb8cabcef72aa30abd Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:54:18 +0200 Subject: [PATCH 55/85] Manual application of changes made in the base branch persist-zone-fixes-and-improvements that were not yet merged into this branch. --- Changelog.md | 98 ++++++++++++++++++++++++++++++++++++-- src/persistence/mod.rs | 9 ++-- src/persistence/persist.rs | 6 +++ src/persistence/restore.rs | 60 ++++++++++++----------- src/persistence/zone.rs | 40 +++++++++------- 5 files changed, 158 insertions(+), 55 deletions(-) diff --git a/Changelog.md b/Changelog.md index 99f6de589..1fea5758d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -14,28 +14,113 @@ Released yyyy-mm-dd. ### Acknowledgements --> -## Unreleased version +## 0.1.0-beta5 'Got that holiday feeling' -Released yyyy-mm-dd. +Released 2026-07-10. ### Breaking changes -### New +- Persist zone fixes and improvements. ([#804] by @ximon18) ### Bug fixes +- Overhaul how re-signing timers are managed, preventing race conditions causing + crashes when zones are removed. ([#863] by @bal-e, reported in [#730] by + @jpmens) +- Prohibit removing a zone while it is being restored from disk. + ([#886] by @bal-e) + +### Other changes + +- Speedup zone restore by parallelizing sorting. ([#872] by @ximon18) +- Make logging during zone restoration consistent. ([#874] by @ximon18) +- Improve memory use of NSEC(3) in incremental signing. ([#842] by + @Philip-NLnetLabs) +- Log the reason when unable to restore zone state on startup. ([#878] by + @ximon18) +- Add metrics for the duration of the last loading and signing operations. + ([#876] by @tertsdiepraam) + +### Known issues + +- Removing a zone whilst it is being signed causes a panic. ([#888]) +- Removing a zone does not cleanup its persisted state files. ([#889]) + +### Acknowledgements + +Thanks to @bortzmeyer, @bzwitt, @gryphius and @jpmens for testing Cascade and +providing valuable feedback! + +[#730]: https://github.com/NLnetLabs/cascade/issues/730 +[#804]: https://github.com/NLnetLabs/cascade/pull/804 +[#842]: https://github.com/NLnetLabs/cascade/pull/842 +[#863]: https://github.com/NLnetLabs/cascade/pull/863 +[#872]: https://github.com/NLnetLabs/cascade/pull/872 +[#874]: https://github.com/NLnetLabs/cascade/pull/874 +[#876]: https://github.com/NLnetLabs/cascade/pull/876 +[#878]: https://github.com/NLnetLabs/cascade/pull/878 +[#888]: https://github.com/NLnetLabs/cascade/pull/888 +[#889]: https://github.com/NLnetLabs/cascade/pull/889 + +## 0.1.0-beta4 'Irish Goodbye' + +Released 2026-07-03. + +### Breaking changes + +- Remove systemd socket binding to port 53 in supplied packages ([#847] by + @ximon18). + +- Add prometheus metrics for zone transfers and loaded zone bytes and records. + ([#538] by @mozzieongit and @tertsdiepraam) + +### Bug fixes + +- If the input zone contains DNSSEC records, they are removed during signing, + but (changes to them) were still showing up in IXFR output from the signed + zone; remove them from IXFRs ([#835] by @bal-e, reported in [#798] by + @gryphius). + +- Overhaul how signing operations are enqueued for concurrency control, + preventing aborted signing operations from accumulating memory ([#789] by + @bal-e, reported in [#675] by @Philip-NLnetLabs) + ### Other changes - Extend cascade tsig remove error to report the users of the key. ([#719] by @ximon18) +- Reduce the memory use of RRSIGs during incremental signing. ([#824] by + @Philip-NLnetLabs) ### Documentation improvements +- Update note about compatibility with NetHSM. ([#621] by @jans23) +- Document the `cascade info` command ([#838] by @ximon18) +- Add a `CONTRIBUTING.md` ([#850] by @tertsdiepraam) + ### Known issues +- Cascade can crash due to a race condition when removing a zone. ([#730], + reported by @jpmens) + ### Acknowledgements +Thanks to @jpmens, @gryphius, and @jans23 for testing Cascade and providing +valuable feedback and contributions! + +[#621]: https://github.com/NLnetLabs/cascade/pull/621 [#719]: https://github.com/NLnetLabs/cascade/pull/719 +[#824]: https://github.com/NLnetLabs/cascade/pull/824 +[#835]: https://github.com/NLnetLabs/cascade/pull/835 +[#838]: https://github.com/NLnetLabs/cascade/pull/838 +[#789]: https://github.com/NLnetLabs/cascade/pull/789 +[#841]: https://github.com/NLnetLabs/cascade/pull/841 +[#847]: https://github.com/NLnetLabs/cascade/pull/847 +[#850]: https://github.com/NLnetLabs/cascade/pull/850 + +[#730]: https://github.com/NLnetLabs/cascade/issues/730 +[#798]: https://github.com/NLnetLabs/cascade/issues/798 +[#675]: https://github.com/NLnetLabs/cascade/issues/675 ## 0.1.0-beta3 'Villa Volta' @@ -76,8 +161,8 @@ raised at https://github.com/NLnetLabs/cascade/issues. ### Acknowledgements -Thanks to @davidgroves, @jpmens, @gryphius, and @marcgweg for testing Cascade and providing -valuable feedback! +Thanks to @davidgroves, @jpmens, @gryphius, and @marcgweg for testing Cascade +and providing valuable feedback! [#639]: https://github.com/NLnetLabs/cascade/pull/639 [#738]: https://github.com/NLnetLabs/cascade/pull/738 @@ -117,6 +202,7 @@ raised at https://github.com/NLnetLabs/cascade/issues. - Ignore re-signing without a published signed instance. ([#795] by @bal-e) - Account for TTL-only changes in incremental re-signing. ([#803] by @Philip-NLnetLabs) +- Actually use changes on policy reload. ([#606] by @bal-e) ### Documentation improvements @@ -135,6 +221,7 @@ raised at https://github.com/NLnetLabs/cascade/issues. Thanks to @jpmens and @gryphius for testing Cascade and providing valuable feedback and contributions to the documentation. +[#606]: https://github.com/NLnetLabs/cascade/pull/606 [#704]: https://github.com/NLnetLabs/cascade/pull/704 [#708]: https://github.com/NLnetLabs/cascade/pull/708 [#711]: https://github.com/NLnetLabs/cascade/pull/711 @@ -322,6 +409,7 @@ Our continued thanks to @jpmens, @bortzmeyer, @gryphius and also to @alarig, [#518]: https://github.com/NLnetLabs/cascade/pull/518 [#521]: https://github.com/NLnetLabs/cascade/pull/521 [#536]: https://github.com/NLnetLabs/cascade/pull/536 +[#538]: https://github.com/NLnetLabs/cascade/pull/538 [#539]: https://github.com/NLnetLabs/cascade/pull/539 [#550]: https://github.com/NLnetLabs/cascade/pull/550 [#555]: https://github.com/NLnetLabs/cascade/pull/555 diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index a005bab96..e4b0ab73f 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -54,11 +54,12 @@ //! //! After a diff is persisted successfully: //! - The diff is stored in memory alongside the zone in -//! `StorageState::diffs` so that it can be served in response to an IXFR -//! request from a downstream nameserver. +//! [`StorageState::diffs`](crate::zone::StorageState::diffs) so that it +//! can be served in response to an IXFR request from a downstream +//! nameserver. //! - The path that the diff file was written to is appended to -//! `ZoneState::persistence.loaded_diff_paths` or -//! `ZoneState::persistence.signed_diff_paths` and the zone state is +//! [`PersistenceState::loaded_diffs`] or +//! [`PersistenceState::signed_diffs`] and the zone state is //! immediately saved to disk. //! //! # Panics diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 2d47b8aac..ae3cdfe46 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -20,6 +20,9 @@ use crate::{ }; /// Persist the data for a loaded instance of a zone. +/// +/// Data is persisted in the form of an AXFR wire format snapshot file plus +/// zero or more IXFR wire format diff files. #[tracing::instrument( level = "trace", skip_all, @@ -85,6 +88,9 @@ pub fn persist_loaded( } /// Persist the data for a signed instance of a zone. +/// +/// Data is persisted in the form of an AXFR wire format snapshot file plus +/// zero or more IXFR wire format diff files. #[tracing::instrument( level = "trace", skip_all, diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 6454681b0..29398cfdd 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -40,23 +40,24 @@ pub fn restore_loaded( center: &Arc
, restorer: &mut LoadedZoneRestorer, ) -> io::Result { - // Remove any existing diffs. + let diff_infos; + let restore_base_idx; + + // Use a block so that we don't hold the zone state lock longer than + // necessary. { - let mut state = zone.write(center); - state.storage.diffs.clear(); + let state = zone.read(); + diff_infos = state.persistence.loaded_diffs.diffs().clone(); + restore_base_idx = state.persistence.loaded_diffs.restore_base_idx(); } - let state = zone.read(); - let mut diff_infos = state.persistence.loaded_diffs.diffs().iter(); - let Some(snapshot_path) = diff_infos.next().map(|d| d.path()) else { + let mut diff_infos_iter = diff_infos.iter(); + let Some(snapshot_path) = diff_infos_iter.next().map(|d| d.path()) else { return io::Result::Ok(false); }; info!("Restoring persisted loaded data for zone '{}'", zone.name); - trace!( - "Restoring from loaded diffs: {:?}", - state.persistence.loaded_diffs - ); + trace!("Restoring from loaded diffs: {diff_infos:?}"); // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in an @@ -88,9 +89,8 @@ pub fn restore_loaded( let mut all_serials = vec![]; let mut diffs_to_store: Vec> = vec![]; - let restore_base_idx = state.persistence.loaded_diffs.restore_base_idx(); - for (idx, diff_info) in diff_infos.enumerate() { + for (idx, diff_info) in diff_infos_iter.enumerate() { let (start_serial, end_serial) = if idx < restore_base_idx { trace!( "Building standalone IXFR diff #{idx} from '{}'", @@ -152,12 +152,11 @@ pub fn restore_loaded( let end_serial: u32 = end_serial.into(); all_serials.push((start_serial, end_serial)); } - drop(state); let num_diffs_to_restore = diffs_to_store.len(); trace!( - "Restoring {} loaded diffs for zone {} with serials: {all_serials:?}", - num_diffs_to_restore, zone.name + "Restoring {num_diffs_to_restore} loaded diffs for zone {} with serials: {all_serials:?}", + zone.name ); let mut state = zone.write(center); @@ -173,7 +172,7 @@ pub fn restore_loaded( io::Result::Ok(true) } -/// Restore the loaded instance data of a zone. +/// Restore the signed instance data of a zone. /// /// Returns Ok(true) if data was stored, Ok(false) if there was nothing to /// restore, or Err(..) on error. @@ -187,17 +186,24 @@ pub fn restore_signed( center: &Arc
, restorer: &mut SignedZoneRestorer, ) -> io::Result { - let state = zone.read(); - let mut diff_infos = state.persistence.signed_diffs.diffs().iter(); - let Some(snapshot_path) = diff_infos.next().map(|d| d.path()) else { + let diff_infos; + let restore_base_idx; + + // Use a block so that we don't hold the zone state lock longer than + // necessary. + { + let state = zone.read(); + diff_infos = state.persistence.signed_diffs.diffs().clone(); + restore_base_idx = state.persistence.signed_diffs.restore_base_idx(); + } + + let mut diff_infos_iter = diff_infos.iter(); + let Some(snapshot_path) = diff_infos_iter.next().map(|d| d.path()) else { return io::Result::Ok(false); }; info!("Restoring persisted signed data for zone '{}'", zone.name); - trace!( - "Restoring from signed paths: {:?}", - state.persistence.signed_diffs - ); + trace!("Restoring from signed diffs: {diff_infos:?}",); // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in an @@ -229,7 +235,6 @@ pub fn restore_signed( let mut all_serials = vec![]; let mut diffs_to_store: Vec<(Option, Arc)> = vec![]; - let restore_base_idx = state.persistence.signed_diffs.restore_base_idx(); // Load each diff and apply it to the zone, retrieving a single DiffData // per signed diff. Store each signed DiffData alongside the corresponding @@ -239,7 +244,7 @@ pub fn restore_signed( // AXFR requests. Skip diffs that were included in the snapshot during the // last compaction event but still need to be loaded into memory to serve // in IXFR responses. - for (idx, diff_info) in diff_infos.enumerate() { + for (idx, diff_info) in diff_infos_iter.enumerate() { let (start_serial, end_serial) = if idx < restore_base_idx { trace!( "Building standalone IXFR diff #{idx} from '{}'", @@ -302,12 +307,11 @@ pub fn restore_signed( let end_serial: u32 = end_serial.into(); all_serials.push((start_serial, end_serial)); } - drop(state); let num_diffs_to_restore = diffs_to_store.len(); trace!( - "Restoring {} signed diffs for zone {} with serials: {all_serials:?}", - num_diffs_to_restore, zone.name + "Restoring {num_diffs_to_restore} signed diffs for zone {} with serials: {all_serials:?}", + zone.name ); if let Some((_, last_diff)) = diffs_to_store.last() { diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index d3bfc09f1..8fbe4ebb4 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -249,23 +249,27 @@ fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) //----------- PersistenceState ----------------------------------------------- /// State related to data persistence for a zone. +/// +/// Each zone consists of records from the loaded zone and, once signed, +/// changes in the set of records that result from signing. +/// +/// Each time a zone is reloaded from the source records may change resulting +/// in a loaded diff. Each time a zone is (re)signed records that were created +/// by the last signing operation may be changed resulting in a signed diff. +/// +/// The set of loaded and signed diffs are each stored in two sets of files +/// per zone, the loaded set and the signed set. The first file in each set +/// is a diff against nothing aka a snapshot. Each subsequent file is a diff +/// against the previous file. #[derive(Debug)] pub struct PersistenceState { /// Ongoing persist/restore operations. pub ongoing: BackgroundTasks, - /// Locations of persisted unsigned zone diffs to enable IXFR from - /// the upstream to resume on restart, and to enable a complete latest - /// unsigned version of the zone to be reconstituted. + /// Metadata about the persisted loaded zone data files for a zone. pub loaded_diffs: PersistedDiffManager, - /// Locations of persisted signed zone diffs to ensure IXFR out toward - /// downstreams is still possible after restart, and to enable a complete - /// latest signed version of the zone to be reconsituted. For each path - /// we also remember the associated loaded zone serial otherwise we lose - /// track of which loaded serial the signed diff relates to. Only signed - /// diffs triggered by a change in the loaded zone actually has an - /// associated loaded diff serial. + /// Metadata about the persisted signed zone data files for a zone. pub signed_diffs: PersistedDiffManager, } @@ -939,6 +943,14 @@ impl std::fmt::Display for IxfrZoneDiffs { } } +fn log_stored_diff(r#type: &'static str, updating: bool, from: Serial, to: Serial) { + if updating { + trace!("Updating existing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); + } else { + trace!("Storing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); + } +} + //------------ RelatedSignedDiff --------------------------------------------- struct RelatedSignedDiff { @@ -958,11 +970,3 @@ impl RelatedSignedDiff { } } } - -fn log_stored_diff(r#type: &'static str, updating: bool, from: Serial, to: Serial) { - if updating { - trace!("Updating existing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); - } else { - trace!("Storing IXFR in-memory diff for SOA {type} serial -{from:?}:+{to:?}"); - } -} From 7c2fa1645761f15428b63f8dc9868d7e8ce8c4bd Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:21:27 +0200 Subject: [PATCH 56/85] Make cargo doc happy. --- src/persistence/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index e4b0ab73f..f8ef62e90 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -58,8 +58,8 @@ //! can be served in response to an IXFR request from a downstream //! nameserver. //! - The path that the diff file was written to is appended to -//! [`PersistenceState::loaded_diffs`] or -//! [`PersistenceState::signed_diffs`] and the zone state is +//! [`PersistenceState::loaded_diffs`](crate::persistence::zone::PersistenceState::loaded_diffs) or +//! [`PersistenceState::signed_diffs`](crate::persistence::zone::PersistenceState::signed_diffs) and the zone state is //! immediately saved to disk. //! //! # Panics From 24f0e5650c93f79b36918fd6fdc58ee7ebfa8f68 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:11:32 +0200 Subject: [PATCH 57/85] Review feedback: Remove accidentally committed file. --- cascade.conf | 231 --------------------------------------------------- 1 file changed, 231 deletions(-) delete mode 100644 cascade.conf diff --git a/cascade.conf b/cascade.conf deleted file mode 100644 index b1ffd3ada..000000000 --- a/cascade.conf +++ /dev/null @@ -1,231 +0,0 @@ -# Configuring Cascade -# =================== -# -# This is a template file. Uncommented lines demonstrate the default settings. -# You can copy this and customize it to your liking, or write a configuration -# file from scratch using this as a reference. - -# The configuration file version. -# -# This is the only required option. All other settings, and their defaults, are -# associated with this version number. More versions may be added in the future -# and Cascade may drop support for older versions over time. -# -# - 'v1': This format. -version = "v1" - - -# The directory storing zone policies. -# -# Zone policies are user-managed files configuring groups of zones. You can -# modify them as you like, then ask Cascade to reload them with 'cascade policy -# reload'. -policy-dir = "/tmp/cascade/policies" - -# The directory storing per-zone state files. -# -# Cascade maintains an internal state file for every known zone here. These -# files should not be modified manually, but they can be backed up and restored -# in the event of filesystem corruption. -zone-state-dir = "/tmp/cascade/zone-state" - -# The file storing TSIG key secrets. -# -# This is an internal state file containing sensitive cryptographic material. -# It should not be modified manually, but it can be backed up and restored in -# the event of filesystem corruption. Carefully consider its security. -tsig-store-path = "/tmp/cascade/tsig-keys.db" - -# The file storing KMIP credentials. -# -# This is an internal state file containing sensitive cryptographic material. -# It should not be modified manually, but it can be backed up and restored in -# the event of filesystem corruption. Carefully consider its security. -kmip-credentials-store-path = "/tmp/cascade/kmip/credentials.db" - -# The directory storing rollover states and on-disk DNSSEC keys. -# -# For every zone, the state of its DNSSEC keys (which keys are used, ongoing -# rollovers, etc.) are stored here. If on-disk keys are used to sign zones, -# they are stored also here. -# -# The organization of this directory (file names and file formats) constitutes -# internal implementation details. It should not be modified manually, but -# it can be backed up and restored in the event of filesystem corruption. -# Carefully consider its security. -# -# TODO: Move rollover state files to a separate directory? -keys-dir = "/tmp/cascade/keys" - -# The directory containing KMIP server state. -# -# Information about known KMIP servers is stored in this directory. -# -# The organization of this directory (file names and file formats) constitutes -# internal implementation details. It should not be modified manually, but -# it can be backed up and restored in the event of filesystem corruption. -kmip-server-state-dir = "/tmp/cascade/kmip" - -# The path to the dnst binary Cascade should use. -# -# Cascade relies on the 'dnst' program () in -# order to perform DNSSEC key rollovers. You can specify an absolute path here, -# or just 'dnst' if it is in $PATH. -dnst-binary-path = "/tmp/cascade-dnst" - - -# Settings relevant to any daemon program. -[daemon] - -# The minimum severity of the messages logged by the daemon. -# -# Messages at or above the specified severity level will be logged. The -# following levels are defined: -# - 'trace': A function or variable was interacted with, for debugging. -# - 'debug': Something occurred that may be relevant to debugging. -# - 'info': Things are proceeding as expected. -# - 'warning': Something does not appear to be correct. -# - 'error': Something went wrong (but Cascade can recover). -# - 'critical': Something went wrong and Cascade can't function at all. -log-level = "trace" - -# The location the daemon writes logs to. -# -# - type 'file': Logs are appended line-by-line to the specified file path. -# -# If it is a terminal, ANSI escape codes may be used to style the output. -# -# - type 'stdout': Logs are written to stdout. (The default) -# -# If it is a terminal, ANSI escape codes may be used to style the output. -# -# - type 'stderr': Logs are written to stderr. -# -# If it is a terminal, ANSI escape codes may be used to style the output. -# -# - type 'syslog': Logs are written to the UNIX syslog. -# -# This option is only supported on UNIX systems. -# -# NOTE: When using systemd, 'syslog' and 'stdout' are the most reliable options. -# systemd environments are often heavily isolated, making file-based logging -# difficult. -#log-target = { type = "stdout" } -#log-target = { type = "syslog" } -log-target = { type = "file", path = "/tmp/cascade.log" } - -# Whether to apply internal daemonization. -# -# 'Daemonization' involves several steps: -# - Forking the process to disconnect it from the terminal -# - Tracking the new process' PID (by storing it in a file) -# - Binding privileged ports (below 1024) as configured -# - Dropping administrator privileges -# -# These features may be provided by an external system service manager, such as -# systemd. If no such service manager is being used, Cascade can provide such -# features itself, by setting this option to 'true'. This will also enable the -# 'pid-file' and 'identity' settings (although they remain optional). -# -# TODO: Link to a dedicated systemd / daemonization guide for Cascade. -daemonize = false - -# The path to a PID file to maintain, if any. -# -# If specified, Cascade will maintain a PID file at this location; it will be a -# simple plain-text file containing the PID number of the daemon process. This -# option is only supported if 'daemonize' is true. -pid-file = "/tmp/cascade/cascade.pid" - -# An identity (user and group) to assume after startup. -# -# Cascade will assume the specified identity after initialization. Note that -# this will fail if Cascade is started without administrator privileges. This -# option is only supported if 'daemonize' is 'true'. -# -# The identity can be specified as ':' or just ''; in the -# latter case, the identically named group will be used. Numeric IDs are not -# supported; only names can be used. -# -# NOTE: When using systemd, you should rely on its 'User=' and 'Group=' options -# instead. See . -#identity = "cascade:cascade" - - -# How Cascade is controlled. -[remote-control] -# Where to serve Cascade's HTTP API. -# -# The HTTP API can be used to monitor and control Cascade. The addresses refer -# to TCP sockets that will be listened on for HTTP requests. At the moment, -# security mechanisms like TLS are not supported. -# -# These sockets may be bound by systemd and passed into Cascade. If systemd -# does not provide them, Cascade will bind them itself (and will do so before -# dropping privileges, if that is enabled). -servers = ["127.0.0.1:2006"] - - -# How zones are loaded. -[loader] - -# How loaded zones are reviewed. -[loader.review] -# Where to serve loaded zones for review. -# -# A DNS server will be bound to these addresses, and will serve the contents of -# all loaded zones. This can be used to verify the consistency of these zones. -# -# Unless explicitly specified (e.g. 'udp://localhost:4541'), each address will -# be served over UDP and TCP. An empty array will disable serving entirely. -# -# These sockets may be bound by systemd and passed into Cascade. If systemd -# does not provide them, Cascade will bind them itself (and will do so before -# dropping privileges, if that is enabled). -# -# TODO: Note that this MUST be different to load.notify-listeners and signer.review.servers. -servers = ["127.0.0.1:2007"] - - -# How zones are signed. -[signer] - -# How signed zones are reviewed. -[signer.review] -# Where to serve signed zones for review. -# -# A DNS server will be bound to these addresses, and will serve the contents of -# all signed (but not necessarily published) zones. This can be used to check -# the correctness of the signer. -# -# Unless explicitly specified (e.g. 'udp://localhost:4542'), each address will -# be served over UDP and TCP. An empty array will disable serving entirely. -# -# These sockets may be bound by systemd and passed into Cascade. If systemd -# does not provide them, Cascade will bind them itself (and will do so before -# dropping privileges, if that is enabled). -# -# TODO: Note that this MUST be different to load.notify-listeners and loader.review.servers. -servers = ["127.0.0.1:2008"] - - -# DNSSEC key management. -[key-manager] - - -# How zones are published. -[server] -# Where to serve published zones. -# -# A DNS server will be bound to these addresses, and will serve the contents of -# all published zones. This is the final output from Cascade. -# -# Unless explicitly specified (e.g. 'udp://localhost:4543'), each address will -# be served over UDP and TCP. At least one address must be specified. -# -# These sockets may be bound by systemd and passed into Cascade. If systemd -# does not provide them, Cascade will bind them itself (and will do so before -# dropping privileges, if that is enabled). -# -# TODO: Note that this can be the same as loader.notify-listeners -servers = ["127.0.0.1:2005"] From 0b81ed9b3aad780e0fa545d042a71d8183852c70 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:19:23 +0200 Subject: [PATCH 58/85] Review feedback: Typo correction. Co-authored-by: Terts Diepraam --- doc/manual/source/man/cascaded-policy.toml.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/source/man/cascaded-policy.toml.rst b/doc/manual/source/man/cascaded-policy.toml.rst index f4c561789..32b46d43a 100644 --- a/doc/manual/source/man/cascaded-policy.toml.rst +++ b/doc/manual/source/man/cascaded-policy.toml.rst @@ -630,7 +630,7 @@ The ``[server.outbound]`` section. .. option:: max-diffs-size = 20 The maximum size allowed for in-memory diffs for a single zone, defined as - a parcentage of the size of the last published version of the zone. If + a percentage of the size of the last published version of the zone. If storing a new diff would exceed the limit, diffs will be discarded to make space starting with the oldest diffs first. From 30e3b7060d0c62a1fdc6b48390b7acd210a8abd8 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:25:14 +0200 Subject: [PATCH 59/85] Be more specific about diff size. --- doc/manual/source/man/cascaded-policy.toml.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/manual/source/man/cascaded-policy.toml.rst b/doc/manual/source/man/cascaded-policy.toml.rst index 32b46d43a..44859b55a 100644 --- a/doc/manual/source/man/cascaded-policy.toml.rst +++ b/doc/manual/source/man/cascaded-policy.toml.rst @@ -629,10 +629,14 @@ The ``[server.outbound]`` section. .. option:: max-diffs-size = 20 - The maximum size allowed for in-memory diffs for a single zone, defined as - a percentage of the size of the last published version of the zone. If - storing a new diff would exceed the limit, diffs will be discarded to make - space starting with the oldest diffs first. + The maximum size allowed for in-memory diffs for a single zone, defined + as a percentage of the size of the last published version of the zone, + with zone size measured as the number of records in the zone and diff size + measured as the total number of records added by all diffs for the zone + plus the total number of records removed by all diffs for the zone. + + If storing a new diff would exceed the limit, diffs will be discarded to + make space starting with the oldest diffs first. This setting has no impact on the number of diffs persisted to disk. See max-diffs to control that. From 83c93ee4b707f98a228ea332b9c0b12948b0849d Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:27:19 +0200 Subject: [PATCH 60/85] Clarify how diff limits are combined. --- doc/manual/source/man/cascaded-policy.toml.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/manual/source/man/cascaded-policy.toml.rst b/doc/manual/source/man/cascaded-policy.toml.rst index 44859b55a..3c75f0dc4 100644 --- a/doc/manual/source/man/cascaded-policy.toml.rst +++ b/doc/manual/source/man/cascaded-policy.toml.rst @@ -645,6 +645,9 @@ The ``[server.outbound]`` section. diffs for a zone always exceed the limit, then no diffs will be stored in memory and IXFR requests will be responded to with an AXFR instead. + .. note:: The `max-diffs` limit is applied first, then additional diffs + will be discarded as needed to meet the `max-diffs-size` limit. + Files ----- From d1db39051165c3f64db16a747a5888c568457798 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:29:42 +0200 Subject: [PATCH 61/85] Review feedback: Use correct config file setting name syntax. --- doc/manual/source/zone-transfers.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index 1e3313847..bfc0b729b 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -85,13 +85,13 @@ forcing them to costly fallback to AXFR transfers. Cascade has two policy settings which limit the amount of memory and disk space used per zone to store diffs: -- ``server.outbound.max_diffs`` -- ``server.outbound.max_diffs_size`` +- ``server.outbound.max-diffs`` +- ``server.outbound.max-diffs-size`` ``max_diffs`` specifies the maximum numer of IXFR diffs per zone that Cascade may keep in memory and on disk. -``max_diffs_size`` specifies the maximum size of all diffs combined that may +``max-diffs-size`` specifies the maximum size of all diffs combined that may be stored in memory per zone and is defined in relation to the size (number of records) of the published zone. From e931338c2fb602599d1ab9a2eb46bb805042492c Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:30:38 +0200 Subject: [PATCH 62/85] Review feedback: Typo correction. Co-authored-by: Terts Diepraam --- etc/policy.template.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/policy.template.toml b/etc/policy.template.toml index 90189863a..f05465e56 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -506,7 +506,7 @@ mode = "off" #max-diffs = 5 # The maximum size allowed for in-memory diffs for a single zone, defined as a -# parcentage of the size of the last published version of the zone. If storing +# percentage of the size of the last published version of the zone. If storing # a new diff would exceed the limit, diffs will be discarded to make space # starting with the oldest diffs first. # From ec73680e3b4ef4532fd73edfde64fb3bff4a9a6d Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:04:52 +0200 Subject: [PATCH 63/85] Review feedback: No need for abs_diff(), and return early. --- src/persistence/zone.rs | 176 ++++++++++++++++++++-------------------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 72cdebcf5..02646850b 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -348,63 +348,66 @@ impl PersistenceState { "Checking if compaction is needed for zone '{}': {num_signed_diffs} > {max_diffs}", zone.name ); - if num_signed_diffs > max_diffs { + if num_signed_diffs <= max_diffs { + return; + } + + debug!( + "Compacting persisted diffs for zone '{}' with {} diffs > {} max diffs", + zone.name, num_signed_diffs, max_diffs + ); + let num_diffs_to_remove = num_signed_diffs - max_diffs; + let loaded_snapshot_path = &loaded_snapshot_path.unwrap().path; + let signed_snapshot_path = &signed_snapshot_path.unwrap().path; + + // Get access to the published records for the zone, so that we + // can write new loaded and signed snapshot files to disk. + if let Ok(viewer) = viewer.try_read() + && let Some(reader) = viewer.read() + { debug!( - "Compacting persisted diffs for zone '{}' with {} diffs > {} max diffs", - zone.name, num_signed_diffs, max_diffs + "Writing loaded zone snapshot to {}", + loaded_snapshot_path.display() + ); + crate::persistence::persist_to_file_from_parts( + loaded_snapshot_path, + None, + reader.soa().clone(), + [].iter(), + reader.loaded_records(), ); - let num_diffs_to_remove = num_signed_diffs.abs_diff(max_diffs); - let loaded_snapshot_path = &loaded_snapshot_path.unwrap().path; - let signed_snapshot_path = &signed_snapshot_path.unwrap().path; - - // Get access to the published records for the zone, so that we - // can write new loaded and signed snapshot files to disk. - if let Ok(viewer) = viewer.try_read() - && let Some(reader) = viewer.read() - { - debug!( - "Writing loaded zone snapshot to {}", - loaded_snapshot_path.display() - ); - crate::persistence::persist_to_file_from_parts( - loaded_snapshot_path, - None, - reader.soa().clone(), - [].iter(), - reader.loaded_records(), - ); - debug!( - "Writing new signed zone snapshot to {}", - signed_snapshot_path.display() - ); - crate::persistence::persist_to_file_from_parts( - signed_snapshot_path, - None, - reader.soa().clone(), - [].iter(), - reader.generated_records().iter(), - ); + debug!( + "Writing new signed zone snapshot to {}", + signed_snapshot_path.display() + ); + crate::persistence::persist_to_file_from_parts( + signed_snapshot_path, + None, + reader.soa().clone(), + [].iter(), + reader.generated_records().iter(), + ); - // Now that we have re-written the snapshots using the latest - // published version of the zone we don't need any of the - // on-disk persisted diffs that were previously applied on top - // of the old snapshot to re-create the zone. - // - // We might still however need some of those on-disk diffs so - // that we can reload them on startup to be able to serve them - // as IXFR diffs to downstream nameservers. - // - // Check which ones we can delete and after deleting them - // update our record of the first on-disk diff file that - // should be applied on top of the updated snapshot. - - // Remove the first N oldest signed diffs and their - // corresponding loaded diffs. Skip the first "diff" as it is - // the snapshot, not a diff. - let mut idx = 0; - let mut loaded_serials_to_remove = vec![]; - state + // Now that we have re-written the snapshots using the latest + // published version of the zone we don't need any of the + // on-disk persisted diffs that were previously applied on top + // of the old snapshot to re-create the zone. + // + // We might still however need some of those on-disk diffs so + // that we can reload them on startup to be able to serve them + // as IXFR diffs to downstream nameservers. + // + // Check which ones we can delete and after deleting them + // update our record of the first on-disk diff file that + // should be applied on top of the updated snapshot. + + // Remove the first N oldest signed diffs and their + // corresponding loaded diffs. Skip the first "diff" as it is + // the snapshot, not a diff. + let mut idx = 0; + let mut loaded_serials_to_remove = vec![]; + state .persistence .signed_diffs .diff_infos @@ -430,47 +433,44 @@ impl PersistenceState { keep }); - // Remove the corresponding loaded diffs. - for loaded_serial in loaded_serials_to_remove.into_iter() { - if let Some(found_item) = state + // Remove the corresponding loaded diffs. + for loaded_serial in loaded_serials_to_remove.into_iter() { + if let Some(found_item) = state + .persistence + .loaded_diffs + .diffs() + .iter() + .find(|item| item.loaded_serial == Some(loaded_serial)) + .cloned() + { + trace!( + "Compaction for zone '{}': removing loaded diff for loaded serial {loaded_serial}", + zone.name + ); + let _ = state .persistence .loaded_diffs - .diffs() - .iter() - .find(|item| item.loaded_serial == Some(loaded_serial)) - .cloned() - { - trace!( - "Compaction for zone '{}': removing loaded diff for loaded serial {loaded_serial}", - zone.name + .diff_infos + .remove(&found_item); + if let Err(err) = std::fs::remove_file(&found_item.path) { + warn!( + "Failed to remove persisted zone data file '{}' while compacting zone '{}': {err}", + zone.name, + found_item.path.display() ); - let _ = state - .persistence - .loaded_diffs - .diff_infos - .remove(&found_item); - if let Err(err) = std::fs::remove_file(&found_item.path) { - warn!( - "Failed to remove persisted zone data file '{}' while compacting zone '{}': {err}", - zone.name, - found_item.path.display() - ); - } } } - - state.persistence.loaded_diffs.restore_base_idx = - state.persistence.loaded_diffs.len(); - state.persistence.signed_diffs.restore_base_idx = - state.persistence.signed_diffs.len(); - trace!( - "Compaction complete: next_idx: loaded={}, signed={}, restore_base_idx: loaded={}, signed={}", - state.persistence.loaded_diffs.next_idx, - state.persistence.signed_diffs.next_idx, - state.persistence.loaded_diffs.restore_base_idx, - state.persistence.signed_diffs.restore_base_idx - ); } + + state.persistence.loaded_diffs.restore_base_idx = state.persistence.loaded_diffs.len(); + state.persistence.signed_diffs.restore_base_idx = state.persistence.signed_diffs.len(); + trace!( + "Compaction complete: next_idx: loaded={}, signed={}, restore_base_idx: loaded={}, signed={}", + state.persistence.loaded_diffs.next_idx, + state.persistence.signed_diffs.next_idx, + state.persistence.loaded_diffs.restore_base_idx, + state.persistence.signed_diffs.restore_base_idx + ); } } From d9e27b4da4c80091c8b4f7148d3676f2f50231b6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:06:43 +0200 Subject: [PATCH 64/85] Review feedback: Use let else style. --- src/persistence/zone.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 02646850b..cb0d3ceea 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -362,9 +362,11 @@ impl PersistenceState { // Get access to the published records for the zone, so that we // can write new loaded and signed snapshot files to disk. - if let Ok(viewer) = viewer.try_read() - && let Some(reader) = viewer.read() - { + let Ok(viewer) = viewer.try_read() else { + return; + }; + + if let Some(reader) = viewer.read() { debug!( "Writing loaded zone snapshot to {}", loaded_snapshot_path.display() From e345918653cc8c25fc6716d6438945c73201f1f5 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:08:17 +0200 Subject: [PATCH 65/85] Rewrap comments after use of early return caused changes to indenting. --- src/persistence/zone.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index cb0d3ceea..465874590 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -360,8 +360,8 @@ impl PersistenceState { let loaded_snapshot_path = &loaded_snapshot_path.unwrap().path; let signed_snapshot_path = &signed_snapshot_path.unwrap().path; - // Get access to the published records for the zone, so that we - // can write new loaded and signed snapshot files to disk. + // Get access to the published records for the zone, so that we can + // write new loaded and signed snapshot files to disk. let Ok(viewer) = viewer.try_read() else { return; }; @@ -392,21 +392,21 @@ impl PersistenceState { ); // Now that we have re-written the snapshots using the latest - // published version of the zone we don't need any of the - // on-disk persisted diffs that were previously applied on top - // of the old snapshot to re-create the zone. + // published version of the zone we don't need any of the on-disk + // persisted diffs that were previously applied on top of the old + // snapshot to re-create the zone. // - // We might still however need some of those on-disk diffs so - // that we can reload them on startup to be able to serve them - // as IXFR diffs to downstream nameservers. + // We might still however need some of those on-disk diffs so that + // we can reload them on startup to be able to serve them as IXFR + // diffs to downstream nameservers. // - // Check which ones we can delete and after deleting them - // update our record of the first on-disk diff file that - // should be applied on top of the updated snapshot. + // Check which ones we can delete and after deleting them update + // our record of the first on-disk diff file that should be + // applied on top of the updated snapshot. - // Remove the first N oldest signed diffs and their - // corresponding loaded diffs. Skip the first "diff" as it is - // the snapshot, not a diff. + // Remove the first N oldest signed diffs and their corresponding + // loaded diffs. Skip the first "diff" as it is the snapshot, not + // a diff. let mut idx = 0; let mut loaded_serials_to_remove = vec![]; state @@ -414,8 +414,8 @@ impl PersistenceState { .signed_diffs .diff_infos .retain(|diff_info| { - // Keep only the snapshot and diffs newer than the ones - // to remove. + // Keep only the snapshot and diffs newer than the + // ones to remove. let keep = idx == 0 || idx > num_diffs_to_remove; trace!("Compaction for zone '{}': removing {num_diffs_to_remove} diffs: retain diff #{idx}: {keep}", zone.name); idx += 1; From 1822cf04c07f9a7b3613e6396430538eda54c3e8 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:45:25 +0200 Subject: [PATCH 66/85] Review feedback: use checked_add(). --- src/persistence/zone.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 465874590..994cc275e 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -607,10 +607,7 @@ impl PersistedDiffManager { let file_info = PersistedDiffFileInfo::new(path.clone(), loaded_serial, signed_serial); assert!(self.diff_infos.insert(file_info)); - - // replace with strict_add() once our MSRV reaches 1.91.0. - assert_ne!(self.next_idx, usize::MAX); - self.next_idx += 1; + self.next_idx.checked_add(1).unwrap(); path } From b6e834eaf08ab9482d072266ed6795c33ad58c42 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:48:46 +0200 Subject: [PATCH 67/85] Review feedback: Remove into_iter(). --- src/persistence/zone.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 994cc275e..bf2ce6364 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -436,7 +436,7 @@ impl PersistenceState { }); // Remove the corresponding loaded diffs. - for loaded_serial in loaded_serials_to_remove.into_iter() { + for loaded_serial in loaded_serials_to_remove { if let Some(found_item) = state .persistence .loaded_diffs From ce5cf14d78802fe1feaa53cfdb62261f6333a200 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:51:25 +0200 Subject: [PATCH 68/85] Review feedback: Less cryptic `cascade policy show` output. --- crates/cli/src/commands/policy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/policy.rs b/crates/cli/src/commands/policy.rs index c7a046d2d..1dde76903 100644 --- a/crates/cli/src/commands/policy.rs +++ b/crates/cli/src/commands/policy.rs @@ -295,7 +295,7 @@ fn print_server_policy( println!(" outbound:"); print_nameserver_comms_policy("provide XFR to", provide_xfr_to); print_nameserver_comms_policy("send NOTIFY to", send_notify_to); - println!(" max diffs: {max_diffs} ({max_diffs_size}%)"); + println!(" max diffs: {max_diffs} totaling less than {max_diffs_size}% of the published record count"); } fn print_review(ReviewPolicyInfo { mode, on_reject }: &ReviewPolicyInfo) { From 02c10785de2d6cd1f6e305e830dda009883ee9dc Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:01:06 +0200 Subject: [PATCH 69/85] Review feedback: Hopefully improved description of max-diffs vs max-diffs-size. --- etc/policy.template.toml | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/etc/policy.template.toml b/etc/policy.template.toml index f05465e56..cfc669ba2 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -480,8 +480,8 @@ mode = "off" #provide-xfr-to = ["127.0.0.1", "127.0.0.1^my-tsig-key"] # The maximum number of "sequences of differential information" (diffs) that -# the server may store per zone in order to respond to RFC 1995 Incremental -# Zone Transfer (IXFR) requests. +# the server may store per zone **in-memory** in order to respond to RFC 1995 +# Incremental Zone Transfer (IXFR) requests. # # A diff is created when changes to the input zone are received and when # re-signing alters the signed records generated when signing last occurred. @@ -489,15 +489,21 @@ mode = "off" # Diffs are persisted to disk once approved and will be restored on next start # of the Cascade daemon. # -# Excess in-memory diffs are discarded as soon as possible after approval of a -# new diff, or on policy reload if the diff maximums were reduced compared to -# previous version of the policy. +# Excess **in-memory** diffs are discarded as soon as possible after approval +# of a new diff, or on policy reload if the diff maximums were reduced +# compared to previous version of the policy. # -# Persisted diffs are discarded periodically to reduce the impact of persisted -# diff compaction. The number of persisted diffs may therefore temporarily -# exceed the limit specified here until purging of persisted diffs next -# occurs. Discarding of persisted diffs will be skipped for zones that are in -# maintenance mode. +# Diffs persisted to disk will also be deleted if above the specified +# max-diffs limit, but not immediately, and if not needed to restore the +# zone on Cascade restart. Deletion of diffs on disk (so-called 'compaction') +# requires re-writing the entire zone to disk which for large zones could take +# time and for rapidly changing zones is thus not desirable to do on every +# change to the zone, and therefore is done periodically rather than on every +# change. Diffs that exceed the max-diffs limit but which are still required +# to restore the zone (because compactiion has not occurred yet) will remain +# on disk until it is safe to delete them. +# +# Compaction will be skipped for zones that are in maintenance mode. # # Note that while RFC 1955 section 5 suggests that diffs be purged based on # total IXFR response size and/or if older than the SOA expire period, Cascade @@ -505,15 +511,15 @@ mode = "off" # support for RFC 1995 section 6 condensation of multiple versions. #max-diffs = 5 -# The maximum size allowed for in-memory diffs for a single zone, defined as a -# percentage of the size of the last published version of the zone. If storing -# a new diff would exceed the limit, diffs will be discarded to make space -# starting with the oldest diffs first. +# The maximum size allowed for **in-memory** diffs for a single zone, defined +# as a percentage of the size (by record count) of the last published version +# of the zone. If storing a new diff would exceed the limit, **in-memory** +# diffs will be discarded to make space starting with the oldest diffs first. # # This setting has no impact on the number of diffs persisted to disk. See # max-diffs to control that. # -# The default is 20%. If set to 0, or a value low enough such that the set of -# diffs for a zone always exceed the limit, then no diffs will be stored in -# memory and IXFR requests will be responded to with an AXFR instead. +# The default is 20%. If set to 0, or a value low enough such that the set +# of diffs for a zone always exceed the limit, then no diffs will be stored +# **in-memory** and IXFR requests will be responded to with an AXFR instead. #max-diffs-size = 20 From 33b9e497d6e702d45ed62ca358748414a21f2439 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:10:05 +0200 Subject: [PATCH 70/85] cargo fmt. --- crates/cli/src/commands/policy.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/policy.rs b/crates/cli/src/commands/policy.rs index 1dde76903..a51e93546 100644 --- a/crates/cli/src/commands/policy.rs +++ b/crates/cli/src/commands/policy.rs @@ -295,7 +295,9 @@ fn print_server_policy( println!(" outbound:"); print_nameserver_comms_policy("provide XFR to", provide_xfr_to); print_nameserver_comms_policy("send NOTIFY to", send_notify_to); - println!(" max diffs: {max_diffs} totaling less than {max_diffs_size}% of the published record count"); + println!( + " max diffs: {max_diffs} totaling less than {max_diffs_size}% of the published record count" + ); } fn print_review(ReviewPolicyInfo { mode, on_reject }: &ReviewPolicyInfo) { From 893ce511c0849580e17707ec215d8b72a1dd26f0 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:10:28 +0200 Subject: [PATCH 71/85] Review feedback: include policy TOML example, and reword max-diffs descriptions slightly. --- doc/manual/source/zone-transfers.rst | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/doc/manual/source/zone-transfers.rst b/doc/manual/source/zone-transfers.rst index bfc0b729b..1516b328b 100644 --- a/doc/manual/source/zone-transfers.rst +++ b/doc/manual/source/zone-transfers.rst @@ -85,20 +85,23 @@ forcing them to costly fallback to AXFR transfers. Cascade has two policy settings which limit the amount of memory and disk space used per zone to store diffs: -- ``server.outbound.max-diffs`` -- ``server.outbound.max-diffs-size`` +.. code-block:: toml + + [server.outbound] + max-diffs = 5 + max-diffs-size = 20 ``max_diffs`` specifies the maximum numer of IXFR diffs per zone that Cascade may keep in memory and on disk. -``max-diffs-size`` specifies the maximum size of all diffs combined that may -be stored in memory per zone and is defined in relation to the size (number of -records) of the published zone. +``max-diffs-size`` specifies the maximum number of records that all diffs for +a zone combined that may be stored in memory per zone as a percentage of the +number of records in the currently published version of the zone. Note that diffs on-disk are (a) lazily removed, and so may persist longer than expected, and (b) on-disk diffs may be needed to restore the published zone on restart of Cascade and will then be removed once the persisted zone record -data has been compacted at which point it is safe to delete diffs. +data has been compacted at which point it is safe for Cascade to delete them. .. caution:: Do not manually remove on-disk diff files as doing so may prevent Cascade restoring the last published version of the zone if the From b9fc37ff31f30976b7991c2f12044c9895174227 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:39:12 +0200 Subject: [PATCH 72/85] Review feedback: Move code used by both persist and restore to the module level. Also update discard_excess_diffs() to follow the changes introduced by https://github.com/NLnetLabs/cascade/pull/877. --- src/persistence/mod.rs | 70 +++++++++++++++++++++++++++++++++-- src/persistence/persist.rs | 76 +------------------------------------- src/persistence/restore.rs | 35 +----------------- src/persistence/zone.rs | 13 +++++-- src/zone/mod.rs | 13 +++++++ 5 files changed, 91 insertions(+), 116 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index f8ef62e90..dc7df020b 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -142,12 +142,9 @@ use crate::{ }; mod persist; -pub use persist::{ - discard_excess_diffs, persist_loaded, persist_signed, persist_to_file_from_parts, -}; - mod restore; use restore::{restore_loaded, restore_signed}; +use tracing::trace; pub mod zone; @@ -307,3 +304,68 @@ impl Default for Restorer { Self::new() } } + +//------------ discard_excess_diffs() ---------------------------------------- + +/// Calculate from policy and published zone metadata the limits to apply +/// and then trim zone diffs to be within those limits. +// +// TODO: Ideally this would be done as part of PersistentDiffManager::trim() +// but PersistentDiffManager has no access to policy or instance signed +// metadata. +pub fn discard_excess_diffs(center: &Arc
, zone: &Arc) { + let mut state = zone.write(center); + + if let Some(policy) = state.policy.as_ref() + && let Some(signed_metadata) = state.signed_metadata() + { + // Fetch diff purging settings from policy. + let max_diffs = policy.server.outbound.max_diffs; + let max_size_percentage = policy.server.outbound.max_diffs_size; + + // Calculate the maximum number of records that a set of diffs can be based on + // the policy settings. IxfrZoneDiffs can't do this for us as it has + // no access to `last_published`. + let current_size = signed_metadata.num_records().get(); + let max_size = calc_max_diff_size(max_size_percentage, current_size); + + trace!( + "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={current_size}, max_size={max_size_percentage}% ({max_size} RRs)", + zone.name, + ); + state.storage.diffs.trim(max_diffs, max_size); + } +} + +/// Calculate the maximum size a diff can be as a percentage of the last +/// published zone. +fn calc_max_diff_size(max_size_percentage: usize, current_size: u64) -> usize { + let percentage = max_size_percentage as f64 / 100.0; + (current_size as f64 * percentage) as usize +} + +#[cfg(test)] +mod tests { + use super::calc_max_diff_size; + + #[test] + pub fn test_calc_max_diff_size() { + let empty_zone = 0; + assert_eq!(calc_max_diff_size(0, empty_zone), 0); + assert_eq!(calc_max_diff_size(50, empty_zone), 0); + assert_eq!(calc_max_diff_size(100, empty_zone), 0); + assert_eq!(calc_max_diff_size(1000, empty_zone), 0); + + let small_zone = 5; + assert_eq!(calc_max_diff_size(0, small_zone), 0); + assert_eq!(calc_max_diff_size(50, small_zone), 2); + assert_eq!(calc_max_diff_size(100, small_zone), 5); + assert_eq!(calc_max_diff_size(1000, small_zone), 50); + + let large_zone = 500000; + assert_eq!(calc_max_diff_size(0, large_zone), 0); + assert_eq!(calc_max_diff_size(50, large_zone), 250000); + assert_eq!(calc_max_diff_size(100, large_zone), 500000); + assert_eq!(calc_max_diff_size(1000, large_zone), 5000000); + } +} diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 4c0f03d4d..4cf0d89ca 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -15,7 +15,8 @@ use tracing::trace; use crate::{ center::Center, - zone::{LastPublished, Zone, save_state_now}, + persistence::discard_excess_diffs, + zone::{Zone, save_state_now}, }; /// Persist the data for a loaded instance of a zone. @@ -330,76 +331,3 @@ fn store_diff( } diffs.store_signed_diff(loaded_serial, signed_diff.clone()); } - -//------------ discard_excess_diffs() ---------------------------------------- - -pub fn discard_excess_diffs(center: &Arc
, zone: &Arc) { - let mut state = zone.write(center); - if let Some(policy) = state.policy.as_ref() - && let Some(last_published) = state.last_published.as_ref() - { - // Fetch diff purging settings from policy. - let max_diffs = policy.server.outbound.max_diffs; - let max_size_percentage = policy.server.outbound.max_diffs_size; - - // Calculate the maximum number of records that a set of diffs can be based on - // the policy settings. IxfrZoneDiffs can't do this for us as it has - // no access to `last_published`. - let max_size = calc_max_diff_size(max_size_percentage, last_published); - - trace!( - "Discarding excess in-memory diffs for zone '{}' with settings max_diffs={max_diffs}, current_size={}, max_size={max_size_percentage}% ({max_size} RRs)", - zone.name, last_published.num_records - ); - state.storage.diffs.trim(max_diffs, max_size); - } -} - -/// Calculate the maximum size a diff can be as a percentage of the last -/// published zone. -fn calc_max_diff_size(max_size_percentage: usize, last_published: &LastPublished) -> usize { - let current_size = last_published.num_records as f64; - let percentage = max_size_percentage as f64 / 100.0; - (current_size * percentage) as usize -} - -#[cfg(test)] -mod tests { - use std::time::SystemTime; - - use domain::base::Serial; - - use crate::zone::LastPublished; - - use super::calc_max_diff_size; - - #[test] - pub fn test_calc_max_diff_size() { - let empty_zone = last_published(0); - assert_eq!(calc_max_diff_size(0, &empty_zone), 0); - assert_eq!(calc_max_diff_size(50, &empty_zone), 0); - assert_eq!(calc_max_diff_size(100, &empty_zone), 0); - assert_eq!(calc_max_diff_size(1000, &empty_zone), 0); - - let small_zone = last_published(5); - assert_eq!(calc_max_diff_size(0, &small_zone), 0); - assert_eq!(calc_max_diff_size(50, &small_zone), 2); - assert_eq!(calc_max_diff_size(100, &small_zone), 5); - assert_eq!(calc_max_diff_size(1000, &small_zone), 50); - - let large_zone = last_published(500000); - assert_eq!(calc_max_diff_size(0, &large_zone), 0); - assert_eq!(calc_max_diff_size(50, &large_zone), 250000); - assert_eq!(calc_max_diff_size(100, &large_zone), 500000); - assert_eq!(calc_max_diff_size(1000, &large_zone), 5000000); - } - - fn last_published(num_records: usize) -> LastPublished { - LastPublished { - loaded_serial: Serial::from(0), - signed_serial: Serial::from(0), - timestamp: SystemTime::now(), - num_records, - } - } -} diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 29398cfdd..7d9b8d646 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -314,41 +314,8 @@ pub fn restore_signed( zone.name ); - if let Some((_, last_diff)) = diffs_to_store.last() { + { let mut state = zone.write(center); - - // Build a new loaded SOA RR from the last published SOA RR with - // the serial number replaced with that of the last loaded serial. - // We don't have access to the actual last loaded SOA here, only - // it's serial number as that is all that we stored in the state - // that we restored. We can't set `published_loaded_soa` in - // restore_loaded() as we don't know at that point if the last - // loaded diff was later published. - // - // TODO: Could fields in the SOA other than the serial differ - // between the loaded SOA and the published SOA, and thus is it - // then insufficient to use the last published SOA RR as the basis - // for constructing a last published loaded SOA RR? - let mut template_soa = last_diff.added_soa.clone().expect( - "To be restoring a signed zone we must have had a diff which must have added a SOA", - ); - - let last_pub_loaded_serial = state - .last_published - .as_ref() - .map(|p| p.loaded_serial.0) - .unwrap(); - let last_pub_signed_serial = state - .last_published - .as_ref() - .map(|p| p.signed_serial.0) - .unwrap(); - - template_soa.rdata.serial = last_pub_loaded_serial.into(); - state.storage.published_loaded_soa = Some(template_soa.clone()); - template_soa.rdata.serial = last_pub_signed_serial.into(); - state.storage.published_soa = Some(template_soa); - for (loaded_serial, diff) in diffs_to_store { // Store the signed diff to be used as part of serving an IXFR. state.storage.diffs.store_signed_diff(loaded_serial, diff); diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index bf2ce6364..31c2b8649 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -12,6 +12,7 @@ use tracing::{debug, info, trace, trace_span, warn}; use crate::{ center::Center, + persistence::persist::{persist_loaded, persist_signed, persist_to_file_from_parts}, server::{LoadedReviewServer, PublicationServer, SignedReviewServer}, util::BackgroundTasks, zone::{Zone, ZoneHandle, ZoneState, save_state_now}, @@ -137,6 +138,10 @@ impl ZonePersistenceHandle<'_> { let (loaded_reviewer, signed_reviewer, viewer) = handle.storage().finish_signed_restoration(restored); + // The call to instances.restore() below will update current + // instance metadata to match restored persisted instance + // metadata so we don't handle that here. + handle.signer().on_restoration(); // Register the zone against the zone servers. @@ -172,7 +177,7 @@ impl ZonePersistenceHandle<'_> { .ongoing .spawn_blocking(span, move || { debug!("Persisting the loaded instance"); - let persisted = super::persist_loaded(&zone, ¢er, persister); + let persisted = persist_loaded(&zone, ¢er, persister); debug!("Persisting the loaded instance completed"); // NOTE: The outer function, which is spawning the background @@ -202,7 +207,7 @@ impl ZonePersistenceHandle<'_> { .ongoing .spawn_blocking(span, move || { debug!("Persisting the signed instance"); - let persisted = super::persist_signed(&zone, ¢er, persister); + let persisted = persist_signed(&zone, ¢er, persister); debug!("Persisting the signed instance completed"); // NOTE: The outer function, which is spawning the background @@ -371,7 +376,7 @@ impl PersistenceState { "Writing loaded zone snapshot to {}", loaded_snapshot_path.display() ); - crate::persistence::persist_to_file_from_parts( + persist_to_file_from_parts( loaded_snapshot_path, None, reader.soa().clone(), @@ -383,7 +388,7 @@ impl PersistenceState { "Writing new signed zone snapshot to {}", signed_snapshot_path.display() ); - crate::persistence::persist_to_file_from_parts( + persist_to_file_from_parts( signed_snapshot_path, None, reader.soa().clone(), diff --git a/src/zone/mod.rs b/src/zone/mod.rs index 6bfc29378..19714c73b 100644 --- a/src/zone/mod.rs +++ b/src/zone/mod.rs @@ -424,6 +424,19 @@ impl ZoneState { .rev() .find(|item| item.event.is_of_type(typ) && (serial.is_none() || item.serial == serial)) } + + /// Get the most recent signed metadata for the zone. + /// + /// During restore the metadata for the currently published instance is + /// not available yet so use the last persisted zone metadata instead. + pub fn signed_metadata(&self) -> Option<&SignedInstance> { + match (&self.instances.persisted, &self.instances.current) { + (None, None) => None, + (None, Some(i)) => Some(&i.signed), + (Some(i), None) => Some(&i.signed), + (Some(_), Some(i)) => Some(&i.signed), + } + } } impl Default for ZoneState { From b2e723d490f75e95df15179aca3dc1b1d84cfdf8 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:39:29 +0200 Subject: [PATCH 73/85] FIX: Actually update next_idx. --- src/persistence/zone.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 31c2b8649..6c04a8c8b 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -612,7 +612,7 @@ impl PersistedDiffManager { let file_info = PersistedDiffFileInfo::new(path.clone(), loaded_serial, signed_serial); assert!(self.diff_infos.insert(file_info)); - self.next_idx.checked_add(1).unwrap(); + self.next_idx = self.next_idx.checked_add(1).unwrap(); path } From f23a31ce339de94e5cdb5fdc0fe945ff29a3e8b3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:40:14 +0200 Subject: [PATCH 74/85] FIX: Ensure after storing diffs that we are not exceeding limits. Ensuring before storing would allow the to-be-stored diff to then take us over the configured limits. --- src/persistence/persist.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 4cf0d89ca..f79c60734 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -313,8 +313,8 @@ fn store_for_ixfr_out( // Ignore the diff if it is not acceptable, e.g. if it changes more than // X% of the records in the zone or crosses some other threshold. if signed_diff.removed_soa.is_some() && signed_diff.added_soa.is_some() { - discard_excess_diffs(center, zone); store_diff(center, zone, loaded_diff, signed_diff); + discard_excess_diffs(center, zone); } } From 7c6c823af8d287ad570edf7fcbdcd1962f7b75c3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:40:24 +0200 Subject: [PATCH 75/85] Additional trace logging. --- src/persistence/zone.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 6c04a8c8b..aa8a7b033 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -611,6 +611,11 @@ impl PersistedDiffManager { .into_std_path_buf(); let file_info = PersistedDiffFileInfo::new(path.clone(), loaded_serial, signed_serial); + trace!( + "Pushing diff with loaded serial {loaded_serial:?} and signed serial {signed_serial:?} for zone '{}' with path '{}'", + zone.name, + path.display() + ); assert!(self.diff_infos.insert(file_info)); self.next_idx = self.next_idx.checked_add(1).unwrap(); From 1ed1077848511ef65985e97729303f80b204baec Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:40:37 +0200 Subject: [PATCH 76/85] Added some TODOs. --- src/persistence/zone.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index aa8a7b033..fadf50b21 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -372,6 +372,14 @@ impl PersistenceState { }; if let Some(reader) = viewer.read() { + // TODO: The steps below if interrupted (e.g. by crash, OOM + // kill, system failure or power outage) could lead to zone data + // corruption on restore. One possible solution could be to write + // persisted records for a zone to a directory specific to that + // zone, and during compaction create a new directory with all + // the necessary data then update in state on disk the path to + // the directory to use to be the new directory and delete the + // old directory. debug!( "Writing loaded zone snapshot to {}", loaded_snapshot_path.display() @@ -844,6 +852,8 @@ impl IxfrZoneDiffs { let to_serial = diff.added_soa.as_ref().map(|s| s.rdata.serial).unwrap(); let old = self.loaded_diffs.insert(from_serial.into(), diff); log_stored_diff("loaded", old.is_some(), from_serial, to_serial); + // TODO: Ideally we would invoke self.trim() here but we lack the + // necessary information to do so. } pub fn store_signed_diff(&mut self, loaded_serial: Option, diff: Arc) { @@ -852,6 +862,8 @@ impl IxfrZoneDiffs { let related_diff = RelatedSignedDiff::new(diff, loaded_serial); let old = self.signed_diffs.insert(from_serial.into(), related_diff); log_stored_diff("signed", old.is_some(), from_serial, to_serial); + // TODO: Ideally we would invoke self.trim() here but we lack the + // necessary information to do so. } pub fn get(&self, from_serial: Serial) -> Vec<(Arc, Arc)> { From bb6b7fadba152da54908d4022eb6e59017403df6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:48:54 +0200 Subject: [PATCH 77/85] Review feedback: Clearer separation of next_idx and restore_base_idx. --- src/persistence/zone.rs | 68 ++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index fadf50b21..cbdbc2dc8 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -477,14 +477,26 @@ impl PersistenceState { } } - state.persistence.loaded_diffs.restore_base_idx = state.persistence.loaded_diffs.len(); - state.persistence.signed_diffs.restore_base_idx = state.persistence.signed_diffs.len(); + state + .persistence + .loaded_diffs + .first_diff_to_apply_on_restore = state.persistence.loaded_diffs.len(); + state + .persistence + .signed_diffs + .first_diff_to_apply_on_restore = state.persistence.signed_diffs.len(); trace!( "Compaction complete: next_idx: loaded={}, signed={}, restore_base_idx: loaded={}, signed={}", - state.persistence.loaded_diffs.next_idx, - state.persistence.signed_diffs.next_idx, - state.persistence.loaded_diffs.restore_base_idx, - state.persistence.signed_diffs.restore_base_idx + state.persistence.loaded_diffs.next_uniqifier, + state.persistence.signed_diffs.next_uniqifier, + state + .persistence + .loaded_diffs + .first_diff_to_apply_on_restore, + state + .persistence + .signed_diffs + .first_diff_to_apply_on_restore ); } } @@ -548,9 +560,10 @@ pub struct PersistedDiffManager { /// Which kind of data are we storing, loaded or signed? record_source: PersistedDiffRecordSource, - /// The index value to use when constructing the next file name to write - /// to. - next_idx: usize, + /// A value that when included in the construction of a path for a persisted + /// file will make that path unique for the zone that this instance of + /// PersistedDiffManager relates to. + next_uniqifier: usize, /// The index of the first diff_info to apply to the snapshot when restoring. /// @@ -559,9 +572,19 @@ pub struct PersistedDiffManager { /// still track their paths so that we can load them for use in responding to /// IXFR client requests. So we need to remember which index to start applying /// diffs to the snapshot from. - restore_base_idx: usize, + first_diff_to_apply_on_restore: usize, /// The collection of persisted data file paths in this set. + /// + /// The first entry always refers to a persisted snapshot of the zone records + /// at a point in time. + /// + /// Subsequent entries refer to persisted diffs that should be applied in + /// sequence to the snapshot upon restore. However, if the [`Compacter`] has + /// replaced the snapshot content such that it includes some of the persisted + /// diffs, entries [1..restore_base_idx] may only be needed to respond to IXFR + /// requests (see [`Self::gete_diffs()`] and [`Service::ixfr()`]) and must not + /// be used for restoration. diff_infos: BTreeSet, } @@ -572,14 +595,14 @@ impl PersistedDiffManager { pub fn from_parts( record_source: PersistedDiffRecordSource, - next_idx: usize, - restore_base_idx: usize, + next_uniqifier: usize, + first_diff_to_apply_on_restore: usize, diff_infos: BTreeSet, ) -> Self { Self { record_source, - next_idx, - restore_base_idx, + next_uniqifier, + first_diff_to_apply_on_restore, diff_infos, } } @@ -615,7 +638,10 @@ impl PersistedDiffManager { let path = center .config .zone_state_dir - .join(format!("{zone_name}.{data_file_type}.{}", self.next_idx)) + .join(format!( + "{zone_name}.{data_file_type}.{}", + self.next_uniqifier + )) .into_std_path_buf(); let file_info = PersistedDiffFileInfo::new(path.clone(), loaded_serial, signed_serial); @@ -625,7 +651,7 @@ impl PersistedDiffManager { path.display() ); assert!(self.diff_infos.insert(file_info)); - self.next_idx = self.next_idx.checked_add(1).unwrap(); + self.next_uniqifier = self.next_uniqifier.checked_add(1).unwrap(); path } @@ -651,7 +677,7 @@ impl PersistedDiffManager { // snapshot. // TODO: Maybe we should separate out snapshot files from diff // files. - self.next_idx = 0; + self.next_uniqifier = 0; } else { // When removing a diff the specified serial must match that of // the last diff that we have. @@ -672,8 +698,8 @@ impl PersistedDiffManager { pub fn clear(&mut self) { self.diff_infos.clear(); - self.next_idx = 0; - self.restore_base_idx = 0; + self.next_uniqifier = 0; + self.first_diff_to_apply_on_restore = 0; } pub fn is_empty(&self) -> bool { @@ -681,7 +707,7 @@ impl PersistedDiffManager { } pub fn next_idx(&self) -> usize { - self.next_idx + self.next_uniqifier } pub fn diffs(&self) -> &BTreeSet { @@ -693,7 +719,7 @@ impl PersistedDiffManager { } pub fn restore_base_idx(&self) -> usize { - self.restore_base_idx + self.first_diff_to_apply_on_restore } } From d1bc2adf7a769cd4f7ffc9dc756c64c4b57a10c9 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:49:19 +0200 Subject: [PATCH 78/85] Note some of the known limitations of the current implementation to be addressed in future. --- src/persistence/mod.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index dc7df020b..3f3173ed0 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -132,6 +132,25 @@ //! viewer for the zone. //! - `IxfrZoneDiffs` stores diffs used when responding to an RFC 1995 IXFR //! request, and offers lookup and trim operations. +//! +//! # TODO +//! +//! The current implementation could be improved by: +//! +//! - Removing next_idx and instead using a UUID to make persistence paths +//! unique. This would avoid the need for incrementing the id, and keeping +//! track of the last id both in memory and in persisted state. +//! - Don't access and mutate the state of PersistedDiffManager from outside +//! the type as this makes it dangerous to change the way +//! PersistedDiffManager manipulates its own state as code outside +//! PersistedDiffManager may depend on assumptions about how the internal +//! state is constructed. +//! - Track snapshots separately to diffs rather than treating the first diff +//! as a snapshot, to make it clearer which logic applies only to snapshots +//! which logic applies only to diffs, and which logic applies to both. +//! - Track diffs left behind after compaction but still required for IXFR +//! responses separately to diffs that should be applied on restore to the +//! persistedsnapshot. use std::{sync::Arc, time::Duration}; use crate::{ From e30a010947c034ce528d99fb6f9a3615cdb33b09 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:54:47 +0200 Subject: [PATCH 79/85] More TODO notes. --- src/persistence/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 3f3173ed0..308cf1d2d 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -151,6 +151,15 @@ //! - Track diffs left behind after compaction but still required for IXFR //! responses separately to diffs that should be applied on restore to the //! persistedsnapshot. +//! - Storing persistent diff info could possibly be done as a Vec-like type +//! instead of as a BTreeSet. Attempts to do so introduced confusing error +//! prone index logic but that was before serial number relationships were +//! stored with diffs and included error prone assumptions involved in +//! attempting to determine such relationships from vec indices alone. It +//! may now require less and simpler index calculation logic. However, the +//! set type currrently also ensures no duplicate entries by diff path. If +//! next_idx were replaced by a UUID the paths would always be unique which +//! would diminish or remove the need for a 'set' type. use std::{sync::Arc, time::Duration}; use crate::{ From 552f6989f31b14b2f9c6d2c1b40ad30493454b47 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:57:35 +0200 Subject: [PATCH 80/85] Review feedback: Rename from_parts() to for_existing_diffs(). --- src/persistence/zone.rs | 9 +++++++-- src/zone/state/v1.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index cbdbc2dc8..761ab1537 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -590,10 +590,15 @@ pub struct PersistedDiffManager { impl PersistedDiffManager { pub fn new(record_source: PersistedDiffRecordSource) -> Self { - Self::from_parts(record_source, 0, 0, Default::default()) + Self { + record_source, + next_uniqifier: 0, + first_diff_to_apply_on_restore: 0, + diff_infos: BTreeSet::new(), + } } - pub fn from_parts( + pub fn for_exisitng_diffs( record_source: PersistedDiffRecordSource, next_uniqifier: usize, first_diff_to_apply_on_restore: usize, diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 88b8e3bf6..ee5e7caa6 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -880,7 +880,7 @@ impl PersistedDiffsSpec { true => PersistedDiffRecordSource::Signed, false => PersistedDiffRecordSource::Loaded, }; - PersistedDiffManager::from_parts( + PersistedDiffManager::for_exisitng_diffs( is_signed, self.next_idx, self.restore_base_idx, From b56510f0db99f952edad21d4b4345fe64f69260e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:05:24 +0200 Subject: [PATCH 81/85] Review feedback: Clarify that not all diffs are applied if compaction has occurred. --- src/persistence/mod.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 308cf1d2d..3d728a25f 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -69,6 +69,21 @@ //! no way of knowing what else may be failing and abort as it is not safe to //! continue under such circumstances. //! +//! # Compaction +//! +//! The `Compacter` replaces an existing snapshot and diffs with an +//! up-tao-date snapshot. It may leave some diffs behind [1..N] such that entry +//! 0 is still the snapshot but the first applicable diff is entry N. Diffs +//! 1..N can be left behind in the case that they are still required to +//! respond to IXFR requests but not for restoration (as they have been folded +//! into the updated snapshot). +//! +//! Compaction is done as a background operation periodically. This is to +//! prevent a situation where a rapdily changing large zone has to be locked +//! for compaction after every small change and being a large zone the +//! compaction process would be comparatively slow. Given that compaction is +//! an optimization it does not need to be done on every change to the zone. +//! //! # Restoration //! //! Zones are created in memory at Cascade startup in storage state @@ -89,10 +104,11 @@ //! logged as a WARNing and Cascade will continue. //! //! Restoration of a zone is achieved by replacing the current (empty) zone -//! content with the loaded snapshot, then applying each loaded diff file -//! to the snapshot one at a time. The diffs are also kept in-memory for -//! responding to IXFR requests from downstream nameservers. The signed -//! snapshot and diffs are also restored like this. +//! content with the loaded snapshot, then applying each applicable loaded +//! diff file to the snapshot one at a time. If the `Compacter` has folded +//! diffs into the snapshot it may be that restoration must skip some diffs +//! as they exist already in the snapshot and were kept only because they +//! are still needed to respond to IXFR requests. //! //! Any diff that was available at a review server will have been lost. //! However as only approved data gets persisted, there should be no need @@ -160,6 +176,7 @@ //! set type currrently also ensures no duplicate entries by diff path. If //! next_idx were replaced by a UUID the paths would always be unique which //! would diminish or remove the need for a 'set' type. +//! - Make compaction occur when a zone is idle, rather than periodically. use std::{sync::Arc, time::Duration}; use crate::{ From f9005f622f1d2346c31446dd31e897da5c5b7467 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:12:17 +0200 Subject: [PATCH 82/85] Review feedback: Complete the renaming of restore_base_idx and add more comments. --- src/persistence/restore.rs | 16 ++++++++++++---- src/persistence/zone.rs | 12 ++++++++++-- src/zone/state/v1.rs | 4 ++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 7d9b8d646..fd7cfa8b9 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -41,14 +41,17 @@ pub fn restore_loaded( restorer: &mut LoadedZoneRestorer, ) -> io::Result { let diff_infos; - let restore_base_idx; + let first_diff_to_apply_on_restore; // Use a block so that we don't hold the zone state lock longer than // necessary. { let state = zone.read(); diff_infos = state.persistence.loaded_diffs.diffs().clone(); - restore_base_idx = state.persistence.loaded_diffs.restore_base_idx(); + first_diff_to_apply_on_restore = state + .persistence + .loaded_diffs + .first_diff_to_apply_on_restore(); } let mut diff_infos_iter = diff_infos.iter(); @@ -91,7 +94,9 @@ pub fn restore_loaded( let mut diffs_to_store: Vec> = vec![]; for (idx, diff_info) in diff_infos_iter.enumerate() { - let (start_serial, end_serial) = if idx < restore_base_idx { + // Note: idx is zero-based but as we skipped over the first entry (the + // snapshot) we need to add one to get a correct index. + let (start_serial, end_serial) = if (idx + 1) < first_diff_to_apply_on_restore { trace!( "Building standalone IXFR diff #{idx} from '{}'", diff_info.path().display() @@ -194,7 +199,10 @@ pub fn restore_signed( { let state = zone.read(); diff_infos = state.persistence.signed_diffs.diffs().clone(); - restore_base_idx = state.persistence.signed_diffs.restore_base_idx(); + restore_base_idx = state + .persistence + .signed_diffs + .first_diff_to_apply_on_restore(); } let mut diff_infos_iter = diff_infos.iter(); diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 761ab1537..ce3893ee6 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -477,6 +477,14 @@ impl PersistenceState { } } + // As we may have folded diffs into the snapshot, upon next + // restoration the first diff to apply to the snapshot is no + // longer the first after the snapshot but the first after + // we skip diffs that have been retained for responding to IXFR + // requests but are now part of the snapshot. + // + // Update the index of the first diff to apply on restore to + // account for the compaction done above. state .persistence .loaded_diffs @@ -593,7 +601,7 @@ impl PersistedDiffManager { Self { record_source, next_uniqifier: 0, - first_diff_to_apply_on_restore: 0, + first_diff_to_apply_on_restore: 1, diff_infos: BTreeSet::new(), } } @@ -723,7 +731,7 @@ impl PersistedDiffManager { self.diff_infos.len() } - pub fn restore_base_idx(&self) -> usize { + pub fn first_diff_to_apply_on_restore(&self) -> usize { self.first_diff_to_apply_on_restore } } diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index ee5e7caa6..107aa4bd4 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -893,7 +893,7 @@ impl PersistedDiffsSpec { Self { is_signed: false, next_idx: loaded_diffs.next_idx(), - restore_base_idx: loaded_diffs.restore_base_idx(), + restore_base_idx: loaded_diffs.first_diff_to_apply_on_restore(), diff_infos: loaded_diffs .diffs() .iter() @@ -907,7 +907,7 @@ impl PersistedDiffsSpec { Self { is_signed: true, next_idx: signed_diffs.next_idx(), - restore_base_idx: signed_diffs.restore_base_idx(), + restore_base_idx: signed_diffs.first_diff_to_apply_on_restore(), diff_infos: signed_diffs .diffs() .iter() From 521a7e66eeef9c17aa194def7604343b645159d8 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:20:03 +0200 Subject: [PATCH 83/85] Make cargo doc happy. --- src/persistence/zone.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index ce3893ee6..1a113d77f 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -587,12 +587,12 @@ pub struct PersistedDiffManager { /// The first entry always refers to a persisted snapshot of the zone records /// at a point in time. /// - /// Subsequent entries refer to persisted diffs that should be applied in - /// sequence to the snapshot upon restore. However, if the [`Compacter`] has - /// replaced the snapshot content such that it includes some of the persisted - /// diffs, entries [1..restore_base_idx] may only be needed to respond to IXFR - /// requests (see [`Self::gete_diffs()`] and [`Service::ixfr()`]) and must not - /// be used for restoration. + /// Subsequent entries refer to persisted diffs that should be + /// applied in sequence to the snapshot upon restore. However, if the + /// [`Compacter`](crate::persistence::Compacter) has replaced the snapshot + /// content such that it includes some of the persisted diffs, entries + /// [1..restore_base_idx] may only be needed to respond to IXFR requests + /// and must not be used for restoration. diff_infos: BTreeSet, } From c401f24f73d73127042e410209827467f91aeca6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:29:53 +0200 Subject: [PATCH 84/85] Review feedback: Typo correction. Co-authored-by: Terts Diepraam --- etc/policy.template.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/policy.template.toml b/etc/policy.template.toml index cfc669ba2..ad462e895 100644 --- a/etc/policy.template.toml +++ b/etc/policy.template.toml @@ -500,7 +500,7 @@ mode = "off" # time and for rapidly changing zones is thus not desirable to do on every # change to the zone, and therefore is done periodically rather than on every # change. Diffs that exceed the max-diffs limit but which are still required -# to restore the zone (because compactiion has not occurred yet) will remain +# to restore the zone (because compaction has not occurred yet) will remain # on disk until it is safe to delete them. # # Compaction will be skipped for zones that are in maintenance mode. From 148d1f2915372759e66eff5cf3cd974e16095e48 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:40:41 +0200 Subject: [PATCH 85/85] Typo fix: exisitng -> existing. --- src/persistence/zone.rs | 2 +- src/zone/state/v1.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 1a113d77f..d873b2ab6 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -606,7 +606,7 @@ impl PersistedDiffManager { } } - pub fn for_exisitng_diffs( + pub fn for_existing_diffs( record_source: PersistedDiffRecordSource, next_uniqifier: usize, first_diff_to_apply_on_restore: usize, diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 107aa4bd4..eabadb118 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -880,7 +880,7 @@ impl PersistedDiffsSpec { true => PersistedDiffRecordSource::Signed, false => PersistedDiffRecordSource::Loaded, }; - PersistedDiffManager::for_exisitng_diffs( + PersistedDiffManager::for_existing_diffs( is_signed, self.next_idx, self.restore_base_idx,