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 volatile = cache
656 .5
657 .is_some_and(|until| std::time::Instant::now() < until);
658 if !volatile
659 && self
660 .inner
661 .result_blob_cache
662 .put(RESULT_CACHE_BLOB_NAMESPACE, key, put)
663 .is_err()
664 {
665 cache.2 = true;
667 cache.3 = cache.3.wrapping_add(1);
668 return;
669 }
670
671 if volatile {
672 cache.4.insert(key.to_string());
673 } else {
674 cache.4.remove(key);
675 }
676 let capacity = self.result_cache_capacity();
677 let (ref mut map, ref mut order, ref mut untracked, ref mut revision, ..) = *cache;
678 match map.entry(key.to_string()) {
680 Entry::Occupied(mut slot) => {
681 slot.insert(entry);
682 }
683 Entry::Vacant(slot) => {
684 order.push_back(slot.key().clone());
685 slot.insert(entry);
686 }
687 }
688 let evicted = trim_result_cache(map, order, capacity);
689 *untracked |= evicted > 0;
690 *revision = revision.wrapping_add(1);
691 if evicted > 0 {
692 let (ref map, _, _, _, ref mut volatile_keys, ..) = *cache;
693 volatile_keys.retain(|key| map.contains_key(key));
694 }
695 drop(cache);
696 self.record_result_cache_evictions(evicted);
697 }
698
699 pub fn invalidate_result_cache(&self) {
700 self.invalidate_result_cache_inner();
701 }
702
703 fn invalidate_blob_result_namespace(&self) -> bool {
704 match self
705 .inner
706 .result_blob_cache
707 .try_invalidate_namespace(RESULT_CACHE_BLOB_NAMESPACE)
708 {
709 Ok(_) => false,
710 Err(error) => {
711 tracing::error!(?error, "failed to persist result-cache invalidation");
712 true
713 }
714 }
715 }
716
717 pub(crate) fn invalidate_result_cache_process_only(&self) {
718 self.invalidate_result_cache_inner();
719 }
720
721 fn invalidate_result_cache_inner(&self) {
722 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
723 self.invalidate_result_cache_during_wal_capture();
724 return;
725 }
726 let store = self.inner.db.store();
727 let _publication = store.vault_publication_lock.lock();
728 let _promotion = self.inner.result_blob_promotion.lock();
729 let mut blob_entries = self.inner.result_blob_entries.write();
730 blob_entries.0.clear();
731 blob_entries.1.clear();
732 blob_entries.4.clear();
733 blob_entries.5 = None;
734 if let Some(epoch) = self.inner.db.store().result_cache_epoch() {
735 blob_entries.6 = epoch;
736 }
737 blob_entries.2 = self.invalidate_blob_result_namespace();
738 blob_entries.3 = blob_entries.3.wrapping_add(1);
739 let mut ask_entries = self.inner.ask_answer_cache_entries.write();
740 ask_entries.0.clear();
741 ask_entries.1.clear();
742 self.inner
743 .result_blob_cache
744 .invalidate_namespace(super::ASK_ANSWER_CACHE_NAMESPACE);
745 }
746
747 pub(crate) fn invalidate_result_cache_for_table(&self, table: &str) {
748 #[cfg(test)]
749 self.index_store_ref().mutation_test_hook(
750 table,
751 super::index_store::MutationTestPhase::BeforeResultCacheInvalidation,
752 );
753 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
754 self.invalidate_result_cache_during_wal_capture();
755 return;
756 }
757 let store = self.inner.db.store();
758 let _publication = store.vault_publication_lock.lock();
759 let _promotion = self.inner.result_blob_promotion.lock();
760 {
761 let mut blob_entries = self.inner.result_blob_entries.write();
762 let previous_epoch = blob_entries.6;
763 if let Some(epoch) = self.inner.db.store().result_cache_epoch() {
764 if previous_epoch.checked_add(1) == Some(epoch) {
765 for entry in blob_entries.0.values_mut() {
766 if entry.data_epoch == previous_epoch && !entry.scopes.contains(table) {
767 entry.data_epoch = epoch;
768 }
769 }
770 } else if previous_epoch != epoch && !blob_entries.0.is_empty() {
771 blob_entries.5 =
774 Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
775 }
776 blob_entries.6 = epoch;
777 }
778 blob_entries.3 = blob_entries.3.wrapping_add(1);
780 let has_match = blob_entries
781 .0
782 .values()
783 .any(|entry| entry.scopes.contains(table));
784 let has_durable_match = blob_entries
785 .0
786 .iter()
787 .any(|(key, entry)| entry.scopes.contains(table) && !blob_entries.4.contains(key));
788 if blob_entries.2 || has_durable_match {
789 blob_entries.0.clear();
790 blob_entries.1.clear();
791 blob_entries.4.clear();
792 blob_entries.2 = self.invalidate_blob_result_namespace();
793 blob_entries.3 = blob_entries.3.wrapping_add(1);
794 } else if has_match {
795 blob_entries
796 .0
797 .retain(|_, entry| !entry.scopes.contains(table));
798 let (ref map, ref mut order, _, ref mut revision, ref mut volatile_keys, ..) =
799 *blob_entries;
800 order.retain(|key| map.contains_key(key));
801 volatile_keys.retain(|key| map.contains_key(key));
802 *revision = revision.wrapping_add(1);
803 }
804 if has_match {
805 blob_entries.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
806 }
807 }
808 let mut ask_entries = self.inner.ask_answer_cache_entries.write();
809 ask_entries.0.clear();
810 ask_entries.1.clear();
811 self.inner
812 .result_blob_cache
813 .invalidate_namespace(super::ASK_ANSWER_CACHE_NAMESPACE);
814 }
815}
816
817#[cfg(test)]
818mod crash_tests {
819 use crate::{RedDBOptions, RedDBRuntime};
820 use std::sync::Arc;
821
822 const CHILD_PATH: &str = "REDDB_RESULT_CACHE_COMMIT_GAP_TEST_PATH";
823 const CHILD_OPERATION: &str = "REDDB_RESULT_CACHE_COMMIT_GAP_TEST_OPERATION";
824
825 #[test]
826 fn payload_without_a_data_epoch_is_not_rehydrated() {
827 assert!(super::decode_result_cache_payload(b"invalid-payload").is_none());
828 }
829
830 #[test]
831 fn result_cache_format_waits_for_wal_capture_to_finish() {
832 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
833 crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
834 let during_capture = runtime.result_cache_snapshot().0;
835 let actions = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
836 assert!(actions.is_empty());
837 assert_eq!(during_capture, None);
838 assert!(!runtime.db().store().result_cache_format_fenced());
839 assert!(runtime.result_cache_snapshot().0.is_some());
840 assert!(runtime.db().store().result_cache_format_fenced());
841 }
842
843 #[test]
844 fn write_and_control_statements_do_not_initialize_result_cache_format() {
845 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
846 for sql in [
847 "CREATE TABLE cache_format_gate (id INT)",
848 "INSERT INTO cache_format_gate (id) VALUES (1)",
849 "BEGIN",
850 "ROLLBACK",
851 ] {
852 runtime.execute_query(sql).expect("non-read statement");
853 assert!(!runtime.db().store().result_cache_format_fenced(), "{sql}");
854 }
855 runtime.execute_query("SCRUB").expect("uncached scrub");
856 assert!(!runtime.db().store().result_cache_format_fenced());
857 runtime
858 .execute_query("SELECT * FROM red.stats WHERE collection = 'cache_format_gate'")
859 .expect("computed stats read");
860 assert!(!runtime.db().store().result_cache_format_fenced());
861 runtime.execute_query("SHOW STATS").expect("show stats");
862 assert!(!runtime.db().store().result_cache_format_fenced());
863 runtime
864 .execute_query("SELECT id FROM cache_format_gate")
865 .expect("read statement");
866 assert!(runtime.db().store().result_cache_format_fenced());
867 }
868
869 #[test]
870 fn uncached_native_read_frame_does_not_initialize_result_cache_format() {
871 use crate::runtime::statement_frame::{StatementExecutionFrame, StatementIdentity};
872
873 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
874 let _frame = StatementExecutionFrame::build(
875 &runtime,
876 StatementIdentity::UncachedText("SEARCH CONTEXT"),
877 )
878 .expect("native read frame");
879 assert!(!runtime.db().store().result_cache_format_fenced());
880 }
881
882 #[test]
883 fn promotion_gate_does_not_block_hits_and_orders_invalidation() {
884 use std::sync::mpsc;
885 use std::time::Duration;
886
887 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
888 for sql in [
889 "CREATE TABLE promotion_gate (id INT)",
890 "INSERT INTO promotion_gate (id) VALUES (1)",
891 "SELECT id FROM promotion_gate ORDER BY id",
892 "INSERT INTO promotion_gate (id) VALUES (2)",
893 "SELECT id FROM promotion_gate ORDER BY id",
894 ] {
895 runtime.execute_query(sql).expect("prepare volatile result");
896 }
897
898 let guard = runtime.inner.result_blob_promotion.lock();
899 let reader = runtime.clone();
900 let (read_tx, read_rx) = mpsc::channel();
901 let reader_thread = std::thread::spawn(move || {
902 let rows = reader
903 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
904 .expect("cached read")
905 .result
906 .records
907 .len();
908 read_tx.send(rows).expect("report read");
909 });
910 let read = read_rx.recv_timeout(Duration::from_secs(3));
911 drop(guard);
912 reader_thread.join().expect("reader thread");
913 assert_eq!(read.expect("hit blocked by promotion gate"), 2);
914
915 let guard = runtime.inner.result_blob_promotion.lock();
916 let invalidator = runtime.clone();
917 let (started_tx, started_rx) = mpsc::channel();
918 let (done_tx, done_rx) = mpsc::channel();
919 let invalidator_thread = std::thread::spawn(move || {
920 started_tx.send(()).expect("report start");
921 invalidator.invalidate_result_cache_for_table("promotion_gate");
922 done_tx.send(()).expect("report invalidation");
923 });
924 let started = started_rx.recv_timeout(Duration::from_secs(3));
925 let completed_early = done_rx.recv_timeout(Duration::from_millis(100));
926 drop(guard);
927 assert!(started.is_ok(), "invalidation did not start");
928 assert!(
929 matches!(completed_early, Err(mpsc::RecvTimeoutError::Timeout)),
930 "invalidation must wait for an in-flight promotion"
931 );
932 done_rx
933 .recv_timeout(Duration::from_secs(3))
934 .expect("invalidation did not finish");
935 invalidator_thread.join().expect("invalidator thread");
936 let before = runtime.result_cache_metrics();
937 assert_eq!(
938 runtime
939 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
940 .expect("read after invalidation")
941 .result
942 .records
943 .len(),
944 2
945 );
946 assert_eq!(
947 runtime.result_cache_metrics().1 - before.1,
948 1,
949 "invalidation must remove the cached result"
950 );
951 }
952
953 #[test]
954 fn durable_cache_publication_and_write_invalidation_do_not_invert_locks() {
955 use std::sync::mpsc;
956 use std::time::{Duration, Instant};
957
958 let directory = tempfile::tempdir().expect("temporary directory");
959 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
960 directory.path().join("cache-lock-order.rdb"),
961 ))
962 .expect("runtime");
963 for sql in [
964 "CREATE TABLE locked_write (id INT)",
965 "CREATE TABLE cache_fill (id INT)",
966 "INSERT INTO locked_write (id) VALUES (1)",
967 "INSERT INTO cache_fill (id) VALUES (1)",
968 ] {
969 runtime.execute_query(sql).expect(sql);
970 }
971 runtime.result_cache_snapshot();
972 std::thread::sleep(super::RESULT_CACHE_WRITE_QUIET_PERIOD + Duration::from_millis(50));
973
974 let (write_paused_tx, write_paused_rx) = mpsc::channel();
975 let (resume_write_tx, resume_write_rx) = mpsc::channel();
976 let writer = runtime.clone();
977 let (writer_done_tx, writer_done_rx) = mpsc::channel();
978 let writer_thread = std::thread::spawn(move || {
979 let store = writer.db().store();
980 let _publication = store.vault_publication_lock.lock();
981 write_paused_tx
982 .send(store.vault_publication_lock.is_owned_by_current_thread())
983 .expect("report paused writer");
984 resume_write_rx
985 .recv_timeout(Duration::from_secs(10))
986 .expect("resume writer");
987 writer.invalidate_result_cache_for_table("locked_write");
988 writer_done_tx.send(()).expect("report writer result");
989 });
990 assert!(
991 write_paused_rx
992 .recv_timeout(Duration::from_secs(5))
993 .expect("writer did not reach invalidation"),
994 "write invalidation must still own the durable publication fence"
995 );
996
997 let cache_filler = runtime.clone();
998 let (cache_done_tx, cache_done_rx) = mpsc::channel();
999 let cache_thread = std::thread::spawn(move || {
1000 let result = cache_filler.execute_query("SELECT id FROM cache_fill ORDER BY id");
1001 cache_done_tx
1002 .send(result)
1003 .expect("report cache publication result");
1004 });
1005
1006 let deadline = Instant::now() + Duration::from_secs(2);
1012 while Instant::now() < deadline {
1013 if runtime.inner.result_blob_promotion.try_lock().is_none() {
1014 break;
1015 }
1016 std::thread::sleep(Duration::from_millis(10));
1017 }
1018 resume_write_tx.send(()).expect("resume writer");
1019
1020 let writer_result = writer_done_rx.recv_timeout(Duration::from_secs(3));
1021 let cache_result = cache_done_rx.recv_timeout(Duration::from_secs(3));
1022 assert!(
1023 writer_result.is_ok() && cache_result.is_ok(),
1024 "durable cache publication deadlocked with write invalidation"
1025 );
1026 writer_result.unwrap();
1027 cache_result.unwrap().expect("cache publication result");
1028 writer_thread.join().expect("writer thread");
1029 cache_thread.join().expect("cache thread");
1030 }
1031
1032 #[test]
1033 fn published_write_cannot_return_an_old_in_memory_result() {
1034 use std::sync::mpsc;
1035 use std::time::Duration;
1036
1037 let directory = tempfile::tempdir().expect("temporary directory");
1038 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1039 directory.path().join("live-gap.rdb"),
1040 ))
1041 .expect("runtime");
1042 for sql in [
1043 "CREATE TABLE live_gap (id INT)",
1044 "INSERT INTO live_gap (id) VALUES (1)",
1045 "SELECT id FROM live_gap ORDER BY id",
1046 ] {
1047 runtime.execute_query(sql).expect("prepare cache");
1048 }
1049 let (published_tx, published_rx) = mpsc::channel();
1050 let (resume_tx, resume_rx) = mpsc::channel();
1051 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1052 *runtime.index_store_ref().mutation_hook.lock() =
1053 Some(Arc::new(move |collection, phase| {
1054 if collection == "live_gap"
1055 && phase == super::super::index_store::MutationTestPhase::StoragePublished
1056 {
1057 published_tx.send(()).expect("report publication");
1058 resume_rx
1059 .lock()
1060 .recv_timeout(Duration::from_secs(10))
1061 .expect("resume writer");
1062 }
1063 }));
1064 let writer = runtime.clone();
1065 let writer_thread = std::thread::spawn(move || {
1066 writer.execute_query("INSERT INTO live_gap (id) VALUES (2)")
1067 });
1068 published_rx
1069 .recv_timeout(Duration::from_secs(10))
1070 .expect("storage publication");
1071 let reader = runtime.clone();
1072 let (read_tx, read_rx) = mpsc::channel();
1073 let reader_thread = std::thread::spawn(move || {
1074 let rows = reader
1075 .execute_query("SELECT id FROM live_gap ORDER BY id")
1076 .expect("read during publication")
1077 .result
1078 .records
1079 .len();
1080 read_tx.send(rows).expect("report rows");
1081 });
1082 let early = read_rx.recv_timeout(Duration::from_secs(2));
1083 resume_tx.send(()).expect("resume writer");
1084 writer_thread.join().expect("writer thread").expect("write");
1085 let rows = match early {
1086 Ok(rows) => rows,
1087 Err(mpsc::RecvTimeoutError::Timeout) => read_rx
1088 .recv_timeout(Duration::from_secs(10))
1089 .expect("reader finished"),
1090 Err(error) => panic!("reader failed: {error}"),
1091 };
1092 reader_thread.join().expect("reader thread");
1093 assert_eq!(
1094 rows, 2,
1095 "old cached result was returned after storage publication"
1096 );
1097 }
1098
1099 #[test]
1100 fn unrelated_pending_write_keeps_a_cached_read_available() {
1101 use std::sync::mpsc;
1102 use std::time::Duration;
1103
1104 let directory = tempfile::tempdir().expect("temporary directory");
1105 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1106 directory.path().join("pending-unrelated.rdb"),
1107 ))
1108 .expect("runtime");
1109 for sql in [
1110 "CREATE TABLE cached_items (id INT)",
1111 "CREATE TABLE unrelated_items (id INT)",
1112 "INSERT INTO cached_items (id) VALUES (1)",
1113 "INSERT INTO unrelated_items (id) VALUES (1)",
1114 "SELECT id FROM cached_items ORDER BY id",
1115 "SELECT id FROM cached_items ORDER BY id",
1116 ] {
1117 runtime.execute_query(sql).expect(sql);
1118 }
1119 let before = runtime.result_cache_metrics();
1120 runtime
1121 .execute_query("SELECT id FROM cached_items ORDER BY id")
1122 .expect("warm hit");
1123 assert_eq!(runtime.result_cache_metrics().0 - before.0, 1);
1124 let (pending_tx, pending_rx) = mpsc::channel();
1125 let (resume_tx, resume_rx) = mpsc::channel();
1126 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1127 runtime
1128 .db()
1129 .store()
1130 .set_result_cache_pending_hook(Some(Arc::new(move || {
1131 pending_tx.send(()).expect("report pending");
1132 resume_rx
1133 .lock()
1134 .recv_timeout(Duration::from_secs(10))
1135 .expect("resume");
1136 })));
1137 let writer = runtime.clone();
1138 let writer_thread = std::thread::spawn(move || {
1139 writer.execute_query("UPDATE unrelated_items SET id = 2 WHERE id = 1")
1140 });
1141 pending_rx
1142 .recv_timeout(Duration::from_secs(10))
1143 .expect("publication pending");
1144 let before = runtime.result_cache_metrics();
1145 let reader = runtime.clone();
1146 let (read_tx, read_rx) = mpsc::channel();
1147 let reader_thread = std::thread::spawn(move || {
1148 read_tx
1149 .send(reader.execute_query("SELECT id FROM cached_items ORDER BY id"))
1150 .unwrap();
1151 });
1152 let result = read_rx.recv_timeout(Duration::from_secs(3));
1153 runtime.db().store().set_result_cache_pending_hook(None);
1154 resume_tx.send(()).expect("resume writer");
1155 writer_thread.join().unwrap().expect("write");
1156 reader_thread.join().unwrap();
1157 assert_eq!(
1158 result
1159 .expect("cached read must not wait")
1160 .expect("read")
1161 .result
1162 .records
1163 .len(),
1164 1
1165 );
1166 assert_eq!(
1167 runtime.result_cache_metrics().0 - before.0,
1168 1,
1169 "unrelated pending write must preserve the hit"
1170 );
1171 }
1172
1173 #[test]
1174 fn committed_insert_before_cache_invalidation_does_not_recover_stale_result() {
1175 if let Some(path) = std::env::var_os(CHILD_PATH) {
1176 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(path))
1177 .expect("open child database");
1178 let operation = std::env::var(CHILD_OPERATION).expect("child operation");
1179 if operation.starts_with("format_crash:") || operation.starts_with("format_seal:") {
1180 let store = runtime.db().store();
1181 store
1182 .insert_auto(
1183 "format_data",
1184 crate::storage::UnifiedEntity::table_row(
1185 crate::storage::EntityId::new(0),
1186 "format_data",
1187 0,
1188 vec![reddb_types::Value::Integer(42)],
1189 ),
1190 )
1191 .expect("seed durable data");
1192 if let Some(point) = operation.strip_prefix("format_crash:") {
1193 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", point);
1194 } else {
1195 let point = operation.strip_prefix("format_seal:").unwrap().to_owned();
1196 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1197 move |_, phase| {
1198 if phase == super::super::index_store::MutationTestPhase::ResultCacheFormatCheckpointed {
1199 if point == "before" {
1200 std::process::exit(86);
1201 }
1202 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &point);
1203 }
1204 },
1205 ));
1206 }
1207 runtime
1208 .execute_query("SELECT * FROM format_data")
1209 .expect("format activation");
1210 panic!("format crash point was not reached");
1211 }
1212 for sql in [
1213 "CREATE TABLE cache_gap (id INT)",
1214 "INSERT INTO cache_gap (id) VALUES (1)",
1215 "SELECT id FROM cache_gap ORDER BY id",
1216 ] {
1217 runtime.execute_query(sql).expect("prepare cache");
1218 }
1219 if operation == "format_fence" {
1220 assert!(runtime.db().store().result_cache_format_fenced());
1221 std::process::exit(86);
1222 }
1223 if operation == "transaction" {
1224 let (key, mut old_entry) = {
1225 let cache = runtime.inner.result_blob_entries.read();
1226 let (key, entry) = cache.0.iter().next().expect("warm original result");
1227 (key.clone(), entry.clone())
1228 };
1229 super::super::execution_context::set_current_connection_id(901);
1230 runtime.execute_query("BEGIN").expect("begin transaction");
1231 runtime
1232 .execute_query("INSERT INTO cache_gap (id) VALUES (2)")
1233 .expect("stage insert");
1234 let reader = runtime.clone();
1235 std::thread::spawn(move || {
1236 super::super::execution_context::set_current_connection_id(902);
1237 let read = reader
1238 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1239 .expect("cache committed view");
1240 assert_eq!(
1241 read.result.records.len(),
1242 1,
1243 "uncommitted row must not be visible"
1244 );
1245 old_entry.data_epoch = reader.db().store().result_cache_epoch().expect("epoch");
1248 let payload =
1249 super::encode_result_cache_payload(&old_entry).expect("encode old result");
1250 reader
1251 .inner
1252 .result_blob_cache
1253 .put(
1254 super::RESULT_CACHE_BLOB_NAMESPACE,
1255 &key,
1256 crate::storage::cache::BlobCachePut::new(payload),
1257 )
1258 .expect("persist old result");
1259 reader.checkpoint().expect("persist cached result");
1260 let hit = reader
1261 .inner
1262 .result_blob_cache
1263 .get(super::RESULT_CACHE_BLOB_NAMESPACE, &key)
1264 .expect("old entry remains");
1265 assert!(super::decode_result_cache_payload(hit.value()).is_some());
1266 })
1267 .join()
1268 .expect("pre-commit reader");
1269 } else {
1270 runtime.checkpoint().expect("persist cached result");
1271 }
1272 let transaction = operation == "transaction";
1273 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1274 move |collection, phase| {
1275 if (transaction && phase == super::super::index_store::MutationTestPhase::TransactionWalCommitted)
1276 || (!transaction && collection == "cache_gap"
1277 && phase == super::super::index_store::MutationTestPhase::BeforeResultCacheInvalidation)
1278 {
1279 std::process::exit(86);
1280 }
1281 },
1282 ));
1283 if operation.starts_with("wal_") {
1284 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &operation);
1285 }
1286 let sql = match operation.as_str() {
1287 "insert"
1288 | "wal_after_frame_write"
1289 | "wal_after_frame_sync"
1290 | "wal_after_superblock_write" => "INSERT INTO cache_gap (id) VALUES (2)",
1291 "update" => "UPDATE cache_gap SET id = 2 WHERE id = 1",
1292 "delete" => "DELETE FROM cache_gap WHERE id = 1",
1293 "transaction" => "COMMIT",
1294 _ => panic!("unknown operation"),
1295 };
1296 runtime
1297 .execute_query(sql)
1298 .expect("hook must terminate before return");
1299 panic!("storage-published hook was not reached");
1300 }
1301
1302 let dir = tempfile::tempdir().expect("temporary directory");
1303 for (operation, expected) in [
1304 ("insert", Some(vec![1, 2])),
1305 ("update", Some(vec![2])),
1306 ("delete", Some(vec![])),
1307 ("transaction", Some(vec![1, 2])),
1308 ("wal_after_frame_write", None),
1309 ("wal_after_frame_sync", None),
1310 ("wal_after_superblock_write", None),
1311 ] {
1312 let path = dir.path().join(format!("cache-gap-{operation}.rdb"));
1313 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
1314 .args([
1315 "--exact",
1316 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1317 "--nocapture",
1318 ])
1319 .env(CHILD_PATH, &path)
1320 .env(CHILD_OPERATION, operation)
1321 .output()
1322 .expect("run child");
1323 assert_eq!(
1324 output.status.code(),
1325 Some(if operation.starts_with("wal_") {
1326 173
1327 } else {
1328 86
1329 }),
1330 "{operation} child did not reach crash hook: {}",
1331 String::from_utf8_lossy(&output.stderr)
1332 );
1333 let runtime =
1334 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1335 let before = runtime.result_cache_metrics();
1336 let cached = runtime
1337 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1338 .expect("cached read");
1339 let after = runtime.result_cache_metrics();
1340 let values = |result: &crate::runtime::RuntimeQueryResult| {
1341 result
1342 .result
1343 .records
1344 .iter()
1345 .map(|record| match record.get("id") {
1346 Some(reddb_types::Value::Integer(value)) => *value,
1347 other => panic!("expected id, got {other:?}"),
1348 })
1349 .collect::<Vec<_>>()
1350 };
1351 runtime
1352 .execute_query("SET CONFIG runtime.result_cache.enabled = false")
1353 .expect("disable cache");
1354 let actual = runtime
1355 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1356 .expect("authoritative read");
1357 let actual = values(&actual);
1358 if let Some(expected) = expected {
1359 assert_eq!(actual, expected, "{operation} committed data must survive");
1360 } else {
1361 assert!(
1362 actual == vec![1] || actual == vec![1, 2],
1363 "{operation} recovered a partial write"
1364 );
1365 }
1366 assert_eq!(
1367 values(&cached),
1368 actual,
1369 "{operation} recovered a stale result"
1370 );
1371 if actual != vec![1] {
1372 assert_eq!(
1373 after.1 - before.1,
1374 1,
1375 "{operation} must reject the old L2 entry"
1376 );
1377 }
1378 }
1379 }
1380
1381 #[test]
1382 fn result_cache_format_fences_both_embedded_superblocks() {
1383 use std::io::{Seek, SeekFrom, Write};
1384 let directory = tempfile::tempdir().expect("temporary directory");
1385 let path = directory.path().join("format-fence.rdb");
1386 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1387 .args([
1388 "--exact",
1389 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1390 "--nocapture",
1391 ])
1392 .env(CHILD_PATH, &path)
1393 .env(CHILD_OPERATION, "format_fence")
1394 .status()
1395 .expect("run child");
1396 assert_eq!(status.code(), Some(86));
1397 for index in 0..2 {
1398 let copy = directory.path().join(format!("format-fence-{index}.rdb"));
1399 std::fs::copy(&path, ©).expect("copy database");
1400 let mut file = std::fs::OpenOptions::new().write(true).open(©).unwrap();
1401 file.seek(SeekFrom::Start(
1402 index * reddb_file::EMBEDDED_RDB_SUPERBLOCK_SIZE + 64,
1403 ))
1404 .unwrap();
1405 file.write_all(&[0xff]).unwrap();
1406 file.sync_all().unwrap();
1407 drop(file);
1408 let open = crate::storage::EmbeddedRdbArtifact::open(©).expect("other superblock");
1409 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1410 .unwrap()
1411 .unwrap();
1412 assert_eq!(
1413 reddb_file::decode_native_store_header(&snapshot).unwrap(),
1414 reddb_file::STORE_VERSION_V13
1415 );
1416 }
1417 }
1418
1419 #[test]
1420 fn result_cache_format_fence_and_warm_result_survive_restart() {
1421 let directory = tempfile::tempdir().expect("temporary directory");
1422 let path = directory.path().join("warm-fenced.rdb");
1423 {
1424 let runtime =
1425 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("runtime");
1426 for sql in [
1427 "CREATE TABLE warm_fence (id INT)",
1428 "INSERT INTO warm_fence (id) VALUES (1)",
1429 "SELECT id FROM warm_fence ORDER BY id",
1430 ] {
1431 runtime.execute_query(sql).expect(sql);
1432 }
1433 assert!(runtime.db().store().result_cache_format_fenced());
1434 }
1435 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1436 assert!(
1437 runtime.db().store().result_cache_format_fenced(),
1438 "durable fence must load before the first query"
1439 );
1440 let before = runtime.result_cache_metrics();
1441 assert_eq!(
1442 runtime
1443 .execute_query("SELECT id FROM warm_fence ORDER BY id")
1444 .expect("read")
1445 .result
1446 .records
1447 .len(),
1448 1
1449 );
1450 assert_eq!(
1451 runtime.result_cache_metrics().0 - before.0,
1452 1,
1453 "valid result must recover warm"
1454 );
1455 }
1456
1457 #[test]
1458 fn result_cache_format_checkpoint_survives_interrupted_publication() {
1459 let directory = tempfile::tempdir().expect("temporary directory");
1460 for (operation, expected_status) in [
1461 ("format_crash:snapshot_after_image_write", 173),
1462 ("format_crash:snapshot_after_image_sync", 173),
1463 ("format_crash:snapshot_after_manifest_write", 173),
1464 ("format_crash:snapshot_after_manifest_sync", 173),
1465 ("format_crash:snapshot_after_superblock_write", 173),
1466 ("format_seal:before", 86),
1467 ("format_seal:wal_after_frame_write", 173),
1468 ("format_seal:wal_after_frame_sync", 173),
1469 ("format_seal:wal_after_superblock_write", 173),
1470 ] {
1471 let path = directory
1472 .path()
1473 .join(format!("{}.rdb", operation.replace(':', "-")));
1474 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1475 .args([
1476 "--exact",
1477 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1478 "--nocapture",
1479 ])
1480 .env(CHILD_PATH, &path)
1481 .env(CHILD_OPERATION, operation)
1482 .status().expect("run child");
1483 assert_eq!(status.code(), Some(expected_status), "crash at {operation}");
1484 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path))
1485 .expect("recover database");
1486 assert_eq!(
1487 runtime
1488 .db()
1489 .store()
1490 .get_collection("format_data")
1491 .expect("recovered table")
1492 .count(),
1493 1
1494 );
1495 runtime
1496 .execute_query("SELECT * FROM format_data")
1497 .expect("activate format after recovery");
1498 assert!(runtime.db().store().result_cache_format_fenced());
1499 }
1500 }
1501}