use crate::content::node::{NodeState, PropertyState, PropertyValues};
use crate::content::property::PropertyValue;
use crate::content::provider::SegmentProvider;
use crate::content::value::BinaryValue;
use crate::segment::identifier::SegmentIdentifier;
use crate::segment::record::RecordIdentifier;
use std::collections::{HashMap, HashSet};
pub(crate) struct HistoryFacts {
pub(crate) record: RecordIdentifier,
pub(crate) path: String,
pub(crate) nodes: u64,
pub(crate) inline_binary_bytes: u64,
pub(crate) external_references: u64,
pub(crate) bulk_segments: HashSet<SegmentIdentifier>,
pub(crate) newest_version_created: Option<i64>,
pub(crate) freezes_a_configuration: bool,
pub(crate) internal_identifiers: Vec<u128>,
}
#[derive(Default)]
pub(crate) struct VersionStorageCensus {
histories: HashMap<u128, HistoryFacts>,
pub(crate) live_history_bulk: HashSet<SegmentIdentifier>,
pub(crate) intermediates: Vec<(String, RecordIdentifier)>,
pub(crate) histories_under_intermediate: HashMap<String, u64>,
pub(crate) malformed_identifiers: u64,
pub(crate) total_histories: u64,
live_identifiers: HashSet<u128>,
}
impl VersionStorageCensus {
pub(crate) fn mark_live(&mut self, identifier: u128) {
self.live_identifiers.insert(identifier);
}
pub(crate) fn resolve_live_matches(&mut self) {
for identifier in std::mem::take(&mut self.live_identifiers) {
if let Some(facts) = self.histories.remove(&identifier) {
self.live_history_bulk.extend(facts.bulk_segments);
}
}
}
pub(crate) fn orphans(&self) -> &HashMap<u128, HistoryFacts> {
&self.histories
}
}
pub(crate) struct VersionStoragePreScan<'census> {
pub(crate) census: &'census mut VersionStorageCensus,
pub(crate) external_binaries: &'census mut super::ExternalBinaryCensus,
pub(crate) collect_internal_identifiers: bool,
current: Option<(String, u128)>,
}
impl<'census> VersionStoragePreScan<'census> {
pub(crate) fn new(
census: &'census mut VersionStorageCensus,
external_binaries: &'census mut super::ExternalBinaryCensus,
collect_internal_identifiers: bool,
) -> Self {
Self {
census,
external_binaries,
collect_internal_identifiers,
current: None,
}
}
fn begin_history(&mut self, path: &str, node: &NodeState<'_>, properties: &[PropertyState]) {
self.census.total_histories += 1;
for ancestor in intermediate_prefixes(path) {
*self
.census
.histories_under_intermediate
.entry(ancestor.to_owned())
.or_default() += 1;
}
let Some(identifier) =
single_string_property(properties, "jcr:versionableUuid").and_then(parse_identifier)
else {
self.census.malformed_identifiers += 1;
self.current = None;
return;
};
self.census.histories.insert(
identifier,
HistoryFacts {
record: node.record_identifier(),
path: path.to_owned(),
nodes: 0,
inline_binary_bytes: 0,
external_references: 0,
bulk_segments: HashSet::new(),
newest_version_created: None,
freezes_a_configuration: false,
internal_identifiers: Vec::new(),
},
);
self.current = Some((path.to_owned(), identifier));
}
fn attribute(
&mut self,
provider: &dyn SegmentProvider,
identifier: u128,
properties: &[PropertyState],
) {
let collect_internal = self.collect_internal_identifiers;
let Some(facts) = self.census.histories.get_mut(&identifier) else {
return;
};
facts.nodes += 1;
let primary_type = single_name_property(properties, "jcr:primaryType");
for property in properties {
for value in property_values(property) {
match value {
PropertyValue::Binary(BinaryValue::Inline {
length,
record_identifier,
}) => {
facts.inline_binary_bytes =
facts.inline_binary_bytes.saturating_add(*length);
collect_bulk_blocks(
provider,
*record_identifier,
*length,
&mut facts.bulk_segments,
);
}
PropertyValue::Binary(BinaryValue::External { .. }) => {
facts.external_references += 1;
}
_ => {}
}
}
if property.name == "jcr:created"
&& primary_type == Some("nt:version")
&& let Some(text) = first_text(property)
&& let Some(epoch_seconds) = parse_iso8601_epoch_seconds(&text)
{
facts.newest_version_created = Some(
facts
.newest_version_created
.map_or(epoch_seconds, |newest| newest.max(epoch_seconds)),
);
}
if property.name == "jcr:frozenPrimaryType"
&& first_text(property).as_deref() == Some("nt:configuration")
{
facts.freezes_a_configuration = true;
}
if collect_internal
&& property.name == "jcr:uuid"
&& let Some(parsed) = first_text(property).as_deref().and_then(parse_identifier)
{
facts.internal_identifiers.push(parsed);
}
}
}
}
impl crate::tooling::VerifiedContentObserver for VersionStoragePreScan<'_> {
fn node_verified(
&mut self,
provider: &dyn SegmentProvider,
path: &str,
node: &NodeState<'_>,
properties: &[PropertyState],
) {
for property in properties {
for value in property_values(property) {
self.external_binaries.observe_value(value);
}
}
if let Some((current_path, identifier)) = &self.current {
if within(path, current_path) {
let identifier = *identifier;
self.attribute(provider, identifier, properties);
return;
}
self.current = None;
}
if single_name_property(properties, "jcr:primaryType") == Some("nt:versionHistory") {
let begun = path.to_owned();
self.begin_history(&begun, node, properties);
if let Some((_, identifier)) = &self.current {
let identifier = *identifier;
self.attribute(provider, identifier, properties);
}
return;
}
if !path.is_empty() {
self.census
.intermediates
.push((path.to_owned(), node.record_identifier()));
}
}
}
fn within(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn intermediate_prefixes(path: &str) -> impl Iterator<Item = &str> {
path.match_indices('/')
.skip(1)
.map(|(position, _)| &path[..position])
}
fn single_string_property<'properties>(
properties: &'properties [PropertyState],
name: &str,
) -> Option<&'properties str> {
properties
.iter()
.find(|property| property.name == name)
.and_then(|property| match &property.values {
PropertyValues::Single(PropertyValue::String(text) | PropertyValue::Name(text)) => {
Some(text.as_str())
}
_ => None,
})
}
fn single_name_property<'properties>(
properties: &'properties [PropertyState],
name: &str,
) -> Option<&'properties str> {
single_string_property(properties, name)
}
fn property_values(property: &PropertyState) -> impl Iterator<Item = &PropertyValue> {
match &property.values {
PropertyValues::Single(value) => std::slice::from_ref(value).iter(),
PropertyValues::Multiple(values) => values.iter(),
}
}
fn first_text(property: &PropertyState) -> Option<String> {
property_values(property)
.next()
.and_then(PropertyValue::as_text)
}
pub(crate) fn collect_bulk_blocks(
provider: &dyn SegmentProvider,
value: RecordIdentifier,
length: u64,
bulk_segments: &mut HashSet<SegmentIdentifier>,
) {
if length < crate::writer::record_writer::MEDIUM_VALUE_LIMIT as u64 {
return;
}
let block_count = length.div_ceil(crate::content::value::BLOCK_SIZE);
let Ok(view) = provider.segment(value.segment) else {
return;
};
let Ok(list_identifier) = view.read_record_identifier(value.record_number, 8, 0) else {
return;
};
let Ok(blocks) =
crate::content::list::uncounted_list_entries(provider, list_identifier, block_count)
else {
return;
};
for block in blocks {
if block.segment.is_bulk_segment() {
bulk_segments.insert(block.segment);
}
}
}
pub(crate) fn parse_identifier(text: &str) -> Option<u128> {
let bytes = text.as_bytes();
if bytes.len() != 36 {
return None;
}
let mut value: u128 = 0;
for (position, byte) in bytes.iter().enumerate() {
match position {
8 | 13 | 18 | 23 => {
if *byte != b'-' {
return None;
}
}
_ => {
let digit = (*byte as char).to_digit(16)?;
value = (value << 4) | u128::from(digit);
}
}
}
Some(value)
}
pub(crate) fn parse_iso8601_epoch_seconds(text: &str) -> Option<i64> {
crate::java::parse_epoch_milliseconds(text).map(|milliseconds| milliseconds.div_euclid(1_000))
}
#[cfg(test)]
mod tests {
use super::{intermediate_prefixes, parse_identifier, parse_iso8601_epoch_seconds, within};
#[test]
fn epochs_match_a_hand_computed_table() {
let table = [
("1970-01-01T00:00:00.000Z", 0),
("1970-01-01T01:00:00.000+01:00", 0),
("1969-12-31T23:00:00.000-01:00", 0),
("2012-03-01T12:30:45.678+01:00", 1_330_601_445),
("2026-08-19T07:02:27.000Z", 1_787_122_947),
("1969-12-31T23:59:59.999Z", -1),
];
for (text, expected) in table {
assert_eq!(
parse_iso8601_epoch_seconds(text),
Some(expected),
"for {text}"
);
}
}
#[test]
fn malformed_dates_parse_to_nothing() {
for text in [
"",
"2012-03-01",
"2012-03-01T12:30:45",
"2012-13-01T12:30:45.000Z",
"2012-03-01T12:30:45.678+0100",
"2026-08-19T07:02:27Z",
"not a date at all",
] {
assert_eq!(parse_iso8601_epoch_seconds(text), None, "for {text}");
}
}
#[test]
fn identifiers_parse_case_insensitively_and_reject_malformation() {
let lower = parse_identifier("00b6d84c-9256-5b98-a45f-1bb0a9fef2ef");
let upper = parse_identifier("00B6D84C-9256-5B98-A45F-1BB0A9FEF2EF");
assert!(lower.is_some());
assert_eq!(lower, upper);
for text in [
"",
"00b6d84c92565b98a45f1bb0a9fef2ef",
"00b6d84c-9256-5b98-a45f-1bb0a9fef2eg",
"00b6d84c-9256-5b98-a45f-1bb0a9fef2ef0",
] {
assert_eq!(parse_identifier(text), None, "for {text}");
}
}
#[test]
fn subtree_membership_respects_name_boundaries() {
assert!(within("/00/1f/2e/history", "/00/1f/2e/history"));
assert!(within("/00/1f/2e/history/1.0", "/00/1f/2e/history"));
assert!(!within("/00/1f/2e/history-two", "/00/1f/2e/history"));
assert!(!within("/00/1f/2e", "/00/1f/2e/history"));
}
#[test]
fn intermediate_prefixes_name_every_proper_ancestor() {
let prefixes: Vec<&str> = intermediate_prefixes("/00/1f/2e/history").collect();
assert_eq!(prefixes, ["/00", "/00/1f", "/00/1f/2e"]);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PurgeSelection {
pub(crate) omitted_records: Vec<RecordIdentifier>,
pub(crate) histories: u64,
pub(crate) nodes: u64,
pub(crate) selected_identifiers: HashSet<u128>,
pub(crate) kept_by_age: u64,
pub(crate) kept_configurations: u64,
pub(crate) kept_by_references: u64,
pub(crate) context_dependent_intermediates: Vec<RecordIdentifier>,
}
pub(crate) fn select_purge(
census: &VersionStorageCensus,
minimum_age: Option<std::time::Duration>,
now_epoch_seconds: i64,
demoted_by_references: &HashSet<u128>,
) -> PurgeSelection {
let mut selection = PurgeSelection {
omitted_records: Vec::new(),
histories: 0,
nodes: 0,
selected_identifiers: HashSet::new(),
kept_by_age: 0,
kept_configurations: 0,
kept_by_references: 0,
context_dependent_intermediates: Vec::new(),
};
let mut selected_paths: Vec<&str> = Vec::new();
for (identifier, facts) in census.orphans() {
if facts.freezes_a_configuration {
selection.kept_configurations += 1;
continue;
}
if let Some(minimum_age) = minimum_age {
let old_enough = facts.newest_version_created.is_some_and(|created| {
now_epoch_seconds.saturating_sub(created)
>= i64::try_from(minimum_age.as_secs()).unwrap_or(i64::MAX)
});
if !old_enough {
selection.kept_by_age += 1;
continue;
}
}
if demoted_by_references.contains(identifier) {
selection.kept_by_references += 1;
continue;
}
selection.histories += 1;
selection.nodes += facts.nodes;
selection.selected_identifiers.insert(*identifier);
selection.omitted_records.push(facts.record);
selected_paths.push(&facts.path);
}
let mut selected_under: HashMap<&str, u64> = HashMap::new();
for path in &selected_paths {
for ancestor in intermediate_prefixes(path) {
*selected_under.entry(ancestor).or_default() += 1;
}
}
let mut blocked_children: HashMap<&str, bool> = HashMap::new();
let mut by_depth: Vec<&(String, RecordIdentifier)> = census.intermediates.iter().collect();
by_depth.sort_by_key(|(path, _)| std::cmp::Reverse(path.matches('/').count()));
for (path, record) in by_depth {
let total = census
.histories_under_intermediate
.get(path.as_str())
.copied()
.unwrap_or(0);
let selected = selected_under.get(path.as_str()).copied().unwrap_or(0);
let child_blocks = blocked_children
.get(path.as_str())
.copied()
.unwrap_or(false);
let omissible = total != 0 && selected == total && !child_blocks;
if omissible {
selection.omitted_records.push(*record);
} else {
if selected != 0 {
selection.context_dependent_intermediates.push(*record);
}
if let Some(separator) = path.rfind('/')
&& separator != 0
{
*blocked_children.entry(&path[..separator]).or_default() = true;
}
}
}
selection
}
pub(crate) fn demoted_by_inbound_references(
repository: &crate::store::Repository,
content_root: RecordIdentifier,
version_storage_record: Option<RecordIdentifier>,
candidate_identifiers: &HashMap<u128, u128>,
observer: &mut dyn crate::progress::ProgressObserver,
) -> crate::error::Result<HashSet<u128>> {
crate::progress::observe(
observer,
&crate::progress::Step::new(
"checking references into purged histories",
crate::progress::WorkUnit::Nodes,
),
|observer| {
let mut demoted = HashSet::new();
let mut visited = crate::packed_records::PackedRecordSet::new();
let mut pending = vec![content_root];
let mut traced = crate::progress::StrideCounter::new(512);
while let Some(record) = pending.pop() {
if visited.contains(record) {
continue;
}
visited.insert(record);
traced.advance(observer);
let node = repository.node(record);
for property in node.properties()? {
if !matches!(
property.property_type,
crate::content::property::PropertyType::Reference
| crate::content::property::PropertyType::WeakReference
) {
continue;
}
for value in property_values(&property) {
if let Some(target) = value.as_text().as_deref().and_then(parse_identifier)
&& let Some(history) = candidate_identifiers.get(&target)
{
demoted.insert(*history);
}
}
}
for (_, child) in node.child_node_entries()? {
if Some(child.record_identifier()) == version_storage_record {
continue;
}
pending.push(child.record_identifier());
}
}
traced.finish(observer);
if !demoted.is_empty() {
observer.step_concluded(&format!(
"{} histories kept: something still references them",
crate::units::format_count(crate::progress::count(demoted.len())),
));
}
Ok(demoted)
},
)
}
pub(crate) fn candidate_internal_identifiers(
census: &VersionStorageCensus,
candidates: &HashSet<u128>,
) -> HashMap<u128, u128> {
let mut map = HashMap::new();
for (identifier, facts) in census.orphans() {
if !candidates.contains(identifier) {
continue;
}
for internal in &facts.internal_identifiers {
map.insert(*internal, *identifier);
}
}
map
}