Module: Rigor::Cache::FileDigest
- Defined in:
- lib/rigor/cache/file_digest.rb
Overview
Per-run content-digest memo AND the ADR-87 stat-then-digest freshness choke point. Within a single
Rigor::Analysis::Runner#run, the same absolute path is SHA-256'd at most once — across every descriptor
that digests files: the run-diagnostics dependency descriptor (Runner#analyzed_file_entries), the
ADR-45 Descriptor#fresh? validation of that descriptor, the RBS signature tree
(RbsDescriptor.file_entries), and every plugin producer's watch:-glob validation
(Descriptor::GlobEntry). Those sets overlap heavily (an analyzed file is often also inside a plugin's
watch glob; the RBS tree is digested by both the runner and the RBS producers), so the memo removes the
redundant re-hashing that dominates warm-hit validation.
ADR-87 WD1 layers a stat tier ON TOP of the memo: a :stat Descriptor::FileEntry packs a
(digest, size, mtime_ns, ctime_ns, inode) tuple plus the run's recording instant, and FileDigest.stat_fresh?
validates it by stat-ing the file first and only falling back to FileDigest.hexdigest (this memo) when the tuple
moved or the racy window fires. On a true null build an unchanged file is a single File::Stat call and
zero SHA-256 bytes. The SHA-256 digest remains the sole change AUTHORITY — the stat tier only decides
whether the digest needs recomputing.
Soundness: the memo + recording-instant + strict flag are process-local (thread-locals, so Ractor-safe
and never crossing a fork boundary), never persisted to disk, and scoped to exactly one run via
FileDigest.with_run (which installs a fresh table and restores the previous one on exit — a second run, or a run
that re-reads a file the user edited between runs, gets a clean table). Within a single run the analyzer
already assumes the filesystem is stable — Descriptor#fresh? validates the whole dependency set against
the live tree in one pass and never re-checks mid-run — so caching a coherent per-run snapshot changes
nothing about what a run observes. Outside a run scope (no active table) every call digests directly,
identical to a bare Digest::SHA256.file.
Constant Summary collapse
- STRICT_ENV =
Set in the environment to force the strict digest-always validation path for a single run, regardless of the
cache.validationconfig setting (the env wins). The escape hatch for a filesystem whose stat tuples cannot be trusted (a coarse-timestamp mount, a restored-from-backup tree). "RIGOR_STRICT_VALIDATION"
Class Method Summary collapse
-
.hexdigest(path) ⇒ Object
The SHA-256 hex digest of
path's current content. -
.ns_of(time) ⇒ Object
Full-nanosecond integer for a
Time(fromFile::Stat#mtime/#ctime). -
.pack_stat(path, digest) ⇒ Object
ADR-87 WD1 — packs the
:statFileEntry value:"<digest> <size> <mtime_ns> <ctime_ns> <inode> <recording_instant_ns>". - .recording_instant_ns ⇒ Object
-
.stat_fresh?(path, packed) ⇒ Boolean
ADR-87 WD1 — validates a
:statFileEntry value against the live filesystem. -
.strict_validation? ⇒ Boolean
Whether digest-always validation is forced for this run.
-
.with_run(strict: false) ⇒ Object
Runs
blockwith a fresh per-run digest table installed, restoring whatever table was active before (nil on the normal top-level run; a parent table only if a run were somehow nested).
Class Method Details
.hexdigest(path) ⇒ Object
The SHA-256 hex digest of path's current content. Served from the active per-run table when one is
installed (computing + storing it on first request for that path), otherwise computed directly. A
read failure (missing / unreadable file) propagates exactly as Digest::SHA256.file would and is NOT
memoised, so a caller's own rescue path is unchanged.
66 67 68 69 70 71 |
# File 'lib/rigor/cache/file_digest.rb', line 66 def self.hexdigest(path) memo = Thread.current[MEMO_KEY] return Digest::SHA256.file(path).hexdigest if memo.nil? memo[path] ||= Digest::SHA256.file(path).hexdigest end |
.ns_of(time) ⇒ Object
Full-nanosecond integer for a Time (from File::Stat#mtime/#ctime). Public so GlobEntry packs
its per-file rows with the identical resolution the FileEntry tuple uses.
143 144 145 |
# File 'lib/rigor/cache/file_digest.rb', line 143 def self.ns_of(time) (time.tv_sec * 1_000_000_000) + time.tv_nsec end |
.pack_stat(path, digest) ⇒ Object
ADR-87 WD1 — packs the :stat FileEntry value: "<digest> <size> <mtime_ns> <ctime_ns> <inode> <recording_instant_ns>". The digest is supplied by the caller (already computed via hexdigest or
from in-memory content). Returns nil when the file cannot be stat-ed (a race between the digest and the
stat), so the caller can fall back to a plain :digest entry.
85 86 87 88 89 90 |
# File 'lib/rigor/cache/file_digest.rb', line 85 def self.pack_stat(path, digest) st = File.stat(path) "#{digest} #{st.size} #{ns(st.mtime)} #{ns(st.ctime)} #{st.ino} #{recording_instant_ns}" rescue SystemCallError nil end |
.recording_instant_ns ⇒ Object
111 112 113 |
# File 'lib/rigor/cache/file_digest.rb', line 111 def self.recording_instant_ns Thread.current[INSTANT_KEY] || now_ns end |
.stat_fresh?(path, packed) ⇒ Boolean
ADR-87 WD1 — validates a :stat FileEntry value against the live filesystem. Stats the file first;
when the (size, mtime_ns, ctime_ns, inode) tuple is unchanged AND the entry is not racy, the file is
fresh with zero bytes hashed. When the tuple moved or the entry is racy, the recorded digest is the
authority: the file is re-hashed and fresh iff the digest still matches (a touch — a moved stat over
identical content — therefore validates as fresh). Any stat failure (missing / unreadable file) raises
and the caller reads it as stale. In strict mode the stat tier is skipped entirely.
98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/rigor/cache/file_digest.rb', line 98 def self.stat_fresh?(path, packed) parsed = parse_stat(packed) return false if parsed.nil? digest = parsed[0] return hexdigest(path) == digest if strict_validation? st = File.stat(path) return true if !racy?(parsed) && tuple_matches?(st, parsed) hexdigest(path) == digest end |
.strict_validation? ⇒ Boolean
Returns whether digest-always validation is forced for this run. The env var wins over the
run-scoped config flag so an operator can bypass the stat tier without editing .rigor.yml.
75 76 77 78 79 |
# File 'lib/rigor/cache/file_digest.rb', line 75 def self.strict_validation? return true if ENV[STRICT_ENV] == "1" Thread.current[STRICT_KEY] == true end |
.with_run(strict: false) ⇒ Object
Runs block with a fresh per-run digest table installed, restoring whatever table was active before
(nil on the normal top-level run; a parent table only if a run were somehow nested). Always restores,
even on a raise, so a failed run never leaks its table into the next one. The recording instant is
captured at run start — a file whose recorded mtime is not strictly older than it is treated as racy
(always re-hashed), catching a file edited during the analysis window. strict: forces the
digest-always path for cache.validation: digest.
48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/rigor/cache/file_digest.rb', line 48 def self.with_run(strict: false) previous_memo = Thread.current[MEMO_KEY] previous_instant = Thread.current[INSTANT_KEY] previous_strict = Thread.current[STRICT_KEY] Thread.current[MEMO_KEY] = {} Thread.current[INSTANT_KEY] = now_ns Thread.current[STRICT_KEY] = strict yield ensure Thread.current[MEMO_KEY] = previous_memo Thread.current[INSTANT_KEY] = previous_instant Thread.current[STRICT_KEY] = previous_strict end |