Skip to main content

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}
119
120// Unit tests live in `tests/unit_hot_update.rs` — see the note in
121// `src/runtime/locking.rs` about lib-test target having pre-existing
122// unrelated compile errors.
123#[cfg(test)]
124#[cfg(any())]
125mod tests {
126    use super::*;
127
128    fn hs(items: &[&str]) -> HashSet<String> {
129        items.iter().map(|s| s.to_string()).collect()
130    }
131
132    #[test]
133    fn no_indexed_cols_modified_and_fits_page_allows_hot() {
134        let indexed = hs(&["email", "org_id"]);
135        let modified = hs(&["last_login_at"]);
136        let d = decide(&HotUpdateInputs {
137            collection: "users",
138            indexed_columns: &indexed,
139            modified_columns: &modified,
140            new_tuple_size: 100,
141            page_free_space: 4096,
142        });
143        assert!(d.can_hot);
144        assert_eq!(d.indexed_blocker, None);
145    }
146
147    #[test]
148    fn indexed_column_modified_blocks_hot() {
149        let indexed = hs(&["email", "org_id"]);
150        let modified = hs(&["email"]);
151        let d = decide(&HotUpdateInputs {
152            collection: "users",
153            indexed_columns: &indexed,
154            modified_columns: &modified,
155            new_tuple_size: 100,
156            page_free_space: 4096,
157        });
158        assert!(!d.can_hot);
159        assert_eq!(d.indexed_blocker.as_deref(), Some("email"));
160    }
161
162    #[test]
163    fn new_tuple_too_large_blocks_hot() {
164        let indexed = hs(&["id"]);
165        let modified = hs(&["body"]);
166        let d = decide(&HotUpdateInputs {
167            collection: "docs",
168            indexed_columns: &indexed,
169            modified_columns: &modified,
170            new_tuple_size: 5000,
171            page_free_space: 4096,
172        });
173        assert!(!d.can_hot);
174        assert_eq!(d.indexed_blocker, None);
175    }
176
177    #[test]
178    fn unlimited_free_space_bypasses_fit_check() {
179        let indexed = hs(&[]);
180        let modified = hs(&["v"]);
181        let d = decide(&HotUpdateInputs {
182            collection: "t",
183            indexed_columns: &indexed,
184            modified_columns: &modified,
185            new_tuple_size: 999_999_999,
186            page_free_space: usize::MAX,
187        });
188        assert!(d.can_hot);
189    }
190
191    #[test]
192    fn empty_modified_columns_trivially_passes_the_index_gate() {
193        // An UPDATE with an empty SET (no columns changed) still
194        // matches the HOT gate — fits-page + no indexed col touched.
195        let indexed = hs(&["email"]);
196        let modified = hs(&[]);
197        let d = decide(&HotUpdateInputs {
198            collection: "users",
199            indexed_columns: &indexed,
200            modified_columns: &modified,
201            new_tuple_size: 50,
202            page_free_space: 4096,
203        });
204        assert!(d.can_hot);
205        assert_eq!(d.indexed_blocker, None);
206    }
207
208    #[test]
209    fn indexed_blocker_picks_first_match_deterministically() {
210        // When multiple modified columns are indexed, any one of
211        // them is a valid blocker. Just verify we pick SOME indexed
212        // column — order doesn't matter to the caller, which only
213        // logs it.
214        let indexed = hs(&["a", "b", "c"]);
215        let modified = hs(&["a", "b"]);
216        let d = decide(&HotUpdateInputs {
217            collection: "t",
218            indexed_columns: &indexed,
219            modified_columns: &modified,
220            new_tuple_size: 50,
221            page_free_space: 4096,
222        });
223        assert!(!d.can_hot);
224        let blocker = d.indexed_blocker.expect("must have a blocker");
225        assert!(blocker == "a" || blocker == "b", "got {blocker}");
226    }
227}