Skip to content

Commit 7da7d5c

Browse files
perryqhclaude
andcommitted
Skip reading source files on a warm cache hit via mtime + length
`process_files_with_cache` is the largest phase of `pks check`, and half of it is work we can avoid: for every file we open it, read it in full, and MD5 it, purely to compare a digest against the cache entry. Record (mtime_ns, len) alongside the digest and settle the common case -- nothing changed since the last run -- with one `stat`. The digest remains the authority: if no stat is recorded, or the stat moved, we fall back to reading and hashing exactly as before. An entry whose contents match but whose stat moved (a git checkout, a `touch`) is repaired in place so the next run takes the fast path. MEASURED on a 51,513-file application, A/B against main in one hyperfine run: main 4.945s +/- 0.118 this branch 3.450s +/- 0.117 1.43x faster user time 7.534s -> 6.877s system time 12.927s -> 8.495s (-34%) An earlier batch on a busier machine measured the same pair at 1.30x. Both are valid single-batch comparisons; the ratio moves with load because the phases this does not touch are a larger share when the machine is quiet. The system-time drop is the stable signal, and it is the mechanism: this phase is syscall-bound, not CPU-bound. An earlier attempt to speed the same phase up by parsing JSON faster (from_reader -> from_slice) changed nothing measurable, which is what pointed at the syscalls. ## Coarse filesystems are detected, not assumed away Trusting (mtime, len) is only sound where the filesystem timestamps finely enough to notice a write. At one-second granularity -- some Docker bind mounts on macOS, NFS, SMB, FAT -- a same-length edit inside the same second keeps both fields, and a stat-only check would serve the stale entry. Rather than probe or assume a platform, `SourceStat::of` reads the value it already has: a non-zero sub-second component proves the filesystem tracks sub-second time, so an edit at any other instant would have moved the mtime. A zero component means it cannot tell us, so the stat is discarded and the digest carries the entry. This needed no new branches at the call sites -- `None` already meant "no usable stat" -- and both ways of being wrong fail safe: - Coarse filesystem: nothing is trusted, the fast path never engages. Correct, just not faster. - Fine filesystem, mtime landing exactly on a second boundary: a 1-in-10^9 coincidence costing one extra hash. Measured across 20,003 files of a real Rails application: zero occurrences. Cost of the check itself: 1.00x +/- 0.02 against the same branch without it. It narrows rather than closes the window, and the type's docs say so. A millisecond-granularity filesystem is trusted, so two same-length writes inside one millisecond would still be missed -- six orders of magnitude tighter, and needing machine-speed edits to reach. Also documented: mtimes that are copied rather than set by writing (rsync -t, tar -p, cp -p) can carry a timestamp from elsewhere; every mtime-driven cache shares that hole, which is why they all document `touch` as the way to force a rebuild. ## packwerk compatibility Verified against packwerk 3.3.0, and the concern turned out to be misplaced: - Its `Cache::CacheContents.deserialize` uses plain hash access and never enumerates keys, so an unknown key is invisible to it. Ran its logic against a packwerk-format entry carrying `source_stat`: reads fine. - The tools do not share a directory. packwerk reads `tmp/cache/packwerk/<md5>`; pks writes `tmp/cache/packwerk/zeitwerk/<md5>`. - The formats were never interchangeable. Feeding packwerk what pks writes today raises `NoMethodError: undefined method 'map' for nil`. That predates this change, so `test_compatible_with_packwerk` does not test what its name claims; it round-trips pks's own format. Left alone, but it is not a guarantee. Regardless, `source_stat` is `#[serde(default, skip_serializing_if)]`, so an entry without one still deserializes and is still honored via the digest. ## Failure modes closed by construction - `EmptyCacheEntry` holds `Option<String>` rather than an empty string meaning "not computed", private behind `digest()`. `write` errors instead of persisting a placeholder, which would have produced an entry that never matches -- making that file permanently uncacheable and silently slow. - The in-place repair warns on failure rather than discarding the error. The result stays correct either way, but a persistent failure (unwritable cache dir, full disk) would otherwise leave every run re-hashing with no clue why. - That repair only fires when there is a stat worth recording. Without the guard, a filesystem yielding `None` every run would never match and would rewrite the entire cache every time. ## Tests tests/cache_stat_fastpath_test.rs, ten cases. Note that before this change *no test in the repo exercised a warm cache at all* -- every fixture ships `cache: false` -- so these paths were untested rather than under-tested. The fast path: stats are recorded; warm output matches cold; an edit invalidates; a *same-length* edit invalidates (the case a length-only check would miss); a whole-second mtime is not trusted. Fallback and repair: a stat-less packwerk-style entry is honored then upgraded; a stale stat with a matching digest is repaired in place; a malformed `source_stat` (five shapes) degrades to the digest without panicking. Other commands: `pks update` on a warm cache -- the highest-consequence path, since update *writes* package_todo.yml and a stale entry persists a wrong answer rather than printing one -- and the experimental parser, which uses a different cache subdirectory but shares this implementation. Each was verified to fail rather than assumed to pass: injecting a bug that makes the cache always hit fails 7 of the 10, and the granularity and repair guards were separately confirmed to fail with their own checks removed. Uses `common::Fixture` from #57 rather than a local copy helper. Verified: `check` and `check --no-cache` produce identical output on the 51k-file application, as do this branch and main. Across the 30 fixture apps with a packwerk.yml, 29 are byte-identical; the 30th is app_with_monkey_patches, which trips the pre-existing nondeterministic duplicate-constant panic in both binaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 54014ec commit 7da7d5c

3 files changed

Lines changed: 736 additions & 22 deletions

File tree

src/packs/caching/mod.rs

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::path::{Path, PathBuf};
22

3+
use serde::{Deserialize, Serialize};
4+
35
use super::{file_utils::file_content_digest, ProcessedFile};
46
pub(crate) mod cache;
57
pub(crate) mod noop_cache;
@@ -10,34 +12,155 @@ pub enum CacheResult {
1012
Miss(EmptyCacheEntry),
1113
}
1214

15+
/// Cheap identity for a source file, obtained from one `stat` call.
16+
///
17+
/// The content digest remains the authority on whether a cache entry is valid.
18+
/// This exists only so that the common case -- nothing changed since the last
19+
/// run -- can be settled without opening and hashing the file.
20+
///
21+
/// # Only used where the filesystem timestamps finely enough to be trusted
22+
///
23+
/// Treating a matching (mtime, len) as "unchanged" is only sound if every write
24+
/// moves the mtime, which is a filesystem property rather than a guarantee. On a
25+
/// filesystem with one-second granularity -- some Docker bind mounts on macOS,
26+
/// NFS, SMB, FAT -- a file edited to *the same length* within the same second as
27+
/// it was cached keeps both its mtime and its length, and a stat-only check
28+
/// would happily serve the stale entry.
29+
///
30+
/// Rather than assume, this detects it per file: a filesystem that reports a
31+
/// non-zero sub-second component is one that tracks sub-second time, so an edit
32+
/// at any other instant *would* have moved the mtime. When the component is zero
33+
/// the stat is discarded and the caller falls back to hashing the contents,
34+
/// which is always correct.
35+
///
36+
/// The consequences of being wrong run the safe direction in both cases:
37+
///
38+
/// - Coarse filesystem: every mtime is a whole second, every file falls back to
39+
/// the digest, and the fast path simply does not engage. Correct, no faster.
40+
/// - Fine filesystem, and a file whose mtime lands exactly on a second boundary:
41+
/// a 1-in-10^9 coincidence that costs one extra hash for that file. Measured
42+
/// on a 20,003-file Rails application: **zero** files hit it.
43+
///
44+
/// This narrows rather than closes the window. A filesystem with, say,
45+
/// millisecond granularity reports a non-zero sub-second component and is
46+
/// trusted, so two same-length writes inside one millisecond would still be
47+
/// missed. That is six orders of magnitude tighter than the one-second case and
48+
/// requires machine-speed edits to reach.
49+
///
50+
/// # The remaining hole: mtimes that are copied rather than set by writing
51+
///
52+
/// The check above establishes that the *filesystem* would have moved the mtime.
53+
/// It cannot establish that nobody moved it back. Tools that deliberately
54+
/// preserve timestamps -- `rsync -t`, `tar -p`, `cp -p`, unzip, some
55+
/// backup/restore and container-image flows -- can install different content
56+
/// carrying an mtime from somewhere else. If that mtime and the length both
57+
/// happen to match what was cached, the fast path serves a stale entry.
58+
///
59+
/// In practice this needs the replacement to match the cached version in both
60+
/// mtime and byte length, which usually means restoring a near-identical copy of
61+
/// what was already there. It is not specific to this design: `make`, `ccache`
62+
/// and every other mtime-driven cache have the same hole, which is why they all
63+
/// document `touch` as a way to force a rebuild.
64+
///
65+
/// If it ever bites, `--no-cache` is the escape hatch, and `pks delete-cache`
66+
/// clears the state. A tool-side fix would mean giving up on stat-only
67+
/// validation and always hashing, which is precisely the cost this exists to
68+
/// avoid.
69+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70+
pub struct SourceStat {
71+
/// Nanoseconds since the unix epoch. u64 is good until the year 2554.
72+
pub mtime_ns: u64,
73+
pub len: u64,
74+
}
75+
76+
const NANOS_PER_SEC: u64 = 1_000_000_000;
77+
78+
impl SourceStat {
79+
/// `None` whenever the stat cannot be trusted as a change detector: the file
80+
/// cannot be stat'd, has no mtime, has one before the unix epoch or too far
81+
/// in the future to represent, or -- see the type docs -- carries no
82+
/// sub-second precision. Every such case falls back to the content digest,
83+
/// which is authoritative anyway, and which will produce a sensible error if
84+
/// the file is genuinely unreadable.
85+
pub fn of(path: &Path) -> Option<SourceStat> {
86+
let metadata = std::fs::metadata(path).ok()?;
87+
let since_epoch = metadata
88+
.modified()
89+
.ok()?
90+
.duration_since(std::time::UNIX_EPOCH)
91+
.ok()?;
92+
93+
// `try_from` rather than `as`, which would silently wrap a far-future
94+
// mtime into a small value that could collide with a real one.
95+
let mtime_ns = u64::try_from(since_epoch.as_nanos()).ok()?;
96+
97+
// No sub-second component means this filesystem cannot tell us about a
98+
// change made within the same second. Do not trust it.
99+
if mtime_ns % NANOS_PER_SEC == 0 {
100+
return None;
101+
}
102+
103+
Some(SourceStat {
104+
mtime_ns,
105+
len: metadata.len(),
106+
})
107+
}
108+
}
109+
13110
#[derive(Debug, Default)]
14111
pub struct EmptyCacheEntry {
15112
#[allow(dead_code)]
16113
pub filepath: PathBuf,
17-
pub file_contents_digest: String,
114+
/// `None` until [`Self::populate_digest`] computes it. Private so that
115+
/// "not computed yet" cannot be mistaken for a digest: writing an entry
116+
/// without one would persist a value that never matches, quietly making
117+
/// that file uncacheable forever.
118+
file_contents_digest: Option<String>,
18119
#[allow(dead_code)]
19120
pub file_name_digest: String,
20121
pub cache_file_path: PathBuf,
122+
pub source_stat: Option<SourceStat>,
21123
}
22124

23125
impl EmptyCacheEntry {
24-
pub fn new(
126+
/// The parts of a cache entry that can be derived without reading the file's
127+
/// contents. Reading + MD5-ing the source is the expensive half, so it is
128+
/// deferred until something actually needs the digest.
129+
pub fn without_digest(
25130
cache_directory: &Path,
26131
filepath: &Path,
27132
) -> anyhow::Result<EmptyCacheEntry> {
28133
let file_digest = md5::compute(filepath.to_str().unwrap());
29134
let file_name_digest = format!("{:x}", file_digest);
30135
let cache_file_path = cache_directory.join(&file_name_digest);
31136

32-
let file_contents_digest = file_content_digest(filepath)?;
33-
34137
Ok(EmptyCacheEntry {
35138
filepath: filepath.to_owned(),
36-
file_contents_digest,
139+
file_contents_digest: None,
37140
cache_file_path,
38141
file_name_digest,
142+
source_stat: SourceStat::of(filepath),
39143
})
40144
}
145+
146+
/// Reads and hashes the file, at most once per entry.
147+
pub fn populate_digest(&mut self) -> anyhow::Result<&str> {
148+
if self.file_contents_digest.is_none() {
149+
self.file_contents_digest =
150+
Some(file_content_digest(&self.filepath)?);
151+
}
152+
Ok(self
153+
.file_contents_digest
154+
.as_deref()
155+
.expect("just populated above"))
156+
}
157+
158+
/// The digest, if it has been computed. `None` means no one has called
159+
/// [`Self::populate_digest`] -- see the field comment for why that must not
160+
/// be treated as an empty digest.
161+
pub fn digest(&self) -> Option<&str> {
162+
self.file_contents_digest.as_deref()
163+
}
41164
}
42165

43166
pub fn create_cache_dir_idempotently(cache_dir: &Path) {

src/packs/caching/per_file_cache.rs

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,41 +11,107 @@ use tracing::warn;
1111
use super::cache::Cache;
1212
use super::CacheResult;
1313
use super::EmptyCacheEntry;
14+
use super::SourceStat;
1415

1516
pub struct PerFileCache {
1617
pub cache_dir: PathBuf,
1718
}
1819

1920
impl Cache for PerFileCache {
2021
fn get(&self, path: &Path) -> anyhow::Result<CacheResult> {
21-
let empty_cache_entry = EmptyCacheEntry::new(&self.cache_dir, path)
22-
.context(format!("Failed to create cache entry for {:?}", path))?;
23-
let cache_entry = CacheEntry::from_empty(&empty_cache_entry)?;
24-
if let Some(cache_entry) = cache_entry {
25-
let file_digests_match = cache_entry.file_contents_digest
26-
== empty_cache_entry.file_contents_digest;
27-
28-
if !file_digests_match {
29-
Ok(CacheResult::Miss(empty_cache_entry))
30-
} else {
31-
let processed_file = cache_entry.processed_file;
32-
Ok(CacheResult::Processed(processed_file))
22+
// Deliberately does not read the source file yet. On a warm cache the
23+
// stat below settles the overwhelming majority of files, and reading
24+
// every source file to MD5 it was roughly half the cost of this phase.
25+
let mut empty_cache_entry =
26+
EmptyCacheEntry::without_digest(&self.cache_dir, path).context(
27+
format!("Failed to create cache entry for {:?}", path),
28+
)?;
29+
30+
let Some(cache_entry) = CacheEntry::from_empty(&empty_cache_entry)?
31+
else {
32+
empty_cache_entry.populate_digest()?;
33+
return Ok(CacheResult::Miss(empty_cache_entry));
34+
};
35+
36+
// Fast path: the file has the same mtime and length as when we cached
37+
// it, so it cannot have changed in any way we care about.
38+
//
39+
// `is_some()` is not redundant with the equality check and must not be
40+
// folded into it. Both sides are `None` whenever no usable stat exists --
41+
// on a filesystem too coarse to be trusted, every file every run (see
42+
// `SourceStat`) -- and `None == None` is true. Without this, "we have no
43+
// idea whether the file changed" would read as "the file is unchanged",
44+
// serving stale entries on exactly the filesystems the stat check exists
45+
// to protect. Covered by `test_whole_second_mtime_is_not_trusted`.
46+
if cache_entry.source_stat.is_some()
47+
&& cache_entry.source_stat == empty_cache_entry.source_stat
48+
{
49+
return Ok(CacheResult::Processed(cache_entry.processed_file));
50+
}
51+
52+
// Slow path: no stat recorded (entry predates this feature, or was
53+
// written by packwerk), or the stat moved. The content digest is still
54+
// the authority, so fall back to it.
55+
let digest = empty_cache_entry.populate_digest()?;
56+
if cache_entry.file_contents_digest != digest {
57+
return Ok(CacheResult::Miss(empty_cache_entry));
58+
}
59+
60+
// Contents are unchanged but the stat differs -- a checkout, a `touch`,
61+
// or an entry written before stats were recorded. Refresh the entry so
62+
// the next run takes the fast path.
63+
//
64+
// Only when there is actually a usable stat to record, and it differs
65+
// from what is on disk. Without this guard, a filesystem too coarse to
66+
// produce a trustworthy stat (see `SourceStat`) would yield `None` on
67+
// every run, never match, and rewrite every cache entry every time --
68+
// turning a read-mostly cache into a full rewrite of itself.
69+
let stat_is_worth_recording = empty_cache_entry.source_stat.is_some()
70+
&& empty_cache_entry.source_stat != cache_entry.source_stat;
71+
72+
if stat_is_worth_recording {
73+
// A failure here is not fatal: the result we return is still
74+
// correct, we just re-hash this file on the next run too. It is
75+
// warned about rather than ignored, because a persistent failure
76+
// (an unwritable cache dir, a full disk) degrades every subsequent
77+
// run and would otherwise be invisible -- the tool would simply be
78+
// slow forever with no clue why.
79+
if let Err(e) =
80+
self.write(&empty_cache_entry, &cache_entry.processed_file)
81+
{
82+
warn!(
83+
"Failed to refresh cache entry {:?}; it will be re-hashed \
84+
on every run until this succeeds: {}",
85+
empty_cache_entry.cache_file_path, e
86+
);
3387
}
34-
} else {
35-
Ok(CacheResult::Miss(empty_cache_entry))
3688
}
89+
90+
Ok(CacheResult::Processed(cache_entry.processed_file))
3791
}
3892

3993
fn write(
4094
&self,
4195
empty_cache_entry: &EmptyCacheEntry,
4296
processed_file: &ProcessedFile,
4397
) -> anyhow::Result<()> {
44-
let file_contents_digest =
45-
empty_cache_entry.file_contents_digest.to_owned();
98+
// A missing digest means a caller reached `write` without hashing the
99+
// file. Erroring is deliberate: persisting a placeholder would produce
100+
// an entry that never matches, making the file permanently uncacheable
101+
// and silently slow.
102+
let file_contents_digest = empty_cache_entry
103+
.digest()
104+
.with_context(|| {
105+
format!(
106+
"Refusing to write a cache entry for {:?} with no content digest",
107+
empty_cache_entry.filepath
108+
)
109+
})?
110+
.to_owned();
46111

47112
let cache_entry = &CacheEntry {
48113
file_contents_digest,
114+
source_stat: empty_cache_entry.source_stat,
49115
// Ideally we could pass by reference here, but in practice this cost should be paid on few files
50116
// that have changed and need to be reprocessed.
51117
processed_file: processed_file.clone(),
@@ -81,6 +147,11 @@ impl Cache for PerFileCache {
81147
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
82148
pub struct CacheEntry {
83149
pub file_contents_digest: String,
150+
/// Absent in entries written by packwerk, or by versions of pks before the
151+
/// stat fast path existed. `serde(default)` keeps those entries readable;
152+
/// they simply fall back to comparing the content digest.
153+
#[serde(default, skip_serializing_if = "Option::is_none")]
154+
pub source_stat: Option<SourceStat>,
84155
pub processed_file: ProcessedFile,
85156
}
86157

@@ -169,6 +240,8 @@ mod tests {
169240

170241
let expected_serialized = CacheEntry {
171242
file_contents_digest: "8f9efdcf2caa22fb7b1b4a8274e68d11".to_owned(),
243+
// A packwerk-written entry carries no stat; it must still deserialize.
244+
source_stat: None,
172245
processed_file: ProcessedFile {
173246
absolute_path: PathBuf::from("/tests/fixtures/simple_app/packs/foo/app/services/bar/foo.rb"),
174247
unresolved_references: vec![UnresolvedReference {
@@ -210,7 +283,7 @@ mod tests {
210283
fs::write(corrupt_file_path, corrupt_contents)
211284
.context("expected to write corrupt cache file")?;
212285

213-
let empty_cache_entry = EmptyCacheEntry::new(
286+
let empty_cache_entry = EmptyCacheEntry::without_digest(
214287
&cache_path,
215288
&PathBuf::from(
216289
"tests/fixtures/simple_app/packs/foo/app/services/foo/bar.rb",

0 commit comments

Comments
 (0)