Skip to main content

Module cache

Module cache 

Source
Expand description

An on-disk cache of what checking one module reported (issues #3, #19).

Building a checker costs about 13.5 ms and type-checking a few thousand lines of Teal costs about a second (crates/htl-core/benches/check.rs). A run that changed nothing should pay neither, and a run that changed one module should pay for that module and what depends on it rather than for the project.

§One entry per module

#3 shipped this as one entry per invocation, because at the time it looked as though skipping individual modules would change what the rest of them saw. Two things settled that: #21 made a file’s result independent of its position in the walk, and measurement showed the shared module store to be an optimisation rather than a precondition — a dependency being checked first does not change what its requirer reports. So an entry is now per module, and a run is the sum of them.

§What makes an entry

The key is the module as this invocation names it — the path spelling the checker will put into the diagnostics, plus the lint selection and the working directory. --strict and --format are deliberately absent: they change how a run is summarized and what it exits with, not what any module reports, so runs that differ only in those share their modules’ entries.

The inputs are the module and everything reading it required, by content hash. The probes are the directories a require could resolve in, by the set of module names in each — a new .tl appearing earlier on the search path changes what a name resolves to while every recorded hash still matches, and nothing else would catch it. This is the hole ccache documents in its direct mode.

§No mtimes anywhere

Content hashes only. Timestamp-based invalidation is where this class of tool historically breaks — second-granular filesystems on macOS, mtimes zeroed by Docker layers, clock skew, fresh CI checkouts invalidating everything — and hashing a module costs microseconds against the milliseconds it saves.

§Failure is a miss

Every error path here returns “no entry” rather than propagating. A corrupt file, an unreadable directory, a store on a read-only filesystem: the module gets checked, as it would have been anyway. The one invariant worth stating is mypy’s: an entry is written whole or not at all, via a temporary file and a rename, so a reader never sees half of one. Set HTL_CACHE_DEBUG=1 to print why a lookup missed.

§Why this lives in htl-core

The store began in the CLI, which was the only reader. The linker is a reader too (#100): htl build and include_bundle! walk the require closure through crate::link, and a module whose gen entry still holds — the same entry htl test writes and replays — has no reason to be generated again. htl-cli depends on htl, which depends on htl-macros, so a store the macros can open has to sit below both. The CLI keeps its flags, its cache status command and its report types; what moved is the store and the JSON shapes it writes, and the conversions between those shapes and crate::CheckInfo, which every reader was carrying a copy of.

Only htl check sweeps (Cache::sweep). It is the one reader that sees the whole project, so its keep-list is the whole project’s; a build or a macro expansion sees one closure, and sweeping from that view would evict every other entry the project has.

Structs§

Cache
A store rooted at a project.
CheckInfoJson
A CheckInfo as an entry stores it.
Contents
What a project’s store holds.
DependencyErrorJson
One htl::DependencyError as an entry stores it.
DependencyJson
What a dependency’s diagnostic carries besides its text: the file that required it and where the file lives. Stored with the diagnostic (Recorded) so a replay says exactly what the run said, and decides the same way whether to say it.
EditJson
One replacement in a FixJson: a half-open span and what goes there.
EntrySummary
One entry, as htl cache status describes it.
FixJson
A Fix as an entry (and the CLI’s --format json) stores it.
Key
The identity of one module under one invocation: same key, same question.
Module
What checking one module reported.
Options
Everything the store needs from outside itself.
Recorded
One diagnostic exactly as it was handed to the sink, so a replay goes through the same printing code the original run did rather than through a reconstruction of it. Reconstructing text from parsed fields is how a cache starts printing subtly different output from the run it claims to reproduce.
RequireJson
One literal require and where the checker resolved it. Kept because the project-level cycle lint runs over every file’s requires, replayed ones included — a cycle that closes through a module nobody edited is still a cycle.
Stats
What one run did with the store.

Enums§

Mode
How much of a run one entry covers.

Constants§

CHECK
An entry holding what checking a module reported.
GEN
An entry holding that, plus the Lua the module generates.
MODULE
An entry holding a required module’s generated Lua, keyed by the file it is.
RUN
One entry covering a whole walk (Mode::WholeRun).

Functions§

describe
Read the store and say what is in it.
gen_key
The key for one test file’s checked-and-generated form.
module_gen_key
The key for a module’s checked-and-generated form, by the file it is.
module_key
The key for one module in this invocation.
normal
A path in the form it is stored and compared in: absolute and symlink-resolved where possible.
requires_json
CheckInfo’s requires in the form an entry stores them.
root_for
Where a project’s store lives: beside the htl.toml found from path, or nowhere.
run_key
The key for the walk as a whole, under Mode::WholeRun.
scratch_root
Why a proc macro must not write a store under root, if it must not.
search_dirs
Directories a require could resolve in, listed whether or not they exist yet.
source_mentions_require
Whether a module’s source could require anything: the token appears in it.