Skip to main content

reddb_server/runtime/
result_cache_runtime.rs

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    // Execution measurements must not survive as a persisted cached execution.
164    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        // Single hash lookup: drive the LRU-order push_back only on Vacant.
515        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    // The cache sweeper calls this after writes have been quiet. A single
536    // promotion runs at a time, and it does not hold the result-map lock
537    // during L2 I/O. Invalidation and foreground puts take the same gate.
538    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            // A failed or evicted publication may still have left L2 state.
594            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            // An unsuccessful L2 publication may have left an entry behind.
650            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        // Single hash lookup: drive the LRU-order push_back only on Vacant.
663        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                    // Multiple or out-of-order writes leave dependencies
752                    // uncertain. Recompute without synchronous publication.
753                    blob_entries.5 =
754                        Some(std::time::Instant::now() + RESULT_CACHE_WRITE_QUIET_PERIOD);
755                }
756                blob_entries.6 = epoch;
757            }
758            // A query that started before any write must not publish after it.
759            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                    // Fault-inject an old durable result after transaction DML.
1108                    // Normal queries bypass the cache while any xid is active.
1109                    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, &copy).expect("copy database");
1262            let mut file = std::fs::OpenOptions::new().write(true).open(&copy).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(&copy).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}