use std::collections::{BTreeMap, BTreeSet};
use crate::content::node::NodeState;
use crate::content::property::PropertyValue;
use crate::index::{ASYNC_NODE_NAME, IndexResult};
const PENDING_RELEASE_SUFFIX: &str = "-temp";
const LEASE_SUFFIX: &str = "-lease";
const LAST_INDEXED_TO_SUFFIX: &str = "-LastIndexedTo";
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct AsyncLane {
pub name: String,
pub checkpoint: Option<String>,
pub pending_release: Vec<String>,
pub lease_expiry: Option<i64>,
pub last_indexed_to: Option<String>,
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct AsyncLanes {
lanes: BTreeMap<String, AsyncLane>,
node_present: bool,
}
impl AsyncLanes {
pub fn read(content_root: &NodeState<'_>) -> IndexResult<Self> {
let Some(node) = content_root.child_node(ASYNC_NODE_NAME)? else {
return Ok(Self::default());
};
let mut lanes: BTreeMap<String, AsyncLane> = BTreeMap::new();
for property in node.properties()? {
let (lane_name, field) = split_lane_property(&property.name);
let lane = lanes
.entry(lane_name.to_owned())
.or_insert_with(|| AsyncLane {
name: lane_name.to_owned(),
..AsyncLane::default()
});
let values = crate::index::values_of(&property);
match field {
LaneField::Checkpoint => {
lane.checkpoint =
crate::index::strict_string(Some(&property)).map(str::to_owned);
}
LaneField::PendingRelease => {
lane.pending_release =
values.iter().filter_map(PropertyValue::as_text).collect();
}
LaneField::Lease => {
lane.lease_expiry = crate::index::converting_long(Some(&property));
}
LaneField::LastIndexedTo => {
lane.last_indexed_to = values.first().and_then(PropertyValue::as_text);
}
}
}
Ok(Self {
lanes,
node_present: true,
})
}
#[must_use]
pub fn node_present(&self) -> bool {
self.node_present
}
#[must_use]
pub fn lane(&self, name: &str) -> Option<&AsyncLane> {
self.lanes.get(name)
}
pub fn lanes(&self) -> impl Iterator<Item = &AsyncLane> {
self.lanes.values()
}
pub fn dangling_checkpoints(
content_root: &NodeState<'_>,
super_root: &NodeState<'_>,
) -> IndexResult<Vec<String>> {
let Some(node) = content_root.child_node(ASYNC_NODE_NAME)? else {
return Ok(Vec::new());
};
let existing: BTreeSet<String> = match super_root.child_node("checkpoints")? {
None => BTreeSet::new(),
Some(checkpoints) => checkpoints
.child_node_entries()?
.into_iter()
.map(|(name, _)| name)
.collect(),
};
let mut dangling = BTreeSet::new();
for property in node.properties()? {
if property.name.ends_with(PENDING_RELEASE_SUFFIX) {
continue;
}
for value in crate::index::values_of(&property) {
if let PropertyValue::String(text) = value
&& is_checkpoint_reference(text)
&& !existing.contains(text)
{
dangling.insert(text.clone());
}
}
}
Ok(dangling.into_iter().collect())
}
}
#[derive(Clone, Debug)]
pub enum CheckState<'provider> {
Head(NodeState<'provider>),
LaneCheckpoint {
lane: String,
checkpoint: String,
root: NodeState<'provider>,
},
Uncheckable {
reason: String,
},
}
impl<'provider> CheckState<'provider> {
#[must_use]
pub fn root(&self) -> Option<&NodeState<'provider>> {
match self {
CheckState::Head(root) | CheckState::LaneCheckpoint { root, .. } => Some(root),
CheckState::Uncheckable { .. } => None,
}
}
}
pub fn checked_state_root<'provider>(
super_root: &NodeState<'provider>,
lanes: &AsyncLanes,
definition: &crate::index::IndexDefinition,
) -> IndexResult<CheckState<'provider>> {
let head = super_root.child_node("root")?.ok_or_else(|| {
crate::index::IndexError::Record(crate::Error::InvalidFormat {
details: "the super-root has no \"root\" child node".to_owned(),
})
})?;
let Some(lane_name) = definition.lane.as_deref() else {
return Ok(CheckState::Head(head));
};
let Some(lane) = lanes.lane(lane_name) else {
return Ok(CheckState::Uncheckable {
reason: format!(
"its lane {lane_name:?} has no state on /:async, so nothing says which \
revision its data corresponds to"
),
});
};
let Some(checkpoint) = lane.checkpoint.as_deref() else {
return Ok(CheckState::Uncheckable {
reason: format!(
"its lane {lane_name:?} names no checkpoint, so the lane has not completed \
a run"
),
});
};
let checkpoints = super_root.child_node("checkpoints")?;
let checkpoint_node = match &checkpoints {
None => None,
Some(checkpoints) => checkpoints.child_node(checkpoint)?,
};
let Some(checkpoint_node) = checkpoint_node else {
return Ok(CheckState::Uncheckable {
reason: format!(
"its lane {lane_name:?} resumes from checkpoint {checkpoint:?}, which no \
longer exists; Oak reindexes from scratch here rather than failing"
),
});
};
let Some(root) = checkpoint_node.child_node("root")? else {
return Ok(CheckState::Uncheckable {
reason: format!(
"checkpoint {checkpoint:?}, which lane {lane_name:?} resumes from, has no \
\"root\" child node"
),
});
};
Ok(CheckState::LaneCheckpoint {
lane: lane_name.to_owned(),
checkpoint: checkpoint.to_owned(),
root,
})
}
enum LaneField {
Checkpoint,
PendingRelease,
Lease,
LastIndexedTo,
}
fn split_lane_property(name: &str) -> (&str, LaneField) {
if let Some(lane) = name.strip_suffix(PENDING_RELEASE_SUFFIX) {
(lane, LaneField::PendingRelease)
} else if let Some(lane) = name.strip_suffix(LEASE_SUFFIX) {
(lane, LaneField::Lease)
} else if let Some(lane) = name.strip_suffix(LAST_INDEXED_TO_SUFFIX) {
(lane, LaneField::LastIndexedTo)
} else {
(name, LaneField::Checkpoint)
}
}
fn is_checkpoint_reference(text: &str) -> bool {
let groups: Vec<&str> = text.split('-').collect();
groups.len() == 5
&& [8, 4, 4, 4, 12] == groups.iter().map(|group| group.len()).collect::<Vec<_>>()[..]
&& text
.chars()
.all(|character| character == '-' || character.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::{LaneField, is_checkpoint_reference, split_lane_property};
fn field_name(name: &str) -> (&str, &'static str) {
let (lane, field) = split_lane_property(name);
let field = match field {
LaneField::Checkpoint => "checkpoint",
LaneField::PendingRelease => "pending",
LaneField::Lease => "lease",
LaneField::LastIndexedTo => "last-indexed-to",
};
(lane, field)
}
#[test]
fn the_bare_lane_name_is_the_checkpoint() {
assert_eq!(field_name("async"), ("async", "checkpoint"));
assert_eq!(
field_name("fulltext-async"),
("fulltext-async", "checkpoint")
);
}
#[test]
fn each_suffix_names_its_field_without_splitting_a_hyphenated_lane() {
assert_eq!(
field_name("fulltext-async-temp"),
("fulltext-async", "pending")
);
assert_eq!(
field_name("fulltext-async-lease"),
("fulltext-async", "lease")
);
assert_eq!(
field_name("fulltext-async-LastIndexedTo"),
("fulltext-async", "last-indexed-to")
);
}
#[test]
fn a_checkpoint_reference_is_recognized_only_in_oaks_shape() {
assert!(is_checkpoint_reference(
"8b3d5f2a-1c4e-4a7b-9f01-2d3e4f5a6b7c"
));
assert!(!is_checkpoint_reference("async"));
assert!(!is_checkpoint_reference("not-a-checkpoint"));
assert!(!is_checkpoint_reference("2026-08-17T10:00:00.000Z"));
assert!(!is_checkpoint_reference(""));
assert!(!is_checkpoint_reference("8b3d5f2a1c4e4a7b9f012d3e4f5a6b7c"));
assert!(!is_checkpoint_reference(
"8b3d5f2-1c4e-4a7b-9f01-2d3e4f5a6b7c"
));
assert!(!is_checkpoint_reference(
"8b3d5f2a-1c4e-4a7b-9f01-2d3e4f5a6b7z"
));
}
}