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}