Skip to main content

lean_ctx/core/property_graph/
semantic_cache.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Persistent cache of semantic-backend answers, keyed by call site.
3//!
4//! A row is valid only while the caller file's content hash is unchanged and
5//! its `context` fingerprint still matches (see [`CachedResolution::context`]).
6//! Only definitive answers are stored; transient failures and "no result"
7//! (a cold server answers that while indexing) are retried instead.
8
9use rusqlite::{Connection, OptionalExtension, params};
10
11/// One cached semantic answer.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct CachedResolution {
14    /// Identity of the backend that answered (`lsp:rust-analyzer@…`).
15    pub backend: String,
16    /// `resolved` | `external` | `ambiguous`. For `implementations`,
17    /// `target_file` holds the implementor files newline-separated.
18    pub outcome: String,
19    pub target_file: Option<String>,
20    pub target_line: Option<usize>,
21    pub target_symbol: Option<String>,
22    /// Fingerprint of what the answer depends on *outside* the caller file
23    /// (e.g. which files define the callee's name; the project revision for
24    /// implementations). A mismatch makes the row stale.
25    pub context: String,
26}
27
28/// Call-site key: `(caller_file, 1-based line, 0-based byte column, op)`.
29pub type SiteKey<'a> = (&'a str, usize, usize, &'a str);
30
31pub(super) fn lookup(
32    conn: &Connection,
33    site: SiteKey<'_>,
34    caller_hash: &str,
35) -> anyhow::Result<Option<CachedResolution>> {
36    let (file, line, col, op) = site;
37    let row = conn
38        .query_row(
39            "SELECT backend, outcome, target_file, target_line, target_symbol, context
40             FROM semantic_resolutions
41             WHERE caller_file = ?1 AND line = ?2 AND col = ?3 AND op = ?4
42               AND caller_hash = ?5",
43            params![file, line as i64, col as i64, op, caller_hash],
44            |r| {
45                Ok(CachedResolution {
46                    backend: r.get(0)?,
47                    outcome: r.get(1)?,
48                    target_file: r.get(2)?,
49                    target_line: r.get::<_, Option<i64>>(3)?.map(|l| l as usize),
50                    target_symbol: r.get(4)?,
51                    context: r.get(5)?,
52                })
53            },
54        )
55        .optional()?;
56    Ok(row)
57}
58
59/// `lookup` for a caller that USES the answer (an edge it writes, a target
60/// it names): the row is returned only while every file it derives from is
61/// still served — the caller file and each target file (`target_file` holds
62/// several, newline-separated, for `implementations`). `serves` is the
63/// serve-time check of the opened graph (`CodeGraph::serves_file`: the file
64/// is present inside the project, and under protection the admitted rooted
65/// reader returns it now); it is asked per call and nothing is kept here.
66///
67/// A row is derived from ALL its files: one that is withdrawn withholds the
68/// answer, which is then asked from the backend again like any miss. A row
69/// without a target (`external`, `ambiguous`) depends on the caller alone.
70pub fn lookup_served(
71    conn: &Connection,
72    site: SiteKey<'_>,
73    caller_hash: &str,
74    mut serves: impl FnMut(&str) -> bool,
75) -> anyhow::Result<Option<CachedResolution>> {
76    Ok(lookup(conn, site, caller_hash)?.filter(|row| {
77        serves(site.0)
78            && row
79                .target_file
80                .as_deref()
81                .is_none_or(|targets| targets.lines().all(&mut serves))
82    }))
83}
84
85pub(super) fn store(
86    conn: &Connection,
87    site: SiteKey<'_>,
88    caller_hash: &str,
89    value: &CachedResolution,
90) -> anyhow::Result<()> {
91    let (file, line, col, op) = site;
92    conn.execute(
93        "INSERT INTO semantic_resolutions
94            (caller_file, line, col, op, caller_hash, backend, outcome,
95             target_file, target_line, target_symbol, context)
96         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
97         ON CONFLICT(caller_file, line, col, op) DO UPDATE SET
98            caller_hash = excluded.caller_hash,
99            backend = excluded.backend,
100            outcome = excluded.outcome,
101            target_file = excluded.target_file,
102            target_line = excluded.target_line,
103            target_symbol = excluded.target_symbol,
104            context = excluded.context",
105        params![
106            file,
107            line as i64,
108            col as i64,
109            op,
110            caller_hash,
111            value.backend,
112            value.outcome,
113            value.target_file,
114            value.target_line.map(|l| l as i64),
115            value.target_symbol,
116            value.context,
117        ],
118    )?;
119    Ok(())
120}
121
122/// `store` through storage admission (E3), for the writer of an enrichment
123/// run: `admission` is the ONE policy snapshot of that run. The row is
124/// persisted only if every text it keeps — caller file, backend, outcome,
125/// target file(s), target symbol — passes that snapshot verbatim. `Ok(false)`:
126/// withheld, restricted or rewritten by admission; nothing was written and
127/// the answer is asked from the backend again like any miss.
128pub fn store_admitted(
129    conn: &Connection,
130    site: SiteKey<'_>,
131    caller_hash: &str,
132    value: &CachedResolution,
133    admission: &crate::core::context_admission::stores::StoreAdmission,
134) -> anyhow::Result<bool> {
135    let kept = [
136        Some(site.0),
137        Some(value.backend.as_str()),
138        Some(value.outcome.as_str()),
139        value.target_file.as_deref(),
140        value.target_symbol.as_deref(),
141    ]
142    .into_iter()
143    .flatten()
144    .collect::<Vec<_>>()
145    .join("\n");
146    if admission.admit_text(&kept).as_deref() != Some(kept.as_str()) {
147        return Ok(false);
148    }
149    store(conn, site, caller_hash, value)?;
150    Ok(true)
151}
152
153/// Whether any semantic answer is stored — evidence that enrichment has
154/// consolidated this graph's call edges at least once.
155pub(super) fn any(conn: &Connection) -> anyhow::Result<bool> {
156    Ok(conn.query_row(
157        "SELECT EXISTS(SELECT 1 FROM semantic_resolutions)",
158        [],
159        |r| r.get(0),
160    )?)
161}
162
163/// Drops rows whose caller file is no longer indexed, and rows for indexed
164/// files whose content changed (stale hash). Returns the number removed.
165pub(super) fn prune(
166    conn: &Connection,
167    live_hashes: &std::collections::HashMap<String, String>,
168) -> anyhow::Result<usize> {
169    let mut stmt =
170        conn.prepare("SELECT DISTINCT caller_file, caller_hash FROM semantic_resolutions")?;
171    let stale: Vec<(String, String)> = stmt
172        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
173        .filter_map(Result::ok)
174        .filter(|(file, hash)| live_hashes.get(file) != Some(hash))
175        .collect();
176    let mut removed = 0;
177    for (file, hash) in stale {
178        removed += conn.execute(
179            "DELETE FROM semantic_resolutions WHERE caller_file = ?1 AND caller_hash = ?2",
180            params![file, hash],
181        )?;
182    }
183    Ok(removed)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::core::property_graph::CodeGraph;
190
191    /// G04: a cached answer is used while the caller file and its target are
192    /// served by the opened graph, and no longer once the target is
193    /// withdrawn. The row stays in the store; it is not handed to a user.
194    #[test]
195    fn a_cached_answer_is_used_only_while_its_files_are_served() {
196        let _data = crate::core::data_dir::isolated_data_dir();
197        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
198        let project = tempfile::tempdir().expect("project");
199        let root = project.path().canonicalize().expect("canonical root");
200        let root_text = root.to_str().expect("utf-8 root");
201        for name in ["app.rs", "billing.rs"] {
202            std::fs::write(root.join(name), "pub fn settle() {}\n").expect("source");
203        }
204        let site: SiteKey<'_> = ("app.rs", 2, 4, "definition");
205        let answer = CachedResolution {
206            backend: "lsp:test".to_owned(),
207            outcome: "resolved".to_owned(),
208            target_file: Some("billing.rs".to_owned()),
209            target_line: Some(1),
210            target_symbol: Some("settle".to_owned()),
211            context: "c".to_owned(),
212        };
213        // One opened graph is one request: its gate decides each file once.
214        let used = || {
215            let graph = CodeGraph::open(root_text).expect("graph");
216            lookup_served(&graph.conn, site, "h", |file| graph.serves_file(file)).expect("query")
217        };
218        let graph = CodeGraph::open(root_text).expect("graph");
219        store(&graph.conn, site, "h", &answer).expect("stored");
220        assert_eq!(used(), Some(answer.clone()));
221
222        std::fs::remove_file(root.join("billing.rs")).expect("withdraw");
223        assert_eq!(used(), None);
224        assert_eq!(lookup(&graph.conn, site, "h").expect("row"), Some(answer));
225    }
226}