use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct HotUpdateInputs<'a> {
pub collection: &'a str,
pub indexed_columns: &'a HashSet<String>,
pub modified_columns: &'a HashSet<String>,
pub new_tuple_size: usize,
pub page_free_space: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HotUpdateDecision {
pub can_hot: bool,
pub indexed_blocker: Option<String>,
pub page_free_space: usize,
}
pub const DEFAULT_MAX_CHAIN_HOPS: usize = 32;
pub fn follow_chain<F>(
start_id: crate::storage::unified::entity::EntityId,
max_hops: usize,
mut resolve: F,
) -> crate::storage::unified::entity::EntityId
where
F: FnMut(
crate::storage::unified::entity::EntityId,
) -> Option<crate::storage::unified::entity::EntityId>,
{
let hops_cap = max_hops.max(1);
let mut current = start_id;
for _hop in 0..hops_cap {
match resolve(current) {
Some(next) if next != current => current = next,
_ => return current,
}
}
tracing::warn!(
entity_id = current.raw(),
max_hops = hops_cap,
"hot_update chain walker hit hop cap — likely malformed chain"
);
current
}
pub fn decide(inputs: &HotUpdateInputs<'_>) -> HotUpdateDecision {
let blocker = inputs
.modified_columns
.iter()
.find(|col| inputs.indexed_columns.contains(col.as_str()))
.cloned();
let fits_page = inputs.new_tuple_size <= inputs.page_free_space;
HotUpdateDecision {
can_hot: blocker.is_none() && fits_page,
indexed_blocker: blocker,
page_free_space: inputs.page_free_space,
}
}
#[cfg(test)]
#[cfg(any())]
mod tests {
use super::*;
fn hs(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn no_indexed_cols_modified_and_fits_page_allows_hot() {
let indexed = hs(&["email", "org_id"]);
let modified = hs(&["last_login_at"]);
let d = decide(&HotUpdateInputs {
collection: "users",
indexed_columns: &indexed,
modified_columns: &modified,
new_tuple_size: 100,
page_free_space: 4096,
});
assert!(d.can_hot);
assert_eq!(d.indexed_blocker, None);
}
#[test]
fn indexed_column_modified_blocks_hot() {
let indexed = hs(&["email", "org_id"]);
let modified = hs(&["email"]);
let d = decide(&HotUpdateInputs {
collection: "users",
indexed_columns: &indexed,
modified_columns: &modified,
new_tuple_size: 100,
page_free_space: 4096,
});
assert!(!d.can_hot);
assert_eq!(d.indexed_blocker.as_deref(), Some("email"));
}
#[test]
fn new_tuple_too_large_blocks_hot() {
let indexed = hs(&["id"]);
let modified = hs(&["body"]);
let d = decide(&HotUpdateInputs {
collection: "docs",
indexed_columns: &indexed,
modified_columns: &modified,
new_tuple_size: 5000,
page_free_space: 4096,
});
assert!(!d.can_hot);
assert_eq!(d.indexed_blocker, None);
}
#[test]
fn unlimited_free_space_bypasses_fit_check() {
let indexed = hs(&[]);
let modified = hs(&["v"]);
let d = decide(&HotUpdateInputs {
collection: "t",
indexed_columns: &indexed,
modified_columns: &modified,
new_tuple_size: 999_999_999,
page_free_space: usize::MAX,
});
assert!(d.can_hot);
}
#[test]
fn empty_modified_columns_trivially_passes_the_index_gate() {
let indexed = hs(&["email"]);
let modified = hs(&[]);
let d = decide(&HotUpdateInputs {
collection: "users",
indexed_columns: &indexed,
modified_columns: &modified,
new_tuple_size: 50,
page_free_space: 4096,
});
assert!(d.can_hot);
assert_eq!(d.indexed_blocker, None);
}
#[test]
fn indexed_blocker_picks_first_match_deterministically() {
let indexed = hs(&["a", "b", "c"]);
let modified = hs(&["a", "b"]);
let d = decide(&HotUpdateInputs {
collection: "t",
indexed_columns: &indexed,
modified_columns: &modified,
new_tuple_size: 50,
page_free_space: 4096,
});
assert!(!d.can_hot);
let blocker = d.indexed_blocker.expect("must have a blocker");
assert!(blocker == "a" || blocker == "b", "got {blocker}");
}
}