Repository navigation
Skip reading source files on a warm cache hit via mtime + length #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7da7d5c
031d2b4
50d5d61
14d1689
be9b074
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,43 +9,95 @@ use std::path::PathBuf; | |
| use tracing::warn; | ||
|
|
||
| use super::cache::Cache; | ||
| use super::CacheLookup; | ||
| use super::CacheResult; | ||
| use super::EmptyCacheEntry; | ||
| use super::SourceStat; | ||
|
|
||
| pub struct PerFileCache { | ||
| pub cache_dir: PathBuf, | ||
| } | ||
|
|
||
| impl Cache for PerFileCache { | ||
| fn get(&self, path: &Path) -> anyhow::Result<CacheResult> { | ||
| let empty_cache_entry = EmptyCacheEntry::new(&self.cache_dir, path) | ||
| .context(format!("Failed to create cache entry for {:?}", path))?; | ||
| let cache_entry = CacheEntry::from_empty(&empty_cache_entry)?; | ||
| if let Some(cache_entry) = cache_entry { | ||
| let file_digests_match = cache_entry.file_contents_digest | ||
| == empty_cache_entry.file_contents_digest; | ||
|
|
||
| if !file_digests_match { | ||
| Ok(CacheResult::Miss(empty_cache_entry)) | ||
| } else { | ||
| let processed_file = cache_entry.processed_file; | ||
| Ok(CacheResult::Processed(processed_file)) | ||
| // Deliberately does not read the source file yet. On a warm cache the | ||
| // stat below settles the overwhelming majority of files, and reading | ||
| // every source file to MD5 it was roughly half the cost of this phase. | ||
| let lookup = CacheLookup::new(&self.cache_dir, path); | ||
|
|
||
| let Some(cache_entry) = CacheEntry::from_lookup(&lookup)? else { | ||
| return Ok(CacheResult::Miss(lookup.read_contents()?)); | ||
| }; | ||
|
|
||
| // Fast path: the file has the same mtime and length as when we cached | ||
| // it, so it cannot have changed in any way we care about. | ||
| // | ||
| // `is_some()` is not redundant with the equality check and must not be | ||
| // folded into it. Both sides are `None` whenever no usable stat exists -- | ||
| // on a filesystem too coarse to be trusted, every file every run (see | ||
| // `SourceStat`) -- and `None == None` is true. Without this, "we have no | ||
| // idea whether the file changed" would read as "the file is unchanged", | ||
| // serving stale entries on exactly the filesystems the stat check exists | ||
| // to protect. Covered by `test_whole_second_mtime_is_not_trusted`. | ||
| if cache_entry.source_stat.is_some() | ||
| && cache_entry.source_stat == lookup.source_stat | ||
| { | ||
| return Ok(CacheResult::Processed(cache_entry.processed_file)); | ||
| } | ||
|
|
||
| // Slow path: no stat recorded (entry predates this feature, or was | ||
| // written by packwerk), or the stat moved. The content digest is still | ||
| // the authority, so fall back to it. | ||
| let empty_cache_entry = lookup.read_contents()?; | ||
| if cache_entry.file_contents_digest | ||
| != empty_cache_entry.file_contents_digest | ||
| { | ||
| return Ok(CacheResult::Miss(empty_cache_entry)); | ||
| } | ||
|
|
||
| // Contents are unchanged but the stat differs -- a checkout, a `touch`, | ||
| // or an entry written before stats were recorded. Refresh the entry so | ||
| // the next run takes the fast path. | ||
| // | ||
| // Only when there is actually a usable stat to record, and it differs | ||
| // from what is on disk. Without this guard, a filesystem too coarse to | ||
| // produce a trustworthy stat (see `SourceStat`) would yield `None` on | ||
| // every run, never match, and rewrite every cache entry every time -- | ||
| // turning a read-mostly cache into a full rewrite of itself. | ||
| let stat_is_worth_recording = empty_cache_entry.source_stat.is_some() | ||
| && empty_cache_entry.source_stat != cache_entry.source_stat; | ||
|
|
||
| if stat_is_worth_recording { | ||
| // A failure here is not fatal: the result we return is still | ||
| // correct, we just re-hash this file on the next run too. It is | ||
| // warned about rather than ignored, because a persistent failure | ||
| // (an unwritable cache dir, a full disk) degrades every subsequent | ||
| // run and would otherwise be invisible -- the tool would simply be | ||
| // slow forever with no clue why. | ||
| if let Err(e) = | ||
| self.write(&empty_cache_entry, &cache_entry.processed_file) | ||
| { | ||
| warn!( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This repair-failure path has no direct test coverage — nothing makes the cache dir unwritable/read-only to confirm |
||
| "Failed to refresh cache entry {:?}; it will be re-hashed \ | ||
| on every run until this succeeds: {}", | ||
| empty_cache_entry.cache_file_path, e | ||
| ); | ||
| } | ||
| } else { | ||
| Ok(CacheResult::Miss(empty_cache_entry)) | ||
| } | ||
|
|
||
| Ok(CacheResult::Processed(cache_entry.processed_file)) | ||
| } | ||
|
|
||
| fn write( | ||
| &self, | ||
| empty_cache_entry: &EmptyCacheEntry, | ||
| processed_file: &ProcessedFile, | ||
| ) -> anyhow::Result<()> { | ||
| let file_contents_digest = | ||
| empty_cache_entry.file_contents_digest.to_owned(); | ||
|
|
||
| let cache_entry = &CacheEntry { | ||
| file_contents_digest, | ||
| file_contents_digest: empty_cache_entry | ||
| .file_contents_digest | ||
| .to_owned(), | ||
| source_stat: empty_cache_entry.source_stat, | ||
| // Ideally we could pass by reference here, but in practice this cost should be paid on few files | ||
| // that have changed and need to be reprocessed. | ||
| processed_file: processed_file.clone(), | ||
|
|
@@ -81,14 +133,19 @@ impl Cache for PerFileCache { | |
| #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] | ||
| pub struct CacheEntry { | ||
| pub file_contents_digest: String, | ||
| /// Absent in entries written by packwerk, or by versions of pks before the | ||
| /// stat fast path existed. `serde(default)` keeps those entries readable; | ||
| /// they simply fall back to comparing the content digest. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub source_stat: Option<SourceStat>, | ||
| pub processed_file: ProcessedFile, | ||
| } | ||
|
|
||
| impl CacheEntry { | ||
| pub fn from_empty( | ||
| empty: &EmptyCacheEntry, | ||
| pub fn from_lookup( | ||
| lookup: &CacheLookup, | ||
| ) -> anyhow::Result<Option<CacheEntry>> { | ||
| let cache_file_path = &empty.cache_file_path; | ||
| let cache_file_path = &lookup.cache_file_path; | ||
|
|
||
| if cache_file_path.exists() { | ||
| match read_json_file(cache_file_path) { | ||
|
|
@@ -169,6 +226,8 @@ mod tests { | |
|
|
||
| let expected_serialized = CacheEntry { | ||
| file_contents_digest: "8f9efdcf2caa22fb7b1b4a8274e68d11".to_owned(), | ||
| // A packwerk-written entry carries no stat; it must still deserialize. | ||
| source_stat: None, | ||
| processed_file: ProcessedFile { | ||
| absolute_path: PathBuf::from("/tests/fixtures/simple_app/packs/foo/app/services/bar/foo.rb"), | ||
| unresolved_references: vec![UnresolvedReference { | ||
|
|
@@ -210,14 +269,14 @@ mod tests { | |
| fs::write(corrupt_file_path, corrupt_contents) | ||
| .context("expected to write corrupt cache file")?; | ||
|
|
||
| let empty_cache_entry = EmptyCacheEntry::new( | ||
| let lookup = CacheLookup::new( | ||
| &cache_path, | ||
| &PathBuf::from( | ||
| "tests/fixtures/simple_app/packs/foo/app/services/foo/bar.rb", | ||
| ), | ||
|
Comment on lines
+272
to
276
|
||
| ).context("expected tests/fixtures/simple_app/packs/foo/app/services/foo/bar.rb to exist")?; | ||
| ); | ||
|
|
||
| let entry = CacheEntry::from_empty(&empty_cache_entry)?; | ||
| let entry = CacheEntry::from_lookup(&lookup)?; | ||
| assert!(entry.is_none()); | ||
|
|
||
| Ok(()) | ||
|
|
@@ -252,6 +311,8 @@ mod tests { | |
| { | ||
| let entry = CacheEntry { | ||
| file_contents_digest, | ||
| // None forces the digest comparison; a real stat would hit the fast path instead. | ||
| source_stat: None, | ||
| processed_file: processed_file.clone(), | ||
| }; | ||
| fs::write( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: might be worth a one-line mention here that NFS's close-to-open attribute caching can serve a stale stat independent of the granularity issue described above. Same escape hatches apply (
--no-cache,delete-cache), but it's a distinct mechanism from the rsync/tar case documented here.