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