1use std::collections::hash_map::Entry;
2use std::collections::{HashMap, HashSet, VecDeque};
3
4thread_local! {
5 static DEFERRED_CACHE_INVALIDATION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
6}
7
8use super::{RedDBRuntime, RuntimeQueryResult, RuntimeResultCacheEntry};
9
10const RESULT_CACHE_BLOB_NAMESPACE: &str = "runtime.result_cache";
11const RESULT_CACHE_TTL_SECS: u64 = 30;
12const RESULT_CACHE_MAX_ENTRIES: usize = 1000;
13const RESULT_CACHE_ENABLED_KEY: &str = "runtime.result_cache.enabled";
14const RESULT_CACHE_TTL_KEY: &str = "runtime.result_cache.ttl_seconds";
15const RESULT_CACHE_CAPACITY_KEY: &str = "runtime.result_cache.capacity_entries";
16const RESULT_CACHE_PAYLOAD_MAGIC: &[u8; 8] = b"RDRC0002";
17const RESULT_CACHE_WRITE_QUIET_PERIOD: std::time::Duration = std::time::Duration::from_secs(1);
18
19fn trim_result_cache(
20 map: &mut HashMap<String, RuntimeResultCacheEntry>,
21 order: &mut VecDeque<String>,
22 max_entries: usize,
23) -> u64 {
24 let mut evicted = 0u64;
25 while map.len() > max_entries {
26 if let Some(oldest) = order.pop_front() {
27 if map.remove(&oldest).is_some() {
28 evicted += 1;
29 }
30 } else {
31 break;
32 }
33 }
34 evicted
35}
36
37fn result_cache_fingerprint(result: &RuntimeQueryResult) -> String {
38 format!(
39 "{:?}|{}|{}|{}|{}|{:?}",
40 result.result,
41 result.query,
42 result.statement,
43 result.engine,
44 result.affected_rows,
45 result.statement_type
46 )
47}
48
49fn mode_to_byte(mode: reddb_rql::modes::QueryMode) -> u8 {
50 match mode {
51 reddb_rql::modes::QueryMode::Sql => 0,
52 reddb_rql::modes::QueryMode::Gremlin => 1,
53 reddb_rql::modes::QueryMode::Cypher => 2,
54 reddb_rql::modes::QueryMode::Sparql => 3,
55 reddb_rql::modes::QueryMode::Path => 4,
56 reddb_rql::modes::QueryMode::Natural => 5,
57 reddb_rql::modes::QueryMode::Unknown => 255,
58 }
59}
60
61fn mode_from_byte(byte: u8) -> Option<reddb_rql::modes::QueryMode> {
62 match byte {
63 0 => Some(reddb_rql::modes::QueryMode::Sql),
64 1 => Some(reddb_rql::modes::QueryMode::Gremlin),
65 2 => Some(reddb_rql::modes::QueryMode::Cypher),
66 3 => Some(reddb_rql::modes::QueryMode::Sparql),
67 4 => Some(reddb_rql::modes::QueryMode::Path),
68 5 => Some(reddb_rql::modes::QueryMode::Natural),
69 255 => Some(reddb_rql::modes::QueryMode::Unknown),
70 _ => None,
71 }
72}
73
74fn result_cache_static_str(value: &str) -> Option<&'static str> {
75 match value {
76 "select" => Some("select"),
77 "table" => Some("table"),
78 "join" => Some("join"),
79 "materialized-graph" => Some("materialized-graph"),
80 "runtime-red-schema" => Some("runtime-red-schema"),
81 "runtime-fdw" => Some("runtime-fdw"),
82 "runtime-table-rls" => Some("runtime-table-rls"),
83 "runtime-table" => Some("runtime-table"),
84 "runtime-join-rls" => Some("runtime-join-rls"),
85 "runtime-join" => Some("runtime-join"),
86 "runtime-vector" => Some("runtime-vector"),
87 "runtime-hybrid" => Some("runtime-hybrid"),
88 "runtime-secret" => Some("runtime-secret"),
89 "runtime-config" => Some("runtime-config"),
90 "runtime-tenant" => Some("runtime-tenant"),
91 "runtime-explain" => Some("runtime-explain"),
92 "runtime-tree" => Some("runtime-tree"),
93 "runtime-kv" => Some("runtime-kv"),
94 "runtime-queue" => Some("runtime-queue"),
95 _ => None,
96 }
97}
98
99fn write_u32(out: &mut Vec<u8>, value: usize) -> Option<()> {
100 let value = u32::try_from(value).ok()?;
101 out.extend_from_slice(&value.to_le_bytes());
102 Some(())
103}
104
105fn write_string(out: &mut Vec<u8>, value: &str) -> Option<()> {
106 write_u32(out, value.len())?;
107 out.extend_from_slice(value.as_bytes());
108 Some(())
109}
110
111fn write_bytes(out: &mut Vec<u8>, value: &[u8]) -> Option<()> {
112 write_u32(out, value.len())?;
113 out.extend_from_slice(value);
114 Some(())
115}
116
117fn read_u8(input: &mut &[u8]) -> Option<u8> {
118 let (&value, rest) = input.split_first()?;
119 *input = rest;
120 Some(value)
121}
122
123fn read_u32(input: &mut &[u8]) -> Option<usize> {
124 if input.len() < 4 {
125 return None;
126 }
127 let value = u32::from_le_bytes(input[..4].try_into().ok()?) as usize;
128 *input = &input[4..];
129 Some(value)
130}
131
132fn read_u64(input: &mut &[u8]) -> Option<u64> {
133 if input.len() < 8 {
134 return None;
135 }
136 let value = u64::from_le_bytes(input[..8].try_into().ok()?);
137 *input = &input[8..];
138 Some(value)
139}
140
141fn read_string(input: &mut &[u8]) -> Option<String> {
142 let len = read_u32(input)?;
143 if input.len() < len {
144 return None;
145 }
146 let value = String::from_utf8(input[..len].to_vec()).ok()?;
147 *input = &input[len..];
148 Some(value)
149}
150
151fn read_bytes<'a>(input: &mut &'a [u8]) -> Option<&'a [u8]> {
152 let len = read_u32(input)?;
153 if input.len() < len {
154 return None;
155 }
156 let value = &input[..len];
157 *input = &input[len..];
158 Some(value)
159}
160
161fn encode_result_cache_payload(entry: &RuntimeResultCacheEntry) -> Option<Vec<u8>> {
162 let result = &entry.result;
163 if entry.scopes.iter().any(|scope| {
165 reddb_engine::storage::system_collection_names::canonical_or_self(scope).starts_with("red.")
166 }) || result.result.stats.vector.is_some()
167 || result.result.pre_serialized_json.is_some()
168 || result_cache_static_str(result.statement).is_none()
169 || result_cache_static_str(result.engine).is_none()
170 || result_cache_static_str(result.statement_type).is_none()
171 || result.result.records.iter().any(|record| {
172 !record.nodes.is_empty()
173 || !record.edges.is_empty()
174 || !record.paths.is_empty()
175 || !record.vector_results.is_empty()
176 })
177 {
178 return None;
179 }
180
181 let mut out = Vec::new();
182 out.extend_from_slice(RESULT_CACHE_PAYLOAD_MAGIC);
183 out.extend_from_slice(&entry.data_epoch.to_le_bytes());
184 write_string(&mut out, &result.query)?;
185 out.push(mode_to_byte(result.mode));
186 write_string(&mut out, result.statement)?;
187 write_string(&mut out, result.engine)?;
188 out.extend_from_slice(&result.affected_rows.to_le_bytes());
189 write_string(&mut out, result.statement_type)?;
190
191 write_u32(&mut out, result.result.columns.len())?;
192 for column in &result.result.columns {
193 write_string(&mut out, column)?;
194 }
195 out.extend_from_slice(&result.result.stats.nodes_scanned.to_le_bytes());
196 out.extend_from_slice(&result.result.stats.edges_scanned.to_le_bytes());
197 out.extend_from_slice(&result.result.stats.rows_scanned.to_le_bytes());
198 out.extend_from_slice(&result.result.stats.segments_total.to_le_bytes());
199 out.extend_from_slice(&result.result.stats.segments_scanned.to_le_bytes());
200 out.extend_from_slice(&result.result.stats.segments_pruned.to_le_bytes());
201 out.extend_from_slice(&result.result.stats.exec_time_us.to_le_bytes());
202
203 write_u32(&mut out, result.result.records.len())?;
204 for record in &result.result.records {
205 let fields = record.iter_fields().collect::<Vec<_>>();
206 write_u32(&mut out, fields.len())?;
207 for (name, value) in fields {
208 write_string(&mut out, name)?;
209 let mut encoded = Vec::new();
210 reddb_types::value_codec::encode(value, &mut encoded);
211 write_bytes(&mut out, &encoded)?;
212 }
213 }
214
215 write_u32(&mut out, entry.scopes.len())?;
216 for scope in &entry.scopes {
217 write_string(&mut out, scope)?;
218 }
219 Some(out)
220}
221
222fn decode_result_cache_payload(
223 mut input: &[u8],
224) -> Option<(RuntimeQueryResult, HashSet<String>, u64)> {
225 if input.len() < RESULT_CACHE_PAYLOAD_MAGIC.len()
226 || &input[..RESULT_CACHE_PAYLOAD_MAGIC.len()] != RESULT_CACHE_PAYLOAD_MAGIC
227 {
228 return None;
229 }
230 input = &input[RESULT_CACHE_PAYLOAD_MAGIC.len()..];
231 let data_epoch = read_u64(&mut input)?;
232
233 let query = read_string(&mut input)?;
234 let mode = mode_from_byte(read_u8(&mut input)?)?;
235 let statement = result_cache_static_str(&read_string(&mut input)?)?;
236 let engine = result_cache_static_str(&read_string(&mut input)?)?;
237 let affected_rows = read_u64(&mut input)?;
238 let statement_type = result_cache_static_str(&read_string(&mut input)?)?;
239
240 let mut columns = Vec::new();
241 for _ in 0..read_u32(&mut input)? {
242 columns.push(read_string(&mut input)?);
243 }
244 let stats = crate::storage::query::unified::QueryStats {
245 nodes_scanned: read_u64(&mut input)?,
246 edges_scanned: read_u64(&mut input)?,
247 rows_scanned: read_u64(&mut input)?,
248 segments_total: read_u64(&mut input)?,
249 segments_scanned: read_u64(&mut input)?,
250 segments_pruned: read_u64(&mut input)?,
251 exec_time_us: read_u64(&mut input)?,
252 vector: None,
253 };
254
255 let mut records = Vec::new();
256 for _ in 0..read_u32(&mut input)? {
257 let mut record = crate::storage::query::unified::UnifiedRecord::new();
258 for _ in 0..read_u32(&mut input)? {
259 let name = read_string(&mut input)?;
260 let bytes = read_bytes(&mut input)?;
261 let (value, used) = reddb_types::value_codec::decode(bytes).ok()?;
262 if used != bytes.len() {
263 return None;
264 }
265 record.set_owned(name, value);
266 }
267 records.push(record);
268 }
269
270 let mut scopes = HashSet::new();
271 for _ in 0..read_u32(&mut input)? {
272 scopes.insert(read_string(&mut input)?);
273 }
274 if !input.is_empty() {
275 return None;
276 }
277
278 Some((
279 RuntimeQueryResult {
280 query,
281 mode,
282 statement,
283 engine,
284 result: crate::storage::query::unified::UnifiedResult {
285 columns,
286 records,
287 stats,
288 pre_serialized_json: None,
289 },
290 affected_rows,
291 statement_type,
292 bookmark: None,
293 notice: None,
294 },
295 scopes,
296 data_epoch,
297 ))
298}
299
300impl RedDBRuntime {
301 fn invalidate_result_cache_during_wal_capture(&self) {
302 DEFERRED_CACHE_INVALIDATION.with(|pending| pending.set(true));
303 let store = self.inner.db.store();
304 let _publication = store.vault_publication_lock.lock();
305 let _promotion = self.inner.result_blob_promotion.lock();
306 let mut entries = self.inner.result_blob_entries.write();
307 entries.0.clear();
308 entries.1.clear();
309 entries.2 = true;
310 entries.3 = entries.3.wrapping_add(1);
311 entries.4.clear();
312 entries.5 = None;
313 let mut ask = self.inner.ask_answer_cache_entries.write();
314 ask.0.clear();
315 ask.1.clear();
316 self.inner
317 .result_blob_cache
318 .invalidate_namespace_in_memory(RESULT_CACHE_BLOB_NAMESPACE);
319 self.inner
320 .result_blob_cache
321 .invalidate_namespace_in_memory(super::ASK_ANSWER_CACHE_NAMESPACE);
322 }
323
324 pub(super) fn flush_deferred_cache_invalidation(&self) {
325 if DEFERRED_CACHE_INVALIDATION.with(|pending| pending.replace(false)) {
326 self.invalidate_result_cache();
327 }
328 }
329
330 fn result_cache_enabled(&self) -> bool {
331 self.config_bool(RESULT_CACHE_ENABLED_KEY, true)
332 }
333
334 fn result_cache_ttl_secs(&self) -> u64 {
335 self.config_u64(RESULT_CACHE_TTL_KEY, RESULT_CACHE_TTL_SECS)
336 }
337
338 fn result_cache_capacity(&self) -> usize {
339 self.config_u64(RESULT_CACHE_CAPACITY_KEY, RESULT_CACHE_MAX_ENTRIES as u64) as usize
340 }
341
342 fn ensure_result_cache_format(&self) -> Option<u64> {
343 let store = self.inner.db.store();
344 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
345 return None;
346 }
347 let _publication = store.vault_publication_lock.lock();
348 let _format = self.inner.result_cache_format_lock.lock();
349 if store.result_cache_format_fenced() {
350 return store.result_cache_read_epoch();
351 }
352 if self.inner.db.path().is_some() && store.is_read_only() {
353 return None;
354 }
355 {
356 let _promotion = self.inner.result_blob_promotion.lock();
357 if self
358 .inner
359 .result_blob_cache
360 .try_invalidate_namespace(RESULT_CACHE_BLOB_NAMESPACE)
361 .is_err()
362 {
363 return None;
364 }
365 }
366 store.ensure_result_cache_epoch().ok()?;
367 if self.inner.db.path().is_some() {
368 if let Some(pager) = store.pager() {
369 let version = if store.config().resident_memory_bytes_max.is_some() {
370 reddb_file::file_format::PAGE_RESULT_CACHE_DISK_FILE_VERSION
371 } else {
372 reddb_file::file_format::PAGE_RESULT_CACHE_FILE_VERSION
373 };
374 pager.fence_file_format_version(version).ok()?;
375 } else {
376 self.checkpoint_local().ok()?;
377 #[cfg(test)]
378 self.index_store_ref().mutation_test_hook(
379 "",
380 super::index_store::MutationTestPhase::ResultCacheFormatCheckpointed,
381 );
382 }
383 }
384 store.seal_result_cache_snapshot().ok()?;
385 store.result_cache_read_epoch()
386 }
387
388 pub(super) fn result_cache_snapshot(&self) -> (Option<u64>, u64) {
389 let epoch = if self.result_cache_enabled() {
390 let store = self.inner.db.store();
391 if store.result_cache_format_fenced() {
392 store.result_cache_read_epoch()
393 } else {
394 self.ensure_result_cache_format()
395 }
396 } else {
397 Some(0)
398 };
399 let cache = self.inner.result_blob_entries.read();
400 if cache.6 != 0 || !cache.0.is_empty() || epoch.unwrap_or(0) == 0 {
401 return (epoch, cache.3);
402 }
403 drop(cache);
404 let mut cache = self.inner.result_blob_entries.write();
405 if cache.6 == 0 && cache.0.is_empty() {
406 cache.6 = epoch.unwrap_or(0);
407 }
408 (epoch, cache.3)
409 }
410
411 pub fn result_cache_metrics(&self) -> (u64, u64, u64) {
412 use std::sync::atomic::Ordering::Relaxed;
413 (
414 self.inner.result_cache_hits.load(Relaxed),
415 self.inner.result_cache_misses.load(Relaxed),
416 self.inner.result_cache_evictions.load(Relaxed),
417 )
418 }
419
420 fn record_result_cache_evictions(&self, evicted: u64) {
421 if evicted > 0 {
422 self.inner
423 .result_cache_evictions
424 .fetch_add(evicted, std::sync::atomic::Ordering::Relaxed);
425 }
426 }
427
428 pub(super) fn get_result_cache_entry(
429 &self,
430 key: &str,
431 epoch: Option<u64>,
432 ) -> Option<RuntimeQueryResult> {
433 if !self.result_cache_enabled() {
434 return None;
435 }
436 let hit = self.get_result_cache_entry_inner(key, epoch);
437 let counter = if hit.is_some() {
438 &self.inner.result_cache_hits
439 } else {
440 &self.inner.result_cache_misses
441 };
442 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
443 hit
444 }
445
446 fn get_result_cache_entry_inner(
447 &self,
448 key: &str,
449 epoch: Option<u64>,
450 ) -> Option<RuntimeQueryResult> {
451 epoch.and_then(|epoch| self.get_blob_result_cache_entry(key, epoch))
452 }
453
454 fn result_cache_entry_reusable(&self, entry: &RuntimeResultCacheEntry) -> bool {
455 let store = self.inner.db.store();
456 if !matches!(
457 entry.result.engine,
458 "runtime-table" | "runtime-table-rls" | "runtime-join" | "runtime-join-rls"
459 ) || entry.scopes.is_empty()
460 || entry
461 .scopes
462 .iter()
463 .any(|scope| store.get_collection(scope).is_none())
464 {
465 return store.result_cache_epoch() == Some(entry.data_epoch);
466 }
467 store.result_cache_unchanged_since(entry.data_epoch, &entry.scopes)
468 }
469
470 fn get_blob_result_cache_entry(&self, key: &str, epoch: u64) -> Option<RuntimeQueryResult> {
471 self.get_blob_result_cache_entry_from_blob_cache(key, epoch)
472 }
473
474 fn get_blob_result_cache_entry_from_blob_cache(
475 &self,
476 key: &str,
477 epoch: u64,
478 ) -> Option<RuntimeQueryResult> {
479 {
480 let cache = self.inner.result_blob_entries.read();
481 if cache.4.contains(key) {
482 let entry = cache.0.get(key)?;
483 return (self.result_cache_entry_reusable(entry)
484 && entry.cached_at.elapsed().as_secs() < self.result_cache_ttl_secs())
485 .then(|| entry.result.clone());
486 }
487 }
488 let revision = self.inner.result_blob_entries.read().3;
493 let hit = self
494 .inner
495 .result_blob_cache
496 .get(RESULT_CACHE_BLOB_NAMESPACE, key)?;
497 {
498 let cache = self.inner.result_blob_entries.read();
499 if cache.3 != revision {
500 return None;
501 }
502 if let Some(entry) = cache.0.get(key) {
503 return self
504 .result_cache_entry_reusable(entry)
505 .then(|| entry.result.clone());
506 }
507 }
508
509 let (result, scopes, data_epoch) = decode_result_cache_payload(hit.value())?;
510 if data_epoch != epoch || self.inner.db.store().result_cache_epoch() != Some(epoch) {
511 return None;
512 }
513 let mut cache = self.inner.result_blob_entries.write();
514 if cache.3 != revision {
515 return None;
516 }
517 let (ref mut map, ref mut order, ref mut untracked, ..) = *cache;
518 let new_entry = RuntimeResultCacheEntry {
519 result: result.clone(),
520 cached_at: std::time::Instant::now(),
521 scopes,
522 data_epoch,
523 };
524 match map.entry(key.to_string()) {
526 Entry::Occupied(mut slot) => {
527 slot.insert(new_entry);
528 }
529 Entry::Vacant(slot) => {
530 order.push_back(slot.key().clone());
531 slot.insert(new_entry);
532 }
533 }
534 let evicted = trim_result_cache(map, order, self.result_cache_capacity());
535 *untracked |= evicted > 0;
536 if evicted > 0 {
537 let (ref map, _, _, _, ref mut volatile_keys, ..) = *cache;
538 volatile_keys.retain(|key| map.contains_key(key));
539 }
540 drop(cache);
541 self.record_result_cache_evictions(evicted);
542 Some(result)
543 }
544
545 pub(super) fn promote_volatile_result_cache_entry(&self) {
549 let store = self.inner.db.store();
550 let Some(_publication) = store.vault_publication_lock.try_lock() else {
551 return;
552 };
553 let Some(_promotion) = self.inner.result_blob_promotion.try_lock() else {
554 return;
555 };
556 let ttl = self.result_cache_ttl_secs();
557 let Some(epoch) = store.result_cache_epoch() else {
558 return;
559 };
560 let candidate = {
561 let cache = self.inner.result_blob_entries.read();
562 if cache
563 .5
564 .is_none_or(|until| std::time::Instant::now() < until)
565 {
566 return;
567 }
568 cache.4.iter().find_map(|key| {
569 let entry = cache.0.get(key)?;
570 (entry.data_epoch == epoch && entry.cached_at.elapsed().as_secs() < ttl)
571 .then(|| (key.clone(), entry.clone()))
572 })
573 };
574 let Some((key, entry)) = candidate else {
575 return;
576 };
577 let remaining =
578 std::time::Duration::from_secs(ttl).saturating_sub(entry.cached_at.elapsed());
579 if remaining.is_zero() {
580 return;
581 }
582 let ttl_ms = u64::try_from(remaining.as_millis())
583 .unwrap_or(u64::MAX)
584 .max(1);
585 let policy = crate::storage::cache::BlobCachePolicy::default()
586 .ttl_ms(ttl_ms)
587 .priority(200);
588 let bytes = encode_result_cache_payload(&entry)
589 .unwrap_or_else(|| result_cache_fingerprint(&entry.result).into_bytes());
590 let put = crate::storage::cache::BlobCachePut::new(bytes)
591 .with_dependencies(entry.scopes.iter().cloned())
592 .with_policy(policy);
593 let result = self
594 .inner
595 .result_blob_cache
596 .put(RESULT_CACHE_BLOB_NAMESPACE, &key, put);
597 let mut cache = self.inner.result_blob_entries.write();
598 if result.is_ok()
599 && cache.4.contains(&key)
600 && cache
601 .0
602 .get(&key)
603 .is_some_and(|current| current.cached_at == entry.cached_at)
604 {
605 cache.4.remove(&key);
606 } else {
607 cache.2 = true;
609 if result.is_err() {
610 cache.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
611 }
612 }
613 cache.3 = cache.3.wrapping_add(1);
614 }
615
616 pub(super) fn put_result_cache_entry(
617 &self,
618 key: &str,
619 entry: RuntimeResultCacheEntry,
620 epoch: Option<u64>,
621 revision: u64,
622 ) {
623 if !self.result_cache_enabled() {
624 return;
625 }
626 if epoch.is_some() {
627 self.put_blob_result_cache_entry(key, entry, revision);
628 }
629 }
630
631 fn put_blob_result_cache_entry(
632 &self,
633 key: &str,
634 entry: RuntimeResultCacheEntry,
635 revision: u64,
636 ) {
637 let policy = crate::storage::cache::BlobCachePolicy::default()
638 .ttl_ms(self.result_cache_ttl_secs() * 1000)
639 .priority(200);
640 let dependencies = entry.scopes.iter().cloned().collect::<Vec<_>>();
641 let bytes = encode_result_cache_payload(&entry)
642 .unwrap_or_else(|| result_cache_fingerprint(&entry.result).into_bytes());
643 let put = crate::storage::cache::BlobCachePut::new(bytes)
644 .with_dependencies(dependencies)
645 .with_policy(policy);
646 let store = self.inner.db.store();
647 let _publication = store.vault_publication_lock.lock();
648 let _promotion = self.inner.result_blob_promotion.lock();
649 let mut cache = self.inner.result_blob_entries.write();
650 if cache.3 != revision
651 || self.inner.db.store().result_cache_epoch() != Some(entry.data_epoch)
652 {
653 return;
654 }
655 let defer_l2 = self.inner.db.path().is_some() && store.pager().is_none();
656 if defer_l2 && cache.5.is_none() {
657 cache.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
658 }
659 let volatile = defer_l2
660 || cache
661 .5
662 .is_some_and(|until| std::time::Instant::now() < until);
663 if !volatile
664 && self
665 .inner
666 .result_blob_cache
667 .put(RESULT_CACHE_BLOB_NAMESPACE, key, put)
668 .is_err()
669 {
670 cache.2 = true;
672 cache.3 = cache.3.wrapping_add(1);
673 return;
674 }
675
676 if volatile {
677 cache.4.insert(key.to_string());
678 } else {
679 cache.4.remove(key);
680 }
681 let capacity = self.result_cache_capacity();
682 let (ref mut map, ref mut order, ref mut untracked, ref mut revision, ..) = *cache;
683 match map.entry(key.to_string()) {
685 Entry::Occupied(mut slot) => {
686 slot.insert(entry);
687 }
688 Entry::Vacant(slot) => {
689 order.push_back(slot.key().clone());
690 slot.insert(entry);
691 }
692 }
693 let evicted = trim_result_cache(map, order, capacity);
694 *untracked |= evicted > 0;
695 *revision = revision.wrapping_add(1);
696 if evicted > 0 {
697 let (ref map, _, _, _, ref mut volatile_keys, ..) = *cache;
698 volatile_keys.retain(|key| map.contains_key(key));
699 }
700 drop(cache);
701 self.record_result_cache_evictions(evicted);
702 }
703
704 pub fn invalidate_result_cache(&self) {
705 self.invalidate_result_cache_inner();
706 }
707
708 fn invalidate_blob_result_namespace(&self) -> bool {
709 match self
710 .inner
711 .result_blob_cache
712 .try_invalidate_namespace(RESULT_CACHE_BLOB_NAMESPACE)
713 {
714 Ok(_) => false,
715 Err(error) => {
716 tracing::error!(?error, "failed to persist result-cache invalidation");
717 true
718 }
719 }
720 }
721
722 pub(crate) fn invalidate_result_cache_process_only(&self) {
723 self.invalidate_result_cache_inner();
724 }
725
726 fn invalidate_result_cache_inner(&self) {
727 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
728 self.invalidate_result_cache_during_wal_capture();
729 return;
730 }
731 let store = self.inner.db.store();
732 let _publication = store.vault_publication_lock.lock();
733 let _promotion = self.inner.result_blob_promotion.lock();
734 let mut blob_entries = self.inner.result_blob_entries.write();
735 blob_entries.0.clear();
736 blob_entries.1.clear();
737 blob_entries.4.clear();
738 blob_entries.5 = None;
739 if let Some(epoch) = self.inner.db.store().result_cache_epoch() {
740 blob_entries.6 = epoch;
741 }
742 blob_entries.2 = self.invalidate_blob_result_namespace();
743 blob_entries.3 = blob_entries.3.wrapping_add(1);
744 let mut ask_entries = self.inner.ask_answer_cache_entries.write();
745 ask_entries.0.clear();
746 ask_entries.1.clear();
747 self.inner
748 .result_blob_cache
749 .invalidate_namespace(super::ASK_ANSWER_CACHE_NAMESPACE);
750 }
751
752 pub(crate) fn invalidate_result_cache_for_table(&self, table: &str) {
753 #[cfg(test)]
754 self.index_store_ref().mutation_test_hook(
755 table,
756 super::index_store::MutationTestPhase::BeforeResultCacheInvalidation,
757 );
758 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
759 self.invalidate_result_cache_during_wal_capture();
760 return;
761 }
762 let store = self.inner.db.store();
763 let _publication = store.vault_publication_lock.lock();
764 let _promotion = self.inner.result_blob_promotion.lock();
765 {
766 let mut blob_entries = self.inner.result_blob_entries.write();
767 let previous_epoch = blob_entries.6;
768 if let Some(epoch) = self.inner.db.store().result_cache_epoch() {
769 if previous_epoch.checked_add(1) == Some(epoch) {
770 for entry in blob_entries.0.values_mut() {
771 if entry.data_epoch == previous_epoch && !entry.scopes.contains(table) {
772 entry.data_epoch = epoch;
773 }
774 }
775 } else if previous_epoch != epoch && !blob_entries.0.is_empty() {
776 blob_entries.5 =
779 Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
780 }
781 blob_entries.6 = epoch;
782 }
783 blob_entries.3 = blob_entries.3.wrapping_add(1);
785 let has_match = blob_entries
786 .0
787 .values()
788 .any(|entry| entry.scopes.contains(table));
789 let has_durable_match = blob_entries
790 .0
791 .iter()
792 .any(|(key, entry)| entry.scopes.contains(table) && !blob_entries.4.contains(key));
793 if blob_entries.2 || has_durable_match {
794 blob_entries.0.clear();
795 blob_entries.1.clear();
796 blob_entries.4.clear();
797 blob_entries.2 = self.invalidate_blob_result_namespace();
798 blob_entries.3 = blob_entries.3.wrapping_add(1);
799 } else if has_match {
800 blob_entries
801 .0
802 .retain(|_, entry| !entry.scopes.contains(table));
803 let (ref map, ref mut order, _, ref mut revision, ref mut volatile_keys, ..) =
804 *blob_entries;
805 order.retain(|key| map.contains_key(key));
806 volatile_keys.retain(|key| map.contains_key(key));
807 *revision = revision.wrapping_add(1);
808 }
809 if has_match {
810 blob_entries.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
811 }
812 }
813 let mut ask_entries = self.inner.ask_answer_cache_entries.write();
814 ask_entries.0.clear();
815 ask_entries.1.clear();
816 self.inner
817 .result_blob_cache
818 .invalidate_namespace(super::ASK_ANSWER_CACHE_NAMESPACE);
819 }
820}
821
822#[cfg(test)]
823mod crash_tests {
824 use crate::{RedDBOptions, RedDBRuntime};
825 use std::sync::Arc;
826
827 const CHILD_PATH: &str = "REDDB_RESULT_CACHE_COMMIT_GAP_TEST_PATH";
828 const CHILD_OPERATION: &str = "REDDB_RESULT_CACHE_COMMIT_GAP_TEST_OPERATION";
829
830 #[test]
831 fn payload_without_a_data_epoch_is_not_rehydrated() {
832 assert!(super::decode_result_cache_payload(b"invalid-payload").is_none());
833 }
834
835 #[test]
836 fn result_cache_format_waits_for_wal_capture_to_finish() {
837 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
838 crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
839 let during_capture = runtime.result_cache_snapshot().0;
840 let actions = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
841 assert!(actions.is_empty());
842 assert_eq!(during_capture, None);
843 assert!(!runtime.db().store().result_cache_format_fenced());
844 assert!(runtime.result_cache_snapshot().0.is_some());
845 assert!(runtime.db().store().result_cache_format_fenced());
846 }
847
848 #[test]
849 fn write_and_control_statements_do_not_initialize_result_cache_format() {
850 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
851 for sql in [
852 "CREATE TABLE cache_format_gate (id INT)",
853 "INSERT INTO cache_format_gate (id) VALUES (1)",
854 "BEGIN",
855 "ROLLBACK",
856 ] {
857 runtime.execute_query(sql).expect("non-read statement");
858 assert!(!runtime.db().store().result_cache_format_fenced(), "{sql}");
859 }
860 runtime.execute_query("SCRUB").expect("uncached scrub");
861 assert!(!runtime.db().store().result_cache_format_fenced());
862 runtime
863 .execute_query("SELECT * FROM red.stats WHERE collection = 'cache_format_gate'")
864 .expect("computed stats read");
865 assert!(!runtime.db().store().result_cache_format_fenced());
866 runtime.execute_query("SHOW STATS").expect("show stats");
867 assert!(!runtime.db().store().result_cache_format_fenced());
868 runtime
869 .execute_query("SELECT id FROM cache_format_gate")
870 .expect("read statement");
871 assert!(runtime.db().store().result_cache_format_fenced());
872 }
873
874 #[test]
875 fn uncached_native_read_frame_does_not_initialize_result_cache_format() {
876 use crate::runtime::statement_frame::{StatementExecutionFrame, StatementIdentity};
877
878 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
879 let _frame = StatementExecutionFrame::build(
880 &runtime,
881 StatementIdentity::UncachedText("SEARCH CONTEXT"),
882 )
883 .expect("native read frame");
884 assert!(!runtime.db().store().result_cache_format_fenced());
885 }
886
887 #[test]
888 fn promotion_gate_does_not_block_hits_and_orders_invalidation() {
889 use std::sync::mpsc;
890 use std::time::Duration;
891
892 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
893 for sql in [
894 "CREATE TABLE promotion_gate (id INT)",
895 "INSERT INTO promotion_gate (id) VALUES (1)",
896 "SELECT id FROM promotion_gate ORDER BY id",
897 "INSERT INTO promotion_gate (id) VALUES (2)",
898 "SELECT id FROM promotion_gate ORDER BY id",
899 ] {
900 runtime.execute_query(sql).expect("prepare volatile result");
901 }
902
903 let guard = runtime.inner.result_blob_promotion.lock();
904 let reader = runtime.clone();
905 let (read_tx, read_rx) = mpsc::channel();
906 let reader_thread = std::thread::spawn(move || {
907 let rows = reader
908 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
909 .expect("cached read")
910 .result
911 .records
912 .len();
913 read_tx.send(rows).expect("report read");
914 });
915 let read = read_rx.recv_timeout(Duration::from_secs(3));
916 drop(guard);
917 reader_thread.join().expect("reader thread");
918 assert_eq!(read.expect("hit blocked by promotion gate"), 2);
919
920 let guard = runtime.inner.result_blob_promotion.lock();
921 let invalidator = runtime.clone();
922 let (started_tx, started_rx) = mpsc::channel();
923 let (done_tx, done_rx) = mpsc::channel();
924 let invalidator_thread = std::thread::spawn(move || {
925 started_tx.send(()).expect("report start");
926 invalidator.invalidate_result_cache_for_table("promotion_gate");
927 done_tx.send(()).expect("report invalidation");
928 });
929 let started = started_rx.recv_timeout(Duration::from_secs(3));
930 let completed_early = done_rx.recv_timeout(Duration::from_millis(100));
931 drop(guard);
932 assert!(started.is_ok(), "invalidation did not start");
933 assert!(
934 matches!(completed_early, Err(mpsc::RecvTimeoutError::Timeout)),
935 "invalidation must wait for an in-flight promotion"
936 );
937 done_rx
938 .recv_timeout(Duration::from_secs(3))
939 .expect("invalidation did not finish");
940 invalidator_thread.join().expect("invalidator thread");
941 let before = runtime.result_cache_metrics();
942 assert_eq!(
943 runtime
944 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
945 .expect("read after invalidation")
946 .result
947 .records
948 .len(),
949 2
950 );
951 assert_eq!(
952 runtime.result_cache_metrics().1 - before.1,
953 1,
954 "invalidation must remove the cached result"
955 );
956 }
957
958 #[test]
959 fn durable_cache_publication_and_write_invalidation_do_not_invert_locks() {
960 use std::sync::mpsc;
961 use std::time::{Duration, Instant};
962
963 let directory = tempfile::tempdir().expect("temporary directory");
964 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
965 directory.path().join("cache-lock-order.rdb"),
966 ))
967 .expect("runtime");
968 for sql in [
969 "CREATE TABLE locked_write (id INT)",
970 "CREATE TABLE cache_fill (id INT)",
971 "INSERT INTO locked_write (id) VALUES (1)",
972 "INSERT INTO cache_fill (id) VALUES (1)",
973 ] {
974 runtime.execute_query(sql).expect(sql);
975 }
976 runtime.result_cache_snapshot();
977 std::thread::sleep(super::RESULT_CACHE_WRITE_QUIET_PERIOD + Duration::from_millis(50));
978
979 let (write_paused_tx, write_paused_rx) = mpsc::channel();
980 let (resume_write_tx, resume_write_rx) = mpsc::channel();
981 let writer = runtime.clone();
982 let (writer_done_tx, writer_done_rx) = mpsc::channel();
983 let writer_thread = std::thread::spawn(move || {
984 let store = writer.db().store();
985 let _publication = store.vault_publication_lock.lock();
986 write_paused_tx
987 .send(store.vault_publication_lock.is_owned_by_current_thread())
988 .expect("report paused writer");
989 resume_write_rx
990 .recv_timeout(Duration::from_secs(10))
991 .expect("resume writer");
992 writer.invalidate_result_cache_for_table("locked_write");
993 writer_done_tx.send(()).expect("report writer result");
994 });
995 assert!(
996 write_paused_rx
997 .recv_timeout(Duration::from_secs(5))
998 .expect("writer did not reach invalidation"),
999 "write invalidation must still own the durable publication fence"
1000 );
1001
1002 let cache_filler = runtime.clone();
1003 let (cache_done_tx, cache_done_rx) = mpsc::channel();
1004 let cache_thread = std::thread::spawn(move || {
1005 let result = cache_filler.execute_query("SELECT id FROM cache_fill ORDER BY id");
1006 cache_done_tx
1007 .send(result)
1008 .expect("report cache publication result");
1009 });
1010
1011 let deadline = Instant::now() + Duration::from_secs(2);
1017 while Instant::now() < deadline {
1018 if runtime.inner.result_blob_promotion.try_lock().is_none() {
1019 break;
1020 }
1021 std::thread::sleep(Duration::from_millis(10));
1022 }
1023 resume_write_tx.send(()).expect("resume writer");
1024
1025 let writer_result = writer_done_rx.recv_timeout(Duration::from_secs(3));
1026 let cache_result = cache_done_rx.recv_timeout(Duration::from_secs(3));
1027 assert!(
1028 writer_result.is_ok() && cache_result.is_ok(),
1029 "durable cache publication deadlocked with write invalidation"
1030 );
1031 writer_result.unwrap();
1032 cache_result.unwrap().expect("cache publication result");
1033 writer_thread.join().expect("writer thread");
1034 cache_thread.join().expect("cache thread");
1035 }
1036
1037 #[test]
1038 fn published_write_cannot_return_an_old_in_memory_result() {
1039 use std::sync::mpsc;
1040 use std::time::Duration;
1041
1042 let directory = tempfile::tempdir().expect("temporary directory");
1043 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1044 directory.path().join("live-gap.rdb"),
1045 ))
1046 .expect("runtime");
1047 for sql in [
1048 "CREATE TABLE live_gap (id INT)",
1049 "INSERT INTO live_gap (id) VALUES (1)",
1050 "SELECT id FROM live_gap ORDER BY id",
1051 ] {
1052 runtime.execute_query(sql).expect("prepare cache");
1053 }
1054 let (published_tx, published_rx) = mpsc::channel();
1055 let (resume_tx, resume_rx) = mpsc::channel();
1056 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1057 *runtime.index_store_ref().mutation_hook.lock() =
1058 Some(Arc::new(move |collection, phase| {
1059 if collection == "live_gap"
1060 && phase == super::super::index_store::MutationTestPhase::StoragePublished
1061 {
1062 published_tx.send(()).expect("report publication");
1063 resume_rx
1064 .lock()
1065 .recv_timeout(Duration::from_secs(10))
1066 .expect("resume writer");
1067 }
1068 }));
1069 let writer = runtime.clone();
1070 let writer_thread = std::thread::spawn(move || {
1071 writer.execute_query("INSERT INTO live_gap (id) VALUES (2)")
1072 });
1073 published_rx
1074 .recv_timeout(Duration::from_secs(10))
1075 .expect("storage publication");
1076 let reader = runtime.clone();
1077 let (read_tx, read_rx) = mpsc::channel();
1078 let reader_thread = std::thread::spawn(move || {
1079 let rows = reader
1080 .execute_query("SELECT id FROM live_gap ORDER BY id")
1081 .expect("read during publication")
1082 .result
1083 .records
1084 .len();
1085 read_tx.send(rows).expect("report rows");
1086 });
1087 let early = read_rx.recv_timeout(Duration::from_secs(2));
1088 resume_tx.send(()).expect("resume writer");
1089 writer_thread.join().expect("writer thread").expect("write");
1090 let rows = match early {
1091 Ok(rows) => rows,
1092 Err(mpsc::RecvTimeoutError::Timeout) => read_rx
1093 .recv_timeout(Duration::from_secs(10))
1094 .expect("reader finished"),
1095 Err(error) => panic!("reader failed: {error}"),
1096 };
1097 reader_thread.join().expect("reader thread");
1098 assert_eq!(
1099 rows, 2,
1100 "old cached result was returned after storage publication"
1101 );
1102 }
1103
1104 #[test]
1105 fn unrelated_pending_write_keeps_a_cached_read_available() {
1106 use std::sync::mpsc;
1107 use std::time::Duration;
1108
1109 let directory = tempfile::tempdir().expect("temporary directory");
1110 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1111 directory.path().join("pending-unrelated.rdb"),
1112 ))
1113 .expect("runtime");
1114 for sql in [
1115 "CREATE TABLE cached_items (id INT)",
1116 "CREATE TABLE unrelated_items (id INT)",
1117 "INSERT INTO cached_items (id) VALUES (1)",
1118 "INSERT INTO unrelated_items (id) VALUES (1)",
1119 "SELECT id FROM cached_items ORDER BY id",
1120 "SELECT id FROM cached_items ORDER BY id",
1121 ] {
1122 runtime.execute_query(sql).expect(sql);
1123 }
1124 let before = runtime.result_cache_metrics();
1125 runtime
1126 .execute_query("SELECT id FROM cached_items ORDER BY id")
1127 .expect("warm hit");
1128 assert_eq!(runtime.result_cache_metrics().0 - before.0, 1);
1129 let (pending_tx, pending_rx) = mpsc::channel();
1130 let (resume_tx, resume_rx) = mpsc::channel();
1131 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1132 runtime
1133 .db()
1134 .store()
1135 .set_result_cache_pending_hook(Some(Arc::new(move || {
1136 pending_tx.send(()).expect("report pending");
1137 resume_rx
1138 .lock()
1139 .recv_timeout(Duration::from_secs(10))
1140 .expect("resume");
1141 })));
1142 let writer = runtime.clone();
1143 let writer_thread = std::thread::spawn(move || {
1144 writer.execute_query("UPDATE unrelated_items SET id = 2 WHERE id = 1")
1145 });
1146 pending_rx
1147 .recv_timeout(Duration::from_secs(10))
1148 .expect("publication pending");
1149 let before = runtime.result_cache_metrics();
1150 let reader = runtime.clone();
1151 let (read_tx, read_rx) = mpsc::channel();
1152 let reader_thread = std::thread::spawn(move || {
1153 read_tx
1154 .send(reader.execute_query("SELECT id FROM cached_items ORDER BY id"))
1155 .unwrap();
1156 });
1157 let result = read_rx.recv_timeout(Duration::from_secs(3));
1158 runtime.db().store().set_result_cache_pending_hook(None);
1159 resume_tx.send(()).expect("resume writer");
1160 writer_thread.join().unwrap().expect("write");
1161 reader_thread.join().unwrap();
1162 assert_eq!(
1163 result
1164 .expect("cached read must not wait")
1165 .expect("read")
1166 .result
1167 .records
1168 .len(),
1169 1
1170 );
1171 assert_eq!(
1172 runtime.result_cache_metrics().0 - before.0,
1173 1,
1174 "unrelated pending write must preserve the hit"
1175 );
1176 }
1177
1178 #[test]
1179 fn committed_insert_before_cache_invalidation_does_not_recover_stale_result() {
1180 if let Some(path) = std::env::var_os(CHILD_PATH) {
1181 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(path))
1182 .expect("open child database");
1183 let operation = std::env::var(CHILD_OPERATION).expect("child operation");
1184 if operation.starts_with("format_crash:") || operation.starts_with("format_seal:") {
1185 let store = runtime.db().store();
1186 store
1187 .insert_auto(
1188 "format_data",
1189 crate::storage::UnifiedEntity::table_row(
1190 crate::storage::EntityId::new(0),
1191 "format_data",
1192 0,
1193 vec![reddb_types::Value::Integer(42)],
1194 ),
1195 )
1196 .expect("seed durable data");
1197 if let Some(point) = operation.strip_prefix("format_crash:") {
1198 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", point);
1199 } else {
1200 let point = operation.strip_prefix("format_seal:").unwrap().to_owned();
1201 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1202 move |_, phase| {
1203 if phase == super::super::index_store::MutationTestPhase::ResultCacheFormatCheckpointed {
1204 if point == "before" {
1205 std::process::exit(86);
1206 }
1207 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &point);
1208 }
1209 },
1210 ));
1211 }
1212 runtime
1213 .execute_query("SELECT * FROM format_data")
1214 .expect("format activation");
1215 panic!("format crash point was not reached");
1216 }
1217 for sql in [
1218 "CREATE TABLE cache_gap (id INT)",
1219 "INSERT INTO cache_gap (id) VALUES (1)",
1220 "SELECT id FROM cache_gap ORDER BY id",
1221 ] {
1222 runtime.execute_query(sql).expect("prepare cache");
1223 }
1224 if operation == "format_fence" {
1225 assert!(runtime.db().store().result_cache_format_fenced());
1226 std::process::exit(86);
1227 }
1228 if operation == "transaction" {
1229 let (key, mut old_entry) = {
1230 let cache = runtime.inner.result_blob_entries.read();
1231 let (key, entry) = cache.0.iter().next().expect("warm original result");
1232 (key.clone(), entry.clone())
1233 };
1234 super::super::execution_context::set_current_connection_id(901);
1235 runtime.execute_query("BEGIN").expect("begin transaction");
1236 runtime
1237 .execute_query("INSERT INTO cache_gap (id) VALUES (2)")
1238 .expect("stage insert");
1239 let reader = runtime.clone();
1240 std::thread::spawn(move || {
1241 super::super::execution_context::set_current_connection_id(902);
1242 let read = reader
1243 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1244 .expect("cache committed view");
1245 assert_eq!(
1246 read.result.records.len(),
1247 1,
1248 "uncommitted row must not be visible"
1249 );
1250 old_entry.data_epoch = reader.db().store().result_cache_epoch().expect("epoch");
1253 let payload =
1254 super::encode_result_cache_payload(&old_entry).expect("encode old result");
1255 reader
1256 .inner
1257 .result_blob_cache
1258 .put(
1259 super::RESULT_CACHE_BLOB_NAMESPACE,
1260 &key,
1261 crate::storage::cache::BlobCachePut::new(payload),
1262 )
1263 .expect("persist old result");
1264 reader.checkpoint().expect("persist cached result");
1265 let hit = reader
1266 .inner
1267 .result_blob_cache
1268 .get(super::RESULT_CACHE_BLOB_NAMESPACE, &key)
1269 .expect("old entry remains");
1270 assert!(super::decode_result_cache_payload(hit.value()).is_some());
1271 })
1272 .join()
1273 .expect("pre-commit reader");
1274 } else {
1275 runtime.checkpoint().expect("persist cached result");
1276 }
1277 let transaction = operation == "transaction";
1278 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1279 move |collection, phase| {
1280 if (transaction && phase == super::super::index_store::MutationTestPhase::TransactionWalCommitted)
1281 || (!transaction && collection == "cache_gap"
1282 && phase == super::super::index_store::MutationTestPhase::BeforeResultCacheInvalidation)
1283 {
1284 std::process::exit(86);
1285 }
1286 },
1287 ));
1288 if operation.starts_with("wal_") {
1289 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &operation);
1290 }
1291 let sql = match operation.as_str() {
1292 "insert"
1293 | "wal_after_frame_write"
1294 | "wal_after_frame_sync"
1295 | "wal_after_superblock_write" => "INSERT INTO cache_gap (id) VALUES (2)",
1296 "update" => "UPDATE cache_gap SET id = 2 WHERE id = 1",
1297 "delete" => "DELETE FROM cache_gap WHERE id = 1",
1298 "transaction" => "COMMIT",
1299 _ => panic!("unknown operation"),
1300 };
1301 runtime
1302 .execute_query(sql)
1303 .expect("hook must terminate before return");
1304 panic!("storage-published hook was not reached");
1305 }
1306
1307 let dir = tempfile::tempdir().expect("temporary directory");
1308 for (operation, expected) in [
1309 ("insert", Some(vec![1, 2])),
1310 ("update", Some(vec![2])),
1311 ("delete", Some(vec![])),
1312 ("transaction", Some(vec![1, 2])),
1313 ("wal_after_frame_write", None),
1314 ("wal_after_frame_sync", None),
1315 ("wal_after_superblock_write", None),
1316 ] {
1317 let path = dir.path().join(format!("cache-gap-{operation}.rdb"));
1318 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
1319 .args([
1320 "--exact",
1321 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1322 "--nocapture",
1323 ])
1324 .env(CHILD_PATH, &path)
1325 .env(CHILD_OPERATION, operation)
1326 .output()
1327 .expect("run child");
1328 assert_eq!(
1329 output.status.code(),
1330 Some(if operation.starts_with("wal_") {
1331 173
1332 } else {
1333 86
1334 }),
1335 "{operation} child did not reach crash hook: {}",
1336 String::from_utf8_lossy(&output.stderr)
1337 );
1338 let runtime =
1339 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1340 let before = runtime.result_cache_metrics();
1341 let cached = runtime
1342 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1343 .expect("cached read");
1344 let after = runtime.result_cache_metrics();
1345 let values = |result: &crate::runtime::RuntimeQueryResult| {
1346 result
1347 .result
1348 .records
1349 .iter()
1350 .map(|record| match record.get("id") {
1351 Some(reddb_types::Value::Integer(value)) => *value,
1352 other => panic!("expected id, got {other:?}"),
1353 })
1354 .collect::<Vec<_>>()
1355 };
1356 runtime
1357 .execute_query("SET CONFIG runtime.result_cache.enabled = false")
1358 .expect("disable cache");
1359 let actual = runtime
1360 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1361 .expect("authoritative read");
1362 let actual = values(&actual);
1363 if let Some(expected) = expected {
1364 assert_eq!(actual, expected, "{operation} committed data must survive");
1365 } else {
1366 assert!(
1367 actual == vec![1] || actual == vec![1, 2],
1368 "{operation} recovered a partial write"
1369 );
1370 }
1371 assert_eq!(
1372 values(&cached),
1373 actual,
1374 "{operation} recovered a stale result"
1375 );
1376 if actual != vec![1] {
1377 assert_eq!(
1378 after.1 - before.1,
1379 1,
1380 "{operation} must reject the old L2 entry"
1381 );
1382 }
1383 }
1384 }
1385
1386 #[test]
1387 fn result_cache_format_fences_both_embedded_superblocks() {
1388 use std::io::{Seek, SeekFrom, Write};
1389 let directory = tempfile::tempdir().expect("temporary directory");
1390 let path = directory.path().join("format-fence.rdb");
1391 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1392 .args([
1393 "--exact",
1394 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1395 "--nocapture",
1396 ])
1397 .env(CHILD_PATH, &path)
1398 .env(CHILD_OPERATION, "format_fence")
1399 .status()
1400 .expect("run child");
1401 assert_eq!(status.code(), Some(86));
1402 for index in 0..2 {
1403 let copy = directory.path().join(format!("format-fence-{index}.rdb"));
1404 std::fs::copy(&path, ©).expect("copy database");
1405 let mut file = std::fs::OpenOptions::new().write(true).open(©).unwrap();
1406 file.seek(SeekFrom::Start(
1407 index * reddb_file::EMBEDDED_RDB_SUPERBLOCK_SIZE + 64,
1408 ))
1409 .unwrap();
1410 file.write_all(&[0xff]).unwrap();
1411 file.sync_all().unwrap();
1412 drop(file);
1413 let open = crate::storage::EmbeddedRdbArtifact::open(©).expect("other superblock");
1414 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1415 .unwrap()
1416 .unwrap();
1417 assert_eq!(
1418 reddb_file::decode_native_store_header(&snapshot).unwrap(),
1419 reddb_file::STORE_VERSION_V13
1420 );
1421 }
1422 }
1423
1424 #[test]
1425 fn result_cache_format_fence_and_warm_result_survive_restart() {
1426 let directory = tempfile::tempdir().expect("temporary directory");
1427 let path = directory.path().join("warm-fenced.rdb");
1428 {
1429 let runtime =
1430 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("runtime");
1431 for sql in [
1432 "CREATE TABLE warm_fence (id INT)",
1433 "INSERT INTO warm_fence (id) VALUES (1)",
1434 "SELECT id FROM warm_fence ORDER BY id",
1435 ] {
1436 runtime.execute_query(sql).expect(sql);
1437 }
1438 assert!(runtime.db().store().result_cache_format_fenced());
1439 runtime.inner.result_blob_entries.write().5 = Some(std::time::Instant::now());
1440 runtime.promote_volatile_result_cache_entry();
1441 }
1442 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1443 assert!(
1444 runtime.db().store().result_cache_format_fenced(),
1445 "durable fence must load before the first query"
1446 );
1447 let before = runtime.result_cache_metrics();
1448 assert_eq!(
1449 runtime
1450 .execute_query("SELECT id FROM warm_fence ORDER BY id")
1451 .expect("read")
1452 .result
1453 .records
1454 .len(),
1455 1
1456 );
1457 assert_eq!(
1458 runtime.result_cache_metrics().0 - before.0,
1459 1,
1460 "valid result must recover warm"
1461 );
1462 }
1463
1464 #[test]
1465 fn result_cache_format_checkpoint_survives_interrupted_publication() {
1466 let directory = tempfile::tempdir().expect("temporary directory");
1467 for (operation, expected_status) in [
1468 ("format_crash:snapshot_after_image_write", 173),
1469 ("format_crash:snapshot_after_image_sync", 173),
1470 ("format_crash:snapshot_after_manifest_write", 173),
1471 ("format_crash:snapshot_after_manifest_sync", 173),
1472 ("format_crash:snapshot_after_superblock_write", 173),
1473 ("format_seal:before", 86),
1474 ("format_seal:wal_after_frame_write", 173),
1475 ("format_seal:wal_after_frame_sync", 173),
1476 ("format_seal:wal_after_superblock_write", 173),
1477 ] {
1478 let path = directory
1479 .path()
1480 .join(format!("{}.rdb", operation.replace(':', "-")));
1481 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1482 .args([
1483 "--exact",
1484 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1485 "--nocapture",
1486 ])
1487 .env(CHILD_PATH, &path)
1488 .env(CHILD_OPERATION, operation)
1489 .status().expect("run child");
1490 assert_eq!(status.code(), Some(expected_status), "crash at {operation}");
1491 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path))
1492 .expect("recover database");
1493 assert_eq!(
1494 runtime
1495 .db()
1496 .store()
1497 .get_collection("format_data")
1498 .expect("recovered table")
1499 .count(),
1500 1
1501 );
1502 runtime
1503 .execute_query("SELECT * FROM format_data")
1504 .expect("activate format after recovery");
1505 assert!(runtime.db().store().result_cache_format_fenced());
1506 }
1507 }
1508}