1use std::collections::HashMap;
2use xlog_core::{RelId, ScalarType, Schema};
3use xlog_cuda::{CudaBuffer, JoinIndexV2};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub(crate) struct JoinIndexKey {
7 pub(crate) rel: RelId,
8 pub(crate) version: u64,
9 pub(crate) key_cols: Vec<usize>,
10 pub(crate) schema: JoinIndexSchemaSignature,
11 pub(crate) device_ordinal: u32,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub(crate) struct JoinIndexSchemaSignature {
16 column_types: Vec<ScalarType>,
17 row_size_bytes: usize,
18}
19
20impl JoinIndexSchemaSignature {
21 fn from_schema(schema: &Schema) -> Self {
22 Self {
23 column_types: (0..schema.arity())
24 .filter_map(|idx| schema.column_type(idx))
25 .collect(),
26 row_size_bytes: schema.row_size_bytes(),
27 }
28 }
29}
30
31impl JoinIndexKey {
32 pub(crate) fn new(
33 rel: RelId,
34 version: u64,
35 key_cols: Vec<usize>,
36 schema: &Schema,
37 device_ordinal: u32,
38 ) -> Self {
39 Self {
40 rel,
41 version,
42 key_cols,
43 schema: JoinIndexSchemaSignature::from_schema(schema),
44 device_ordinal,
45 }
46 }
47}
48
49struct CachedJoinIndex {
50 index: Option<JoinIndexV2>,
51 bytes: u64,
52 last_used: u64,
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
57pub struct JoinIndexCacheStats {
58 pub lookups: u64,
60 pub hits: u64,
62 pub misses: u64,
64 pub builds: u64,
66 pub evictions: u64,
68 pub invalidations: u64,
70 pub stale_rejections: u64,
72 pub background_build_requests: u64,
74 pub background_builds_completed: u64,
76 pub background_builds_deferred: u64,
78 pub entries: usize,
80 pub total_bytes: u64,
82}
83
84pub(crate) struct JoinIndexCache {
85 entries: HashMap<JoinIndexKey, CachedJoinIndex>,
86 clock: u64,
87 total_bytes: u64,
88 pub(crate) max_bytes: u64,
89 stats: JoinIndexCacheStats,
90}
91
92pub(crate) fn estimate_join_index_bytes(right: &CudaBuffer, right_keys: &[usize]) -> u64 {
96 if right_keys.is_empty() {
97 return u64::MAX;
98 }
99
100 let mut key_bytes_per_row: u64 = 0;
101 for &k in right_keys {
102 let Some(ty) = right.schema().column_type(k) else {
103 return u64::MAX;
104 };
105 key_bytes_per_row = key_bytes_per_row.saturating_add(ty.size_bytes() as u64);
106 }
107
108 let num_rows = right.num_rows();
109 let packed_bytes = num_rows.saturating_mul(key_bytes_per_row);
110 let target = num_rows.saturating_mul(2).max(1024);
111 let num_buckets = target.next_power_of_two();
112
113 packed_bytes
115 .saturating_add(num_buckets.saturating_mul(8))
116 .saturating_add(num_rows.saturating_mul(12))
117}
118
119impl JoinIndexCache {
120 pub(crate) fn new(max_bytes: u64) -> Self {
121 Self {
122 entries: HashMap::new(),
123 clock: 0,
124 total_bytes: 0,
125 max_bytes,
126 stats: JoinIndexCacheStats::default(),
127 }
128 }
129
130 pub(crate) fn should_build(
135 &self,
136 est_index_bytes: u64,
137 build_heat: f32,
138 remaining_device_bytes: u64,
139 device_budget_bytes: u64,
140 ) -> bool {
141 let heat_threshold = if self.max_bytes > 0 && est_index_bytes > self.max_bytes / 2 {
142 0.6
143 } else {
144 0.3
145 };
146 let has_room =
147 remaining_device_bytes >= est_index_bytes.saturating_add(device_budget_bytes / 10);
148
149 build_heat >= heat_threshold && est_index_bytes <= self.max_bytes && has_room
150 }
151
152 pub(crate) fn clear(&mut self) {
153 let removed = self.entries.len() as u64;
154 self.entries.clear();
155 self.clock = 0;
156 self.total_bytes = 0;
157 self.stats.invalidations = self.stats.invalidations.saturating_add(removed);
158 }
159
160 pub(crate) fn get(&mut self, key: &JoinIndexKey) -> Option<&JoinIndexV2> {
161 self.stats.lookups = self.stats.lookups.saturating_add(1);
162 let Some(entry) = self.entries.get_mut(key) else {
163 self.stats.misses = self.stats.misses.saturating_add(1);
164 return None;
165 };
166 self.clock = self.clock.saturating_add(1);
167 entry.last_used = self.clock;
168 if let Some(index) = &entry.index {
169 self.stats.hits = self.stats.hits.saturating_add(1);
170 Some(index)
171 } else {
172 self.stats.misses = self.stats.misses.saturating_add(1);
173 None
174 }
175 }
176
177 pub(crate) fn insert(&mut self, key: JoinIndexKey, index: JoinIndexV2) {
178 let bytes = index.estimated_bytes();
179 if bytes > self.max_bytes {
180 return;
181 }
182
183 self.evict_until_fits(bytes);
184
185 self.clock = self.clock.saturating_add(1);
186 let last_used = self.clock;
187
188 if let Some(prev) = self.entries.remove(&key) {
189 self.total_bytes = self.total_bytes.saturating_sub(prev.bytes);
190 }
191
192 self.total_bytes = self.total_bytes.saturating_add(bytes);
193 self.entries.insert(
194 key,
195 CachedJoinIndex {
196 index: Some(index),
197 bytes,
198 last_used,
199 },
200 );
201 self.stats.builds = self.stats.builds.saturating_add(1);
202 }
203
204 pub(crate) fn remove(&mut self, key: &JoinIndexKey) {
205 if let Some(prev) = self.entries.remove(key) {
206 self.total_bytes = self.total_bytes.saturating_sub(prev.bytes);
207 }
208 }
209
210 pub(crate) fn remove_stale(&mut self, key: &JoinIndexKey) {
211 let before = self.entries.len();
212 self.remove(key);
213 if self.entries.len() < before {
214 self.stats.stale_rejections = self.stats.stale_rejections.saturating_add(1);
215 }
216 }
217
218 pub(crate) fn invalidate_rel(&mut self, rel: RelId) {
219 let keys: Vec<JoinIndexKey> = self
220 .entries
221 .keys()
222 .filter(|k| k.rel == rel)
223 .cloned()
224 .collect();
225 for key in keys {
226 if let Some(entry) = self.entries.remove(&key) {
227 self.total_bytes = self.total_bytes.saturating_sub(entry.bytes);
228 self.stats.invalidations = self.stats.invalidations.saturating_add(1);
229 }
230 }
231 }
232
233 pub(crate) fn evict_until_fits(&mut self, additional_bytes: u64) {
234 while !self.entries.is_empty()
235 && self.total_bytes.saturating_add(additional_bytes) > self.max_bytes
236 {
237 let mut oldest_key: Option<JoinIndexKey> = None;
238 let mut oldest_clock = u64::MAX;
239
240 for (k, v) in &self.entries {
241 if v.last_used < oldest_clock {
242 oldest_clock = v.last_used;
243 oldest_key = Some(k.clone());
244 }
245 }
246
247 let Some(key) = oldest_key else {
248 break;
249 };
250 if let Some(entry) = self.entries.remove(&key) {
251 self.total_bytes = self.total_bytes.saturating_sub(entry.bytes);
252 self.stats.evictions = self.stats.evictions.saturating_add(1);
253 } else {
254 break;
255 }
256 }
257 }
258
259 pub(crate) fn record_background_build_request(&mut self) {
260 self.stats.background_build_requests =
261 self.stats.background_build_requests.saturating_add(1);
262 }
263
264 pub(crate) fn record_background_build_complete(&mut self) {
265 self.stats.background_builds_completed =
266 self.stats.background_builds_completed.saturating_add(1);
267 }
268
269 pub(crate) fn record_background_build_deferred(&mut self) {
270 self.stats.background_builds_deferred =
271 self.stats.background_builds_deferred.saturating_add(1);
272 }
273
274 pub(crate) fn stats(&self) -> JoinIndexCacheStats {
275 let mut stats = self.stats.clone();
276 stats.entries = self.entries.len();
277 stats.total_bytes = self.total_bytes;
278 stats
279 }
280
281 #[cfg(test)]
282 fn insert_test_entry(&mut self, key: JoinIndexKey, bytes: u64) {
283 if bytes > self.max_bytes {
284 return;
285 }
286 self.evict_until_fits(bytes);
287 self.clock = self.clock.saturating_add(1);
288 self.total_bytes = self.total_bytes.saturating_add(bytes);
289 self.entries.insert(
290 key,
291 CachedJoinIndex {
292 index: None,
293 bytes,
294 last_used: self.clock,
295 },
296 );
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use xlog_core::{ScalarType, Schema};
304
305 fn schema(cols: Vec<(&str, ScalarType)>) -> Schema {
306 Schema::new(
307 cols.into_iter()
308 .map(|(name, ty)| (name.to_string(), ty))
309 .collect(),
310 )
311 }
312
313 #[test]
314 fn persistent_key_includes_schema_generation_key_and_device() {
315 let u32_schema = schema(vec![("k", ScalarType::U32)]);
316 let u64_schema = schema(vec![("k", ScalarType::U64)]);
317
318 let key = JoinIndexKey::new(RelId(7), 3, vec![0], &u32_schema, 0);
319 assert_eq!(key.rel, RelId(7));
320 assert_eq!(key.version, 3);
321 assert_eq!(key.key_cols, vec![0]);
322 assert_eq!(key.device_ordinal, 0);
323
324 assert_ne!(
325 key,
326 JoinIndexKey::new(RelId(7), 4, vec![0], &u32_schema, 0),
327 "generation/version must partition stale indexes"
328 );
329 assert_ne!(
330 key,
331 JoinIndexKey::new(RelId(7), 3, vec![0], &u64_schema, 0),
332 "schema changes must partition indexes"
333 );
334 assert_ne!(
335 key,
336 JoinIndexKey::new(RelId(7), 3, vec![0], &u32_schema, 1),
337 "device ordinal must partition indexes"
338 );
339 }
340
341 #[test]
342 fn persistent_cache_budget_evicts_lru_and_records_stats() {
343 let schema = schema(vec![("k", ScalarType::U32)]);
344 let key_a = JoinIndexKey::new(RelId(1), 1, vec![0], &schema, 0);
345 let key_b = JoinIndexKey::new(RelId(2), 1, vec![0], &schema, 0);
346 let mut cache = JoinIndexCache::new(100);
347
348 cache.insert_test_entry(key_a, 60);
349 cache.insert_test_entry(key_b, 60);
350
351 let stats = cache.stats();
352 assert_eq!(stats.entries, 1);
353 assert_eq!(stats.total_bytes, 60);
354 assert_eq!(stats.evictions, 1);
355 }
356
357 #[test]
358 fn persistent_cache_invalidation_records_removed_entries() {
359 let schema = schema(vec![("k", ScalarType::U32)]);
360 let key = JoinIndexKey::new(RelId(1), 1, vec![0], &schema, 0);
361 let mut cache = JoinIndexCache::new(100);
362
363 cache.insert_test_entry(key, 32);
364 cache.invalidate_rel(RelId(1));
365
366 let stats = cache.stats();
367 assert_eq!(stats.entries, 0);
368 assert_eq!(stats.total_bytes, 0);
369 assert_eq!(stats.invalidations, 1);
370 }
371}