falaw.cache#
Content-addressed cache for fal calls.
Why this exists: the directorial workflow (“a single edit, the scene re-renders around it”) only works if unchanged beats don’t re-render. A cache hit is the difference between a 30-second edit-and-preview loop and a 5-minute one.
Design:
Key = SHA256 of (model_id, sorted(arguments_json)). Two structurally identical fal calls collapse to the same key.
Value = the raw fal response, plus optional locally-downloaded asset paths. We persist both the JSON manifest and the binary downloads.
Cache root:
$FALAW_CACHE_DIRor$FALAW_DATA_DIR/cacheor~/.config/falaw/cache.The cache is per-process aware via lru_cache for hot lookups; on-disk for persistence across runs.
The key arguments are not always the wire arguments. A chained plan
(generate_image → image_to_video) sends fal a URL for the upstream
image, but a URL is minted fresh per upload — so keying on it guarantees a
miss the moment the upstream is genuinely re-executed, and the miss re-bills
the expensive downstream call for unchanged work. cached_call_fal()
therefore takes an optional key_arguments: the same call with upstream
references resolved to content hashes (sha256:<hex>) instead of URLs.
See falaw.content for how those hashes are produced.
Usage:
from falaw.cache import cached_call_fal raw = cached_call_fal(“fal-ai/flux/dev”, {“prompt”: “…”})
The wrapped variant has the same signature as core.call_fal but skips
the network when the (model_id, arguments) tuple was seen before.
Module Attributes
Sub-directory of the falaw cache holding |
Functions
|
Return the raw fal response if cached, else None. |
|
Persist a fal response. |
Quick summary of the cache: entry count, disk usage, and where it went. |
|
|
Call a fal model, but reuse the cached response when present. |
|
Delete the cache entry for |
|
Emit the synthetic |
|
Download a remote asset to the cache and return the local path. |
- falaw.cache.ASSETS_DIRNAME = 'assets'#
Sub-directory of the falaw cache holding
materialize_asset()copies.Named here rather than in
falaw.prunebecause_asset_path()is what decides the layout; the prune side reads this so the two cannot drift.
- falaw.cache.cache_get(application, arguments, *, backend='fal', key_extra=None)[source]#
Return the raw fal response if cached, else None.
- falaw.cache.cache_put(application, arguments, raw, *, note='', wire_arguments=None, backend='fal', key_extra=None)[source]#
Persist a fal response. Returns the entry directory path.
- Parameters:
application (
str) – fal model id.arguments (
Mapping[str,Any]) – the arguments the entry is keyed on.raw (
dict) – the fal response to store.note (
str) – free-form label recorded in the manifest.wire_arguments (
Optional[Mapping[str,Any]]) – the arguments actually sent to fal, when they differ from the key arguments (chained calls send URLs but are keyed on content hashes). Recorded for debugging only — it never affects the key.backend (
str) – which execution backend producedraw(falaw#15). Joins the cache key only when non-default, same rule asfalaw.canonical.cache_key_payload(); recorded in the manifest under the same condition, for debugging.
- Return type:
The manifest is written to a temporary file and moved into place with
os.replace(), so a reader never sees a half-written entry. That is not hypothetical sinceexecute_plan(concurrency=N): two calls of one Plan that are structurally identical land on the same key, and an interleavedjson.dumpwould leave a permanently unparseable entry — a cache that poisons itself under exactly the fan-out it exists to make cheap.
- falaw.cache.cache_stats()[source]#
Quick summary of the cache: entry count, disk usage, and where it went.
areasis the part worth reading. Since falaw#14 the cache holds the bytes of every generated asset, so a total on its own cannot distinguish gigabytes of irreplaceable blobs from gigabytes ofassets/copies that cost nothing to regenerate — and only the second is safe to reclaim without thinking. Each area’s economics, and the primitives that reclaim it, are infalaw.prune.size_bytesis every byte under the cache root, unchanged in meaning from before the breakdown existed: theotherarea absorbs whatever the named areas do not claim, so the areas always sum to the whole.(
+SKIPed — it reads the caller’s real cache, a multi-gigabyte walk on the production box.tests.test_prunepins this against a throwaway cache instead.)- Return type:
>>> stats = cache_stats() >>> sorted(stats["areas"]) ['assets', 'content', 'manifests', 'other', 'scenes', 'url_index']
- falaw.cache.cached_call_fal(application, arguments, *, key_arguments=None, refresh=False, on_event=None, backend='fal', key_extra=None)[source]#
Call a fal model, but reuse the cached response when present.
- Parameters:
application (
str) – fal model id.arguments (
Mapping[str,Any]) – model input dict — what is sent on the wire.key_arguments (
Optional[Mapping[str,Any]]) – what the cache entry is keyed on, when that differs from what goes on the wire. Defaults toarguments. The split exists because a chained call must send fal an expiring URL for its upstream input while being keyed on that input’s content hash, so a byte-identical upstream regeneration hits instead of re-billing.refresh (
bool) – if True, bypass the cache and overwrite it with a fresh result.on_event – Per-call subscriber for
falaw.events.ProgressEvent. On a cache hit, a syntheticcache_hitevent is emitted so UIs can show “skipped” instead of “running”.backend (
str) – which execution backend serves this call (falaw#15) — resolved viafalaw.backends. Also joins the cache key (non-default only), so two backends never share an entry. Despite the name, this function is no longer fal-specific; the name is kept because “fal” is still the only backend and every existing call site already spells it this way.
- Return type:
- Returns:
Raw response (whether from cache or network).
- falaw.cache.drop_cache_entry(application, arguments, *, backend='fal', key_extra=None)[source]#
Delete the cache entry for
(application, arguments). Returns whether one existed.The counterpart to
cache_put(), and the mechanism that keeps a cache from becoming a trap. An entry whose response can no longer be turned into a usable artifact — fal deleted the URL and the bytes are not in the content store — must be a miss, not a permanent failure: without this, escaping one dead call means re-running the whole plan.falaw.plan.execute()calls it.The plan-level escapes are
execute_plan(plan, refresh=True), which re-runs every call and keeps what it pays for, andexecute_plan(plan, use_cache=False), which re-runs and keeps nothing — so the second one bills again on the next run (falaw#49). Neither is a substitute for this function: both re-bill every call in the plan, and this drops the one entry that actually went bad.Only the manifest is removed. Blobs in the content store are shared by content hash across entries and are never dropped from here.
- Return type:
- falaw.cache.emit_cache_hit(application, on_event=None)[source]#
Emit the synthetic
cache_hitprogress event forapplication.Shared by
cached_call_fal()andfalaw.plan.execute()(which drives the cache directly, because it must decide whether a hit is usable before committing to it) so a UI sees one event shape either way.- Return type:
- falaw.cache.materialize_asset(url, *, key_hint='', store=None, fetcher=None, refresh=False)[source]#
Download a remote asset to the cache and return the local path.
The local filename is content-addressed by the asset’s bytes, so two URLs serving identical bytes resolve to one file. The extension is a presentational hint for ffmpeg/PIL; the SHA-256 is the address.
Repeat calls are cheap, in three widening circles — this is what makes it safe to call from a loop over 200 shots:
the file is already on disk here (no store lookup, no network) — immutable URLs only, see below;
the bytes are in the content store (no network, or one validating round-trip) — so it still works after fal has expired the URL;
otherwise, one download.
Circle 1 matters on its own: the content store is prunable, so an asset can survive as a materialized file after its blob is gone.
Circle 1 is taken only when the URL cannot change — that is,
falaw.content.is_immutable_url()— because reaching it requires trusting theurl -> hashindex to name the current bytes, and for an arbitrary caller-supplied URL it does not (thorwhalen/falaw#23). A mutable URL goes to circle 2, wherefalaw.content.content_ref_for_url()revalidates before reusing anything; when the bytes really are unchanged that costs one conditional request and still no download, and when they have changed you get the new file instead of silently getting the old one.- Parameters:
url (
str) – the remote asset URL.file://is supported and is used deliberately by downstream packages for locally-rendered media.key_hint (
str) – optional human-readable filename prefix.store – injected
lacing.ArtifactStore; defaults tofalaw.content.default_content_store().fetcher – injected byte source (
url -> Iterable[bytes]); defaults to theurllib-based one. The seam for custom transport (auth headers, retries) and for a hermetic test suite.refresh (
bool) – re-fetch unconditionally, skipping every circle. Rarely needed now: a mutable URL revalidates on its own (falaw#23), so this is for an origin that lies about its validators.
- Raises:
FalAssetFetchError – the bytes could not be retrieved. Unlike a generated-media artifact (which degrades to URL-only — see
falaw.plan.execute()), there is nothing to degrade to here: the caller asked for a local file.- Return type: