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 required_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 let version = pager.header().ok()?.version.max(required_version);
375 pager.fence_file_format_version(version).ok()?;
376 } else {
377 self.checkpoint_local().ok()?;
378 #[cfg(test)]
379 self.index_store_ref().mutation_test_hook(
380 "",
381 super::index_store::MutationTestPhase::ResultCacheFormatCheckpointed,
382 );
383 }
384 }
385 store.seal_result_cache_snapshot().ok()?;
386 store.result_cache_read_epoch()
387 }
388
389 pub(super) fn result_cache_snapshot(&self) -> (Option<u64>, u64) {
390 let epoch = if self.result_cache_enabled() {
391 let store = self.inner.db.store();
392 if store.result_cache_format_fenced() {
393 store.result_cache_read_epoch()
394 } else {
395 self.ensure_result_cache_format()
396 }
397 } else {
398 Some(0)
399 };
400 let cache = self.inner.result_blob_entries.read();
401 if cache.6 != 0 || !cache.0.is_empty() || epoch.unwrap_or(0) == 0 {
402 return (epoch, cache.3);
403 }
404 drop(cache);
405 let mut cache = self.inner.result_blob_entries.write();
406 if cache.6 == 0 && cache.0.is_empty() {
407 cache.6 = epoch.unwrap_or(0);
408 }
409 (epoch, cache.3)
410 }
411
412 pub fn result_cache_metrics(&self) -> (u64, u64, u64) {
413 use std::sync::atomic::Ordering::Relaxed;
414 (
415 self.inner.result_cache_hits.load(Relaxed),
416 self.inner.result_cache_misses.load(Relaxed),
417 self.inner.result_cache_evictions.load(Relaxed),
418 )
419 }
420
421 fn record_result_cache_evictions(&self, evicted: u64) {
422 if evicted > 0 {
423 self.inner
424 .result_cache_evictions
425 .fetch_add(evicted, std::sync::atomic::Ordering::Relaxed);
426 }
427 }
428
429 pub(super) fn get_result_cache_entry(
430 &self,
431 key: &str,
432 epoch: Option<u64>,
433 ) -> Option<RuntimeQueryResult> {
434 if !self.result_cache_enabled() {
435 return None;
436 }
437 let hit = self.get_result_cache_entry_inner(key, epoch);
438 let counter = if hit.is_some() {
439 &self.inner.result_cache_hits
440 } else {
441 &self.inner.result_cache_misses
442 };
443 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
444 hit
445 }
446
447 fn get_result_cache_entry_inner(
448 &self,
449 key: &str,
450 epoch: Option<u64>,
451 ) -> Option<RuntimeQueryResult> {
452 epoch.and_then(|epoch| self.get_blob_result_cache_entry(key, epoch))
453 }
454
455 fn result_cache_entry_reusable(&self, entry: &RuntimeResultCacheEntry) -> bool {
456 let store = self.inner.db.store();
457 let runtime_table_entry = matches!(
458 entry.result.engine,
459 "runtime-table" | "runtime-table-rls" | "runtime-join" | "runtime-join-rls"
460 ) && !entry.scopes.is_empty();
461 if runtime_table_entry
462 && entry
463 .scopes
464 .iter()
465 .any(|scope| !self.inner.index_store.collection_indexes_queryable(scope))
466 {
467 return false;
468 }
469 if !runtime_table_entry
470 || entry
471 .scopes
472 .iter()
473 .any(|scope| store.get_collection(scope).is_none())
474 {
475 return store.result_cache_epoch() == Some(entry.data_epoch);
476 }
477 store.result_cache_unchanged_since(entry.data_epoch, &entry.scopes)
478 }
479
480 fn get_blob_result_cache_entry(&self, key: &str, epoch: u64) -> Option<RuntimeQueryResult> {
481 self.get_blob_result_cache_entry_from_blob_cache(key, epoch)
482 }
483
484 fn get_blob_result_cache_entry_from_blob_cache(
485 &self,
486 key: &str,
487 epoch: u64,
488 ) -> Option<RuntimeQueryResult> {
489 {
490 let cache = self.inner.result_blob_entries.read();
491 if cache.4.contains(key) {
492 let entry = cache.0.get(key)?;
493 return (self.result_cache_entry_reusable(entry)
494 && entry.cached_at.elapsed().as_secs() < self.result_cache_ttl_secs())
495 .then(|| entry.result.clone());
496 }
497 }
498 let revision = self.inner.result_blob_entries.read().3;
503 let hit = self
504 .inner
505 .result_blob_cache
506 .get(RESULT_CACHE_BLOB_NAMESPACE, key)?;
507 {
508 let cache = self.inner.result_blob_entries.read();
509 if cache.3 != revision {
510 return None;
511 }
512 if let Some(entry) = cache.0.get(key) {
513 return self
514 .result_cache_entry_reusable(entry)
515 .then(|| entry.result.clone());
516 }
517 }
518
519 let (result, scopes, data_epoch) = decode_result_cache_payload(hit.value())?;
520 if data_epoch != epoch || self.inner.db.store().result_cache_epoch() != Some(epoch) {
521 return None;
522 }
523 let new_entry = RuntimeResultCacheEntry {
524 result: result.clone(),
525 cached_at: std::time::Instant::now(),
526 scopes,
527 data_epoch,
528 };
529 if !self.result_cache_entry_reusable(&new_entry) {
533 return None;
534 }
535 let mut cache = self.inner.result_blob_entries.write();
536 if cache.3 != revision {
537 return None;
538 }
539 let (ref mut map, ref mut order, ref mut untracked, ..) = *cache;
540 match map.entry(key.to_string()) {
542 Entry::Occupied(mut slot) => {
543 slot.insert(new_entry);
544 }
545 Entry::Vacant(slot) => {
546 order.push_back(slot.key().clone());
547 slot.insert(new_entry);
548 }
549 }
550 let evicted = trim_result_cache(map, order, self.result_cache_capacity());
551 *untracked |= evicted > 0;
552 if evicted > 0 {
553 let (ref map, _, _, _, ref mut volatile_keys, ..) = *cache;
554 volatile_keys.retain(|key| map.contains_key(key));
555 }
556 drop(cache);
557 self.record_result_cache_evictions(evicted);
558 Some(result)
559 }
560
561 pub(super) fn promote_volatile_result_cache_entry(&self) {
565 let store = self.inner.db.store();
566 let Some(_publication) = store.vault_publication_lock.try_lock() else {
567 return;
568 };
569 let Some(_promotion) = self.inner.result_blob_promotion.try_lock() else {
570 return;
571 };
572 let ttl = self.result_cache_ttl_secs();
573 let Some(epoch) = store.result_cache_epoch() else {
574 return;
575 };
576 let candidate = {
577 let cache = self.inner.result_blob_entries.read();
578 if cache
579 .5
580 .is_none_or(|until| std::time::Instant::now() < until)
581 {
582 return;
583 }
584 cache.4.iter().find_map(|key| {
585 let entry = cache.0.get(key)?;
586 (entry.data_epoch == epoch && entry.cached_at.elapsed().as_secs() < ttl)
587 .then(|| (key.clone(), entry.clone()))
588 })
589 };
590 let Some((key, entry)) = candidate else {
591 return;
592 };
593 let remaining =
594 std::time::Duration::from_secs(ttl).saturating_sub(entry.cached_at.elapsed());
595 if remaining.is_zero() {
596 return;
597 }
598 let ttl_ms = u64::try_from(remaining.as_millis())
599 .unwrap_or(u64::MAX)
600 .max(1);
601 let policy = crate::storage::cache::BlobCachePolicy::default()
602 .ttl_ms(ttl_ms)
603 .priority(200);
604 let bytes = encode_result_cache_payload(&entry)
605 .unwrap_or_else(|| result_cache_fingerprint(&entry.result).into_bytes());
606 let put = crate::storage::cache::BlobCachePut::new(bytes)
607 .with_dependencies(entry.scopes.iter().cloned())
608 .with_policy(policy);
609 let result = self
610 .inner
611 .result_blob_cache
612 .put(RESULT_CACHE_BLOB_NAMESPACE, &key, put);
613 let mut cache = self.inner.result_blob_entries.write();
614 if result.is_ok()
615 && cache.4.contains(&key)
616 && cache
617 .0
618 .get(&key)
619 .is_some_and(|current| current.cached_at == entry.cached_at)
620 {
621 cache.4.remove(&key);
622 } else {
623 cache.2 = true;
625 if result.is_err() {
626 cache.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
627 }
628 }
629 cache.3 = cache.3.wrapping_add(1);
630 }
631
632 pub(super) fn put_result_cache_entry(
633 &self,
634 key: &str,
635 entry: RuntimeResultCacheEntry,
636 epoch: Option<u64>,
637 revision: u64,
638 ) {
639 if !self.result_cache_enabled() {
640 return;
641 }
642 if epoch.is_some() {
643 self.put_blob_result_cache_entry(key, entry, revision);
644 }
645 }
646
647 fn put_blob_result_cache_entry(
648 &self,
649 key: &str,
650 entry: RuntimeResultCacheEntry,
651 revision: u64,
652 ) {
653 let policy = crate::storage::cache::BlobCachePolicy::default()
654 .ttl_ms(self.result_cache_ttl_secs() * 1000)
655 .priority(200);
656 let dependencies = entry.scopes.iter().cloned().collect::<Vec<_>>();
657 let bytes = encode_result_cache_payload(&entry)
658 .unwrap_or_else(|| result_cache_fingerprint(&entry.result).into_bytes());
659 let put = crate::storage::cache::BlobCachePut::new(bytes)
660 .with_dependencies(dependencies)
661 .with_policy(policy);
662 let store = self.inner.db.store();
663 let _publication = store.vault_publication_lock.lock();
664 let _promotion = self.inner.result_blob_promotion.lock();
665 let mut cache = self.inner.result_blob_entries.write();
666 if cache.3 != revision
667 || self.inner.db.store().result_cache_epoch() != Some(entry.data_epoch)
668 {
669 return;
670 }
671 let defer_l2 = self.inner.db.path().is_some() && store.pager().is_none();
672 if defer_l2 && cache.5.is_none() {
673 cache.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
674 }
675 let volatile = defer_l2
676 || cache
677 .5
678 .is_some_and(|until| std::time::Instant::now() < until);
679 if !volatile
680 && self
681 .inner
682 .result_blob_cache
683 .put(RESULT_CACHE_BLOB_NAMESPACE, key, put)
684 .is_err()
685 {
686 cache.2 = true;
688 cache.3 = cache.3.wrapping_add(1);
689 return;
690 }
691
692 if volatile {
693 cache.4.insert(key.to_string());
694 } else {
695 cache.4.remove(key);
696 }
697 let capacity = self.result_cache_capacity();
698 let (ref mut map, ref mut order, ref mut untracked, ref mut revision, ..) = *cache;
699 match map.entry(key.to_string()) {
701 Entry::Occupied(mut slot) => {
702 slot.insert(entry);
703 }
704 Entry::Vacant(slot) => {
705 order.push_back(slot.key().clone());
706 slot.insert(entry);
707 }
708 }
709 let evicted = trim_result_cache(map, order, capacity);
710 *untracked |= evicted > 0;
711 *revision = revision.wrapping_add(1);
712 if evicted > 0 {
713 let (ref map, _, _, _, ref mut volatile_keys, ..) = *cache;
714 volatile_keys.retain(|key| map.contains_key(key));
715 }
716 drop(cache);
717 self.record_result_cache_evictions(evicted);
718 }
719
720 pub fn invalidate_result_cache(&self) {
721 self.invalidate_result_cache_inner();
722 }
723
724 fn invalidate_blob_result_namespace(&self) -> bool {
725 match self
726 .inner
727 .result_blob_cache
728 .try_invalidate_namespace(RESULT_CACHE_BLOB_NAMESPACE)
729 {
730 Ok(_) => false,
731 Err(error) => {
732 tracing::error!(?error, "failed to persist result-cache invalidation");
733 true
734 }
735 }
736 }
737
738 pub(crate) fn invalidate_result_cache_process_only(&self) {
739 self.invalidate_result_cache_inner();
740 }
741
742 fn invalidate_result_cache_inner(&self) {
743 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
744 self.invalidate_result_cache_during_wal_capture();
745 return;
746 }
747 let store = self.inner.db.store();
748 let _publication = store.vault_publication_lock.lock();
749 let _promotion = self.inner.result_blob_promotion.lock();
750 let mut blob_entries = self.inner.result_blob_entries.write();
751 blob_entries.0.clear();
752 blob_entries.1.clear();
753 blob_entries.4.clear();
754 blob_entries.5 = None;
755 if let Some(epoch) = self.inner.db.store().result_cache_epoch() {
756 blob_entries.6 = epoch;
757 }
758 blob_entries.2 = self.invalidate_blob_result_namespace();
759 blob_entries.3 = blob_entries.3.wrapping_add(1);
760 let mut ask_entries = self.inner.ask_answer_cache_entries.write();
761 ask_entries.0.clear();
762 ask_entries.1.clear();
763 self.inner
764 .result_blob_cache
765 .invalidate_namespace(super::ASK_ANSWER_CACHE_NAMESPACE);
766 }
767
768 pub(crate) fn invalidate_result_cache_for_table(&self, table: &str) {
769 #[cfg(test)]
770 self.index_store_ref().mutation_test_hook(
771 table,
772 super::index_store::MutationTestPhase::BeforeResultCacheInvalidation,
773 );
774 if crate::storage::UnifiedStore::deferred_store_wal_capture_active() {
775 self.invalidate_result_cache_during_wal_capture();
776 return;
777 }
778 let store = self.inner.db.store();
779 let _publication = store.vault_publication_lock.lock();
780 let _promotion = self.inner.result_blob_promotion.lock();
781 {
782 let mut blob_entries = self.inner.result_blob_entries.write();
783 let previous_epoch = blob_entries.6;
784 if let Some(epoch) = self.inner.db.store().result_cache_epoch() {
785 if previous_epoch.checked_add(1) == Some(epoch) {
786 for entry in blob_entries.0.values_mut() {
787 if entry.data_epoch == previous_epoch && !entry.scopes.contains(table) {
788 entry.data_epoch = epoch;
789 }
790 }
791 } else if previous_epoch != epoch && !blob_entries.0.is_empty() {
792 blob_entries.5 =
795 Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
796 }
797 blob_entries.6 = epoch;
798 }
799 blob_entries.3 = blob_entries.3.wrapping_add(1);
801 let has_match = blob_entries
802 .0
803 .values()
804 .any(|entry| entry.scopes.contains(table));
805 let has_durable_match = blob_entries
806 .0
807 .iter()
808 .any(|(key, entry)| entry.scopes.contains(table) && !blob_entries.4.contains(key));
809 if blob_entries.2 || has_durable_match {
810 blob_entries.0.clear();
811 blob_entries.1.clear();
812 blob_entries.4.clear();
813 blob_entries.2 = self.invalidate_blob_result_namespace();
814 blob_entries.3 = blob_entries.3.wrapping_add(1);
815 } else if has_match {
816 blob_entries
817 .0
818 .retain(|_, entry| !entry.scopes.contains(table));
819 let (ref map, ref mut order, _, ref mut revision, ref mut volatile_keys, ..) =
820 *blob_entries;
821 order.retain(|key| map.contains_key(key));
822 volatile_keys.retain(|key| map.contains_key(key));
823 *revision = revision.wrapping_add(1);
824 }
825 if has_match {
826 blob_entries.5 = Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
827 }
828 }
829 let mut ask_entries = self.inner.ask_answer_cache_entries.write();
830 ask_entries.0.clear();
831 ask_entries.1.clear();
832 self.inner
833 .result_blob_cache
834 .invalidate_namespace(super::ASK_ANSWER_CACHE_NAMESPACE);
835 }
836}
837
838#[cfg(test)]
839mod crash_tests {
840 use crate::{RedDBOptions, RedDBRuntime};
841 use std::sync::Arc;
842
843 const CHILD_PATH: &str = "REDDB_RESULT_CACHE_COMMIT_GAP_TEST_PATH";
844 const CHILD_OPERATION: &str = "REDDB_RESULT_CACHE_COMMIT_GAP_TEST_OPERATION";
845
846 #[test]
847 fn payload_without_a_data_epoch_is_not_rehydrated() {
848 assert!(super::decode_result_cache_payload(b"invalid-payload").is_none());
849 }
850
851 #[test]
852 fn result_cache_format_waits_for_wal_capture_to_finish() {
853 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
854 crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
855 let during_capture = runtime.result_cache_snapshot().0;
856 let actions = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
857 assert!(actions.is_empty());
858 assert_eq!(during_capture, None);
859 assert!(!runtime.db().store().result_cache_format_fenced());
860 assert!(runtime.result_cache_snapshot().0.is_some());
861 assert!(runtime.db().store().result_cache_format_fenced());
862 }
863
864 #[test]
865 fn write_and_control_statements_do_not_initialize_result_cache_format() {
866 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
867 for sql in [
868 "CREATE TABLE cache_format_gate (id INT)",
869 "INSERT INTO cache_format_gate (id) VALUES (1)",
870 "BEGIN",
871 "ROLLBACK",
872 ] {
873 runtime.execute_query(sql).expect("non-read statement");
874 assert!(!runtime.db().store().result_cache_format_fenced(), "{sql}");
875 }
876 runtime.execute_query("SCRUB").expect("uncached scrub");
877 assert!(!runtime.db().store().result_cache_format_fenced());
878 runtime
879 .execute_query("SELECT * FROM red.stats WHERE collection = 'cache_format_gate'")
880 .expect("computed stats read");
881 assert!(!runtime.db().store().result_cache_format_fenced());
882 runtime.execute_query("SHOW STATS").expect("show stats");
883 assert!(!runtime.db().store().result_cache_format_fenced());
884 runtime
885 .execute_query("SELECT id FROM cache_format_gate")
886 .expect("read statement");
887 assert!(runtime.db().store().result_cache_format_fenced());
888 }
889
890 #[test]
891 fn uncached_native_read_frame_does_not_initialize_result_cache_format() {
892 use crate::runtime::statement_frame::{StatementExecutionFrame, StatementIdentity};
893
894 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
895 let _frame = StatementExecutionFrame::build(
896 &runtime,
897 StatementIdentity::UncachedText("SEARCH CONTEXT"),
898 )
899 .expect("native read frame");
900 assert!(!runtime.db().store().result_cache_format_fenced());
901 }
902
903 #[test]
904 fn promotion_gate_does_not_block_hits_and_orders_invalidation() {
905 use std::sync::mpsc;
906 use std::time::Duration;
907
908 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
909 for sql in [
910 "CREATE TABLE promotion_gate (id INT)",
911 "INSERT INTO promotion_gate (id) VALUES (1)",
912 "SELECT id FROM promotion_gate ORDER BY id",
913 "INSERT INTO promotion_gate (id) VALUES (2)",
914 "SELECT id FROM promotion_gate ORDER BY id",
915 ] {
916 runtime.execute_query(sql).expect("prepare volatile result");
917 }
918
919 let guard = runtime.inner.result_blob_promotion.lock();
920 let reader = runtime.clone();
921 let (read_tx, read_rx) = mpsc::channel();
922 let reader_thread = std::thread::spawn(move || {
923 let rows = reader
924 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
925 .expect("cached read")
926 .result
927 .records
928 .len();
929 read_tx.send(rows).expect("report read");
930 });
931 let read = read_rx.recv_timeout(Duration::from_secs(3));
932 drop(guard);
933 reader_thread.join().expect("reader thread");
934 assert_eq!(read.expect("hit blocked by promotion gate"), 2);
935
936 let guard = runtime.inner.result_blob_promotion.lock();
937 let invalidator = runtime.clone();
938 let (started_tx, started_rx) = mpsc::channel();
939 let (done_tx, done_rx) = mpsc::channel();
940 let invalidator_thread = std::thread::spawn(move || {
941 started_tx.send(()).expect("report start");
942 invalidator.invalidate_result_cache_for_table("promotion_gate");
943 done_tx.send(()).expect("report invalidation");
944 });
945 let started = started_rx.recv_timeout(Duration::from_secs(3));
946 let completed_early = done_rx.recv_timeout(Duration::from_millis(100));
947 drop(guard);
948 assert!(started.is_ok(), "invalidation did not start");
949 assert!(
950 matches!(completed_early, Err(mpsc::RecvTimeoutError::Timeout)),
951 "invalidation must wait for an in-flight promotion"
952 );
953 done_rx
954 .recv_timeout(Duration::from_secs(3))
955 .expect("invalidation did not finish");
956 invalidator_thread.join().expect("invalidator thread");
957 let before = runtime.result_cache_metrics();
958 assert_eq!(
959 runtime
960 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
961 .expect("read after invalidation")
962 .result
963 .records
964 .len(),
965 2
966 );
967 assert_eq!(
968 runtime.result_cache_metrics().1 - before.1,
969 1,
970 "invalidation must remove the cached result"
971 );
972 }
973
974 #[test]
975 fn durable_cache_publication_and_write_invalidation_do_not_invert_locks() {
976 use std::sync::mpsc;
977 use std::time::{Duration, Instant};
978
979 let directory = tempfile::tempdir().expect("temporary directory");
980 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
981 directory.path().join("cache-lock-order.rdb"),
982 ))
983 .expect("runtime");
984 for sql in [
985 "CREATE TABLE locked_write (id INT)",
986 "CREATE TABLE cache_fill (id INT)",
987 "INSERT INTO locked_write (id) VALUES (1)",
988 "INSERT INTO cache_fill (id) VALUES (1)",
989 ] {
990 runtime.execute_query(sql).expect(sql);
991 }
992 runtime.result_cache_snapshot();
993 std::thread::sleep(super::RESULT_CACHE_WRITE_QUIET_PERIOD + Duration::from_millis(50));
994
995 let (write_paused_tx, write_paused_rx) = mpsc::channel();
996 let (resume_write_tx, resume_write_rx) = mpsc::channel();
997 let writer = runtime.clone();
998 let (writer_done_tx, writer_done_rx) = mpsc::channel();
999 let writer_thread = std::thread::spawn(move || {
1000 let store = writer.db().store();
1001 let _publication = store.vault_publication_lock.lock();
1002 write_paused_tx
1003 .send(store.vault_publication_lock.is_owned_by_current_thread())
1004 .expect("report paused writer");
1005 resume_write_rx
1006 .recv_timeout(Duration::from_secs(10))
1007 .expect("resume writer");
1008 writer.invalidate_result_cache_for_table("locked_write");
1009 writer_done_tx.send(()).expect("report writer result");
1010 });
1011 assert!(
1012 write_paused_rx
1013 .recv_timeout(Duration::from_secs(5))
1014 .expect("writer did not reach invalidation"),
1015 "write invalidation must still own the durable publication fence"
1016 );
1017
1018 let cache_filler = runtime.clone();
1019 let (cache_done_tx, cache_done_rx) = mpsc::channel();
1020 let cache_thread = std::thread::spawn(move || {
1021 let result = cache_filler.execute_query("SELECT id FROM cache_fill ORDER BY id");
1022 cache_done_tx
1023 .send(result)
1024 .expect("report cache publication result");
1025 });
1026
1027 let deadline = Instant::now() + Duration::from_secs(2);
1033 while Instant::now() < deadline {
1034 if runtime.inner.result_blob_promotion.try_lock().is_none() {
1035 break;
1036 }
1037 std::thread::sleep(Duration::from_millis(10));
1038 }
1039 resume_write_tx.send(()).expect("resume writer");
1040
1041 let writer_result = writer_done_rx.recv_timeout(Duration::from_secs(3));
1042 let cache_result = cache_done_rx.recv_timeout(Duration::from_secs(3));
1043 assert!(
1044 writer_result.is_ok() && cache_result.is_ok(),
1045 "durable cache publication deadlocked with write invalidation"
1046 );
1047 writer_result.unwrap();
1048 cache_result.unwrap().expect("cache publication result");
1049 writer_thread.join().expect("writer thread");
1050 cache_thread.join().expect("cache thread");
1051 }
1052
1053 #[test]
1054 fn published_write_cannot_return_an_old_in_memory_result() {
1055 use std::sync::mpsc;
1056 use std::time::Duration;
1057
1058 let directory = tempfile::tempdir().expect("temporary directory");
1059 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1060 directory.path().join("live-gap.rdb"),
1061 ))
1062 .expect("runtime");
1063 for sql in [
1064 "CREATE TABLE live_gap (id INT)",
1065 "INSERT INTO live_gap (id) VALUES (1)",
1066 "SELECT id FROM live_gap ORDER BY id",
1067 ] {
1068 runtime.execute_query(sql).expect("prepare cache");
1069 }
1070 let (published_tx, published_rx) = mpsc::channel();
1071 let (resume_tx, resume_rx) = mpsc::channel();
1072 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1073 *runtime.index_store_ref().mutation_hook.lock() =
1074 Some(Arc::new(move |collection, phase| {
1075 if collection == "live_gap"
1076 && phase == super::super::index_store::MutationTestPhase::StoragePublished
1077 {
1078 published_tx.send(()).expect("report publication");
1079 resume_rx
1080 .lock()
1081 .recv_timeout(Duration::from_secs(10))
1082 .expect("resume writer");
1083 }
1084 }));
1085 let writer = runtime.clone();
1086 let writer_thread = std::thread::spawn(move || {
1087 writer.execute_query("INSERT INTO live_gap (id) VALUES (2)")
1088 });
1089 published_rx
1090 .recv_timeout(Duration::from_secs(10))
1091 .expect("storage publication");
1092 let reader = runtime.clone();
1093 let (read_tx, read_rx) = mpsc::channel();
1094 let reader_thread = std::thread::spawn(move || {
1095 let rows = reader
1096 .execute_query("SELECT id FROM live_gap ORDER BY id")
1097 .expect("read during publication")
1098 .result
1099 .records
1100 .len();
1101 read_tx.send(rows).expect("report rows");
1102 });
1103 let early = read_rx.recv_timeout(Duration::from_secs(2));
1104 resume_tx.send(()).expect("resume writer");
1105 writer_thread.join().expect("writer thread").expect("write");
1106 let rows = match early {
1107 Ok(rows) => rows,
1108 Err(mpsc::RecvTimeoutError::Timeout) => read_rx
1109 .recv_timeout(Duration::from_secs(10))
1110 .expect("reader finished"),
1111 Err(error) => panic!("reader failed: {error}"),
1112 };
1113 reader_thread.join().expect("reader thread");
1114 assert_eq!(
1115 rows, 2,
1116 "old cached result was returned after storage publication"
1117 );
1118 }
1119
1120 #[test]
1121 fn unrelated_pending_write_keeps_a_cached_read_available() {
1122 use std::sync::mpsc;
1123 use std::time::Duration;
1124
1125 let directory = tempfile::tempdir().expect("temporary directory");
1126 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1127 directory.path().join("pending-unrelated.rdb"),
1128 ))
1129 .expect("runtime");
1130 for sql in [
1131 "CREATE TABLE cached_items (id INT)",
1132 "CREATE TABLE unrelated_items (id INT)",
1133 "INSERT INTO cached_items (id) VALUES (1)",
1134 "INSERT INTO unrelated_items (id) VALUES (1)",
1135 "SELECT id FROM cached_items ORDER BY id",
1136 "SELECT id FROM cached_items ORDER BY id",
1137 ] {
1138 runtime.execute_query(sql).expect(sql);
1139 }
1140 let before = runtime.result_cache_metrics();
1141 runtime
1142 .execute_query("SELECT id FROM cached_items ORDER BY id")
1143 .expect("warm hit");
1144 assert_eq!(runtime.result_cache_metrics().0 - before.0, 1);
1145 let (pending_tx, pending_rx) = mpsc::channel();
1146 let (resume_tx, resume_rx) = mpsc::channel();
1147 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1148 runtime
1149 .db()
1150 .store()
1151 .set_result_cache_pending_hook(Some(Arc::new(move || {
1152 pending_tx.send(()).expect("report pending");
1153 resume_rx
1154 .lock()
1155 .recv_timeout(Duration::from_secs(10))
1156 .expect("resume");
1157 })));
1158 let writer = runtime.clone();
1159 let writer_thread = std::thread::spawn(move || {
1160 writer.execute_query("UPDATE unrelated_items SET id = 2 WHERE id = 1")
1161 });
1162 pending_rx
1163 .recv_timeout(Duration::from_secs(10))
1164 .expect("publication pending");
1165 let before = runtime.result_cache_metrics();
1166 let reader = runtime.clone();
1167 let (read_tx, read_rx) = mpsc::channel();
1168 let reader_thread = std::thread::spawn(move || {
1169 read_tx
1170 .send(reader.execute_query("SELECT id FROM cached_items ORDER BY id"))
1171 .unwrap();
1172 });
1173 let result = read_rx.recv_timeout(Duration::from_secs(3));
1174 runtime.db().store().set_result_cache_pending_hook(None);
1175 resume_tx.send(()).expect("resume writer");
1176 writer_thread.join().unwrap().expect("write");
1177 reader_thread.join().unwrap();
1178 assert_eq!(
1179 result
1180 .expect("cached read must not wait")
1181 .expect("read")
1182 .result
1183 .records
1184 .len(),
1185 1
1186 );
1187 assert_eq!(
1188 runtime.result_cache_metrics().0 - before.0,
1189 1,
1190 "unrelated pending write must preserve the hit"
1191 );
1192 }
1193
1194 #[test]
1195 fn committed_insert_before_cache_invalidation_does_not_recover_stale_result() {
1196 if let Some(path) = std::env::var_os(CHILD_PATH) {
1197 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(path))
1198 .expect("open child database");
1199 let operation = std::env::var(CHILD_OPERATION).expect("child operation");
1200 if operation.starts_with("format_crash:") || operation.starts_with("format_seal:") {
1201 let store = runtime.db().store();
1202 store
1203 .insert_auto(
1204 "format_data",
1205 crate::storage::UnifiedEntity::table_row(
1206 crate::storage::EntityId::new(0),
1207 "format_data",
1208 0,
1209 vec![reddb_types::Value::Integer(42)],
1210 ),
1211 )
1212 .expect("seed durable data");
1213 if let Some(point) = operation.strip_prefix("format_crash:") {
1214 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", point);
1215 } else {
1216 let point = operation.strip_prefix("format_seal:").unwrap().to_owned();
1217 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1218 move |_, phase| {
1219 if phase == super::super::index_store::MutationTestPhase::ResultCacheFormatCheckpointed {
1220 if point == "before" {
1221 std::process::exit(86);
1222 }
1223 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &point);
1224 }
1225 },
1226 ));
1227 }
1228 runtime
1229 .execute_query("SELECT * FROM format_data")
1230 .expect("format activation");
1231 panic!("format crash point was not reached");
1232 }
1233 for sql in [
1234 "CREATE TABLE cache_gap (id INT)",
1235 "INSERT INTO cache_gap (id) VALUES (1)",
1236 "SELECT id FROM cache_gap ORDER BY id",
1237 ] {
1238 runtime.execute_query(sql).expect("prepare cache");
1239 }
1240 if operation == "format_fence" {
1241 assert!(runtime.db().store().result_cache_format_fenced());
1242 std::process::exit(86);
1243 }
1244 if operation == "transaction" {
1245 let (key, mut old_entry) = {
1246 let cache = runtime.inner.result_blob_entries.read();
1247 let (key, entry) = cache.0.iter().next().expect("warm original result");
1248 (key.clone(), entry.clone())
1249 };
1250 super::super::execution_context::set_current_connection_id(901);
1251 runtime.execute_query("BEGIN").expect("begin transaction");
1252 runtime
1253 .execute_query("INSERT INTO cache_gap (id) VALUES (2)")
1254 .expect("stage insert");
1255 let reader = runtime.clone();
1256 std::thread::spawn(move || {
1257 super::super::execution_context::set_current_connection_id(902);
1258 let read = reader
1259 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1260 .expect("cache committed view");
1261 assert_eq!(
1262 read.result.records.len(),
1263 1,
1264 "uncommitted row must not be visible"
1265 );
1266 old_entry.data_epoch = reader.db().store().result_cache_epoch().expect("epoch");
1269 let payload =
1270 super::encode_result_cache_payload(&old_entry).expect("encode old result");
1271 reader
1272 .inner
1273 .result_blob_cache
1274 .put(
1275 super::RESULT_CACHE_BLOB_NAMESPACE,
1276 &key,
1277 crate::storage::cache::BlobCachePut::new(payload),
1278 )
1279 .expect("persist old result");
1280 reader.checkpoint().expect("persist cached result");
1281 let hit = reader
1282 .inner
1283 .result_blob_cache
1284 .get(super::RESULT_CACHE_BLOB_NAMESPACE, &key)
1285 .expect("old entry remains");
1286 assert!(super::decode_result_cache_payload(hit.value()).is_some());
1287 })
1288 .join()
1289 .expect("pre-commit reader");
1290 } else {
1291 runtime.checkpoint().expect("persist cached result");
1292 }
1293 let transaction = operation == "transaction";
1294 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1295 move |collection, phase| {
1296 if (transaction && phase == super::super::index_store::MutationTestPhase::TransactionWalCommitted)
1297 || (!transaction && collection == "cache_gap"
1298 && phase == super::super::index_store::MutationTestPhase::BeforeResultCacheInvalidation)
1299 {
1300 std::process::exit(86);
1301 }
1302 },
1303 ));
1304 if operation.starts_with("wal_") {
1305 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &operation);
1306 }
1307 let sql = match operation.as_str() {
1308 "insert"
1309 | "wal_after_frame_write"
1310 | "wal_after_frame_sync"
1311 | "wal_after_superblock_write" => "INSERT INTO cache_gap (id) VALUES (2)",
1312 "update" => "UPDATE cache_gap SET id = 2 WHERE id = 1",
1313 "delete" => "DELETE FROM cache_gap WHERE id = 1",
1314 "transaction" => "COMMIT",
1315 _ => panic!("unknown operation"),
1316 };
1317 runtime
1318 .execute_query(sql)
1319 .expect("hook must terminate before return");
1320 panic!("storage-published hook was not reached");
1321 }
1322
1323 let dir = tempfile::tempdir().expect("temporary directory");
1324 for (operation, expected) in [
1325 ("insert", Some(vec![1, 2])),
1326 ("update", Some(vec![2])),
1327 ("delete", Some(vec![])),
1328 ("transaction", Some(vec![1, 2])),
1329 ("wal_after_frame_write", None),
1330 ("wal_after_frame_sync", None),
1331 ("wal_after_superblock_write", None),
1332 ] {
1333 let path = dir.path().join(format!("cache-gap-{operation}.rdb"));
1334 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
1335 .args([
1336 "--exact",
1337 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1338 "--nocapture",
1339 ])
1340 .env(CHILD_PATH, &path)
1341 .env(CHILD_OPERATION, operation)
1342 .output()
1343 .expect("run child");
1344 assert_eq!(
1345 output.status.code(),
1346 Some(if operation.starts_with("wal_") {
1347 173
1348 } else {
1349 86
1350 }),
1351 "{operation} child did not reach crash hook: {}",
1352 String::from_utf8_lossy(&output.stderr)
1353 );
1354 let runtime =
1355 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1356 let before = runtime.result_cache_metrics();
1357 let cached = runtime
1358 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1359 .expect("cached read");
1360 let after = runtime.result_cache_metrics();
1361 let values = |result: &crate::runtime::RuntimeQueryResult| {
1362 result
1363 .result
1364 .records
1365 .iter()
1366 .map(|record| match record.get("id") {
1367 Some(reddb_types::Value::Integer(value)) => *value,
1368 other => panic!("expected id, got {other:?}"),
1369 })
1370 .collect::<Vec<_>>()
1371 };
1372 runtime
1373 .execute_query("SET CONFIG runtime.result_cache.enabled = false")
1374 .expect("disable cache");
1375 let actual = runtime
1376 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1377 .expect("authoritative read");
1378 let actual = values(&actual);
1379 if let Some(expected) = expected {
1380 assert_eq!(actual, expected, "{operation} committed data must survive");
1381 } else {
1382 assert!(
1383 actual == vec![1] || actual == vec![1, 2],
1384 "{operation} recovered a partial write"
1385 );
1386 }
1387 assert_eq!(
1388 values(&cached),
1389 actual,
1390 "{operation} recovered a stale result"
1391 );
1392 if actual != vec![1] {
1393 assert_eq!(
1394 after.1 - before.1,
1395 1,
1396 "{operation} must reject the old L2 entry"
1397 );
1398 }
1399 }
1400 }
1401
1402 #[test]
1403 fn result_cache_format_fences_both_embedded_superblocks() {
1404 use std::io::{Seek, SeekFrom, Write};
1405 let directory = tempfile::tempdir().expect("temporary directory");
1406 let path = directory.path().join("format-fence.rdb");
1407 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1408 .args([
1409 "--exact",
1410 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1411 "--nocapture",
1412 ])
1413 .env(CHILD_PATH, &path)
1414 .env(CHILD_OPERATION, "format_fence")
1415 .status()
1416 .expect("run child");
1417 assert_eq!(status.code(), Some(86));
1418 for index in 0..2 {
1419 let copy = directory.path().join(format!("format-fence-{index}.rdb"));
1420 std::fs::copy(&path, ©).expect("copy database");
1421 let mut file = std::fs::OpenOptions::new().write(true).open(©).unwrap();
1422 file.seek(SeekFrom::Start(
1423 index * reddb_file::EMBEDDED_RDB_SUPERBLOCK_SIZE + 64,
1424 ))
1425 .unwrap();
1426 file.write_all(&[0xff]).unwrap();
1427 file.sync_all().unwrap();
1428 drop(file);
1429 let open = crate::storage::EmbeddedRdbArtifact::open(©).expect("other superblock");
1430 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1431 .unwrap()
1432 .unwrap();
1433 assert_eq!(
1434 reddb_file::decode_native_store_header(&snapshot).unwrap(),
1435 reddb_file::STORE_VERSION_CURRENT
1436 );
1437 }
1438 }
1439
1440 #[test]
1441 fn result_cache_format_fence_and_warm_result_survive_restart() {
1442 let directory = tempfile::tempdir().expect("temporary directory");
1443 let path = directory.path().join("warm-fenced.rdb");
1444 {
1445 let runtime =
1446 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("runtime");
1447 for sql in [
1448 "CREATE TABLE warm_fence (id INT)",
1449 "INSERT INTO warm_fence (id) VALUES (1)",
1450 "SELECT id FROM warm_fence ORDER BY id",
1451 ] {
1452 runtime.execute_query(sql).expect(sql);
1453 }
1454 assert!(runtime.db().store().result_cache_format_fenced());
1455 runtime.inner.result_blob_entries.write().5 = Some(std::time::Instant::now());
1456 runtime.promote_volatile_result_cache_entry();
1457 }
1458 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1459 assert!(
1460 runtime.db().store().result_cache_format_fenced(),
1461 "durable fence must load before the first query"
1462 );
1463 let before = runtime.result_cache_metrics();
1464 assert_eq!(
1465 runtime
1466 .execute_query("SELECT id FROM warm_fence ORDER BY id")
1467 .expect("read")
1468 .result
1469 .records
1470 .len(),
1471 1
1472 );
1473 assert_eq!(
1474 runtime.result_cache_metrics().0 - before.0,
1475 1,
1476 "valid result must recover warm"
1477 );
1478 }
1479
1480 #[test]
1481 fn result_cache_format_checkpoint_survives_interrupted_publication() {
1482 let directory = tempfile::tempdir().expect("temporary directory");
1483 for (operation, expected_status) in [
1484 ("format_crash:snapshot_after_image_write", 173),
1485 ("format_crash:snapshot_after_image_sync", 173),
1486 ("format_crash:snapshot_after_manifest_write", 173),
1487 ("format_crash:snapshot_after_manifest_sync", 173),
1488 ("format_crash:snapshot_after_superblock_write", 173),
1489 ("format_seal:before", 86),
1490 ("format_seal:wal_after_frame_write", 173),
1491 ("format_seal:wal_after_frame_sync", 173),
1492 ("format_seal:wal_after_superblock_write", 173),
1493 ] {
1494 let path = directory
1495 .path()
1496 .join(format!("{}.rdb", operation.replace(':', "-")));
1497 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1498 .args([
1499 "--exact",
1500 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1501 "--nocapture",
1502 ])
1503 .env(CHILD_PATH, &path)
1504 .env(CHILD_OPERATION, operation)
1505 .status().expect("run child");
1506 assert_eq!(status.code(), Some(expected_status), "crash at {operation}");
1507 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path))
1508 .expect("recover database");
1509 assert_eq!(
1510 runtime
1511 .db()
1512 .store()
1513 .get_collection("format_data")
1514 .expect("recovered table")
1515 .count(),
1516 1
1517 );
1518 runtime
1519 .execute_query("SELECT * FROM format_data")
1520 .expect("activate format after recovery");
1521 assert!(runtime.db().store().result_cache_format_fenced());
1522 }
1523 }
1524}