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