Cooper. Cache
(Cooper v0.2.0)
Copy Markdown
Backs Cooper.load_file/2's default caching behavior: caches the
pre-resolve tree a file's own parse+import+loop+merge pipeline
produces (exactly what Cooper.Grammar.run_tree/2 returns) keyed by
its absolute path, and reuses it across calls as long as every file
that contributed to it -- the entry file and every transitively
bare-imported file, Cooper.Loader's own loaded_files tracking --
still has the same mtime it had when the entry was populated.
${...} resolution (Cooper.Resolver) is deliberately not part of
what's cached -- it re-runs, against a freshly-computed
Cooper.Dotenv.env/1, on every single call regardless of hit or
miss, so an ordinary ${NAME} value read is always current.
A scheme:// import never contributes its own path to the
fingerprint (there's no real file behind one to File.stat/1) -- its
content is only as fresh as whatever triggers the rest of the
entry's fingerprint to invalidate, not independently tracked.
A ${?NAME} guard is decided during the cached (parse) phase,
using whatever env was live at populate time -- unlike an ordinary
value read, it does not refresh on every access. It refreshes when
the cache entry itself invalidates: a fingerprinted file changes, an
explicit invalidate/1/clear/0, or (only for calls that opted in
with watch_env: true) the next poll tick after a watched ${NAME}
changes -- see "Telemetry" below. Without watch_env: true, a file
change is the only thing that refreshes it. Documented, not a bug:
making it genuinely per-access-fresh (with no cache involved at all)
would mean not knowing a file's own shape (which statements exist)
until every single read, which is a fundamentally bigger feature than
a read-through cache. (${...} inside an import "..." path is a
separate, unconditional load-time error, not something this cache
ever has to keep fresh -- CASC.md §5.1 doesn't support it, so it
never reaches this cache in the first place.)
Cooper.load_file/2 defaults watch_env to true automatically for
any file that reads ${...} at all -- an ordinary value, a guard, or
both -- and false for one that doesn't reference the environment in
any way. See its own moduledoc.
Concurrency
Reads (fetch/2) hit the :public, read_concurrency: true ETS
table directly from the calling process -- no message passthrough for
the common (warm-cache) case. Only populating an entry goes through
this GenServer, so concurrent misses on the same key coalesce into
one load rather than a stampede of redundant re-parses (the
GenServer re-checks the table itself before loading, in case another
caller populated the entry while this one was waiting its turn).
Deliberately simple, not sharded: populate/3 runs the actual load
inside the GenServer call, which means misses on two different
files are also serialized against each other, not just against
themselves. Fine for the common case (a handful of config files, load
itself is fast) -- revisit with per-key locking (e.g. a Registry)
if that ever becomes a real bottleneck for a specific deployment.
Telemetry
Two events, both no-cost if nothing's attached (:telemetry.execute/3
is a cheap no-op with zero handlers):
[:cooper, :cache, :file_changed]-- measurements:%{system_time: integer()}; metadata:%{path: String.t(), root: String.t(), changed_files: [String.t()]}. Fired when a previously cached entry's fingerprint no longer matches disk -- never on the first population of an entry (nothing "changed" the first time).[:cooper, :cache, :env_changed]-- same measurements shape; metadata:%{path: String.t(), root: String.t(), changed_names: [String.t()]}. Controlled per call viaCooper.load_file/2'swatch_env(defaults totruefor a file that references the environment at all,falseotherwise; pass it explicitly to override) -- seewatch_env/5. Also invalidates the entry (theenv_watchregistration goes with it) -- the nextload_file/2call for this{path, root}does a full reparse against the now-current env, not just a value re-resolve, so a${?NAME}decision that depends on a watched name refreshes within one poll interval of a real change, not only on a file change.
Summary
Functions
Returns a specification to start this module under a supervisor.
Removes every cached entry.
Direct ETS read for {path, root} -- {:ok, tree, vars, env_guard_names} if a cached entry exists and every fingerprinted
file's mtime still matches what's on disk, :miss otherwise (no
entry, or something changed). env_guard_names is every ${?NAME}
guard name used by the file (or any transitively bare-imported file)
-- Cooper.load_file/2 unions it with Cooper.Resolver's own
env-name tracking to decide :watch_env's implicit default and,
when enabled, exactly which names to watch (a guard name is
frequently never read as an ordinary ${NAME} value anywhere else,
so Cooper.Resolver's tracking alone wouldn't see it).
Removes every cached entry for path (any root), if present.
Populates (or refreshes) the {path, root} entry by calling
loader_fun -- a zero-arity function returning {:ok, tree, vars, loaded_files, env_guard_names} (loaded_files is the fingerprint
source: every real file the load touched) or {:error, reason}.
Routed through this GenServer for the stampede protection described
in the moduledoc. A {:error, _} result is returned as-is and never
cached -- a load failure isn't something to keep serving instead of
retrying.
Registers names (a MapSet of env var names, as Cooper.Resolver
tracks internally while resolving) to be polled for changes, starting
from values (%{name => value}) as the known-good baseline -- pass
exactly what those names resolved to just now, not a value
recomputed later, or a real change landing in the gap between this
call and the (necessarily async, see below) cast being processed
would go undetected: the "before" snapshot would already reflect the
"after" value. Later polls re-derive the current values from
Cooper.Dotenv.env/1, computed from opts (only the dotenv-relevant
keys are kept: :env/:dotenv/:dotenv_env/:dotenv_files).
Types
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear() :: :ok
Removes every cached entry.
Direct ETS read for {path, root} -- {:ok, tree, vars, env_guard_names} if a cached entry exists and every fingerprinted
file's mtime still matches what's on disk, :miss otherwise (no
entry, or something changed). env_guard_names is every ${?NAME}
guard name used by the file (or any transitively bare-imported file)
-- Cooper.load_file/2 unions it with Cooper.Resolver's own
env-name tracking to decide :watch_env's implicit default and,
when enabled, exactly which names to watch (a guard name is
frequently never read as an ordinary ${NAME} value anywhere else,
so Cooper.Resolver's tracking alone wouldn't see it).
@spec invalidate(String.t()) :: :ok
Removes every cached entry for path (any root), if present.
@spec populate(String.t(), String.t(), loader_fun()) :: {:ok, term(), map(), MapSet.t()} | {:error, term()}
Populates (or refreshes) the {path, root} entry by calling
loader_fun -- a zero-arity function returning {:ok, tree, vars, loaded_files, env_guard_names} (loaded_files is the fingerprint
source: every real file the load touched) or {:error, reason}.
Routed through this GenServer for the stampede protection described
in the moduledoc. A {:error, _} result is returned as-is and never
cached -- a load failure isn't something to keep serving instead of
retrying.
Fires [:cooper, :cache, :file_changed] when this call refreshes
an entry that already existed (any existing watch_env/5
registration for the entry survives the refresh); never on a genuinely
fresh population.
Registers names (a MapSet of env var names, as Cooper.Resolver
tracks internally while resolving) to be polled for changes, starting
from values (%{name => value}) as the known-good baseline -- pass
exactly what those names resolved to just now, not a value
recomputed later, or a real change landing in the gap between this
call and the (necessarily async, see below) cast being processed
would go undetected: the "before" snapshot would already reflect the
"after" value. Later polls re-derive the current values from
Cooper.Dotenv.env/1, computed from opts (only the dotenv-relevant
keys are kept: :env/:dotenv/:dotenv_env/:dotenv_files).
A no-op if names is empty (nothing to watch) or the {path, root}
entry doesn't currently exist (e.g. a concurrent invalidate/1 raced
this call -- the next load re-registers).
A detected change invalidates the entry (this registration goes with
it) in addition to firing [:cooper, :cache, :env_changed] -- see
the moduledoc's "Telemetry" section.
Async (GenServer.cast/2) -- this runs after a load has already
returned its result to the caller, so there's nothing to block on.
Starts this module's polling loop (see the moduledoc's "Telemetry"
section) if it isn't already running; the loop stops itself again
once nothing is being watched.