reddb_server/storage/engine/hot_update.rs
1//! HOT (Heap-Only Tuple) update decision — pure policy helper.
2//!
3//! Mirrors PostgreSQL's `heap_update` fast-path (`heapam.c` around
4//! lines 3976-4031). An UPDATE is eligible for the HOT path when:
5//!
6//! 1. The UPDATE modifies **no column covered by any secondary
7//! index** — skipping secondary-index maintenance is the point.
8//! 2. The new tuple fits inside the **free space on the page that
9//! already holds the old tuple** — stays page-local.
10//!
11//! Decision is a pure function: callers pre-compute the inputs
12//! (indexed columns for the table, columns this UPDATE modifies,
13//! serialized new-row size, page free space) and get back a
14//! verdict + diagnostics. No storage I/O here.
15//!
16//! Wiring lives in the storage/DML layer (P3.T2+). This module is
17//! just the policy.
18
19use std::collections::HashSet;
20
21/// Everything `decide` needs to pick between HOT and the fallback
22/// DELETE+INSERT path.
23#[derive(Debug, Clone)]
24pub struct HotUpdateInputs<'a> {
25 /// Name of the target collection — diagnostic only. Included so
26 /// the returned `indexed_blocker` diagnostic can be self-contained.
27 pub collection: &'a str,
28 /// Every column covered by any secondary index on the collection.
29 /// Pulled from the index registry by the caller.
30 pub indexed_columns: &'a HashSet<String>,
31 /// Columns this UPDATE's SET clause actually mutates. A column
32 /// listed but set to its current value still counts as modified —
33 /// PG's HOT decision is syntactic, not value-comparing.
34 pub modified_columns: &'a HashSet<String>,
35 /// Serialized size (bytes) of the new tuple. Used against
36 /// `page_free_space` to decide same-page fit.
37 pub new_tuple_size: usize,
38 /// Free bytes on the old tuple's page after removing the old
39 /// tuple. `new_tuple_size <= page_free_space` is the fit test.
40 /// Callers can pass `usize::MAX` to skip the page-fit check
41 /// (useful when the storage layer guarantees in-place replace).
42 pub page_free_space: usize,
43}
44
45/// Verdict + diagnostics.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct HotUpdateDecision {
48 /// True when the caller may take the HOT path.
49 pub can_hot: bool,
50 /// When `can_hot` is false and an indexed column blocked the
51 /// decision, its name. `None` means either HOT passed or the
52 /// page-fit check failed.
53 pub indexed_blocker: Option<String>,
54 /// Echoes the input so the caller can log the numeric margin.
55 pub page_free_space: usize,
56}
57
58/// Default for `storage.hot_update.max_chain_hops` — matches PG's
59/// `MAX_HEAP_TUPLE_CHAIN_LEN`. Kept in sync with the matrix entry.
60pub const DEFAULT_MAX_CHAIN_HOPS: usize = 32;
61
62/// Follow a t_ctid HOT chain from `start` up to `max_hops` times,
63/// invoking `resolve` to fetch the next version by entity id.
64/// Stops when `resolve` returns `None` (terminal version) or when
65/// the budget is exhausted (anomaly — a well-formed chain should
66/// never hit it, so the caller should log). Returns the tuple's
67/// final visible version to the caller.
68///
69/// Storage-layer page-local rewrite + chain construction lands in
70/// a later pass. This function is the policy-side reader counterpart
71/// so we don't have to retrofit callers later; today every chain
72/// is length 1 because the writer never mints a successor.
73pub fn follow_chain<F>(
74 start_id: crate::storage::unified::entity::EntityId,
75 max_hops: usize,
76 mut resolve: F,
77) -> crate::storage::unified::entity::EntityId
78where
79 F: FnMut(
80 crate::storage::unified::entity::EntityId,
81 ) -> Option<crate::storage::unified::entity::EntityId>,
82{
83 let hops_cap = max_hops.max(1);
84 let mut current = start_id;
85 for _hop in 0..hops_cap {
86 match resolve(current) {
87 Some(next) if next != current => current = next,
88 _ => return current,
89 }
90 }
91 // Bounded — prevents a malformed chain from looping forever.
92 // Returning the last known version is the conservative choice.
93 tracing::warn!(
94 entity_id = current.raw(),
95 max_hops = hops_cap,
96 "hot_update chain walker hit hop cap — likely malformed chain"
97 );
98 current
99}
100
101/// Pure decision function. Returns `can_hot=true` when both
102/// conditions hold; populates `indexed_blocker` when at least one
103/// modified column is indexed.
104pub fn decide(inputs: &HotUpdateInputs<'_>) -> HotUpdateDecision {
105 let blocker = inputs
106 .modified_columns
107 .iter()
108 .find(|col| inputs.indexed_columns.contains(col.as_str()))
109 .cloned();
110
111 let fits_page = inputs.new_tuple_size <= inputs.page_free_space;
112
113 HotUpdateDecision {
114 can_hot: blocker.is_none() && fits_page,
115 indexed_blocker: blocker,
116 page_free_space: inputs.page_free_space,
117 }
118}