use crate::binding::{map_memory_bytes, value_memory_bytes};
use crate::memory::DEFAULT_EXECUTION_BATCH_ROWS;
use crate::pipeline::runtime_checkpoint;
use crate::store::{admit_graph_read, GraphExecutionRead, ScanControl};
use crate::{QueryMemoryClass, QueryMemoryLease, QueryMemoryLedger};
use hawdb_core::{Catalog, HawDBError, LabelId, Result, RuntimeTaskContext, Value};
use hawdb_storage::mutation::{
ensure_additional_mutation_limits, remaining_mutation_affected_rows, GraphMutation,
MutationLimits, NodeSetAssignment, NodeSetValue, PropertyFilter,
};
use hawdb_storage::read_view::GraphReadAllocator;
use hawdb_storage::{NodeId, ProjectedNodeRecord};
use std::collections::{BTreeMap, BTreeSet};
use std::mem::size_of;
#[cfg(test)]
mod tests;
#[derive(Debug)]
pub struct StagedMutations {
mutations: Vec<GraphMutation>,
ledger: QueryMemoryLedger,
charge: QueryMemoryLease,
merge_endpoint_charge: Option<QueryMemoryLease>,
charges_list: bool,
}
#[derive(Debug)]
pub struct StagedCharge {
_mutations: QueryMemoryLease,
_merge_endpoints: Option<QueryMemoryLease>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EndpointResolutionReport {
pub patterns: usize,
pub label_scans: usize,
pub index_lookups: usize,
pub nodes_visited: usize,
pub rewritten_endpoints: usize,
}
impl StagedMutations {
pub(crate) fn charged(ledger: &QueryMemoryLedger) -> Result<Self> {
Self::new(ledger, true)
}
pub(crate) fn uncharged_list(ledger: &QueryMemoryLedger) -> Result<Self> {
Self::new(ledger, false)
}
fn new(ledger: &QueryMemoryLedger, charges_list: bool) -> Result<Self> {
let charge = ledger
.account(
QueryMemoryClass::BlockingState,
"staged graph mutations",
ledger.budget_bytes(),
)
.reserve(size_of::<Self>())?;
Ok(Self {
mutations: Vec::new(),
ledger: ledger.clone(),
charge,
merge_endpoint_charge: None,
charges_list,
})
}
pub(crate) fn push(&mut self, mutation: GraphMutation) -> Result<()> {
if !self.charges_list {
self.mutations.push(mutation);
return Ok(());
}
let spare = if self.mutations.len() == self.mutations.capacity() {
self.mutations.capacity().max(4) * size_of::<GraphMutation>()
} else {
0
};
self.charge.grow(
graph_mutation_memory_bytes(&mutation)
.saturating_sub(size_of::<GraphMutation>())
.saturating_add(spare),
)?;
self.mutations.push(mutation);
Ok(())
}
pub fn mutations(&self) -> &[GraphMutation] {
&self.mutations
}
pub fn len(&self) -> usize {
self.mutations.len()
}
pub fn is_empty(&self) -> bool {
self.mutations.is_empty()
}
pub fn charged_bytes(&self) -> usize {
self.charge.bytes()
}
pub fn into_parts(self) -> (Vec<GraphMutation>, StagedCharge) {
(
self.mutations,
StagedCharge {
_mutations: self.charge,
_merge_endpoints: self.merge_endpoint_charge,
},
)
}
pub fn resolve_endpoints(
&mut self,
catalog: &Catalog,
store: &dyn GraphExecutionRead,
limits: MutationLimits,
memory: &crate::ExecutionMemoryConfig,
task_context: Option<&RuntimeTaskContext>,
) -> Result<EndpointResolutionReport> {
let report = self.resolve_matched_endpoints(catalog, store, limits, task_context)?;
if let Some(charge) = super::preflight::resolve_relationship_merge_endpoints(
&mut self.mutations,
catalog,
store,
limits,
memory,
task_context,
)? {
self.merge_endpoint_charge = Some(charge);
}
Ok(report)
}
pub fn resolve_matched_endpoints(
&mut self,
catalog: &Catalog,
store: &dyn GraphExecutionRead,
limits: MutationLimits,
task_context: Option<&RuntimeTaskContext>,
) -> Result<EndpointResolutionReport> {
let mut report = EndpointResolutionReport::default();
let index_account = self.ledger.account(
QueryMemoryClass::BlockingState,
"matched endpoint resolution",
self.ledger.budget_bytes(),
);
let mut index_charge = index_account.reserve(0)?;
let mut patterns: BTreeMap<(LabelId, Vec<String>), EndpointPattern> = BTreeMap::new();
for (index, mutation) in self.mutations.iter().enumerate() {
if index.is_multiple_of(DEFAULT_EXECUTION_BATCH_ROWS) {
runtime_checkpoint(task_context)?;
}
let GraphMutation::CreateRelationshipsBetweenMatches(request) = mutation else {
continue;
};
for (label, filter) in [
(&request.source_label, &request.source_filter),
(&request.target_label, &request.target_filter),
] {
let Some((label_id, names, values)) = endpoint_key(catalog, label, filter) else {
continue;
};
let pattern = match patterns.entry((label_id, names)) {
std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
std::collections::btree_map::Entry::Vacant(entry) => {
index_charge.grow(
entry.key().1.iter().map(String::len).sum::<usize>()
+ size_of::<((LabelId, Vec<String>), EndpointPattern)>() * 3,
)?;
entry.insert(EndpointPattern::default())
}
};
if let std::collections::btree_map::Entry::Vacant(entry) =
pattern.matches.entry(values)
{
index_charge.grow(endpoint_entry_bytes(entry.key()))?;
entry.insert(Vec::new());
}
}
}
report.patterns = patterns.len();
for ((label_id, names), pattern) in &mut patterns {
let visited = pattern.resolve(
catalog,
store,
&self.ledger,
(*label_id, names),
&mut index_charge,
task_context,
)?;
report.nodes_visited += visited.nodes;
if visited.indexed {
report.index_lookups += 1;
} else {
report.label_scans += 1;
}
}
let mut affected = 0usize;
let mut exact = true;
for (index, mutation) in self.mutations.iter_mut().enumerate() {
if index.is_multiple_of(DEFAULT_EXECUTION_BATCH_ROWS) {
runtime_checkpoint(task_context)?;
}
let GraphMutation::CreateRelationshipsBetweenMatches(request) = mutation else {
continue;
};
if [&request.source_label, &request.target_label]
.into_iter()
.any(|label| !label.is_empty() && catalog.label_id(label).is_none())
{
continue;
}
let resolved = [
endpoint_ids(
&patterns,
catalog,
&request.source_label,
&request.source_filter,
),
endpoint_ids(
&patterns,
catalog,
&request.target_label,
&request.target_filter,
),
];
let remaining = remaining_mutation_affected_rows(affected, limits)?;
if resolved.iter().flatten().any(|ids| ids.len() > remaining) {
return Err(HawDBError::Execution(format!(
"mutation would exceed max_mutation_affected_rows {remaining}"
)));
}
if let [Some(sources), Some(targets)] = &resolved {
let pairs = sources.len().checked_mul(targets.len()).ok_or_else(|| {
HawDBError::Execution("mutation Cartesian product overflow".to_string())
})?;
ensure_additional_mutation_limits(affected, affected, pairs, pairs, limits)?;
affected += pairs;
} else {
exact = false;
}
let creates_nothing = exact && resolved.iter().flatten().any(|ids| ids.is_empty());
for (ids, filter) in resolved
.into_iter()
.zip([&mut request.source_filter, &mut request.target_filter])
{
let Some(ids) = ids else {
continue;
};
let ids = if creates_nothing {
&[][..]
} else {
ids.as_slice()
};
let Some(original) = filter.take() else {
unreachable!("endpoint_key requires a filter");
};
let before = property_filter_memory_bytes(&original);
let resolved = id_led_conjunction(ids, original);
self.charge
.grow(property_filter_memory_bytes(&resolved).saturating_sub(before))?;
*filter = Some(resolved);
report.rewritten_endpoints += 1;
}
}
Ok(report)
}
}
#[derive(Debug, Default)]
struct EndpointPattern {
matches: BTreeMap<Vec<Value>, Vec<NodeId>>,
}
struct PatternVisit {
nodes: usize,
indexed: bool,
}
impl EndpointPattern {
fn resolve(
&mut self,
catalog: &Catalog,
store: &dyn GraphExecutionRead,
ledger: &QueryMemoryLedger,
(label_id, names): (LabelId, &[String]),
index_charge: &mut QueryMemoryLease,
task_context: Option<&RuntimeTaskContext>,
) -> Result<PatternVisit> {
let required = names.iter().cloned().collect::<BTreeSet<_>>();
let read_account = ledger.account(
QueryMemoryClass::ExternalRead,
"matched endpoint scan",
ledger.budget_bytes(),
);
let use_index =
!store.is_out_of_core() && catalog.has_scalar_property_index(label_id, &names[0]);
let mut leading = Vec::new();
let mut leading_charge = read_account.reserve(0)?;
if use_index {
for key in self.matches.keys() {
if leading.last() != Some(&key[0]) {
leading_charge.grow(value_memory_bytes(&key[0]))?;
leading.push(key[0].clone());
}
}
}
let mut admit = |bytes| admit_graph_read(&read_account, task_context, bytes);
let mut allocator = GraphReadAllocator::new(&mut admit, task_context);
let mut nodes = 0usize;
let indexed = {
let matches = &mut self.matches;
let mut consumer = |node: ProjectedNodeRecord| -> Result<ScanControl> {
nodes += 1;
if nodes.is_multiple_of(DEFAULT_EXECUTION_BATCH_ROWS) {
runtime_checkpoint(task_context)?;
}
let ProjectedNodeRecord {
id, mut properties, ..
} = node;
let Some(key) = names
.iter()
.map(|name| properties.remove(name))
.collect::<Option<Vec<_>>>()
else {
return Ok(ScanControl::Continue);
};
if let Some(ids) = matches.get_mut(&key) {
index_charge.grow(size_of::<NodeId>())?;
ids.push(id);
}
Ok(ScanControl::Continue)
};
let indexed = use_index
&& store
.visit_indexed_projected_nodes_by_property_admitted(
label_id,
&names[0],
&leading,
&required,
&mut allocator,
&mut consumer,
)?
.is_some();
if !indexed {
store.visit_projected_nodes_admitted(
Some(label_id),
&required,
&mut allocator,
&mut consumer,
)?;
}
indexed
};
runtime_checkpoint(task_context)?;
for ids in self.matches.values_mut() {
ids.sort_unstable();
ids.dedup();
}
Ok(PatternVisit { nodes, indexed })
}
}
fn endpoint_key(
catalog: &Catalog,
label: &str,
filter: &Option<PropertyFilter>,
) -> Option<(LabelId, Vec<String>, Vec<Value>)> {
if label.is_empty() {
return None;
}
let equalities = match filter.as_ref()? {
PropertyFilter::Eq { .. } => std::slice::from_ref(filter.as_ref()?),
PropertyFilter::And(filters) if !filters.is_empty() => filters.as_slice(),
_ => return None,
};
let mut names = Vec::with_capacity(equalities.len());
let mut values = Vec::with_capacity(equalities.len());
for filter in equalities {
let PropertyFilter::Eq { property, value } = filter else {
return None;
};
if names.last().is_some_and(|last: &String| last >= property) {
return None;
}
names.push(property.clone());
values.push(value.clone());
}
Some((catalog.label_id(label)?, names, values))
}
fn endpoint_ids<'a>(
patterns: &'a BTreeMap<(LabelId, Vec<String>), EndpointPattern>,
catalog: &Catalog,
label: &str,
filter: &Option<PropertyFilter>,
) -> Option<&'a Vec<NodeId>> {
let (label_id, names, values) = endpoint_key(catalog, label, filter)?;
let ids = patterns
.get(&(label_id, names))
.and_then(|pattern| pattern.matches.get(&values))
.expect("every staged endpoint key was registered before resolution");
Some(ids)
}
fn id_led_conjunction(ids: &[NodeId], original: PropertyFilter) -> PropertyFilter {
let ids = match ids {
[id] => PropertyFilter::IdEq {
value: Value::Int(id.0 as i64),
},
ids => PropertyFilter::IdIn {
values: ids.iter().map(|id| Value::Int(id.0 as i64)).collect(),
},
};
let mut filters = vec![ids];
match original {
PropertyFilter::And(rest) => filters.extend(rest),
other => filters.push(other),
}
PropertyFilter::And(filters)
}
fn endpoint_entry_bytes(values: &[Value]) -> usize {
size_of::<(Vec<Value>, Vec<NodeId>)>() * 3
+ values.iter().map(value_memory_bytes).sum::<usize>()
}
pub(crate) fn graph_mutation_memory_bytes(mutation: &GraphMutation) -> usize {
let label_bytes = |label: &str| label.len();
size_of::<GraphMutation>().saturating_add(match mutation {
GraphMutation::CreateNode { label, properties } => {
label_bytes(label).saturating_add(map_memory_bytes(properties))
}
GraphMutation::MergeNode {
label,
match_properties,
on_create_properties,
on_match_assignments,
post_merge_assignments,
} => label_bytes(label)
.saturating_add(map_memory_bytes(match_properties))
.saturating_add(map_memory_bytes(on_create_properties))
.saturating_add(assignments_memory_bytes(on_match_assignments))
.saturating_add(assignments_memory_bytes(post_merge_assignments)),
GraphMutation::CreateRelationshipsBetweenMatches(request) => {
label_bytes(&request.source_label)
.saturating_add(label_bytes(&request.target_label))
.saturating_add(label_bytes(&request.rel_type))
.saturating_add(map_memory_bytes(&request.rel_properties))
.saturating_add(optional_filter_memory_bytes(request.source_filter.as_ref()))
.saturating_add(optional_filter_memory_bytes(request.target_filter.as_ref()))
}
_ => 0,
})
}
fn assignments_memory_bytes(assignments: &[NodeSetAssignment]) -> usize {
assignments.iter().fold(0usize, |total, assignment| {
let value = match &assignment.value {
NodeSetValue::Value(value)
| NodeSetValue::Coalesce { default: value }
| NodeSetValue::PreserveNewerExisting {
incoming: value, ..
} => value_memory_bytes(value),
NodeSetValue::AddInt { .. } | NodeSetValue::DecrementFloorZero => 0,
};
total
.saturating_add(size_of::<NodeSetAssignment>())
.saturating_add(assignment.property.len())
.saturating_add(value)
})
}
fn optional_filter_memory_bytes(filter: Option<&PropertyFilter>) -> usize {
filter.map_or(0, |filter| {
size_of::<PropertyFilter>().saturating_add(property_filter_memory_bytes(filter))
})
}
fn property_filter_memory_bytes(filter: &PropertyFilter) -> usize {
let values = |values: &[Value]| {
values
.iter()
.map(value_memory_bytes)
.fold(0usize, usize::saturating_add)
};
let bound = |bound: &Option<(Value, bool)>| {
bound
.as_ref()
.map_or(0, |(value, _)| value_memory_bytes(value))
};
match filter {
PropertyFilter::And(filters) | PropertyFilter::Or(filters) => {
filters.iter().fold(0usize, |total, filter| {
total
.saturating_add(size_of::<PropertyFilter>())
.saturating_add(property_filter_memory_bytes(filter))
})
}
PropertyFilter::Not(filter) => {
size_of::<PropertyFilter>().saturating_add(property_filter_memory_bytes(filter))
}
PropertyFilter::IdEq { value } | PropertyFilter::IdNotEq { value } => {
value_memory_bytes(value)
}
PropertyFilter::IdRange { lower, upper } => bound(lower).saturating_add(bound(upper)),
PropertyFilter::IdIn { values: list } => values(list),
PropertyFilter::Eq { property, value }
| PropertyFilter::NotEq { property, value }
| PropertyFilter::ListContains { property, value } => {
property.len().saturating_add(value_memory_bytes(value))
}
PropertyFilter::IsNull { property } | PropertyFilter::IsNotNull { property } => {
property.len()
}
PropertyFilter::In {
property,
values: list,
} => property.len().saturating_add(values(list)),
PropertyFilter::ListContainsLower { property, value }
| PropertyFilter::Contains { property, value }
| PropertyFilter::StartsWith { property, value }
| PropertyFilter::EndsWith { property, value } => {
property.len().saturating_add(value.len())
}
PropertyFilter::RegexMatch { property, pattern } => {
property.len().saturating_add(pattern.as_str().len())
}
PropertyFilter::DefaultIfNullOrEq {
property,
empty,
default,
value,
..
} => property
.len()
.saturating_add(value_memory_bytes(empty))
.saturating_add(value_memory_bytes(default))
.saturating_add(value_memory_bytes(value)),
PropertyFilter::Range {
property,
lower,
upper,
} => property
.len()
.saturating_add(bound(lower))
.saturating_add(bound(upper)),
}
}