@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
801 lines • 36.1 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { currentCaller, liveCallers } from "../context/session_context.js";
import { enotsup } from "../errors/fs.js";
import { activeRecords, lineVersion, lostPaths, recordIndex } from "../observe/context.js";
import { DEFAULT_READ_TTL, FileChangeKind, PathSpec } from "../types.js";
import { concat } from "../utils/bytes.js";
import { SystemClock } from "../utils/clock.js";
import { mountKey } from "../utils/key_prefix.js";
import { rstripSlash } from "../utils/slash.js";
import { captureRead } from "./context.js";
import { latestFingerprint, mutationLock } from "./file/io.js";
import { drainBudget } from "./file/mixin.js";
import { CHECKED_LIMIT, LISTING_TRUST_WINDOW, PROBED_LIMIT } from "./index/constants.js";
import { commandStarted, soleCommandStarted, tick } from "./index/scope.js";
import { IndexView } from "./index/view.js";
import { OwnRead } from "./types.js";
import { VersionScope } from "./versions.js";
/**
* Post-mutation cache coherence for one mount.
*
* A backend mutation has two cache consequences: the file-cache entry
* for the path is stale, and the parent directory listing in the index
* cache (including negative knowledge that the path does not exist) is
* stale. This class discharges both, synchronously, at the mutation
* site: core backend mutators report through `cache/context.ts` so
* invalidation happens before the next command in a pipeline runs
* instead of after the whole command tree.
*/
export class CacheManager {
readTtl;
onGone;
mayServeListing;
excludedPrefixes;
conditional;
versionScope = new VersionScope();
fileCache;
index;
clock;
prefix;
cachesReads;
ownsPath;
readGeneration = 0;
view = null;
// Folder to the tick, the monotonic millisecond and the command stamp of
// its listing's last write, by any view of this mount, shared or lock-held.
written = new Map();
// Cache key to what the freshness probe got from the backend: its command
// identity, the read generation then, and the stat.
probed = new Map();
probeBound = PROBED_LIMIT;
// What a listing version check covers (the mount root or a folder) to the
// version the backend answered, the tick taken just before it was sent and
// the monotonic millisecond it was sent at.
checked = new Map();
// The stored version each remembered answer refused, by that answer's tick.
refused = new Map();
// The newest check in flight per key, with its tick and send time.
checking = new Map();
checkBound = CHECKED_LIMIT;
constructor(fileCache, index, prefix, cachesReads, ownsPath = () => true, readTtl = DEFAULT_READ_TTL,
// Cleanup for a child a re-list found gone, injected because this class
// holds no mount and no dispatcher; undefined cleans nothing.
onGone,
// The listing gate every view of this mount asks before serving a
// cached listing; undefined serves them all.
mayServeListing, excludedPrefixes = () => [], conditional = false) {
this.readTtl = readTtl;
this.onGone = onGone;
this.mayServeListing = mayServeListing;
this.excludedPrefixes = excludedPrefixes;
this.conditional = conditional;
this.fileCache = fileCache;
this.index = index;
this.clock = index?.clock ?? new SystemClock();
this.prefix = rstripSlash(prefix);
this.cachesReads = cachesReads;
this.ownsPath = ownsPath;
}
millis() {
return this.clock.monotonic() * 1000;
}
/** Drain raw backend index access before mount cache eviction. */
mutation(call) {
return this.fileCache === null ? call() : mutationLock(this.fileCache, call);
}
/**
* Drop this mount's listings and bodies, path unspecified.
*
* For a write that went past the dispatcher, so nothing here can say which
* file changed, only that this mount may no longer match its backend:
* native code (an external program, a remote runtime line), an account CLI
* writing to its service by id, or a mount command writing through its own
* client. Both caches go, because the two hide different writes: a stale
* listing hides a create or a delete, a stale body an edit. It also retires
* what the running command's probes saw.
*
* The index is expired rather than cleared, while this mount still owns it:
* a cleared index reads exactly like one that was never filled, so a
* backend whose index *is* its listing (github seeds the whole tree once)
* could not tell the drop from an empty repository. Over-evicts when this
* mount is the root and another mount sits beneath it, since keys are
* compared by prefix; a refetch is the safe direction to be wrong in.
* Mirrors Python's `CacheManager.drop`.
*
* @param bodies drop the bodies too; false when the caller clears the whole
* file cache itself.
*/
drop(bodies = true) {
return this.mutation(async () => {
this.retire();
if (this.ownsPath(this.prefix || '/'))
await this.index?.invalidate();
if (!bodies || !this.cachesReads || this.fileCache === null)
return;
await this.fileCache.remove(this.prefix || '/', { subtree: true });
});
}
/**
* Retire every in-flight read and every remembered probe answer.
*
* The one step every cache drop takes: a read that began before it must not
* stamp the cache after it, and a probe answer from before it must not be
* served after it.
*/
retire() {
this.readGeneration += 1;
this.probed.clear();
this.probeBound = PROBED_LIMIT;
}
// A re-list found children gone: the backend changed under the command, so
// nothing its probes saw is safe to serve.
async goneLocked(gone) {
if (gone.length === 0)
return;
this.retire();
await this.onGone?.(gone);
}
/**
* This mount's index, its backend metadata writes bound to the mount's
* lifetime; null when the mount has none.
*
* The view is reused because refill locks are keyed by index identity.
*/
scopeIndex() {
if (this.fileCache === null || this.index === null)
return this.index;
this.view ??= new IndexView(this.index, this.fileCache, this.prefix || '/', this.ownsPath, this.viewOptions());
return this.view;
}
viewOptions(locked = false) {
return {
readTtl: this.readTtl,
excludedPrefixes: this.excludedPrefixes,
onGone: locked
? (gone) => this.goneLocked(gone)
: (gone) => this.mutation(() => this.goneLocked(gone.filter((c) => this.ownsPath(c.path)))),
...(this.mayServeListing === undefined ? {} : { mayServeListing: this.mayServeListing }),
noteWritten: (folder) => {
this.noteWritten(folder);
},
listedThisCommand: (folder) => this.listedThisCommand(folder),
};
}
noteWritten(folder) {
this.written.set(folder, [tick(), this.millis(), soleCommandStarted()]);
}
/**
* Whether the running command fetched `folder`'s listing itself.
*
* Stricter than `listingTrusted`: outside a command no listing is the
* caller's own, and inside one only a listing fetched under the same command
* stamp is: not one another session fetched meanwhile, nor one a nested
* `$(...)` or function body fetched. A listing the command fetched stays its
* own until the command ends, so a long-running command does not see a name
* another client creates after it listed the folder. Mirrors Python's
* `listed_this_command`.
*/
listedThisCommand(folder) {
const written = this.written.get(folder);
const started = soleCommandStarted();
return written !== undefined && started !== null && written[2] === started;
}
/**
* Whether `folder`'s listing is recent enough to serve under fresh.
*
* Inside a command: only if it was fetched since the command started, so
* one command re-lists a folder once however often it reads it. Outside
* any command (FUSE, a programmatic op) there is no command to belong to,
* so a listing fetched within `LISTING_TRUST_WINDOW` seconds is trusted
* instead: one `ls -l` over FUSE is a burst of calls that can share a
* re-list until the window expires.
*
* Every view of the mount, shared or lock-held, records into one map, so
* a glob's write counts for the `ls` that follows it.
*/
listingTrusted(folder) {
const written = this.written.get(folder);
if (written === undefined)
return false;
const [stamp, at] = written;
const started = commandStarted();
if (started !== null)
return stamp > started;
return this.millis() - at < LISTING_TRUST_WINDOW * 1000;
}
// A check answers only a caller inside its window, so the rest are dead
// weight; the next prune waits for the map to double.
pruneChecks() {
for (const [key, [, sentTick, sentAt]] of this.checked) {
if (!this.sentRecently(sentTick, sentAt))
this.checked.delete(key);
}
for (const [key, [, sentTick]] of this.refused) {
if (this.checked.get(key)?.[1] !== sentTick)
this.refused.delete(key);
}
this.checkBound = Math.max(CHECKED_LIMIT, 2 * this.checked.size);
}
/**
* Whether a version check is recent enough to answer for the caller.
*
* The rule `listingTrusted` applies to listings: inside a command, only a
* check sent after the command started, since one sent before may predate a
* change the command must see; outside any command, one sent within
* `LISTING_TRUST_WINDOW` seconds.
*/
sentRecently(sentTick, sentAt) {
const started = commandStarted();
if (started !== null)
return sentTick > started;
return this.millis() - sentAt < LISTING_TRUST_WINDOW * 1000;
}
/**
* The backend's listing version for `key`, asking at most once.
*
* A check recent enough for the caller (`sentRecently`) that answered
* `stored` is reused, so one command checks a mount once however many of
* its folders it lists. Otherwise a check in flight that is recent enough
* is shared, and only then is a new one sent; the newest in flight is the
* one later callers find. A remembered answer that differs from `stored` is
* asked again rather than trusted, since the listing may have been written
* since, unless that answer already refused this same `stored`: nothing
* about that listing changed. `check` answers null when the backend gives
* no version.
*/
async checkedVersion(key, stored, check) {
const checked = this.checked.get(key);
if (checked !== undefined && this.sentRecently(checked[1], checked[2])) {
const refused = this.refused.get(key);
if (checked[0] === stored || (refused?.[0] === stored && refused[1] === checked[1])) {
return checked[0];
}
}
const flight = this.checking.get(key);
const version = await (flight !== undefined && this.sentRecently(flight[0], flight[1])
? flight[2]
: this.sendCheck(key, check));
const answered = this.checked.get(key);
if (version !== stored && answered?.[0] === version) {
this.refused.set(key, [stored, answered[1]]);
}
return version;
}
sendCheck(key, check) {
const sentTick = tick();
const sentAt = this.millis();
const answer = check().then((version) => {
const checked = this.checked.get(key);
// An older check that lands late never replaces a newer one.
if (version !== null && (checked?.[1] ?? 0) < sentTick) {
if (checked === undefined && this.checked.size >= this.checkBound)
this.pruneChecks();
this.checked.set(key, [version, sentTick, sentAt]);
}
return version;
});
const flight = [sentTick, sentAt, answer];
this.checking.set(key, flight);
const done = () => {
if (this.checking.get(key) === flight)
this.checking.delete(key);
};
// Each waiter receives the outcome from `answer` itself; this branch only
// drops the finished check from the map.
void answer.then(done, done);
return answer;
}
/**
* Remember what a probe or a direct stat got from the backend.
*
* Called only with an answer the backend gave: the reconciler's probe, and
* under any read policy a stat that asked the backend itself, since its
* point observation can expire the parent listing the next lookup would
* otherwise walk again. A stat served from an index row -- which may carry
* no content token -- never lands here. A path the backend reports gone
* records nothing: the probe asks the backend only when no answer is
* servable, so there is nothing left to take back.
*/
noteProbed(path, stat) {
const started = commandStarted();
if (started === null)
return;
if (this.probed.size >= this.probeBound) {
this.pruneProbes(started);
// What is left is all the running command's; the next prune waits for
// the map to double, so one large walk stays linear.
this.probeBound = Math.max(PROBED_LIMIT, 2 * this.probed.size);
}
this.probed.set(this.cacheKey(path), [started, this.readGeneration, stat]);
}
// Only the probing command is ever served an answer, so the other commands'
// entries are dead weight here.
pruneProbes(started) {
for (const [key, [stamp]] of this.probed) {
if (stamp !== started)
this.probed.delete(key);
}
}
/** Mutation generation, captured before a freshness probe starts. */
get generation() {
return this.readGeneration;
}
/**
* The backend's answer for `path` from this command's probe.
*
* A read command stats its own operand after the probe already asked the
* backend; asking again resolves through listings the command has not
* re-checked (or that its observation expired), and re-lists every folder
* on the path. The answer is
* served only inside the command that probed, and only while no cache drop
* has landed since: a write in the command (`sed -i`, `> f`), the clear
* after an external program, and a re-list that found the path gone all
* retire it (`retire()`), so the next stat goes back to the backend.
*/
probedStat(path) {
const probed = this.probed.get(this.cacheKey(path));
const started = commandStarted();
if (probed === undefined || started === null)
return null;
const [stamp, generation, stat] = probed;
if (stamp !== started || generation !== this.readGeneration)
return null;
return stat;
}
/** Retain a live fallback row only while its predecessor still owns the slot. */
retainResolvedEntry(path, generation, predecessor, entry) {
const key = this.cacheKey(path);
return this.mutation(async () => {
const index = this.lockedIndex();
if (generation !== this.readGeneration || !this.ownsPath(key) || index === null)
return;
await index.replaceIfUnchanged(key, predecessor, entry);
});
}
/**
* A view for a caller already inside `mutation`.
*
* Never share or retain it beyond that hold. A distinct refill lock
* avoids lock inversion with readers of the shared view.
*/
lockedIndex() {
if (this.fileCache === null || this.index === null)
return this.index;
return new IndexView(this.index, this.fileCache, this.prefix || '/', this.ownsPath, {
locked: true,
...this.viewOptions(true),
});
}
/**
* Drop one directory's cached listing.
*
* Both spellings of the directory go, because a backend may have keyed
* it with or without its trailing slash and an eviction that hits no
* key is silent.
*/
async evictDir(key) {
if (this.index === null)
return;
await this.index.invalidateDir(key);
await this.index.invalidateDir(key + '/');
}
/**
* Cache key for a path, derived rather than inferred.
*
* Both caches this class evicts from are keyed by the mount-absolute virtual
* path, so that is what this returns: the mount prefix still attached, not
* the mount-relative spelling `mountKey` produces on the way there.
*
* Only `virtual` is read, and the key is rebuilt against this manager's own
* prefix, exactly as `Mount.call` rebuilds one before handing a path to
* a backend. The caller's `vfsPath` is deliberately ignored: it is not
* a fact this class can trust, because `PathSpec.fromStrPath` fabricates one
* ("assumed root-mounted") for any caller that does not know its mount.
*
* The earlier version inferred which convention had arrived by comparing the
* two strings, which cannot be done: under a `/d` mount a mount-relative `/d`
* and an absolute `/d` are the same characters naming different files.
* Inferring wrong is quiet rather than loud -- a key one level off simply
* evicts nothing -- which is why it survived. Deriving asks no question that
* has no answer.
*
* Mirrors Python `CacheManager._cache_key`.
*/
cacheKey(path) {
const key = mountKey(path instanceof PathSpec ? path.virtual : path, this.prefix);
if (key === '')
return this.prefix === '' ? '/' : this.prefix;
return `${this.prefix}/${key}`;
}
/** The file cache this manager may read `key` from, if any. */
readableCache(key) {
if (!this.cachesReads || !this.ownsPath(key))
return null;
return this.fileCache;
}
/** A browser fallback cannot attribute an overlapping result to a session. */
sessionHistory(path) {
if (!this.conditional)
return null;
const histories = liveCallers()
.filter((caller) => caller.session !== null || caller.write?.context != null)
.map((caller) => caller.session?.versions ?? null);
const history = histories[0] ?? null;
if (histories.some((held) => held !== history))
throw enotsup('workspace', 'conditional write (overlapping sessions)', path);
return history;
}
/**
* Remember a refusal: the version it lost on stands until a read; null
* forgets it.
*/
lose(path, version) {
this.sessionHistory(path)?.keep(this.versionScope, '/' + mountKey(path.virtual, this.prefix), version);
}
/**
* Record that `caller`'s session found no file at `path` on a read or stat.
* It lifts a refusal the running line marked on `path` earlier, as a read
* that finds the file does. A miss no one session owns (overlapping
* sessions on the fallback storage) is recorded nowhere, so the next write
* goes as it would have. Mirrors Python's `observe_miss`.
*/
observeMiss(path, caller) {
const session = caller?.versionReads === true ? caller.session : null;
if (!this.conditional || session === null || !this.ownsPath(path.virtual))
return;
session.versions.keep(this.versionScope, '/' + mountKey(path.virtual, this.prefix), OwnRead.ABSENT);
const lost = lostPaths(caller?.recording?.records);
lost?.lift(path.virtual, lost.count);
}
/** Record this session's exact result, never a peer's cached token. */
observe(rec, owner) {
const caller = owner ?? currentCaller();
if (!this.conditional ||
caller.session === null ||
!caller.versionReads ||
!this.ownsPath(rec.path))
return;
const history = owner === undefined ? this.sessionHistory(rec.path) : caller.session.versions;
history?.observe(this.versionScope, '/' + mountKey(rec.path, this.prefix), rec);
}
/**
* Retain the refused condition in its session, or in the cache without a
* session. ABSENT, a refused create, is kept only in session history.
*/
async keepVersion(path, version) {
const key = this.cacheKey(path);
const history = this.sessionHistory(path);
if (history !== null) {
history.keep(this.versionScope, '/' + mountKey(path.virtual, this.prefix), version);
return;
}
const cache = this.fileCache;
if (cache === null || !this.ownsPath(key) || typeof version !== 'string')
return;
try {
await mutationLock(cache, () => cache.keepFingerprints({ [key]: version }));
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`version not kept for ${key}: ${msg}`);
}
}
/**
* Conditional mounts use the live session's own observations, with
* current-line refusals first. Without a session, and on unconditional
* mounts, use current-line records then the shared cache's token.
* Cache eviction never clears a session's history.
*/
async readVersion(path) {
return (await this.readVersions([path]))[0] ?? null;
}
/** `readVersion` for many paths; session history needs no I/O. */
async readVersions(paths) {
const history = this.sessionHistory(paths[0] ?? this.prefix);
if (history !== null) {
const lost = lostPaths(activeRecords());
return paths.map((path) => {
const token = lost?.holds(path.virtual) === true ? lost.version(path.virtual) : null;
return token !== null && token !== ''
? token
: history.read(this.versionScope, '/' + mountKey(path.virtual, this.prefix));
});
}
const out = paths.map(() => null);
const pending = [];
const records = activeRecords();
const lost = lostPaths(records);
const index = records !== undefined ? recordIndex(records) : undefined;
paths.forEach((path, i) => {
const key = this.cacheKey(path);
if (index !== undefined) {
const [known, version] = lineVersion(index, lost, key);
if (known) {
out[i] = version;
return;
}
}
if (this.fileCache !== null && this.ownsPath(key))
pending.push([i, key]);
});
if (pending.length > 0 && this.fileCache !== null) {
const tokens = await this.fileCache.fingerprints(pending.map(([, key]) => key));
pending.forEach(([i], j) => {
out[i] = tokens[j] ?? null;
});
}
return out;
}
/**
* Run a cold whole-file read and keep its bytes for the next one.
*
* For the dispatcher, which probed the cache itself. A write that lands
* while the fetch runs
* retires the generation, so the bytes it read are not kept; an answer
* that is not bytes is returned and kept nowhere. `keep`, when given, is
* asked after the fetch with the cache's mutation lock held and has the
* last say over whether the bytes are kept; the dispatcher passes "no
* renderer resolves for the read". It must be synchronous; omitted, the
* bytes are kept. Mirrors Python's `CacheManager.fill`.
*/
async fill(path, fetch, keep) {
const generation = this.readGeneration;
const records = activeRecords();
const start = records?.length ?? 0;
const key = this.cacheKey(path);
const [data, facts] = await captureRead(key, fetch);
if (!(data instanceof Uint8Array))
return data;
await this.keep(key, data, () => facts.length > 0
? facts.every((fp) => fp === facts[0])
? (facts[0] ?? null)
: null
: latestFingerprint(records?.slice(start), key), this.admits(generation, keep));
return data;
}
/**
* Pass a cold streamed read through and keep its bytes for the next one.
*
* The streamed twin of `fill`: the bytes are kept once the last chunk is
* pulled, under the same rules (a write that lands while the stream runs
* retires the generation, and `keep` has the last say), labelled with the
* token this stream's backend recorded (`records`). A stream closed early,
* or larger than the cache's drain budget, keeps nothing, so the cache never
* reads past what the caller pulled and never holds more than it could.
* Mirrors Python's `fill_stream`.
*/
fillStream(path, source, records, keep) {
const key = this.cacheKey(path);
const cache = this.readableCache(key);
if (cache === null)
return source;
return this.filling(cache, key, source, records, keep);
}
async *filling(cache, key, source, records, keep) {
const generation = this.readGeneration;
const budget = drainBudget(cache);
let chunks = [];
let size = 0;
for await (const chunk of source) {
if (chunks !== null) {
size += chunk.byteLength;
chunks = size <= budget ? chunks : null;
chunks?.push(chunk);
}
yield chunk;
}
if (chunks !== null) {
await this.keep(key, concat(chunks), () => latestFingerprint(records, key), this.admits(generation, keep));
}
}
// A cold read's last say: no write landed since it began, and `keep` agrees.
admits(generation, keep) {
return () => generation === this.readGeneration && (keep === undefined || keep());
}
/**
* Keep a write's bytes for the next read, as a read keeps its own.
* `fingerprint` is the token the write's backend answered, null when it
* vouched for none. Mirrors Python's `keep_written`.
*/
async keepWritten(path, data, fingerprint) {
await this.keep(this.cacheKey(path), data, () => fingerprint, () => true);
}
/**
* Store bytes a read or a write settled on, unless they went stale.
*
* The one way bytes enter the file cache. The checks run under the cache's
* mutation lock, so a name this mount stopped owning or caching keeps
* nothing, nor do bytes `admit` refuses. Bytes over the whole cache limit
* would evict every warm entry, so the copy held for the key, now stale,
* goes instead; a store that refuses the bytes (out of memory, a value over
* its size limit) costs only the next read a fetch, so the refusal is
* logged, never thrown, and the stale copy goes too. Mirrors Python's
* `_keep`.
*/
async keep(key, data, token, admit) {
const cache = this.fileCache;
if (cache === null)
return;
await mutationLock(cache, async () => {
if (!(this.cachesReads && this.ownsPath(key) && admit()))
return;
try {
if (data.byteLength > cache.cacheLimit)
await cache.remove(key);
else
await cache.set(key, data, { fingerprint: token(), ttl: this.readTtl });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`cache fill refused for ${key}: ${msg}`);
try {
await cache.remove(key);
}
catch (dropErr) {
const why = dropErr instanceof Error ? dropErr.message : String(dropErr);
console.warn(`stale copy not dropped for ${key}: ${why}`);
}
}
});
}
/**
* Return each cached render's byte length, in path order, without
* revalidating or fetching the bytes.
*
* The size backfill a render-dependent backend cannot answer for itself
* (the dispatcher's stat) runs only where the backend reported no size,
* which is exactly the API mounts, so gating it would turn a stat into a
* backend stat. It answers a length rather than content, so nothing can
* serve unverified bytes through it.
*/
async cachedSizes(paths) {
const keys = paths.map((path) => this.cacheKey(path));
const held = keys.filter((key) => this.readableCache(key) !== null);
if (held.length === 0 || this.fileCache === null)
return keys.map(() => null);
const got = await this.fileCache.sizes(held);
const sizes = new Map(held.map((key, i) => [key, got[i] ?? null]));
return keys.map((key) => sizes.get(key) ?? null);
}
/**
* Whether a read of `path` would be served from the cache, asked of the
* store without fetching the bytes.
*/
async isCached(path) {
const key = this.cacheKey(path);
const cache = this.readableCache(key);
return cache !== null && (await cache.exists(key));
}
/**
* Drop what a change at `path` made stale. The kind decides how far down it
* reaches:
*
* - CREATE, UPDATE, and MOVE at its landing: the body, and the path's entry
* in its parent's listing. A CREATE that answered its new entry's stat
* (`row`) keeps that entry where the parent's listing names it instead, so
* a listing of the parent and the next level of a nested create need no
* request; a row naming another file (a store that matches names without
* case wrote the one it holds) proves nothing, so the parent is listed
* again.
* - DELETE: the body, the path's own listing and its parent entry, and what
* is cached beneath it when a listing at or under it is still cached. That
* probe is for a remover that cannot say it removed a folder (a notified
* DELETE, the vacated side of a MOVE): a folder's nested listings and
* bodies were cached under keys of their own, while a plain file must not
* pay a subtree drop, which on a Redis file cache scans the whole keyspace.
* A remover that knows nothing was below (`leaf`: unlink, rmdir) skips it.
* A folder with no listing cached at or under it keeps the bodies cached
* beneath it until their ttl.
* - UNKNOWN: everything cached at and below the path, as a push notification
* that names only a folder, a recursive delete or a directory rename needs.
*
* Then the path's ancestors drop their entries in their parents' listings: a
* keyed store materializes every missing level of a key in one put, so those
* listings gained entries too. Each affected listing becomes partial and
* keeps its unaffected siblings. Every change retires this mount's in-flight
* reads and probe answers, all but `probed`. Mirrors Python's
* `CacheManager.invalidate`.
*
* @param path the changed path; only `virtual` is read.
* @param kind what happened, which decides the reach.
* @param row a CREATE's new entry, kept in its parent's listing; null drops
* that entry.
* @param options.leaf a DELETE whose remover knows nothing was below the path.
* @param options.probed the backend's answer the change was found by, which
* this command's probe keeps serving for `path` (`probedStat`) past the
* retire.
*/
async invalidate(path, kind, row = null, options = {}) {
const key = this.cacheKey(path);
// The ancestors go even when the path's own invalidation fails; a failure
// of both throws both.
try {
await this.invalidateOwn(key, kind, row, options.leaf === true, options.probed ?? null);
}
catch (error) {
try {
await this.invalidateAncestors(key);
}
catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'invalidation and ancestor cleanup failed');
}
throw error;
}
await this.invalidateAncestors(key);
}
async invalidateOwn(key, kind, row, leaf, probed) {
this.retire();
const started = commandStarted();
if (probed !== null && started !== null) {
this.probed.set(key, [started, this.readGeneration, probed]);
}
if (this.cachesReads && this.fileCache !== null)
await this.fileCache.remove(key);
if (kind === FileChangeKind.UNKNOWN) {
await this.dropBelow(key);
await this.invalidateRemoved(key);
}
else if (kind === FileChangeKind.DELETE) {
await this.removed(key, leaf);
}
else if (kind === FileChangeKind.CREATE &&
row !== null &&
row.name === key.slice(key.lastIndexOf('/') + 1)) {
await this.index?.addEntry(key, row, new Date(this.index.clock.now() * 1000 + this.readTtl * 1000));
}
else {
await this.index?.invalidateEntry(key);
}
}
// The body went before the index is asked, so an index that cannot answer
// still leaves the removed file unserved; the own and parent invalidations
// run even when the probe or the subtree drop fails.
async removed(key, leaf) {
try {
if (!leaf && this.index !== null && (await this.index.holdsSubtree(key))) {
await this.dropBelow(key);
}
}
catch (error) {
try {
await this.invalidateRemoved(key);
}
catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'removal invalidation and cleanup failed');
}
throw error;
}
await this.invalidateRemoved(key);
}
async invalidateAncestors(key) {
let parent = key.slice(0, Math.max(key.lastIndexOf('/'), 0));
while (this.index !== null && parent !== '' && parent !== this.prefix) {
await this.index.invalidateEntry(parent);
parent = parent.slice(0, Math.max(parent.lastIndexOf('/'), 0));
}
}
async invalidateRemoved(key) {
await this.evictDir(key);
await this.index?.invalidateEntry(key);
}
async dropBelow(key) {
if (this.cachesReads && this.fileCache !== null) {
await this.fileCache.remove(key, { subtree: true, excluded: this.excludedPrefixes() });
}
if (this.index !== null)
await this.index.invalidatePrefix(key, this.excludedPrefixes());
}
}
//# sourceMappingURL=manager.js.map