按需(惰性)输入

如果你可以很轻松地在一开始就提供全部输入, 那么 Salsa 的 input 会用得最顺手。 但有时候,输入集合并不是预先已知的。

一个典型例子就是从磁盘读取文件。 你当然可以预先扫描某个目录, 把整棵文件树作为 Salsa input struct 建出来; 但更直接的做法通常是惰性地读取文件。 也就是说,当某个查询第一次请求某个文件的文本时:

  1. 从磁盘读取文件并把它缓存起来。
  2. 为这个路径安装文件系统监听器。
  3. 当监听器收到文件变化通知时,更新缓存中的文件内容。

在 Salsa 里,这种方案是可行的: 你可以把 input 缓存在数据库结构体中, 再给数据库 trait 增加一个方法,从这个缓存里按需取出它们。

一个完整、可运行的文件监听示例可以在 lazy-input 示例 中找到。

它的大致结构如下:

#[salsa::input]
struct File {
    path: PathBuf,
    #[returns(ref)]
    contents: String,
}

#[salsa::db]
trait Db: salsa::Database {
    fn input(&self, path: PathBuf) -> Result<File>;
}

#[salsa::db]
#[derive(Clone)]
struct LazyInputDatabase {
    storage: Storage<Self>,
    logs: Arc<Mutex<Vec<String>>>,
    files: DashMap<PathBuf, File>,
    file_watcher: Arc<Mutex<Debouncer<RecommendedWatcher>>>,
}

impl LazyInputDatabase {
    fn new(tx: Sender<DebounceEventResult>) -> Self {
        let logs: Arc<Mutex<Vec<String>>> = Default::default();
        Self {
            storage: Storage::new(Some(Box::new({
                let logs = logs.clone();
                move |event| {
                    // don't log boring events
                    if let salsa::EventKind::WillExecute { .. } = event.kind {
                        logs.lock().unwrap().push(format!("{event:?}"));
                    }
                }
            }))),
            logs,
            files: DashMap::new(),
            file_watcher: Arc::new(Mutex::new(
                new_debouncer(Duration::from_secs(1), tx).unwrap(),
            )),
        }
    }
}

#[salsa::db]
impl salsa::Database for LazyInputDatabase {}

#[salsa::db]
impl Db for LazyInputDatabase {
    fn input(&self, path: PathBuf) -> Result<File> {
        let path = path
            .canonicalize()
            .wrap_err_with(|| format!("Failed to read {}", path.display()))?;
        Ok(match self.files.entry(path.clone()) {
            // If the file already exists in our cache then just return it.
            Entry::Occupied(entry) => *entry.get(),
            // If we haven't read this file yet set up the watch, read the
            // contents, store it in the cache, and return it.
            Entry::Vacant(entry) => {
                // Set up the watch before reading the contents to try to avoid
                // race conditions.
                let watcher = &mut *self.file_watcher.lock().unwrap();
                watcher
                    .watcher()
                    .watch(&path, RecursiveMode::NonRecursive)
                    .unwrap();
                let contents = std::fs::read_to_string(&path)
                    .wrap_err_with(|| format!("Failed to read {}", path.display()))?;
                *entry.insert(File::new(self, path, contents))
            }
        })
    }
}
  • 我们在 Db trait 上声明一个按需获取 File input 的方法 (它只需要 &dyn Db,不需要 &mut dyn Db)。
  • 每个文件应该只对应一个 input struct, 所以我们会用一个缓存来实现这个方法(DashMap 可以理解为类似 RwLock<HashMap> 的结构)。

负责发起顶层查询的驱动代码,在收到文件变化通知时, 就要负责更新文件内容。 它更新 Salsa input 的方式,和更新其他 input 没有什么区别。

这里我们实现了一个简单的驱动循环: 每当文件发生变化,就重新编译代码。 你可以通过日志观察到, 只有那些可能受到影响的查询才会被重新计算。

fn main() -> Result<()> {
    // Create the channel to receive file change events.
    let (tx, rx) = unbounded();
    let mut db = LazyInputDatabase::new(tx);

    let initial_file_path = std::env::args_os()
        .nth(1)
        .ok_or_else(|| eyre!("Usage: ./lazy-input <input-file>"))?;

    // Create the initial input using the input method so that changes to it
    // will be watched like the other files.
    let initial = db.input(initial_file_path.into())?;
    loop {
        // Compile the code starting at the provided input, this will read other
        // needed files using the on-demand mechanism.
        let sum = compile(&db, initial);
        let diagnostics = compile::accumulated::<Diagnostic>(&db, initial);
        if diagnostics.is_empty() {
            println!("Sum is: {sum}");
        } else {
            for diagnostic in diagnostics {
                println!("{}", diagnostic.0);
            }
        }

        for log in db.logs.lock().unwrap().drain(..) {
            eprintln!("{log}");
        }

        // Wait for file change events, the output can't change unless the
        // inputs change.
        for event in rx.recv()?.unwrap() {
            let path = event.path.canonicalize().wrap_err_with(|| {
                format!("Failed to canonicalize path {}", event.path.display())
            })?;
            let file = match db.files.get(&path) {
                Some(file) => *file,
                None => continue,
            };
            // `path` has changed, so read it and update the contents to match.
            // This creates a new revision and causes the incremental algorithm
            // to kick in, just like any other update to a salsa input.
            let contents = std::fs::read_to_string(path)
                .wrap_err_with(|| format!("Failed to read file {}", event.path.display()))?;
            file.set_contents(&mut db).to(contents);
        }
    }
}