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 promotion_gate_does_not_block_hits_and_orders_invalidation() {
845 use std::sync::mpsc;
846 use std::time::Duration;
847
848 let runtime = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
849 for sql in [
850 "CREATE TABLE promotion_gate (id INT)",
851 "INSERT INTO promotion_gate (id) VALUES (1)",
852 "SELECT id FROM promotion_gate ORDER BY id",
853 "INSERT INTO promotion_gate (id) VALUES (2)",
854 "SELECT id FROM promotion_gate ORDER BY id",
855 ] {
856 runtime.execute_query(sql).expect("prepare volatile result");
857 }
858
859 let guard = runtime.inner.result_blob_promotion.lock();
860 let reader = runtime.clone();
861 let (read_tx, read_rx) = mpsc::channel();
862 let reader_thread = std::thread::spawn(move || {
863 let rows = reader
864 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
865 .expect("cached read")
866 .result
867 .records
868 .len();
869 read_tx.send(rows).expect("report read");
870 });
871 let read = read_rx.recv_timeout(Duration::from_secs(3));
872 drop(guard);
873 reader_thread.join().expect("reader thread");
874 assert_eq!(read.expect("hit blocked by promotion gate"), 2);
875
876 let guard = runtime.inner.result_blob_promotion.lock();
877 let invalidator = runtime.clone();
878 let (started_tx, started_rx) = mpsc::channel();
879 let (done_tx, done_rx) = mpsc::channel();
880 let invalidator_thread = std::thread::spawn(move || {
881 started_tx.send(()).expect("report start");
882 invalidator.invalidate_result_cache_for_table("promotion_gate");
883 done_tx.send(()).expect("report invalidation");
884 });
885 let started = started_rx.recv_timeout(Duration::from_secs(3));
886 let completed_early = done_rx.recv_timeout(Duration::from_millis(100));
887 drop(guard);
888 assert!(started.is_ok(), "invalidation did not start");
889 assert!(
890 matches!(completed_early, Err(mpsc::RecvTimeoutError::Timeout)),
891 "invalidation must wait for an in-flight promotion"
892 );
893 done_rx
894 .recv_timeout(Duration::from_secs(3))
895 .expect("invalidation did not finish");
896 invalidator_thread.join().expect("invalidator thread");
897 let before = runtime.result_cache_metrics();
898 assert_eq!(
899 runtime
900 .execute_query("SELECT id FROM promotion_gate ORDER BY id")
901 .expect("read after invalidation")
902 .result
903 .records
904 .len(),
905 2
906 );
907 assert_eq!(
908 runtime.result_cache_metrics().1 - before.1,
909 1,
910 "invalidation must remove the cached result"
911 );
912 }
913
914 #[test]
915 fn durable_cache_publication_and_write_invalidation_do_not_invert_locks() {
916 use std::sync::mpsc;
917 use std::time::{Duration, Instant};
918
919 let directory = tempfile::tempdir().expect("temporary directory");
920 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
921 directory.path().join("cache-lock-order.rdb"),
922 ))
923 .expect("runtime");
924 for sql in [
925 "CREATE TABLE locked_write (id INT)",
926 "CREATE TABLE cache_fill (id INT)",
927 "INSERT INTO locked_write (id) VALUES (1)",
928 "INSERT INTO cache_fill (id) VALUES (1)",
929 ] {
930 runtime.execute_query(sql).expect(sql);
931 }
932 runtime.result_cache_snapshot();
933 std::thread::sleep(super::RESULT_CACHE_WRITE_QUIET_PERIOD + Duration::from_millis(50));
934
935 let (write_paused_tx, write_paused_rx) = mpsc::channel();
936 let (resume_write_tx, resume_write_rx) = mpsc::channel();
937 let writer = runtime.clone();
938 let (writer_done_tx, writer_done_rx) = mpsc::channel();
939 let writer_thread = std::thread::spawn(move || {
940 let store = writer.db().store();
941 let _publication = store.vault_publication_lock.lock();
942 write_paused_tx
943 .send(store.vault_publication_lock.is_owned_by_current_thread())
944 .expect("report paused writer");
945 resume_write_rx
946 .recv_timeout(Duration::from_secs(10))
947 .expect("resume writer");
948 writer.invalidate_result_cache_for_table("locked_write");
949 writer_done_tx.send(()).expect("report writer result");
950 });
951 assert!(
952 write_paused_rx
953 .recv_timeout(Duration::from_secs(5))
954 .expect("writer did not reach invalidation"),
955 "write invalidation must still own the durable publication fence"
956 );
957
958 let cache_filler = runtime.clone();
959 let (cache_done_tx, cache_done_rx) = mpsc::channel();
960 let cache_thread = std::thread::spawn(move || {
961 let result = cache_filler.execute_query("SELECT id FROM cache_fill ORDER BY id");
962 cache_done_tx
963 .send(result)
964 .expect("report cache publication result");
965 });
966
967 let deadline = Instant::now() + Duration::from_secs(2);
973 while Instant::now() < deadline {
974 if runtime.inner.result_blob_promotion.try_lock().is_none() {
975 break;
976 }
977 std::thread::sleep(Duration::from_millis(10));
978 }
979 resume_write_tx.send(()).expect("resume writer");
980
981 let writer_result = writer_done_rx.recv_timeout(Duration::from_secs(3));
982 let cache_result = cache_done_rx.recv_timeout(Duration::from_secs(3));
983 assert!(
984 writer_result.is_ok() && cache_result.is_ok(),
985 "durable cache publication deadlocked with write invalidation"
986 );
987 writer_result.unwrap();
988 cache_result.unwrap().expect("cache publication result");
989 writer_thread.join().expect("writer thread");
990 cache_thread.join().expect("cache thread");
991 }
992
993 #[test]
994 fn published_write_cannot_return_an_old_in_memory_result() {
995 use std::sync::mpsc;
996 use std::time::Duration;
997
998 let directory = tempfile::tempdir().expect("temporary directory");
999 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1000 directory.path().join("live-gap.rdb"),
1001 ))
1002 .expect("runtime");
1003 for sql in [
1004 "CREATE TABLE live_gap (id INT)",
1005 "INSERT INTO live_gap (id) VALUES (1)",
1006 "SELECT id FROM live_gap ORDER BY id",
1007 ] {
1008 runtime.execute_query(sql).expect("prepare cache");
1009 }
1010 let (published_tx, published_rx) = mpsc::channel();
1011 let (resume_tx, resume_rx) = mpsc::channel();
1012 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1013 *runtime.index_store_ref().mutation_hook.lock() =
1014 Some(Arc::new(move |collection, phase| {
1015 if collection == "live_gap"
1016 && phase == super::super::index_store::MutationTestPhase::StoragePublished
1017 {
1018 published_tx.send(()).expect("report publication");
1019 resume_rx
1020 .lock()
1021 .recv_timeout(Duration::from_secs(10))
1022 .expect("resume writer");
1023 }
1024 }));
1025 let writer = runtime.clone();
1026 let writer_thread = std::thread::spawn(move || {
1027 writer.execute_query("INSERT INTO live_gap (id) VALUES (2)")
1028 });
1029 published_rx
1030 .recv_timeout(Duration::from_secs(10))
1031 .expect("storage publication");
1032 let reader = runtime.clone();
1033 let (read_tx, read_rx) = mpsc::channel();
1034 let reader_thread = std::thread::spawn(move || {
1035 let rows = reader
1036 .execute_query("SELECT id FROM live_gap ORDER BY id")
1037 .expect("read during publication")
1038 .result
1039 .records
1040 .len();
1041 read_tx.send(rows).expect("report rows");
1042 });
1043 let early = read_rx.recv_timeout(Duration::from_secs(2));
1044 resume_tx.send(()).expect("resume writer");
1045 writer_thread.join().expect("writer thread").expect("write");
1046 let rows = match early {
1047 Ok(rows) => rows,
1048 Err(mpsc::RecvTimeoutError::Timeout) => read_rx
1049 .recv_timeout(Duration::from_secs(10))
1050 .expect("reader finished"),
1051 Err(error) => panic!("reader failed: {error}"),
1052 };
1053 reader_thread.join().expect("reader thread");
1054 assert_eq!(
1055 rows, 2,
1056 "old cached result was returned after storage publication"
1057 );
1058 }
1059
1060 #[test]
1061 fn unrelated_pending_write_keeps_a_cached_read_available() {
1062 use std::sync::mpsc;
1063 use std::time::Duration;
1064
1065 let directory = tempfile::tempdir().expect("temporary directory");
1066 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(
1067 directory.path().join("pending-unrelated.rdb"),
1068 ))
1069 .expect("runtime");
1070 for sql in [
1071 "CREATE TABLE cached_items (id INT)",
1072 "CREATE TABLE unrelated_items (id INT)",
1073 "INSERT INTO cached_items (id) VALUES (1)",
1074 "INSERT INTO unrelated_items (id) VALUES (1)",
1075 "SELECT id FROM cached_items ORDER BY id",
1076 "SELECT id FROM cached_items ORDER BY id",
1077 ] {
1078 runtime.execute_query(sql).expect(sql);
1079 }
1080 let before = runtime.result_cache_metrics();
1081 runtime
1082 .execute_query("SELECT id FROM cached_items ORDER BY id")
1083 .expect("warm hit");
1084 assert_eq!(runtime.result_cache_metrics().0 - before.0, 1);
1085 let (pending_tx, pending_rx) = mpsc::channel();
1086 let (resume_tx, resume_rx) = mpsc::channel();
1087 let resume_rx = Arc::new(parking_lot::Mutex::new(resume_rx));
1088 runtime
1089 .db()
1090 .store()
1091 .set_result_cache_pending_hook(Some(Arc::new(move || {
1092 pending_tx.send(()).expect("report pending");
1093 resume_rx
1094 .lock()
1095 .recv_timeout(Duration::from_secs(10))
1096 .expect("resume");
1097 })));
1098 let writer = runtime.clone();
1099 let writer_thread = std::thread::spawn(move || {
1100 writer.execute_query("UPDATE unrelated_items SET id = 2 WHERE id = 1")
1101 });
1102 pending_rx
1103 .recv_timeout(Duration::from_secs(10))
1104 .expect("publication pending");
1105 let before = runtime.result_cache_metrics();
1106 let reader = runtime.clone();
1107 let (read_tx, read_rx) = mpsc::channel();
1108 let reader_thread = std::thread::spawn(move || {
1109 read_tx
1110 .send(reader.execute_query("SELECT id FROM cached_items ORDER BY id"))
1111 .unwrap();
1112 });
1113 let result = read_rx.recv_timeout(Duration::from_secs(3));
1114 runtime.db().store().set_result_cache_pending_hook(None);
1115 resume_tx.send(()).expect("resume writer");
1116 writer_thread.join().unwrap().expect("write");
1117 reader_thread.join().unwrap();
1118 assert_eq!(
1119 result
1120 .expect("cached read must not wait")
1121 .expect("read")
1122 .result
1123 .records
1124 .len(),
1125 1
1126 );
1127 assert_eq!(
1128 runtime.result_cache_metrics().0 - before.0,
1129 1,
1130 "unrelated pending write must preserve the hit"
1131 );
1132 }
1133
1134 #[test]
1135 fn committed_insert_before_cache_invalidation_does_not_recover_stale_result() {
1136 if let Some(path) = std::env::var_os(CHILD_PATH) {
1137 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(path))
1138 .expect("open child database");
1139 let operation = std::env::var(CHILD_OPERATION).expect("child operation");
1140 if operation.starts_with("format_crash:") || operation.starts_with("format_seal:") {
1141 let store = runtime.db().store();
1142 store
1143 .insert_auto(
1144 "format_data",
1145 crate::storage::UnifiedEntity::table_row(
1146 crate::storage::EntityId::new(0),
1147 "format_data",
1148 0,
1149 vec![reddb_types::Value::Integer(42)],
1150 ),
1151 )
1152 .expect("seed durable data");
1153 if let Some(point) = operation.strip_prefix("format_crash:") {
1154 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", point);
1155 } else {
1156 let point = operation.strip_prefix("format_seal:").unwrap().to_owned();
1157 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1158 move |_, phase| {
1159 if phase == super::super::index_store::MutationTestPhase::ResultCacheFormatCheckpointed {
1160 if point == "before" {
1161 std::process::exit(86);
1162 }
1163 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &point);
1164 }
1165 },
1166 ));
1167 }
1168 runtime
1169 .execute_query("SELECT * FROM format_data")
1170 .expect("format activation");
1171 panic!("format crash point was not reached");
1172 }
1173 for sql in [
1174 "CREATE TABLE cache_gap (id INT)",
1175 "INSERT INTO cache_gap (id) VALUES (1)",
1176 "SELECT id FROM cache_gap ORDER BY id",
1177 ] {
1178 runtime.execute_query(sql).expect("prepare cache");
1179 }
1180 if operation == "format_fence" {
1181 assert!(runtime.db().store().result_cache_format_fenced());
1182 std::process::exit(86);
1183 }
1184 if operation == "transaction" {
1185 let (key, mut old_entry) = {
1186 let cache = runtime.inner.result_blob_entries.read();
1187 let (key, entry) = cache.0.iter().next().expect("warm original result");
1188 (key.clone(), entry.clone())
1189 };
1190 super::super::execution_context::set_current_connection_id(901);
1191 runtime.execute_query("BEGIN").expect("begin transaction");
1192 runtime
1193 .execute_query("INSERT INTO cache_gap (id) VALUES (2)")
1194 .expect("stage insert");
1195 let reader = runtime.clone();
1196 std::thread::spawn(move || {
1197 super::super::execution_context::set_current_connection_id(902);
1198 let read = reader
1199 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1200 .expect("cache committed view");
1201 assert_eq!(
1202 read.result.records.len(),
1203 1,
1204 "uncommitted row must not be visible"
1205 );
1206 old_entry.data_epoch = reader.db().store().result_cache_epoch().expect("epoch");
1209 let payload =
1210 super::encode_result_cache_payload(&old_entry).expect("encode old result");
1211 reader
1212 .inner
1213 .result_blob_cache
1214 .put(
1215 super::RESULT_CACHE_BLOB_NAMESPACE,
1216 &key,
1217 crate::storage::cache::BlobCachePut::new(payload),
1218 )
1219 .expect("persist old result");
1220 reader.checkpoint().expect("persist cached result");
1221 let hit = reader
1222 .inner
1223 .result_blob_cache
1224 .get(super::RESULT_CACHE_BLOB_NAMESPACE, &key)
1225 .expect("old entry remains");
1226 assert!(super::decode_result_cache_payload(hit.value()).is_some());
1227 })
1228 .join()
1229 .expect("pre-commit reader");
1230 } else {
1231 runtime.checkpoint().expect("persist cached result");
1232 }
1233 let transaction = operation == "transaction";
1234 *runtime.index_store_ref().mutation_hook.lock() = Some(Arc::new(
1235 move |collection, phase| {
1236 if (transaction && phase == super::super::index_store::MutationTestPhase::TransactionWalCommitted)
1237 || (!transaction && collection == "cache_gap"
1238 && phase == super::super::index_store::MutationTestPhase::BeforeResultCacheInvalidation)
1239 {
1240 std::process::exit(86);
1241 }
1242 },
1243 ));
1244 if operation.starts_with("wal_") {
1245 std::env::set_var("REDDB_EMBEDDED_RDB_CRASH_AT", &operation);
1246 }
1247 let sql = match operation.as_str() {
1248 "insert"
1249 | "wal_after_frame_write"
1250 | "wal_after_frame_sync"
1251 | "wal_after_superblock_write" => "INSERT INTO cache_gap (id) VALUES (2)",
1252 "update" => "UPDATE cache_gap SET id = 2 WHERE id = 1",
1253 "delete" => "DELETE FROM cache_gap WHERE id = 1",
1254 "transaction" => "COMMIT",
1255 _ => panic!("unknown operation"),
1256 };
1257 runtime
1258 .execute_query(sql)
1259 .expect("hook must terminate before return");
1260 panic!("storage-published hook was not reached");
1261 }
1262
1263 let dir = tempfile::tempdir().expect("temporary directory");
1264 for (operation, expected) in [
1265 ("insert", Some(vec![1, 2])),
1266 ("update", Some(vec![2])),
1267 ("delete", Some(vec![])),
1268 ("transaction", Some(vec![1, 2])),
1269 ("wal_after_frame_write", None),
1270 ("wal_after_frame_sync", None),
1271 ("wal_after_superblock_write", None),
1272 ] {
1273 let path = dir.path().join(format!("cache-gap-{operation}.rdb"));
1274 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
1275 .args([
1276 "--exact",
1277 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1278 "--nocapture",
1279 ])
1280 .env(CHILD_PATH, &path)
1281 .env(CHILD_OPERATION, operation)
1282 .output()
1283 .expect("run child");
1284 assert_eq!(
1285 output.status.code(),
1286 Some(if operation.starts_with("wal_") {
1287 173
1288 } else {
1289 86
1290 }),
1291 "{operation} child did not reach crash hook: {}",
1292 String::from_utf8_lossy(&output.stderr)
1293 );
1294 let runtime =
1295 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1296 let before = runtime.result_cache_metrics();
1297 let cached = runtime
1298 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1299 .expect("cached read");
1300 let after = runtime.result_cache_metrics();
1301 let values = |result: &crate::runtime::RuntimeQueryResult| {
1302 result
1303 .result
1304 .records
1305 .iter()
1306 .map(|record| match record.get("id") {
1307 Some(reddb_types::Value::Integer(value)) => *value,
1308 other => panic!("expected id, got {other:?}"),
1309 })
1310 .collect::<Vec<_>>()
1311 };
1312 runtime
1313 .execute_query("SET CONFIG runtime.result_cache.enabled = false")
1314 .expect("disable cache");
1315 let actual = runtime
1316 .execute_query("SELECT id FROM cache_gap ORDER BY id")
1317 .expect("authoritative read");
1318 let actual = values(&actual);
1319 if let Some(expected) = expected {
1320 assert_eq!(actual, expected, "{operation} committed data must survive");
1321 } else {
1322 assert!(
1323 actual == vec![1] || actual == vec![1, 2],
1324 "{operation} recovered a partial write"
1325 );
1326 }
1327 assert_eq!(
1328 values(&cached),
1329 actual,
1330 "{operation} recovered a stale result"
1331 );
1332 if actual != vec![1] {
1333 assert_eq!(
1334 after.1 - before.1,
1335 1,
1336 "{operation} must reject the old L2 entry"
1337 );
1338 }
1339 }
1340 }
1341
1342 #[test]
1343 fn result_cache_format_fences_both_embedded_superblocks() {
1344 use std::io::{Seek, SeekFrom, Write};
1345 let directory = tempfile::tempdir().expect("temporary directory");
1346 let path = directory.path().join("format-fence.rdb");
1347 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1348 .args([
1349 "--exact",
1350 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1351 "--nocapture",
1352 ])
1353 .env(CHILD_PATH, &path)
1354 .env(CHILD_OPERATION, "format_fence")
1355 .status()
1356 .expect("run child");
1357 assert_eq!(status.code(), Some(86));
1358 for index in 0..2 {
1359 let copy = directory.path().join(format!("format-fence-{index}.rdb"));
1360 std::fs::copy(&path, ©).expect("copy database");
1361 let mut file = std::fs::OpenOptions::new().write(true).open(©).unwrap();
1362 file.seek(SeekFrom::Start(
1363 index * reddb_file::EMBEDDED_RDB_SUPERBLOCK_SIZE + 64,
1364 ))
1365 .unwrap();
1366 file.write_all(&[0xff]).unwrap();
1367 file.sync_all().unwrap();
1368 drop(file);
1369 let open = crate::storage::EmbeddedRdbArtifact::open(©).expect("other superblock");
1370 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1371 .unwrap()
1372 .unwrap();
1373 assert_eq!(
1374 reddb_file::decode_native_store_header(&snapshot).unwrap(),
1375 reddb_file::STORE_VERSION_V13
1376 );
1377 }
1378 }
1379
1380 #[test]
1381 fn result_cache_format_fence_and_warm_result_survive_restart() {
1382 let directory = tempfile::tempdir().expect("temporary directory");
1383 let path = directory.path().join("warm-fenced.rdb");
1384 {
1385 let runtime =
1386 RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("runtime");
1387 for sql in [
1388 "CREATE TABLE warm_fence (id INT)",
1389 "INSERT INTO warm_fence (id) VALUES (1)",
1390 "SELECT id FROM warm_fence ORDER BY id",
1391 ] {
1392 runtime.execute_query(sql).expect(sql);
1393 }
1394 assert!(runtime.db().store().result_cache_format_fenced());
1395 }
1396 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path)).expect("reopen");
1397 assert!(
1398 runtime.db().store().result_cache_format_fenced(),
1399 "durable fence must load before the first query"
1400 );
1401 let before = runtime.result_cache_metrics();
1402 assert_eq!(
1403 runtime
1404 .execute_query("SELECT id FROM warm_fence ORDER BY id")
1405 .expect("read")
1406 .result
1407 .records
1408 .len(),
1409 1
1410 );
1411 assert_eq!(
1412 runtime.result_cache_metrics().0 - before.0,
1413 1,
1414 "valid result must recover warm"
1415 );
1416 }
1417
1418 #[test]
1419 fn result_cache_format_checkpoint_survives_interrupted_publication() {
1420 let directory = tempfile::tempdir().expect("temporary directory");
1421 for (operation, expected_status) in [
1422 ("format_crash:snapshot_after_image_write", 173),
1423 ("format_crash:snapshot_after_image_sync", 173),
1424 ("format_crash:snapshot_after_manifest_write", 173),
1425 ("format_crash:snapshot_after_manifest_sync", 173),
1426 ("format_crash:snapshot_after_superblock_write", 173),
1427 ("format_seal:before", 86),
1428 ("format_seal:wal_after_frame_write", 173),
1429 ("format_seal:wal_after_frame_sync", 173),
1430 ("format_seal:wal_after_superblock_write", 173),
1431 ] {
1432 let path = directory
1433 .path()
1434 .join(format!("{}.rdb", operation.replace(':', "-")));
1435 let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
1436 .args([
1437 "--exact",
1438 "runtime::result_cache_runtime::crash_tests::committed_insert_before_cache_invalidation_does_not_recover_stale_result",
1439 "--nocapture",
1440 ])
1441 .env(CHILD_PATH, &path)
1442 .env(CHILD_OPERATION, operation)
1443 .status().expect("run child");
1444 assert_eq!(status.code(), Some(expected_status), "crash at {operation}");
1445 let runtime = RedDBRuntime::with_options(RedDBOptions::persistent(&path))
1446 .expect("recover database");
1447 assert_eq!(
1448 runtime
1449 .db()
1450 .store()
1451 .get_collection("format_data")
1452 .expect("recovered table")
1453 .count(),
1454 1
1455 );
1456 runtime
1457 .execute_query("SELECT * FROM format_data")
1458 .expect("activate format after recovery");
1459 assert!(runtime.db().store().result_cache_format_fenced());
1460 }
1461 }
1462}