perf: stale-while-revalidate cache — always instant startup
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing

Cache is now returned immediately on every run. When stale (age >
cache_ttl_hours), a detached background process rebuilds it
incrementally via --background-refresh, so blocking scans never
happen in the foreground after the first run.

cache_ttl_hours is now actually enforced (previously ignored).
This commit is contained in:
Cinco Euzebio 2026-03-04 23:39:30 -03:00
parent 2abf7e77b4
commit 8aa341080d
5 changed files with 175 additions and 168 deletions

View File

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.9.2] - 2026-03-04
### Changed
- Cache now uses stale-while-revalidate: cached projects are returned immediately and a background process (`--background-refresh`) rebuilds the cache when it is stale, eliminating blocking scans on every invocation
- `cache_ttl_hours` is now enforced: when the cache age exceeds the configured TTL, a background refresh is triggered automatically
## [0.9.1] - 2026-03-01 ## [0.9.1] - 2026-03-01
### Fixed ### Fixed

2
Cargo.lock generated
View File

@ -857,7 +857,7 @@ dependencies = [
[[package]] [[package]]
name = "tmuxido" name = "tmuxido"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "tmuxido" name = "tmuxido"
version = "0.9.1" version = "0.9.2"
edition = "2024" edition = "2024"
[dev-dependencies] [dev-dependencies]

View File

@ -55,9 +55,47 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
&ProjectCache::load, &ProjectCache::load,
&|cache| cache.save(), &|cache| cache.save(),
&scan_all_roots, &scan_all_roots,
&spawn_background_refresh,
) )
} }
/// Rebuilds the project cache incrementally. Intended to be called from a
/// background process spawned by `get_projects` via stale-while-revalidate.
pub fn refresh_cache(config: &Config) -> Result<()> {
match ProjectCache::load()? {
None => {
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
}
Some(mut cache) => {
if cache.dir_mtimes.is_empty() {
// Old cache format — full rescan
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
} else {
// Incremental rescan based on directory mtimes
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache.save()?;
}
}
}
}
Ok(())
}
fn spawn_background_refresh() {
if let Ok(exe) = std::env::current_exe() {
std::process::Command::new(exe)
.arg("--background-refresh")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
}
}
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn get_projects_internal( fn get_projects_internal(
config: &Config, config: &Config,
@ -65,45 +103,31 @@ fn get_projects_internal(
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>, cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>, cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>, scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> { ) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh { if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects); return Ok(projects);
} }
if let Some(mut cache) = cache_loader()? { if let Some(cache) = cache_loader()? {
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo // Cache exists — return immediately (stale-while-revalidate).
if cache.dir_mtimes.is_empty() { // Spawn a background refresh if the cache is stale or in old format.
eprintln!("Upgrading cache, scanning for projects..."); let is_stale =
let (projects, fingerprints) = scanner(config)?; cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
let new_cache = ProjectCache::new(projects.clone(), fingerprints); if is_stale {
cache_saver(&new_cache)?; refresh_spawner();
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache_saver(&cache)?;
eprintln!(
"Cache updated incrementally ({} projects)",
cache.projects.len()
);
} else {
eprintln!("Using cached projects ({} projects)", cache.projects.len());
} }
return Ok(cache.projects); return Ok(cache.projects);
} }
// Sem cache ainda — scan completo inicial // No cache yet — first run, blocking scan is unavoidable.
eprintln!("No cache found, scanning for projects..."); eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scanner(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
Ok(projects) Ok(projects)
} }
@ -196,242 +220,208 @@ mod tests {
use super::*; use super::*;
use std::cell::RefCell; use std::cell::RefCell;
fn create_test_config(cache_enabled: bool) -> Config { fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config {
Config { Config {
paths: vec!["/tmp/test".to_string()], paths: vec!["/tmp/test".to_string()],
max_depth: 3, max_depth: 3,
cache_enabled, cache_enabled,
cache_ttl_hours: 24, cache_ttl_hours,
update_check_interval_hours: 24, update_check_interval_hours: 24,
default_session: session::SessionConfig { windows: vec![] }, default_session: session::SessionConfig { windows: vec![] },
} }
} }
fn fresh_cache(projects: Vec<PathBuf>) -> ProjectCache {
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
ProjectCache::new(projects, fingerprints)
// last_updated = now_secs() — within any reasonable TTL
}
fn stale_cache(projects: Vec<PathBuf>) -> ProjectCache {
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
let mut c = ProjectCache::new(projects, fingerprints);
c.last_updated = 0; // epoch — always older than TTL
c
}
fn call_internal(
config: &Config,
force_refresh: bool,
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> {
get_projects_internal(
config,
force_refresh,
cache_loader,
cache_saver,
scanner,
refresh_spawner,
)
}
#[test] #[test]
fn should_scan_when_cache_disabled() { fn should_scan_when_cache_disabled() {
let config = create_test_config(false); let config = make_config(false, 24);
let projects = vec![PathBuf::from("/tmp/test/project1")]; let expected = vec![PathBuf::from("/p1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal( let result = call_internal(
&config, &config,
false, false,
&|| panic!("should not load cache when disabled"), &|| panic!("loader must not be called when cache disabled"),
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone())) Ok((expected.clone(), HashMap::new()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects); assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_scan_when_force_refresh() { fn should_scan_when_force_refresh() {
let config = create_test_config(true); let config = make_config(true, 24);
let projects = vec![PathBuf::from("/tmp/test/project1")]; let expected = vec![PathBuf::from("/p1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal( let result = call_internal(
&config, &config,
true, true,
&|| panic!("should not load cache when force refresh"), &|| panic!("loader must not be called on force refresh"),
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone())) Ok((expected.clone(), HashMap::new()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects); assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_do_initial_scan_when_no_cache_exists() { fn should_do_blocking_scan_when_no_cache_exists() {
let config = create_test_config(true); let config = make_config(true, 24);
let projects = vec![PathBuf::from("/tmp/test/project1")]; let expected = vec![PathBuf::from("/p1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal( let result = call_internal(
&config, &config,
false, false,
&|| { &|| Ok(None),
*loader_called.borrow_mut() = true;
Ok(None)
},
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone())) Ok((expected.clone(), HashMap::new()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects); assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_upgrade_old_cache_format() { fn should_return_cached_projects_immediately_when_cache_is_fresh() {
let config = create_test_config(true); let config = make_config(true, 24);
let old_projects = vec![PathBuf::from("/old/project")]; let cached = vec![PathBuf::from("/cached/project")];
let new_projects = vec![ let cache = RefCell::new(Some(fresh_cache(cached.clone())));
PathBuf::from("/new/project1"), let spawner_called = RefCell::new(false);
PathBuf::from("/new/project2"),
];
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
// Use RefCell<Option<>> to allow moving into closure multiple times let result = call_internal(
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config, &config,
false, false,
&|| { &|| Ok(cache.borrow_mut().take()),
*loader_called.borrow_mut() = true; &|_| panic!("saver must not be called in foreground"),
// Take the cache out of the RefCell &|_| panic!("scanner must not be called when cache is fresh"),
Ok(old_cache.borrow_mut().take()) &|| *spawner_called.borrow_mut() = true,
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((new_projects.clone(), new_fingerprints.clone()))
},
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner()); assert_eq!(result.unwrap(), cached);
assert!(scanner_called.into_inner()); assert!(
assert_eq!(*saver_count.borrow(), 1); !spawner_called.into_inner(),
assert_eq!(result.unwrap(), new_projects); "fresh cache should not trigger background refresh"
);
} }
#[test] #[test]
fn should_use_cached_projects_when_nothing_changed() { fn should_return_stale_cache_immediately_and_spawn_background_refresh() {
let config = create_test_config(true); let config = make_config(true, 24);
let cached_projects = vec![ let cached = vec![PathBuf::from("/cached/project")];
PathBuf::from("/nonexistent/project1"), let cache = RefCell::new(Some(stale_cache(cached.clone())));
PathBuf::from("/nonexistent/project2"), let spawner_called = RefCell::new(false);
];
// Use a path that doesn't exist - validate_and_update will skip rescan
// because it can't check mtime of non-existent directory
let cached_fingerprints =
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
// Use RefCell<Option<>> to allow moving into closure multiple times let result = call_internal(
let cache = RefCell::new(Some(ProjectCache::new(
cached_projects.clone(),
cached_fingerprints,
)));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config, &config,
false, false,
&|| { &|| Ok(cache.borrow_mut().take()),
*loader_called.borrow_mut() = true; &|_| panic!("saver must not be called in foreground"),
// Take the cache out of the RefCell &|_| panic!("scanner must not be called in foreground"),
Ok(cache.borrow_mut().take()) &|| *spawner_called.borrow_mut() = true,
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
panic!("should not do full scan when cache is valid")
},
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner()); assert_eq!(result.unwrap(), cached);
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update assert!(
// treats it as "changed" and removes projects under that path. spawner_called.into_inner(),
// This test verifies the flow completes - the specific behavior of "stale cache must trigger background refresh"
// validate_and_update is tested separately in cache.rs );
let result_projects = result.unwrap();
// Projects were removed because the tracked directory doesn't exist
assert!(result_projects.is_empty());
} }
#[test] #[test]
fn should_update_incrementally_when_cache_changed() { fn should_spawn_background_refresh_when_cache_has_no_fingerprints() {
let config = create_test_config(true); let config = make_config(true, 24);
let initial_projects = vec![PathBuf::from("/nonexistent/project1")]; let cached = vec![PathBuf::from("/old/project")];
// Use a path that doesn't exist - validate_and_update will treat missing // Old cache format: no dir_mtimes
// directory as a change (unwrap_or(true) in the mtime check) let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new())));
let mut dir_mtimes = HashMap::new(); let spawner_called = RefCell::new(false);
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
// Use RefCell<Option<>> to allow moving into closure multiple times let result = call_internal(
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
let loader_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config, &config,
false, false,
&|| { &|| Ok(old_cache.borrow_mut().take()),
*loader_called.borrow_mut() = true; &|_| panic!("saver must not be called in foreground"),
// Take the cache out of the RefCell &|_| panic!("scanner must not be called in foreground"),
Ok(cache.borrow_mut().take()) &|| *spawner_called.borrow_mut() = true,
},
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| panic!("full scan should not happen with incremental update"),
); );
// validate_and_update is called internally. Since the directory doesn't exist,
// it treats it as "changed" and will try to rescan using scan_from_root.
// We verify the flow completes without panicking.
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner()); assert_eq!(result.unwrap(), cached);
// Note: The saver may or may not be called depending on whether assert!(
// validate_and_update detects changes (missing dir = change) spawner_called.into_inner(),
"old cache format must trigger background refresh"
);
} }
} }

View File

@ -8,8 +8,8 @@ use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update; use tmuxido::self_update;
use tmuxido::update_check; use tmuxido::update_check;
use tmuxido::{ use tmuxido::{
get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard, get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
show_cache_status, setup_shortcut_wizard, show_cache_status,
}; };
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -41,6 +41,10 @@ struct Args {
/// Install the .desktop entry and icon for app launcher integration /// Install the .desktop entry and icon for app launcher integration
#[arg(long)] #[arg(long)]
setup_desktop_shortcut: bool, setup_desktop_shortcut: bool,
/// Internal: rebuild cache in background (used by stale-while-revalidate)
#[arg(long, hide = true)]
background_refresh: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -51,6 +55,13 @@ fn main() -> Result<()> {
return self_update::self_update(); return self_update::self_update();
} }
// Handle background cache refresh (spawned internally by stale-while-revalidate).
// Runs early to avoid unnecessary dependency checks and config prompts.
if args.background_refresh {
let config = Config::load()?;
return refresh_cache(&config);
}
// Handle standalone shortcut setup // Handle standalone shortcut setup
if args.setup_shortcut { if args.setup_shortcut {
return setup_shortcut_wizard(); return setup_shortcut_wizard();