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